Skip to content

Commit a4198d1

Browse files
MyPy example (#414)
* (almost) working MyPy example * Need to fully declare this target because of how it's (not) resolved * [NO TESTS] WIP * Don't need to check the render in * A more full example * Get it all wired up and working * Finish off the story * Fix incorrect example depgraph * [NO TESTS] WIP * Add aspect configurations * Upgrade Python to fix importpath issues with rules_mypy * [NO TESTS] WIP * Update requirements * Another missing no-mypy tag; invert logic? * Switch to opt-in typechecking * [NO TESTS] WIP * Alex: Can reference output_groups via filegroup * Alex: Note the `opt_out_tags` attribute * Alex: Note that this is an opt-in tag * Tweak .bazelrc explainer * Drop FIXME * Alex: Link to config docs * Lint from Marvin Co-authored-by: aspect-workflows[bot] <143031405+aspect-workflows[bot]@users.noreply.github.com> * A few more words here --------- Co-authored-by: aspect-workflows[bot] <143031405+aspect-workflows[bot]@users.noreply.github.com>
1 parent 43aff64 commit a4198d1

File tree

23 files changed

+494
-8
lines changed

23 files changed

+494
-8
lines changed

.bazelrc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ common --incompatible_enable_proto_toolchain_resolution
3636
# see https://github.com/bazelbuild/rules_jvm_external/issues/445
3737
build --repo_env=JAVA_HOME=../bazel_tools/jdk
3838

39+
# In support of py_mypy/...
40+
41+
# register mypy_aspect with Bazel
42+
build --aspects //tools/mypy:defs.bzl%mypy_aspect
43+
44+
# optionally, default enable the mypy checks
45+
build --output_groups=+mypy
46+
3947
# Load any settings & overrides specific to the current user from `.aspect/bazelrc/user.bazelrc`.
4048
# This file should appear in `.gitignore` so that settings are not shared with team members. This
4149
# should be last statement in this config so the user configuration is able to overwrite flags from

MODULE.bazel

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ bazel_dep(name = "rules_oci", version = "2.0.1")
2929
bazel_dep(name = "rules_pkg", version = "1.0.1")
3030
bazel_dep(name = "rules_proto", version = "7.1.0")
3131
bazel_dep(name = "rules_python", version = "0.40.0")
32+
bazel_dep(name = "rules_mypy", version = "0.29.0")
3233
bazel_dep(name = "rules_python_gazelle_plugin", version = "0.35.0")
3334
bazel_dep(name = "rules_swift", version = "1.12.0")
3435
bazel_dep(name = "rules_swift_package_manager", version = "0.12.0")
@@ -123,16 +124,28 @@ pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
123124

124125
python = use_extension("@rules_python//python/extensions:python.bzl", "python")
125126
python.toolchain(
126-
python_version = "3.9",
127+
python_version = "3.11",
127128
)
128129

129130
pip.parse(
130131
hub_name = "pip",
131-
python_version = "3.9",
132+
python_version = "3.11",
132133
requirements_lock = "//requirements:all.txt",
133134
)
134135
use_repo(pip, "pip")
135136

137+
# rules_mypy co-initializes with rules_python in order to discover types and
138+
# stubs packages in your requirements lock and create a mapping from normal
139+
# requirements to stub requirements which can be used to add appropriate stub
140+
# dependencies automatically during typechecking.
141+
types = use_extension("@rules_mypy//mypy:types.bzl", "types")
142+
types.requirements(
143+
name = "pip_types",
144+
pip_requirements = "@pip//:requirements.bzl",
145+
requirements_txt = "//requirements:all.txt",
146+
)
147+
use_repo(types, "pip_types")
148+
136149
#########################
137150
# Java and other JVM languages:
138151
# https://github.com/bazelbuild/rules_jvm_external/blob/master/examples/bzlmod/MODULE.bazel

py_mypy/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
exports_files(["requirements.txt"])

py_mypy/README.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Using MyPy with rules_python
2+
3+
This is a walkthrough of using [rules_mypy](https://github.com/theoremlp/rules_mypy) together with `rules_python` to apply typechecks as part of "building" a Python application.
4+
5+
## How MyPy will work
6+
7+
Bazel's [aspects](https://bazel.build/extending/aspects) allow extensions to traverse the build graph and apply rewrites to it between the analysis pass and before `build` happens.
8+
A common application of aspects is to "bolt on" behavior to existing rules without having to modify them.
9+
Which is exactly what we're going to do here.
10+
11+
One way that an aspect can extend an existing rule is by adding an [`OutputGroupInfo`](https://bazel.build/versions/7.4.0/rules/lib/providers/OutputGroupInfo) provider to the rule.
12+
Output groups are a slightly unusual feature which allows for rule names to be overloaded, and for a rule to provide multiple kinds of outputs.
13+
To take a slightly familiar example, the `py_binary` rule normally outputs a launcher script and a `.runfiles` tree, but it also provides a zipapp output which can be selectively enabled.
14+
Outputs may be selected during a build using the [`--output_groups`](https://bazel.build/reference/command-line-reference#flag--output_groups) flag, or by specifying the `output_group` attribute on a `filegroup` rule consuming targets.
15+
16+
Notionally how this will all work is that:
17+
18+
- We need to create an aspect configured to use whatever `mypy` tool we may want.
19+
- That aspect will extend the `py_*` rules in the build graph to add an output group capturing the MyPy typecheck cache.
20+
These typecheck cache outputs will depend on the typecheck cache outputs of all dependencies.
21+
22+
This all has the effect of creating a build sub-graph parallel to our normal build graph which instead of producing and consuming Python files as dependencies produces and consumes the MyPy analysis caches.
23+
24+
To take a simple example, let's say that we have a small build graph
25+
26+
```mermaid
27+
graph TD
28+
A[data_models] --> B[data_persistence];
29+
A --> C[inventory_management];
30+
A --> D[order_processing];
31+
B --> C;
32+
B --> D;
33+
C --> E[cli];
34+
D --> E;
35+
F["click (3rdparty)"] --> E;
36+
G["pydantic (3rdparty)"] --> A;
37+
```
38+
39+
Ordinarily the dependencies between these rules take the form of the Python source files underlying the rules.
40+
But when we activate our `mypy` aspect and select the `mypy` output group, the cache files from typechecking each* of these targets also become part of that dependency chain.
41+
This allows Bazel to drive typechecking these libraries in depgraph order while caching intermediate results.
42+
43+
We can demonstrate this by looking at the results of `bazel aquery`, which will show MyPy invocations and that the resulting cache trees are dependencies between each of the invocations.
44+
But more on that in a minute.
45+
46+
## Setup
47+
48+
In this example we've set up `rules_python` in combination with `rules_uv`, which provides lockfile compilation.
49+
These two give us a Python dependency solution (including the MyPy we want to use), which we'll feed into `rules_mypy`.
50+
51+
The main trick is in `//tools/mypy:BUILD.bazel`, where we provide a definition of the MyPy CLI binary which we can feed into the checking aspect.
52+
This is important because it allows us to use our locked requirement for MyPy, and to provide MyPy plugins.
53+
If we didn't do this, `rules_mypy` would "helpfully" provide an embedded default version and configuration of MyPy which may or may not be what we want.
54+
55+
We've configured our `.bazelrc` to apply the aspect so that users don't have to think about separately enabling it.
56+
Since there's other Python code in this monorepo which doesn't typecheck and we don't want to have to address that to adopt typing, we're going to use the `opt_in_tags` parameter on the aspect configuration.
57+
This allows us to specify `tags=["mypy"]` on relevant Python targets to selectively apply typechecking rather than just getting mypy checks applied to everything.
58+
We could also use the `opt_out_tags` parameter on the aspect and annotate stuff we don't want to typecheck, but that has more impact for initial adoption.
59+
60+
Otherwise users explicitly have to list `--aspects=...` when they're interested in leveraging typechecks.
61+
62+
For the same reason we've also configured our `.bazelrc` to enable the `mypy` output group by default.
63+
This may or may not be desired behavior, since enabling the `mypy` output makes passing typechecks a blocker for build and test operations.
64+
65+
## Demo
66+
67+
If we use `bazel aquery //py_mypy/cli`, we will see among much other output
68+
69+
```
70+
action 'mypy //py_mypy/cli:cli'
71+
Mnemonic: mypy
72+
Target: //py_mypy/cli:cli
73+
Configuration: darwin_arm64-fastbuild
74+
Execution platform: @@platforms//host:host
75+
AspectDescriptors: [
76+
//tools/mypy:defs.bzl%mypy_aspect(cache='true', color='true')
77+
]
78+
ActionKey: ...
79+
Inputs: [
80+
bazel-out/.../bin/py_mypy/inventory_management/inventory_management.mypy_cache,
81+
bazel-out/.../bin/py_mypy/order_processing/order_processing.mypy_cache,
82+
bazel-out/.../bin/tools/mypy/mypy,
83+
...
84+
]
85+
```
86+
87+
This is the actual typecheck action of the `//py_mypy/cli:cli` target, showing that as inputs it takes (among many other things) the `.mypy_cache` tree results from typechecking the two sub-libraries `inventory_management` and `order_processing`.
88+
89+
If we dig around in the action plan a bit more, we'll also find the typecheck definitions for those products.
90+
For instance if we inspect the `inventory_management` build, we'll find the production action for those cache files.
91+
92+
```
93+
action 'mypy //py_mypy/inventory_management:inventory_management'
94+
Mnemonic: mypy
95+
Target: //py_mypy/inventory_management:inventory_management
96+
Configuration: darwin_arm64-fastbuild
97+
Execution platform: @@platforms//host:host
98+
AspectDescriptors: [
99+
//tools/mypy:defs.bzl%mypy_aspect(cache='true', color='true')
100+
]
101+
ActionKey: ...
102+
Inputs: [
103+
bazel-out/.../bin/py_mypy/data_models/data_models.mypy_cache,
104+
bazel-out/.../bin/tools/mypy/mypy,
105+
...
106+
]
107+
```
108+
109+
This demonstrates that the `rules_mypy` configuration will perform incremental typechecking (only targets which changed will be re-checked except in the case of a cascading failure), to the limit of 1stparty code.
110+
111+
Per [rules_mypy#23](https://github.com/theoremlp/rules_mypy/issues/23), the aspect which creates typecheck rules short-circuits and stops to create annotations when it encounters 3rdparty code.
112+
This bypasses the problem of attempting to apply 1stparty typecheck rules to code which may not conform to them, but creates the problem that because there is no shared `pyspark.mypy_cache` output, 3rdparty libraries may be typechecked (or at least analyzed as part of typechecking) more than once.

py_mypy/cli/BUILD.bazel

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
py_binary(
2+
name = "cli",
3+
main = "__main__.py",
4+
srcs = ["__main__.py"],
5+
deps = [
6+
"//py_mypy/order_processing",
7+
"//py_mypy/inventory_management",
8+
"@pip//click",
9+
],
10+
tags = [
11+
"mypy", # We've explicitly configured the aspect for opt-in
12+
],
13+
)

py_mypy/cli/__main__.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import click
2+
from order_processing import create_order, update_order_shipping
3+
from inventory_management import adjust_stock
4+
5+
@click.group()
6+
def cli():
7+
pass
8+
9+
@cli.command("create-order")
10+
@click.option("--customer-id", type=int, required=True)
11+
@click.option("--items", type=str, required=True) # example: "1:2,2:1"
12+
@click.option("--shipping-address", type=str)
13+
def create_order_cmd(customer_id, items, shipping_address):
14+
items_list = [(int(item.split(":")[0]), int(item.split(":")[1])) for item in items.split(",")]
15+
order = create_order(customer_id, items_list, shipping_address)
16+
click.echo(f"Order created: {order.order_id}")
17+
18+
@cli.command("update-shipping")
19+
@click.option("--order-id", type=int, required=True)
20+
@click.option("--new-address", type=str, required=True)
21+
def update_shipping_cmd(order_id, new_address):
22+
update_order_shipping(order_id, new_address)
23+
click.echo(f"Shipping address updated for order {order_id}")
24+
25+
@cli.command("adjust-stock")
26+
@click.option("--product-id", type=int, required=True)
27+
@click.option("--quantity-change", type=int, required=True)
28+
def adjust_stock_cmd(product_id, quantity_change):
29+
adjust_stock(product_id, quantity_change)
30+
click.echo(f"Stock adjusted for product {product_id}")
31+
32+
if __name__ == "__main__":
33+
cli()

py_mypy/data_models/BUILD.bazel

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package(default_visibility=["//py_mypy:__subpackages__",])
2+
3+
py_library(
4+
name = "data_models",
5+
srcs = [
6+
"data_models.py",
7+
],
8+
deps = [
9+
"@pip//pydantic",
10+
],
11+
imports = [
12+
".",
13+
],
14+
tags = [
15+
"mypy", # We've explicitly configured the aspect for opt-in
16+
],
17+
)

py_mypy/data_models/data_models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from pydantic import BaseModel
2+
from typing import Optional
3+
4+
class Customer(BaseModel):
5+
customer_id: int
6+
name: str
7+
email: str
8+
9+
class Product(BaseModel):
10+
product_id: int
11+
name: str
12+
price: float
13+
14+
class OrderItem(BaseModel):
15+
product: Product
16+
quantity: int
17+
18+
class Order(BaseModel):
19+
order_id: int
20+
customer: Customer
21+
items: list[OrderItem]
22+
shipping_address: Optional[str] = None
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package(default_visibility=[
2+
"//py_mypy/order_processing:__pkg__",
3+
"//py_mypy/inventory_management:__pkg__",
4+
])
5+
6+
py_library(
7+
name = "data_persistence",
8+
srcs = [
9+
"data_persistence.py",
10+
],
11+
imports = [
12+
".",
13+
],
14+
deps = [
15+
"//py_mypy/data_models",
16+
],
17+
tags = [
18+
"mypy", # We've explicitly configured the aspect for opt-in
19+
],
20+
21+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from data_models import Order, Customer
2+
3+
def save_order(order: Order):
4+
with open(f"order_{order.order_id}.json", "w") as f:
5+
f.write(order.json()) # Pydantic's .json() method
6+
7+
def get_customer(customer_id: int) -> Customer | None:
8+
#Simulating customer retrieval. In real code this would be from a database.
9+
if customer_id == 1:
10+
return Customer(customer_id=1, name="John Doe", email="[email protected]")
11+
return None
12+
13+
def update_stock(product_id: int, new_stock: int):
14+
#Simulating database update.
15+
print(f"Updating database: Product {product_id} stock to {new_stock}")

0 commit comments

Comments
 (0)