Convert to aspect
diff --git a/examples/demo/.bazelrc b/examples/demo/.bazelrc index acb122c..7fba3f7 100644 --- a/examples/demo/.bazelrc +++ b/examples/demo/.bazelrc
@@ -3,3 +3,7 @@ common --lockfile_mode=off test --test_output=errors + +# add mypy_aspect and enable it by default +build --aspects //:py.bzl%mypy_aspect +build --output_groups=+mypy
diff --git a/examples/demo/py.bzl b/examples/demo/py.bzl index 039ea95..77ced1d 100644 --- a/examples/demo/py.bzl +++ b/examples/demo/py.bzl
@@ -1,10 +1,6 @@ "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") +load("@rules_mypy//mypy:mypy.bzl", "mypy") -py_library = decorate( - py_target = rules_python_py_library, - types = types, -) +mypy_aspect = mypy(types = types)
diff --git a/examples/demo/thm/BUILD.bazel b/examples/demo/thm/BUILD.bazel index d3fc7e0..5a3aab3 100644 --- a/examples/demo/thm/BUILD.bazel +++ b/examples/demo/thm/BUILD.bazel
@@ -1,4 +1,4 @@ -load("//:py.bzl", "py_library") +load("@rules_python//python:py_library.bzl", "py_library") py_library( name = "thm",
diff --git a/examples/demo/thm/a/BUILD.bazel b/examples/demo/thm/a/BUILD.bazel index 61109fb..33e3411 100644 --- a/examples/demo/thm/a/BUILD.bazel +++ b/examples/demo/thm/a/BUILD.bazel
@@ -1,4 +1,4 @@ -load("//:py.bzl", "py_library") +load("@rules_python//python:py_library.bzl", "py_library") py_library( name = "a",
diff --git a/examples/demo/thm/b/BUILD.bazel b/examples/demo/thm/b/BUILD.bazel index 8b3c7da..fe40a72 100644 --- a/examples/demo/thm/b/BUILD.bazel +++ b/examples/demo/thm/b/BUILD.bazel
@@ -1,5 +1,5 @@ load("@pip//:requirements.bzl", "requirement") -load("//:py.bzl", "py_library") +load("@rules_python//python:py_library.bzl", "py_library") py_library( name = "b",
diff --git a/mypy/mypy.bzl b/mypy/mypy.bzl index 36cfcd3..e77a300 100644 --- a/mypy/mypy.bzl +++ b/mypy/mypy.bzl
@@ -2,70 +2,8 @@ load("//mypy/private:mypy.bzl", _mypy = "mypy", _mypy_cli = "mypy_cli") -# re-export mypy macro +# re-export mypy aspect factory 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, filter = 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. - filter: (optional) a filter function that accepts a label and returns - True if the label should not be used to find upstream caches. - - 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"), - testonly = kwargs.get("testonly"), - tags = kwargs.get("tags"), - filter = filter, - ) - - return decorated_py_target
diff --git a/mypy/private/BUILD.bazel b/mypy/private/BUILD.bazel index 3014dfd..3220650 100644 --- a/mypy/private/BUILD.bazel +++ b/mypy/private/BUILD.bazel
@@ -3,10 +3,11 @@ 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") +load(":mypy.bzl", "mypy_cli") exports_files([ "mypy.py", + "default_mypy.ini", ]) bzl_library( @@ -40,15 +41,6 @@ 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"], @@ -58,11 +50,3 @@ requirement("click"), ], ) - -_mypy( - name = "py_type_library.mypy", - srcs = ["py_type_library.py"], - deps = [ - requirement("click"), - ], -)
diff --git a/mypy/private/default_mypy.ini b/mypy/private/default_mypy.ini new file mode 100644 index 0000000..89b43cb --- /dev/null +++ b/mypy/private/default_mypy.ini
@@ -0,0 +1,2 @@ +[mypy] +# default
diff --git a/mypy/private/mypy.bzl b/mypy/private/mypy.bzl index c8fb8c8..c93f771 100644 --- a/mypy/private/mypy.bzl +++ b/mypy/private/mypy.bzl
@@ -1,23 +1,36 @@ """ -mypy build rule. +mypy aspect. -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. +The mypy aspect runs mypy, succeeding if mypy is error free and failing if mypy produces errors. The +result of the aspect is a mypy cache directory, located at [name].mypy_cache. When provided input cache +directories (the results of other mypy builds), the underlying action 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") +load(":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.", + "directory": "Location of the mypy cache produced by this target.", }, ) -def _mypy_impl(ctx): +def _mypy_impl(target, ctx): + # skip non-root targets + if target.label.workspace_root != "": + return [] + + # only instrument py_* targets + if ctx.rule.kind not in ["py_binary", "py_library", "py_test"]: + return [] + + # disable if a target is tagged "no-mypy" + if "no-mypy" in ctx.rule.attr.tags: + return [] + # we need to help mypy map the location of external deps by setting # MYPYPATH to include the site-packages directories. external_deps = [] @@ -25,52 +38,64 @@ # generated dirs generated_dirs = {} + upstream_caches = [] + types = [] depsets = [] - for dep in ctx.attr.deps: + type_mapping = dict(zip([k.label for k in ctx.attr._types_keys], ctx.attr._types_values)) + additional_types = [ + type_mapping[dep.label] + for dep in ctx.rule.attr.deps + if dep.label in type_mapping + ] + + for dep in (ctx.rule.attr.deps + additional_types): depsets.append(dep.default_runfiles.files) if PyTypeLibraryInfo in dep: - types.append(dep[PyTypeLibraryInfo].directory + "/site-packages") + types.append(dep[PyTypeLibraryInfo].directory.path + "/site-packages") elif dep.label.workspace_root.startswith("external/"): external_deps.append(dep.label.workspace_root + "/site-packages") + if MypyCacheInfo in dep: + upstream_caches.append(dep[MypyCacheInfo].directory) + for file in dep.default_runfiles.files.to_list(): if file.root.path: generated_dirs[file.root.path] = 1 - # TODO: can we use `ctx.bin_dir.path` here to cover generated files - # and as a way to skip iterating over depset contents to find generated - # file roots? + # TODO: can we use `ctx.bin_dir.path` here to cover generated files + # and as a way to skip iterating over depset contents to find generated + # file roots? + unique_generated_dirs = generated_dirs.keys() # 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 + unique_generated_dirs) - cache_directory = ctx.actions.declare_directory(ctx.attr.name + ".mypy_cache") + cache_directory = ctx.actions.declare_directory(ctx.rule.attr.name + ".mypy_cache") args = ctx.actions.args() args.add("--cache-dir", cache_directory.path) + args.add_all([c.path for c in upstream_caches], before_each = "--upstream-cache") + args.add_all(ctx.rule.files.srcs) - args.add_all([ - cache[MypyCacheInfo].cache_directory.path - for cache in ctx.attr.caches - ], before_each = "--upstream-cache") - args.add_all(ctx.files.srcs) - - config_files = [ctx.file.mypy_ini] if ctx.file.mypy_ini else [] + if hasattr(ctx.attr, "_mypy_ini"): + config_files = [ctx.file._mypy_ini] + else: + config_files = [] ctx.actions.run( mnemonic = "mypy", inputs = depset( - direct = ctx.files.srcs + ctx.files.caches + config_files, + direct = ctx.rule.files.srcs + upstream_caches + config_files, transitive = depsets, ), outputs = [cache_directory], - executable = ctx.executable.mypy_cli, + executable = ctx.executable._mypy_cli, arguments = [args], env = { "MYPYPATH": mypy_path, @@ -82,78 +107,56 @@ ) return [ - DefaultInfo( - files = depset([cache_directory]), - runfiles = ctx.runfiles(files = [cache_directory]), - ), - MypyCacheInfo(cache_directory = cache_directory), + MypyCacheInfo(directory = cache_directory), + OutputGroupInfo(mypy = depset([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 _no_op_filter(_label): - return False - -def mypy( - name, - srcs = None, - deps = None, - mypy_ini = None, - mypy_cli = None, - visibility = None, - testonly = None, - tags = None, - filter = None): +def mypy(mypy_cli = None, mypy_ini = None, types = 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) - testonly: (optional) if this is a testonly target - tags: (optional) a list of tags to apply - filter: (optional) a function that accepts a label and returns true - if the label should not be included in upstream cache usage. + mypy_ini: (optional) mypy_ini file to use + 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 mypy aspect. """ + types = types or {} - # enable opt-out - if tags and "no-mypy" in tags: - return + additional_attrs = {} - filter = filter or _no_op_filter - - upstream_caches = [] - if deps: - for dep in deps: - lab = native.package_relative_label(dep) - if lab.workspace_root == "" and not filter(lab): - upstream_caches.append(str(lab) + ".mypy") - - _mypy( - name = name, - srcs = srcs, - deps = deps, - caches = upstream_caches, - mypy_ini = mypy_ini, - mypy_cli = mypy_cli, - visibility = visibility, - testonly = testonly, - tags = tags, + return aspect( + implementation = _mypy_impl, + attr_aspects = ["deps"], + attrs = { + "_mypy_cli": attr.label( + default = mypy_cli or "@rules_mypy//mypy/private:mypy", + cfg = "exec", + executable = True, + ), + "_mypy_ini": attr.label( + # we provide a default here because Bazel won't allow Label attrs + # that are public, or private attrs that have default values of None + default = mypy_ini or "@rules_mypy//mypy/private:default_mypy.ini", + allow_single_file = True, + mandatory = False, + ), + # pass the dict[Label, Label] in parts because Bazel doesn't have + # this kind of attr to pass naturally + "_types_keys": attr.label_list(default = types.keys()), + "_types_values": attr.label_list(default = types.values()), + } | additional_attrs, ) def mypy_cli(name, deps = None, mypy_requirement = None, tags = None):
diff --git a/mypy/private/py_type_library.bzl b/mypy/private/py_type_library.bzl index 93ed190..b763b97 100644 --- a/mypy/private/py_type_library.bzl +++ b/mypy/private/py_type_library.bzl
@@ -29,7 +29,7 @@ runfiles = ctx.runfiles(files = [directory]), ), PyTypeLibraryInfo( - directory = directory.path, + directory = directory, ), ]
diff --git a/mypy/private/types.bzl b/mypy/private/types.bzl index 7cd8ce9..e1a03ab 100644 --- a/mypy/private/types.bzl +++ b/mypy/private/types.bzl
@@ -17,6 +17,7 @@ for requirement in types: content += _PY_TYPE_LIBRARY_TEMPLATE.format( requirement = requirement, + raw = requirement.removeprefix("types-").removesuffix("-stubs"), ) + "\n" return content
diff --git a/readme.md b/readme.md index c56c1aa..901f93c 100644 --- a/readme.md +++ b/readme.md
@@ -1,27 +1,18 @@ # rules_mypy -Bazel rules to decorate `py_*` targets with mypy type-checking. +An aspect to instrument `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. +This aspect will run over any `py_binary`, `py_library` or `py_test`. 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. @@ -50,46 +41,23 @@ use_repo(types, "pip_types") ``` -**Wrap `py_*` rules/macros.** +**Configure `mypy_aspect`.** -If you do not already wrap `py_*` rules with a macro, create a `.bzl` file to wrap these rules: +Define a new aspect in a `.bzl` file (such as `./tools/aspects.bzl`): ```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") +load("@rules_mypy//mypy:mypy.bzl", "mypy") -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, -) +mypy_aspect = mypy(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: +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 `mypy` aspect factory: ```starlark -py_library = decorate( - py_target = rules_python_py_library, +mypy_aspect = mypy( mypy_ini = "//:mypy.ini", types = types, ) @@ -99,15 +67,14 @@ ```starlark load("@pip//:requirements.bzl", "requirements") # '@pip' must match configured pip hub_name -load("@rules_mypy//mypy:mypy.bzl", "decorate", "mypy_cli") +load("@rules_mypy//mypy:mypy.bzl", "mypy", "mypy_cli") mypy_cli( name = "mypy_cli", mypy_requirement = requirement("mypy"), ) -py_library = decorate( - py_target = rules_python_py_library, +mypy_aspect = mypy( mypy_cli = ":mypy_cli", types = types, ) @@ -117,7 +84,7 @@ ```starlark load("@pip//:requirements.bzl", "requirements") # '@pip' must match configured pip hub_name -load("@rules_mypy//mypy:mypy.bzl", "decorate", "mypy_cli") +load("@rules_mypy//mypy:mypy.bzl", "mypy", "mypy_cli") mypy_cli( name = "mypy_cli", @@ -127,8 +94,7 @@ ], ) -py_library = decorate( - py_target = rules_python_py_library, +mypy_aspect = mypy( mypy_cli = ":mypy_cli", types = types, )