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,
 )