Repository: aws/chalice (language: python)

Issue:
Chalice CDK template depends on version of attrs incompatible with aws-cdk-lib
When following the [Chalice CDK guide](https://aws.github.io/chalice/tutorials/cdk.html), a requirement is added for `aws-cdk-lib>2.0,<3.0`. This indirectly depends on `attrs` v22.2.0. 

Chalice [depends](https://github.com/aws/chalice/blob/master/setup.py#L29) on `attrs<21.5.0,>=19.3.0`. Since v22.2.0 isn't within this (oddly narrow) range, no version can exist that satisfies both requirements.

This came to light when I was converting the generated project to use `pip-tools`. To do this:
1. Renamed the `requirements.txt` files to `requirements.in`
2. Added `chalice` to the `runtime/requirements.in` file (since I want to be able to run `chalice local` after `pip-sync`)
3. Ran `pip-compile` on the `infrastructure` directory, then in `runtime`, then in the project root.

When I completed this final step, I was presented with the following exception:

```
Using legacy resolver. Consider using backtracking resolver with `--resolver=backtracking`.
Could not find a version that matches attrs==21.4.0,==22.2.0 (from -r runtime/requirements.txt (line 11))
Tried: 15.0.0, 15.1.0, 15.1.0, 15.2.0, 15.2.0, 16.0.0, 16.0.0, 16.1.0, 16.1.0, 16.2.0, 16.2.0, 16.3.0, 16.3.0, 17.1.0, 17.1.0, 17.2.0, 17.2.0, 17.3.0, 17.3.0, 17.4.0, 17.4.0, 18.1.0, 18.1.0, 18.2.0, 18.2.0, 19.1.0, 19.1.0, 19.2.0, 19.2.0, 19.3.0, 19.3.0, 20.1.0, 20.1.0, 20.2.0, 20.2.0, 20.3.0, 20.3.0, 21.1.0, 21.1.0, 21.2.0, 21.2.0, 21.3.0, 21.3.0, 21.4.0, 21.4.0, 22.1.0, 22.1.0, 22.2.0, 22.2.0
Skipped pre-versions: 15.0.0a1
There are incompatible versions in the resolved dependencies:
  attrs==22.2.0 (from -r infrastructure/requirements.txt (line 7))
  attrs==21.4.0 (from -r runtime/requirements.txt (line 11))
```

Using the backtracking resolver (as suggested) does not help. All it does is produce a slightly different exception that boils down to the same thing.

Since these requirements all come from the Chalice project, it feels like they ought to agree.


Discussion / hints:

Fix patch:
diff --git a/chalice/cli/newproj.py b/chalice/cli/newproj.py
index 35d885d6d..69e6baffc 100644
--- a/chalice/cli/newproj.py
+++ b/chalice/cli/newproj.py
@@ -32,10 +32,10 @@
 import re
 import json
 import fnmatch
+from dataclasses import dataclass
 from typing import Optional, Dict, Any, Iterator, Tuple, Match, List  # noqa
 
 import inquirer
-from attr import attrs, attrib
 
 from chalice.constants import WELCOME_PROMPT
 from chalice.utils import OSUtils
@@ -43,9 +43,7 @@
 
 
 TEMPLATES_DIR = os.path.join(
-    os.path.dirname(
-        os.path.dirname(os.path.abspath(__file__))),
-    'templates'
+    os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'templates'
 )
 VAR_REF_REGEX = r'{{(.*?)}}'
 IGNORE_FILES = ['metadata.json', '*.pyc']
@@ -55,8 +53,9 @@ class BadTemplateError(Exception):
     pass
 
 
-def create_new_project_skeleton(project_name, project_type='legacy'):
-    # type: (str, Optional[str]) -> None
+def create_new_project_skeleton(
+    project_name: str, project_type: Optional[str] = 'legacy'
+) -> None:
     osutils = OSUtils()
     all_projects = list_available_projects(TEMPLATES_DIR, osutils)
     project = [p for p in all_projects if p.key == project_type][0]
@@ -72,43 +71,50 @@ def create_new_project_skeleton(project_name, project_type='legacy'):
     )
 
 
-@attrs
-class ProjectTemplate(object):
-    dirname = attrib()    # type: str
-    metadata = attrib()   # type: Dict[str, Any]
-    key = attrib()        # type: str
+@dataclass
+class ProjectTemplate:
+    dirname: str
+    metadata: Dict[str, Any]
+    key: str
 
     @property
-    def description(self):
-        # type: () -> str
+    def description(self) -> str:
         # Pylint doesn't understand the attrs types.
         # pylint: disable=no-member
         return self.metadata.get('description', self.key)
 
 
 class ProjectCreator(object):
-    def __init__(self, osutils=None):
-        # type: (Optional[OSUtils]) -> None
+    def __init__(self, osutils: Optional[OSUtils] = None) -> None:
         if osutils is None:
             osutils = OSUtils()
         self._osutils = osutils
 
-    def create_new_project(self, source_dir, destination_dir, template_kwargs):
-        # type: (str, str, Dict[str, Any]) -> None
-        for full_src_path, full_dst_path in self._iter_files(source_dir,
-                                                             destination_dir):
+    def create_new_project(
+        self,
+        source_dir: str,
+        destination_dir: str,
+        template_kwargs: Dict[str, Any],
+    ) -> None:
+        for full_src_path, full_dst_path in self._iter_files(
+            source_dir, destination_dir
+        ):
             dest_dir = self._osutils.dirname(full_dst_path)
             if not self._osutils.directory_exists(dest_dir):
                 self._osutils.makedirs(dest_dir)
-            contents = self._osutils.get_file_contents(full_src_path,
-                                                       binary=False)
+            contents = self._osutils.get_file_contents(
+                full_src_path, binary=False
+            )
             templated_contents = get_templated_content(
-                contents, template_kwargs)
+                contents, template_kwargs
+            )
             self._osutils.set_file_contents(
-                full_dst_path, templated_contents, binary=False)
+                full_dst_path, templated_contents, binary=False
+            )
 
-    def _iter_files(self, source_dir, destination_dir):
-        # type: (str, str) -> Iterator[Tuple[str, str]]
+    def _iter_files(
+        self, source_dir: str, destination_dir: str
+    ) -> Iterator[Tuple[str, str]]:
         for rootdir, _, filenames in self._osutils.walk(source_dir):
             for filename in filenames:
                 if self._should_ignore(filename):
@@ -117,37 +123,38 @@ def _iter_files(self, source_dir, destination_dir):
                 # The starting index needs `+ 1` to account for the
                 # trailing `/` char (e.g. foo/bar -> foo/bar/).
                 full_dst_path = os.path.join(
-                    destination_dir, full_src_path[len(source_dir) + 1:])
+                    destination_dir, full_src_path[len(source_dir) + 1:]
+                )
                 yield full_src_path, full_dst_path
 
-    def _should_ignore(self, filename):
-        # type: (str) -> bool
+    def _should_ignore(self, filename: str) -> bool:
         for ignore in IGNORE_FILES:
             if fnmatch.fnmatch(filename, ignore):
                 return True
         return False
 
 
-def get_templated_content(contents, template_kwargs):
-    # type: (str, Dict[str, Any]) -> str
-    def lookup_var(match):
-        # type: (Match) -> str
+def get_templated_content(
+    contents: str, template_kwargs: Dict[str, Any]
+) -> str:
+    def lookup_var(match: Match) -> str:
         var_name = match.group(1)
         try:
             return template_kwargs[var_name]
         except KeyError:
             raise BadTemplateError(
                 "Bad template, referenced template var that does not "
-                "exist: '%s', for template contents:\n%s" % (var_name,
-                                                             contents)
+                "exist: '%s', for template contents:\n%s"
+                % (var_name, contents)
             )
 
     new_contents = re.sub(VAR_REF_REGEX, lookup_var, contents)
     return new_contents
 
 
-def list_available_projects(templates_dir, osutils):
-    # type: (str, OSUtils) -> List[ProjectTemplate]
+def list_available_projects(
+    templates_dir: str, osutils: OSUtils
+) -> List[ProjectTemplate]:
     projects = []
     for dirname in sorted(osutils.get_directory_contents(templates_dir)):
         filename = osutils.joinpath(templates_dir, dirname, 'metadata.json')
@@ -157,14 +164,16 @@ def list_available_projects(templates_dir, osutils):
     return projects
 
 
-def getting_started_prompt():
-    # type: () -> Dict[str, Any]
+def getting_started_prompt() -> Dict[str, Any]:
     print(WELCOME_PROMPT)
     projects = list_available_projects(TEMPLATES_DIR, OSUtils())
     questions = [
         inquirer.Text('project_name', message='Enter the project name'),
-        inquirer.List('project_type', message='Select your project type',
-                      choices=[(p.description, p.key) for p in projects])
+        inquirer.List(
+            'project_type',
+            message='Select your project type',
+            choices=[(p.description, p.key) for p in projects],
+        ),
     ]
     answers = inquirer.prompt(questions)
     return answers
diff --git a/chalice/deploy/appgraph.py b/chalice/deploy/appgraph.py
index c98a9f655..f7e9bcb73 100644
--- a/chalice/deploy/appgraph.py
+++ b/chalice/deploy/appgraph.py
@@ -1,9 +1,9 @@
 import json
 import os
+from dataclasses import asdict
 
 from typing import cast
 from typing import Dict, List, Tuple, Any, Set, Optional, Text, Union  # noqa
-from attr import asdict
 
 from chalice.config import Config  # noqa
 from chalice import app
@@ -19,23 +19,25 @@ class ChaliceBuildError(Exception):
 
 
 class ApplicationGraphBuilder(object):
-    def __init__(self):
-        # type: () -> None
-        self._known_roles = {}  # type: Dict[str, models.IAMRole]
-        self._managed_layer = None  # type: Optional[models.LambdaLayer]
-
-    def build(self, config, stage_name):
-        # type: (Config, str) -> models.Application
-        resources = []  # type: List[models.Model]
+    def __init__(self) -> None:
+        self._known_roles: Dict[str, models.IAMRole] = {}
+        self._managed_layer: Optional[models.LambdaLayer] = None
+
+    def build(self, config: Config, stage_name: str) -> models.Application:
+        resources: List[models.Model] = []
         deployment = models.DeploymentPackage(models.Placeholder.BUILD_STAGE)
         for function in config.chalice_app.pure_lambda_functions:
             resource = self._create_lambda_model(
-                config=config, deployment=deployment,
-                name=function.name, handler_name=function.handler_string,
-                stage_name=stage_name)
+                config=config,
+                deployment=deployment,
+                name=function.name,
+                handler_name=function.handler_string,
+                stage_name=stage_name,
+            )
             resources.append(resource)
         event_resources = self._create_lambda_event_resources(
-            config, deployment, stage_name)
+            config, deployment, stage_name
+        )
         resources.extend(event_resources)
         if config.chalice_app.routes:
             rest_api = self._create_rest_api_model(
@@ -44,55 +46,57 @@ def build(self, config, stage_name):
             resources.append(rest_api)
         if config.chalice_app.websocket_handlers:
             websocket_api = self._create_websocket_api_model(
-                config, deployment, stage_name)
+                config, deployment, stage_name
+            )
             resources.append(websocket_api)
         return models.Application(stage_name, resources)
 
-    def _create_log_group(self,
-                          config,            # type: Config
-                          resource_name,     # type: str
-                          log_group_name     # type: str
-                          ):
-        # type: (...) -> models.LogGroup
-        return models.LogGroup(resource_name=resource_name,
-                               log_group_name=log_group_name,
-                               retention_in_days=config.log_retention_in_days)
+    def _create_log_group(
+        self, config: Config, resource_name: str, log_group_name: str
+    ) -> models.LogGroup:
+        return models.LogGroup(
+            resource_name=resource_name,
+            log_group_name=log_group_name,
+            retention_in_days=config.log_retention_in_days,
+        )
 
     def _create_custom_domain_name(
-            self,
-            api_type,                # type: models.APIType
-            domain_name_data,        # type: StrMapAny
-            endpoint_configuration,  # type: str
-            api_gateway_stage,       # type: str
-    ):
-        # type: (...) -> models.DomainName
+        self,
+        api_type: models.APIType,
+        domain_name_data: StrMapAny,
+        endpoint_configuration: str,
+        api_gateway_stage: str,
+    ) -> models.DomainName:
         url_prefix = domain_name_data.get("url_prefix", '(none)')
         api_mapping_model = self._create_api_mapping_model(
-            url_prefix, api_gateway_stage)
+            url_prefix, api_gateway_stage
+        )
         domain_name = self._create_domain_name_model(
             api_type,
             domain_name_data,
             endpoint_configuration,
-            api_mapping_model
+            api_mapping_model,
         )
         return domain_name
 
-    def _create_api_mapping_model(self,
-                                  key,         # type: str
-                                  stage        # type: str
-                                  ):
-        # type: (...) -> models.APIMapping
+    def _create_api_mapping_model(
+        self, key: str, stage: str
+    ) -> models.APIMapping:
         if key == '/':
             key = '(none)'
         return models.APIMapping(
             resource_name='api_mapping',
             mount_path=key,
-            api_gateway_stage=stage
+            api_gateway_stage=stage,
         )
 
-    def _create_lambda_event_resources(self, config, deployment, stage_name):
-        # type: (Config, models.DeploymentPackage, str) -> List[models.Model]
-        resources = []  # type: List[models.Model]
+    def _create_lambda_event_resources(
+        self,
+        config: Config,
+        deployment: models.DeploymentPackage,
+        stage_name: str,
+    ) -> List[models.Model]:
+        resources: List[models.Model] = []
         for event_source in config.chalice_app.event_sources:
             if isinstance(event_source, app.S3EventConfig):
                 resources.append(
@@ -103,7 +107,10 @@ def _create_lambda_event_resources(self, config, deployment, stage_name):
             elif isinstance(event_source, app.SNSEventConfig):
                 resources.append(
                     self._create_sns_subscription(
-                        config, deployment, event_source, stage_name,
+                        config,
+                        deployment,
+                        event_source,
+                        stage_name,
                     )
                 )
             elif isinstance(event_source, app.CloudWatchEventConfig):
@@ -121,33 +128,45 @@ def _create_lambda_event_resources(self, config, deployment, stage_name):
             elif isinstance(event_source, app.SQSEventConfig):
                 resources.append(
                     self._create_sqs_subscription(
-                        config, deployment, event_source, stage_name,
+                        config,
+                        deployment,
+                        event_source,
+                        stage_name,
                     )
                 )
             elif isinstance(event_source, app.KinesisEventConfig):
                 resources.append(
                     self._create_kinesis_subscription(
-                        config, deployment, event_source, stage_name,
+                        config,
+                        deployment,
+                        event_source,
+                        stage_name,
                     )
                 )
             elif isinstance(event_source, app.DynamoDBEventConfig):
                 resources.append(
                     self._create_ddb_subscription(
-                        config, deployment, event_source, stage_name,
+                        config,
+                        deployment,
+                        event_source,
+                        stage_name,
                     )
                 )
         return resources
 
-    def _create_rest_api_model(self,
-                               config,        # type: Config
-                               deployment,    # type: models.DeploymentPackage
-                               stage_name,    # type: str
-                               ):
-        # type: (...) -> models.RestAPI
+    def _create_rest_api_model(
+        self,
+        config: Config,
+        deployment: models.DeploymentPackage,
+        stage_name: str,
+    ) -> models.RestAPI:
         # Need to mess with the function name for back-compat.
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name='api_handler',
-            handler_name='app.app', stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name='api_handler',
+            handler_name='app.app',
+            stage_name=stage_name,
         )
         # For backwards compatibility with the old deployer, the
         # lambda function for the API handler doesn't have the
@@ -162,21 +181,27 @@ def _create_rest_api_model(self,
         authorizers = []
         for auth in config.chalice_app.builtin_auth_handlers:
             auth_lambda = self._create_lambda_model(
-                config=config, deployment=deployment, name=auth.name,
-                handler_name=auth.handler_string, stage_name=stage_name,
+                config=config,
+                deployment=deployment,
+                name=auth.name,
+                handler_name=auth.handler_string,
+                stage_name=stage_name,
             )
             authorizers.append(auth_lambda)
 
         policy = None
         policy_path = config.api_gateway_policy_file
-        if (config.api_gateway_endpoint_type == 'PRIVATE' and not policy_path):
+        if config.api_gateway_endpoint_type == 'PRIVATE' and not policy_path:
             policy = models.IAMPolicy(
-                document=self._get_default_private_api_policy(config))
+                document=self._get_default_private_api_policy(config)
+            )
         elif policy_path:
             policy = models.FileBasedIAMPolicy(
                 document=models.Placeholder.BUILD_STAGE,
                 filename=os.path.join(
-                    config.project_dir, '.chalice', policy_path))
+                    config.project_dir, '.chalice', policy_path
+                ),
+            )
 
         vpce_ids = None
         if config.api_gateway_endpoint_vpce:
@@ -206,51 +231,63 @@ def _create_rest_api_model(self,
             vpce_ids=vpce_ids,
         )
 
-    def _get_default_private_api_policy(self, config):
-        # type: (Config) -> StrMapAny
-        statements = [{
-            "Effect": "Allow",
-            "Principal": "*",
-            "Action": "execute-api:Invoke",
-            "Resource": "arn:*:execute-api:*:*:*",
-            "Condition": {
-                "StringEquals": {
-                    "aws:SourceVpce": config.api_gateway_endpoint_vpce
-                }
+    def _get_default_private_api_policy(self, config: Config) -> StrMapAny:
+        statements = [
+            {
+                "Effect": "Allow",
+                "Principal": "*",
+                "Action": "execute-api:Invoke",
+                "Resource": "arn:*:execute-api:*:*:*",
+                "Condition": {
+                    "StringEquals": {
+                        "aws:SourceVpce": config.api_gateway_endpoint_vpce
+                    }
+                },
             }
-        }]
+        ]
         return {"Version": "2012-10-17", "Statement": statements}
 
     def _create_websocket_api_model(
-            self,
-            config,      # type: Config
-            deployment,  # type: models.DeploymentPackage
-            stage_name,  # type: str
-    ):
-        # type: (...) -> models.WebsocketAPI
-        connect_handler = None     # type: Optional[models.LambdaFunction]
-        message_handler = None     # type: Optional[models.LambdaFunction]
-        disconnect_handler = None  # type: Optional[models.LambdaFunction]
-
-        routes = {h.route_key_handled: h.handler_string for h
-                  in config.chalice_app.websocket_handlers.values()}
+        self,
+        config: Config,
+        deployment: models.DeploymentPackage,
+        stage_name: str,
+    ) -> models.WebsocketAPI:
+        connect_handler: Optional[models.LambdaFunction] = None
+        message_handler: Optional[models.LambdaFunction] = None
+        disconnect_handler: Optional[models.LambdaFunction] = None
+
+        routes = {
+            h.route_key_handled: h.handler_string
+            for h in config.chalice_app.websocket_handlers.values()
+        }
         if '$connect' in routes:
             connect_handler = self._create_lambda_model(
-                config=config, deployment=deployment, name='websocket_connect',
-                handler_name=routes['$connect'], stage_name=stage_name)
+                config=config,
+                deployment=deployment,
+                name='websocket_connect',
+                handler_name=routes['$connect'],
+                stage_name=stage_name,
+            )
             routes.pop('$connect')
         if '$disconnect' in routes:
             disconnect_handler = self._create_lambda_model(
-                config=config, deployment=deployment,
+                config=config,
+                deployment=deployment,
                 name='websocket_disconnect',
-                handler_name=routes['$disconnect'], stage_name=stage_name)
+                handler_name=routes['$disconnect'],
+                stage_name=stage_name,
+            )
             routes.pop('$disconnect')
         if routes:
             # If there are left over routes they are message handlers.
             handler_string = list(routes.values())[0]
             message_handler = self._create_lambda_model(
-                config=config, deployment=deployment, name='websocket_message',
-                handler_name=handler_string, stage_name=stage_name
+                config=config,
+                deployment=deployment,
+                name='websocket_message',
+                handler_name=handler_string,
+                stage_name=stage_name,
             )
 
         custom_domain_name = None
@@ -268,28 +305,35 @@ def _create_websocket_api_model(
             connect_function=connect_handler,
             message_function=message_handler,
             disconnect_function=disconnect_handler,
-            routes=[h.route_key_handled for h
-                    in config.chalice_app.websocket_handlers.values()],
+            routes=[
+                h.route_key_handled
+                for h in config.chalice_app.websocket_handlers.values()
+            ],
             api_gateway_stage=config.api_gateway_stage,
-            domain_name=custom_domain_name
+            domain_name=custom_domain_name,
         )
 
     def _create_cwe_subscription(
-            self,
-            config,        # type: Config
-            deployment,    # type: models.DeploymentPackage
-            event_source,  # type: app.CloudWatchEventConfig
-            stage_name,    # type: str
-    ):
-        # type: (...) -> models.CloudWatchEvent
+        self,
+        config: Config,
+        deployment: models.DeploymentPackage,
+        event_source: app.CloudWatchEventConfig,
+        stage_name: str,
+    ) -> models.CloudWatchEvent:
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name=event_source.name,
-            handler_name=event_source.handler_string, stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name=event_source.name,
+            handler_name=event_source.handler_string,
+            stage_name=stage_name,
         )
 
         resource_name = event_source.name + '-event'
-        rule_name = '%s-%s-%s' % (config.app_name, config.chalice_stage,
-                                  resource_name)
+        rule_name = '%s-%s-%s' % (
+            config.app_name,
+            config.chalice_stage,
+            resource_name,
+        )
         cwe = models.CloudWatchEvent(
             resource_name=resource_name,
             rule_name=rule_name,
@@ -298,16 +342,19 @@ def _create_cwe_subscription(
         )
         return cwe
 
-    def _create_scheduled_model(self,
-                                config,        # type: Config
-                                deployment,    # type: models.DeploymentPackage
-                                event_source,  # type: app.ScheduledEventConfig
-                                stage_name,    # type: str
-                                ):
-        # type: (...) -> models.ScheduledEvent
+    def _create_scheduled_model(
+        self,
+        config: Config,
+        deployment: models.DeploymentPackage,
+        event_source: app.ScheduledEventConfig,
+        stage_name: str,
+    ) -> models.ScheduledEvent:
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name=event_source.name,
-            handler_name=event_source.handler_string, stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name=event_source.name,
+            handler_name=event_source.handler_string,
+            stage_name=stage_name,
         )
         # Resource names must be unique across a chalice app.
         # However, in the original deployer code, the cloudwatch
@@ -319,13 +366,17 @@ def _create_scheduled_model(self,
         # but we won't be able to do that until the old deployer
         # is gone.
         resource_name = event_source.name + '-event'
-        if isinstance(event_source.schedule_expression,
-                      app.ScheduleExpression):
+        if isinstance(
+            event_source.schedule_expression, app.ScheduleExpression
+        ):
             expression = event_source.schedule_expression.to_string()
         else:
             expression = event_source.schedule_expression
-        rule_name = '%s-%s-%s' % (config.app_name, config.chalice_stage,
-                                  resource_name)
+        rule_name = '%s-%s-%s' % (
+            config.app_name,
+            config.chalice_stage,
+            resource_name,
+        )
         scheduled_event = models.ScheduledEvent(
             resource_name=resource_name,
             rule_name=rule_name,
@@ -335,18 +386,18 @@ def _create_scheduled_model(self,
         )
         return scheduled_event
 
-    def _create_domain_name_model(self,
-                                  protocol,        # type: models.APIType
-                                  data,            # type: StrMapAny
-                                  endpoint_type,   # type: str
-                                  api_mapping      # type: models.APIMapping
-                                  ):
-        # type: (...) -> models.DomainName
+    def _create_domain_name_model(
+        self,
+        protocol: models.APIType,
+        data: StrMapAny,
+        endpoint_type: str,
+        api_mapping: models.APIMapping,
+    ) -> models.DomainName:
         default_name = 'api_gateway_custom_domain'
-        resource_name_map = {
+        resource_name_map: Dict[str, str] = {
             'HTTP': default_name,
-            'WEBSOCKET': 'websocket_api_custom_domain'
-        }  # type: Dict[str, str]
+            'WEBSOCKET': 'websocket_api_custom_domain',
+        }
 
         domain_name = models.DomainName(
             protocol=protocol,
@@ -355,53 +406,57 @@ def _create_domain_name_model(self,
             tls_version=models.TLSVersion.create(data.get('tls_version', '')),
             certificate_arn=data['certificate_arn'],
             tags=data.get('tags'),
-            api_mapping=api_mapping
+            api_mapping=api_mapping,
         )
         return domain_name
 
-    def _create_lambda_model(self,
-                             config,        # type: Config
-                             deployment,    # type: models.DeploymentPackage
-                             name,          # type: str
-                             handler_name,  # type: str
-                             stage_name,    # type: str
-                             ):
-        # type: (...) -> models.LambdaFunction
+    def _create_lambda_model(
+        self,
+        config: Config,
+        deployment: models.DeploymentPackage,
+        name: str,
+        handler_name: str,
+        stage_name: str,
+    ) -> models.LambdaFunction:
         new_config = config.scope(
-            chalice_stage=config.chalice_stage,
-            function_name=name
+            chalice_stage=config.chalice_stage, function_name=name
         )
-        role = self._get_role_reference(
-            new_config, stage_name, name)
+        role = self._get_role_reference(new_config, stage_name, name)
         resource = self._build_lambda_function(
-            new_config, name, handler_name,
-            deployment, role
+            new_config, name, handler_name, deployment, role
         )
         if new_config.log_retention_in_days:
             log_resource_name = '%s-log-group' % name
             log_group_name = '/aws/lambda/%s-%s-%s' % (
-                new_config.app_name, stage_name, name)
+                new_config.app_name,
+                stage_name,
+                name,
+            )
             resource.log_group = self._create_log_group(
-                new_config, log_resource_name, log_group_name)
+                new_config, log_resource_name, log_group_name
+            )
         return resource
 
-    def _get_managed_lambda_layer(self, config):
-        # type: (Config) -> Optional[models.LambdaLayer]
+    def _get_managed_lambda_layer(
+        self, config: Config
+    ) -> Optional[models.LambdaLayer]:
         if not config.automatic_layer:
             return None
         if self._managed_layer is None:
             self._managed_layer = models.LambdaLayer(
                 resource_name='managed-layer',
-                layer_name='%s-%s-%s' % (
-                    config.app_name, config.chalice_stage, 'managed-layer'),
+                layer_name='%s-%s-%s'
+                % (config.app_name, config.chalice_stage, 'managed-layer'),
                 runtime=config.lambda_python_version,
                 deployment_package=models.DeploymentPackage(
-                    models.Placeholder.BUILD_STAGE)
+                    models.Placeholder.BUILD_STAGE
+                ),
             )
         return self._managed_layer
 
-    def _get_role_reference(self, config, stage_name, function_name):
-        # type: (Config, str, str) -> models.IAMRole
+    def _get_role_reference(
+        self, config: Config, stage_name: str, function_name: str
+    ) -> models.IAMRole:
         role = self._create_role_reference(config, stage_name, function_name)
         role_identifier = self._get_role_identifier(role)
         if role_identifier in self._known_roles:
@@ -412,8 +467,7 @@ def _get_role_reference(self, config, stage_name, function_name):
         self._known_roles[role_identifier] = role
         return role
 
-    def _get_role_identifier(self, role):
-        # type: (models.IAMRole) -> str
+    def _get_role_identifier(self, role: models.IAMRole) -> str:
         if isinstance(role, models.PreCreatedIAMRole):
             return role.role_arn
         # We know that if it's not a PreCreatedIAMRole, it's
@@ -421,8 +475,9 @@ def _get_role_identifier(self, role):
         role = cast(models.ManagedIAMRole, role)
         return role.resource_name
 
-    def _create_role_reference(self, config, stage_name, function_name):
-        # type: (Config, str, str) -> models.IAMRole
+    def _create_role_reference(
+        self, config: Config, stage_name: str, function_name: str
+    ) -> models.IAMRole:
         # First option, the user doesn't want us to manage
         # the role at all.
         if not config.manage_iam_role:
@@ -434,18 +489,24 @@ def _create_role_reference(self, config, stage_name, function_name):
         policy = models.IAMPolicy(document=models.Placeholder.BUILD_STAGE)
         if not config.autogen_policy:
             resource_name = '%s_role' % function_name
-            role_name = '%s-%s-%s' % (config.app_name, stage_name,
-                                      function_name)
+            role_name = '%s-%s-%s' % (
+                config.app_name,
+                stage_name,
+                function_name,
+            )
             if config.iam_policy_file is not None:
-                filename = os.path.join(config.project_dir,
-                                        '.chalice',
-                                        config.iam_policy_file)
+                filename = os.path.join(
+                    config.project_dir, '.chalice', config.iam_policy_file
+                )
             else:
-                filename = os.path.join(config.project_dir,
-                                        '.chalice',
-                                        'policy-%s.json' % stage_name)
+                filename = os.path.join(
+                    config.project_dir,
+                    '.chalice',
+                    'policy-%s.json' % stage_name,
+                )
             policy = models.FileBasedIAMPolicy(
-                filename=filename, document=models.Placeholder.BUILD_STAGE)
+                filename=filename, document=models.Placeholder.BUILD_STAGE
+            )
         else:
             resource_name = 'default-role'
             role_name = '%s-%s' % (config.app_name, stage_name)
@@ -460,8 +521,9 @@ def _create_role_reference(self, config, stage_name, function_name):
             policy=policy,
         )
 
-    def _get_vpc_params(self, function_name, config):
-        # type: (str, Config) -> Tuple[List[str], List[str]]
+    def _get_vpc_params(
+        self, function_name: str, config: Config
+    ) -> Tuple[List[str], List[str]]:
         security_group_ids = config.security_group_ids
         subnet_ids = config.subnet_ids
         if security_group_ids and subnet_ids:
@@ -473,26 +535,27 @@ def _get_vpc_params(self, function_name, config):
                 "Invalid VPC params for function '%s', in order to configure "
                 "VPC for a Lambda function, you must provide the subnet_ids "
                 "as well as the security_group_ids, got subnet_ids: %s, "
-                "security_group_ids: %s" % (function_name,
-                                            subnet_ids,
-                                            security_group_ids)
+                "security_group_ids: %s"
+                % (function_name, subnet_ids, security_group_ids)
             )
 
-    def _get_lambda_layers(self, config):
-        # type: (Config) -> List[str]
+    def _get_lambda_layers(self, config: Config) -> List[str]:
         layers = config.layers
         return layers if layers else []
 
-    def _build_lambda_function(self,
-                               config,        # type: Config
-                               name,          # type: str
-                               handler_name,  # type: str
-                               deployment,    # type: models.DeploymentPackage
-                               role,          # type: models.IAMRole
-                               ):
-        # type: (...) -> models.LambdaFunction
+    def _build_lambda_function(
+        self,
+        config: Config,
+        name: str,
+        handler_name: str,
+        deployment: models.DeploymentPackage,
+        role: models.IAMRole,
+    ) -> models.LambdaFunction:
         function_name = '%s-%s-%s' % (
-            config.app_name, config.chalice_stage, name)
+            config.app_name,
+            config.chalice_stage,
+            name,
+        )
         security_group_ids, subnet_ids = self._get_vpc_params(name, config)
         lambda_layers = self._get_lambda_layers(config)
         function = models.LambdaFunction(
@@ -516,8 +579,9 @@ def _build_lambda_function(self,
         self._inject_role_traits(function, role)
         return function
 
-    def _inject_role_traits(self, function, role):
-        # type: (models.LambdaFunction, models.IAMRole) -> None
+    def _inject_role_traits(
+        self, function: models.LambdaFunction, role: models.IAMRole
+    ) -> None:
         if not isinstance(role, models.ManagedIAMRole):
             return
         policy = role.policy
@@ -528,15 +592,17 @@ def _inject_role_traits(self, function, role):
 
     def _create_bucket_notification(
         self,
-        config,      # type: Config
-        deployment,  # type: models.DeploymentPackage
-        s3_event,    # type: app.S3EventConfig
-        stage_name,  # type: str
-    ):
-        # type: (...) -> models.S3BucketNotification
+        config: Config,
+        deployment: models.DeploymentPackage,
+        s3_event: app.S3EventConfig,
+        stage_name: str,
+    ) -> models.S3BucketNotification:
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name=s3_event.name,
-            handler_name=s3_event.handler_string, stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name=s3_event.name,
+            handler_name=s3_event.handler_string,
+            stage_name=stage_name,
         )
         resource_name = s3_event.name + '-s3event'
         s3_bucket = models.S3BucketNotification(
@@ -551,15 +617,17 @@ def _create_bucket_notification(
 
     def _create_sns_subscription(
         self,
-        config,      # type: Config
-        deployment,  # type: models.DeploymentPackage
-        sns_config,  # type: app.SNSEventConfig
-        stage_name,  # type: str
-    ):
-        # type: (...) -> models.SNSLambdaSubscription
+        config: Config,
+        deployment: models.DeploymentPackage,
+        sns_config: app.SNSEventConfig,
+        stage_name: str,
+    ) -> models.SNSLambdaSubscription:
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name=sns_config.name,
-            handler_name=sns_config.handler_string, stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name=sns_config.name,
+            handler_name=sns_config.handler_string,
+            stage_name=stage_name,
         )
         resource_name = sns_config.name + '-sns-subscription'
         sns_subscription = models.SNSLambdaSubscription(
@@ -571,18 +639,20 @@ def _create_sns_subscription(
 
     def _create_sqs_subscription(
         self,
-        config,      # type: Config
-        deployment,  # type: models.DeploymentPackage
-        sqs_config,  # type: app.SQSEventConfig
-        stage_name,  # type: str
-    ):
-        # type: (...) -> models.SQSEventSource
+        config: Config,
+        deployment: models.DeploymentPackage,
+        sqs_config: app.SQSEventConfig,
+        stage_name: str,
+    ) -> models.SQSEventSource:
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name=sqs_config.name,
-            handler_name=sqs_config.handler_string, stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name=sqs_config.name,
+            handler_name=sqs_config.handler_string,
+            stage_name=stage_name,
         )
         resource_name = sqs_config.name + '-sqs-event-source'
-        queue = ''  # type: Union[str, models.QueueARN]
+        queue: Union[str, models.QueueARN] = ''
         if sqs_config.queue_arn is not None:
             queue = models.QueueARN(arn=sqs_config.queue_arn)
         elif sqs_config.queue is not None:
@@ -599,15 +669,17 @@ def _create_sqs_subscription(
 
     def _create_kinesis_subscription(
         self,
-        config,          # type: Config
-        deployment,      # type: models.DeploymentPackage
-        kinesis_config,  # type: app.KinesisEventConfig
-        stage_name,      # type: str
-    ):
-        # type: (...) -> models.KinesisEventSource
+        config: Config,
+        deployment: models.DeploymentPackage,
+        kinesis_config: app.KinesisEventConfig,
+        stage_name: str,
+    ) -> models.KinesisEventSource:
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name=kinesis_config.name,
-            handler_name=kinesis_config.handler_string, stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name=kinesis_config.name,
+            handler_name=kinesis_config.handler_string,
+            stage_name=stage_name,
         )
         resource_name = kinesis_config.name + '-kinesis-event-source'
         batch_window = kinesis_config.maximum_batching_window_in_seconds
@@ -623,15 +695,17 @@ def _create_kinesis_subscription(
 
     def _create_ddb_subscription(
         self,
-        config,          # type: Config
-        deployment,      # type: models.DeploymentPackage
-        ddb_config,      # type: app.DynamoDBEventConfig
-        stage_name,      # type: str
-    ):
-        # type: (...) -> models.DynamoDBEventSource
+        config: Config,
+        deployment: models.DeploymentPackage,
+        ddb_config: app.DynamoDBEventConfig,
+        stage_name: str,
+    ) -> models.DynamoDBEventSource:
         lambda_function = self._create_lambda_model(
-            config=config, deployment=deployment, name=ddb_config.name,
-            handler_name=ddb_config.handler_string, stage_name=stage_name
+            config=config,
+            deployment=deployment,
+            name=ddb_config.name,
+            handler_name=ddb_config.handler_string,
+            stage_name=stage_name,
         )
         resource_name = ddb_config.name + '-dynamodb-event-source'
         batch_window = ddb_config.maximum_batching_window_in_seconds
@@ -647,20 +721,22 @@ def _create_ddb_subscription(
 
 
 class DependencyBuilder(object):
-    def __init__(self):
-        # type: () -> None
+    def __init__(self) -> None:
         pass
 
-    def build_dependencies(self, graph):
-        # type: (models.Model) -> List[models.Model]
-        seen = set()  # type: Set[int]
-        ordered = []  # type: List[models.Model]
+    def build_dependencies(self, graph: models.Model) -> List[models.Model]:
+        seen: Set[int] = set()
+        ordered: List[models.Model] = []
         for resource in graph.dependencies():
             self._traverse(resource, ordered, seen)
         return ordered
 
-    def _traverse(self, resource, ordered, seen):
-        # type: (models.Model, List[models.Model], Set[int]) -> None
+    def _traverse(
+        self,
+        resource: models.Model,
+        ordered: List[models.Model],
+        seen: Set[int],
+    ) -> None:
         for dep in resource.dependencies():
             if id(dep) not in seen:
                 seen.add(id(dep))
@@ -677,18 +753,15 @@ class GraphPrettyPrint(object):
     _NEW_SECTION = '\u251c\u2500\u2500'
     _LINE_VERTICAL = '\u2502'
 
-    def __init__(self, ui):
-        # type: (UI) -> None
+    def __init__(self, ui: UI) -> None:
         self._ui = ui
 
-    def display_graph(self, graph):
-        # type: (models.Model) -> None
+    def display_graph(self, graph: models.Model) -> None:
         self._ui.write("Application\n")
         for model in graph.dependencies():
             self._traverse(model, level=0)
 
-    def _traverse(self, graph, level):
-        # type: (models.Model, int) -> None
+    def _traverse(self, graph: models.Model, level: int) -> None:
         prefix = ('%s   ' % self._LINE_VERTICAL) * level
         spaces = prefix + self._NEW_SECTION + ' '
         model_text = self._get_model_text(graph, spaces, level)
@@ -697,8 +770,9 @@ def _traverse(self, graph, level):
         for model in graph.dependencies():
             self._traverse(model, level + 1)
 
-    def _get_model_text(self, model, spaces, level):
-        # type: (models.Model, Text, int) -> Text
+    def _get_model_text(
+        self, model: models.Model, spaces: Text, level: int
+    ) -> Text:
         name = model.__class__.__name__
         filtered = self._get_filtered_params(model)
         if not filtered:
@@ -713,17 +787,18 @@ def _get_model_text(self, model, spaces, level):
         self._add_remaining_lines(lines, remaining, full)
         return '\n'.join(lines) + ')'
 
-    def _add_remaining_lines(self, lines, remaining, full):
-        # type: (List[str], List[Tuple[str, Any]], Text) -> None
+    def _add_remaining_lines(
+        self, lines: List[str], remaining: List[Tuple[str, Any]], full: Text
+    ) -> None:
         for key, value in remaining:
             if isinstance(value, (list, dict)):
                 value = key.upper()
             current = cast(str, '%s%s=%s,' % (full, key, value))
             lines.append(current)
 
-    def _get_filtered_params(self, model):
-        # type: (models.Model) -> StrMapAny
+    def _get_filtered_params(self, model: models.Model) -> StrMapAny:
         dependencies = model.dependencies()
-        filtered = asdict(
-            model, filter=lambda _, v: v not in dependencies and v)
+        filtered = {
+            k: v for k, v in asdict(model).items() if v not in dependencies
+        }
         return filtered
diff --git a/chalice/deploy/executor.py b/chalice/deploy/executor.py
index 583f2cb0b..0d4f42992 100644
--- a/chalice/deploy/executor.py
+++ b/chalice/deploy/executor.py
@@ -1,8 +1,8 @@
 import re
 import pprint
+from dataclasses import asdict
 
 import jmespath
-from attr import asdict
 from typing import Dict, List, Any  # noqa
 
 from chalice.deploy import models # noqa
diff --git a/chalice/deploy/models.py b/chalice/deploy/models.py
index 03970ed80..89a81cae3 100644
--- a/chalice/deploy/models.py
+++ b/chalice/deploy/models.py
@@ -1,8 +1,9 @@
 # pylint: disable=line-too-long
+from __future__ import annotations
+from dataclasses import dataclass, field
 import enum
 from typing import List, Dict, Optional as Opt, Any, TypeVar, Union, Set  # noqa
 from typing import cast
-from attr import attrs, attrib, Factory
 
 
 class Placeholder(enum.Enum):
@@ -28,8 +29,7 @@ class TLSVersion(enum.Enum):
     TLS_1_2 = 'TLS_1_2'
 
     @classmethod
-    def create(cls, str_version):
-        # type: (str) -> Opt[TLSVersion]
+    def create(cls, str_version: str) -> Opt[TLSVersion]:
         for version in cls:
             if version.value == str_version:
                 return version
@@ -41,178 +41,172 @@ def create(cls, str_version):
 StrMap = Dict[str, str]
 
 
-@attrs
-class Plan(object):
-    instructions = attrib(default=Factory(list))  # type: List[Instruction]
-    messages = attrib(default=Factory(dict))      # type: Dict[int, str]
+@dataclass
+class Plan:
+    instructions: List[Instruction] = field(default_factory=list)
+    messages: Dict[int, str] = field(default_factory=dict)
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class APICall(Instruction):
-    method_name = attrib()             # type: str
-    params = attrib()                  # type: Dict[str, Any]
-    output_var = attrib(default=None)  # type: Opt[str]
+    method_name: str
+    params: Dict[str, Any]
+    output_var: Opt[str] = None
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class StoreValue(Instruction):
-    name = attrib()   # type: str
-    value = attrib()  # type: Any
+    name: str
+    value: Any
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class StoreMultipleValue(Instruction):
-    name = attrib()   # type: str
-    value = attrib(default=Factory(list))  # type: List[Any]
+    name: str
+    value: List[Any] = field(default_factory=list)
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class CopyVariable(Instruction):
-    from_var = attrib()  # type: str
-    to_var = attrib()    # type: str
+    from_var: str
+    to_var: str
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class CopyVariableFromDict(Instruction):
-    from_var = attrib()  # type: str
-    key = attrib()       # type: str
-    to_var = attrib()    # type: str
+    from_var: str
+    key: str
+    to_var: str
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class RecordResource(Instruction):
-    resource_type = attrib()  # type: str
-    resource_name = attrib()  # type: str
-    name = attrib()           # type: str
+    resource_type: str
+    resource_name: str
+    name: str
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class RecordResourceVariable(RecordResource):
-    variable_name = attrib()  # type: str
+    variable_name: str
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class RecordResourceValue(RecordResource):
-    value = attrib()  # type: Any
+    value: Any
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class JPSearch(Instruction):
-    expression = attrib()  # type: Any
-    input_var = attrib()   # type: Any
-    output_var = attrib()  # type: Any
+    expression: Any
+    input_var: Any
+    output_var: Any
 
 
-@attrs(frozen=True)
+@dataclass(frozen=True)
 class BuiltinFunction(Instruction):
-    function_name = attrib()  # type: str
-    args = attrib()           # type: List[Any]
-    output_var = attrib()     # type: str
+    function_name: str
+    args: List[Any]
+    output_var: str
 
 
 class Model(object):
-    def dependencies(self):
-        # type: () -> List[Model]
+    def dependencies(self) -> List[Model]:
         return []
 
 
-@attrs
+@dataclass
 class ManagedModel(Model):
-    resource_name = attrib()  # type: str
+    resource_name: str
     # Subclasses must fill in this attribute.
-    resource_type = ''        # type: str
+    resource_type = ''
 
 
-@attrs
+@dataclass
 class Application(Model):
-    stage = attrib()      # type: str
-    resources = attrib()  # type: List[Model]
+    stage: str
+    resources: List[Model]
 
-    def dependencies(self):
-        # type: () -> List[Model]
+    def dependencies(self) -> List[Model]:
         return self.resources
 
 
-@attrs
+@dataclass
 class DeploymentPackage(Model):
-    filename = attrib()  # type: DV[str]
+    filename: DV[str]
 
 
-@attrs
+@dataclass
 class IAMPolicy(Model):
-    document = attrib()  # type: DV[Dict[str, Any]]
+    document: DV[Dict[str, Any]]
 
 
-@attrs
+@dataclass
 class FileBasedIAMPolicy(IAMPolicy):
-    filename = attrib()  # type: str
+    filename: str
 
 
-@attrs
+@dataclass
 class AutoGenIAMPolicy(IAMPolicy):
-    traits = attrib(default=Factory(set))  # type: Set[RoleTraits]
+    traits: Set[RoleTraits] = field(default_factory=set)
 
 
-@attrs
+@dataclass
 class IAMRole(Model):
     pass
 
 
-@attrs
+@dataclass
 class PreCreatedIAMRole(IAMRole):
-    role_arn = attrib()  # type: str
+    role_arn: str
 
 
-@attrs
+@dataclass
 class ManagedIAMRole(IAMRole, ManagedModel):
     resource_type = 'iam_role'
-    role_name = attrib()     # type: str
-    trust_policy = attrib()  # type: Dict[str, Any]
-    policy = attrib()        # type: IAMPolicy
+    role_name: str
+    trust_policy: Dict[str, Any]
+    policy: IAMPolicy
 
-    def dependencies(self):
-        # type: () -> List[Model]
+    def dependencies(self) -> List[Model]:
         return [self.policy]
 
 
-@attrs
+@dataclass
 class LambdaLayer(ManagedModel):
     resource_type = 'lambda_layer'
-    layer_name = attrib()             # type: str
-    runtime = attrib()                # type: str
-    deployment_package = attrib()     # type: DeploymentPackage
-    is_empty = attrib(default=False)  # type: bool
+    layer_name: str
+    runtime: str
+    deployment_package: DeploymentPackage
+    is_empty: bool = False
 
-    def dependencies(self):
-        # type: () -> List[Model]
+    def dependencies(self) -> List[Model]:
         return [self.deployment_package]
 
 
-@attrs
+@dataclass
 class LambdaFunction(ManagedModel):
     resource_type = 'lambda_function'
-    function_name = attrib()          # type: str
-    deployment_package = attrib()     # type: DeploymentPackage
-    environment_variables = attrib()  # type: StrMap
-    xray = attrib()                   # type: bool
-    runtime = attrib()                # type: str
-    handler = attrib()                # type: str
-    tags = attrib()                   # type: StrMap
-    timeout = attrib()                # type: int
-    memory_size = attrib()            # type: int
-    role = attrib()                   # type: IAMRole
-    security_group_ids = attrib()     # type: List[str]
-    subnet_ids = attrib()             # type: List[str]
-    reserved_concurrency = attrib()   # type: int
+    function_name: str
+    deployment_package: DeploymentPackage
+    environment_variables: StrMap
+    xray: bool
+    runtime: str
+    handler: str
+    tags: StrMap
+    timeout: int
+    memory_size: int
+    role: IAMRole
+    security_group_ids: List[str]
+    subnet_ids: List[str]
+    reserved_concurrency: int
     # These are customer created layers.
-    layers = attrib()                 # type: List[str]
-    managed_layer = attrib(
-        default=None)                 # type: Opt[LambdaLayer]
-    log_group = attrib(default=None)  # type: LogGroup
-
-    def dependencies(self):
-        # type: () -> List[Model]
-        resources = []  # type: List[Model]
+    layers: List[str]
+    managed_layer: Opt[LambdaLayer] = None
+    log_group: Opt[LogGroup] = None
+
+    def dependencies(self) -> List[Model]:
+        resources: List[Model] = []
         if self.managed_layer is not None:
             resources.append(self.managed_layer)
         if self.log_group is not None:
@@ -221,99 +215,95 @@ def dependencies(self):
         return resources
 
 
-@attrs
+@dataclass
 class FunctionEventSubscriber(ManagedModel):
-    lambda_function = attrib()  # type: LambdaFunction
+    lambda_function: LambdaFunction
 
-    def dependencies(self):
-        # type: () -> List[Model]
+    def dependencies(self) -> List[Model]:
         return [self.lambda_function]
 
 
-@attrs
+@dataclass
 class CloudWatchEventBase(FunctionEventSubscriber):
-    rule_name = attrib()        # type: str
+    rule_name: str
 
 
-@attrs
+@dataclass
 class CloudWatchEvent(CloudWatchEventBase):
     resource_type = 'cloudwatch_event'
-    event_pattern = attrib()    # type: str
+    event_pattern: str
 
 
-@attrs
+@dataclass
 class ScheduledEvent(CloudWatchEventBase):
     resource_type = 'scheduled_event'
-    schedule_expression = attrib()  # type: str
-    rule_description = attrib(default=None)     # type: str
+    schedule_expression: str
+    rule_description: Opt[str] = None
 
 
-@attrs
+@dataclass
 class LogGroup(ManagedModel):
     resource_type = 'log_group'
-    log_group_name = attrib()       # type: str
-    retention_in_days = attrib()    # type: int
+    log_group_name: str
+    retention_in_days: int
 
 
-@attrs
+@dataclass
 class APIMapping(ManagedModel):
     resource_type = 'api_mapping'
-    mount_path = attrib()          # type: str
-    api_gateway_stage = attrib()   # type: str
+    mount_path: str
+    api_gateway_stage: str
 
 
-@attrs
+@dataclass
 class DomainName(ManagedModel):
     resource_type = 'domain_name'
-    domain_name = attrib()              # type: str
-    protocol = attrib()                 # type: APIType
-    api_mapping = attrib()              # type: APIMapping
-    certificate_arn = attrib()          # type: str
-    tags = attrib(default=None)         # type: Opt[Dict[str, Any]]
-    tls_version = attrib(default=None)  # type: Opt[TLSVersion]
-
-    def dependencies(self):
-        # type: () -> List[Model]
+    domain_name: str
+    protocol: APIType
+    api_mapping: APIMapping
+    certificate_arn: str
+    tags: Opt[Dict[str, Any]] = None
+    tls_version: Opt[TLSVersion] = None
+
+    def dependencies(self) -> List[Model]:
         return [self.api_mapping]
 
 
-@attrs
+@dataclass
 class RestAPI(ManagedModel):
     resource_type = 'rest_api'
-    swagger_doc = attrib()                       # type: DV[Dict[str, Any]]
-    minimum_compression = attrib()               # type: str
-    api_gateway_stage = attrib()                 # type: str
-    endpoint_type = attrib()                     # type: str
-    lambda_function = attrib()                   # type: LambdaFunction
-    xray = attrib(default=False)                 # type: bool
-    policy = attrib(default=None)                # type: Opt[IAMPolicy]
-    authorizers = attrib(default=Factory(list))  # type: List[LambdaFunction]
-    domain_name = attrib(default=None)           # type: Opt[DomainName]
-    vpce_ids = attrib(default=None)              # type: Opt[List[str]]
-
-    def dependencies(self):
-        # type: () -> List[Model]
-        resources = []  # type: List[Model]
+    swagger_doc: DV[Dict[str, Any]]
+    minimum_compression: str
+    api_gateway_stage: str
+    endpoint_type: str
+    lambda_function: LambdaFunction
+    xray: bool = False
+    policy: Opt[IAMPolicy] = None
+    authorizers: List[LambdaFunction] = field(default_factory=list)
+    domain_name: Opt[DomainName] = None
+    vpce_ids: Opt[List[str]] = None
+
+    def dependencies(self) -> List[Model]:
+        resources: List[Model] = []
         resources.extend([self.lambda_function] + self.authorizers)
         if self.domain_name is not None:
             resources.append(self.domain_name)
         return cast(List[Model], resources)
 
 
-@attrs
+@dataclass
 class WebsocketAPI(ManagedModel):
     resource_type = 'websocket_api'
-    name = attrib()                     # type: str
-    api_gateway_stage = attrib()        # type: str
-    routes = attrib()                   # type: List[str]
-    connect_function = attrib()         # type: Opt[LambdaFunction]
-    message_function = attrib()         # type: Opt[LambdaFunction]
-    disconnect_function = attrib()      # type: Opt[LambdaFunction]
-    domain_name = attrib(default=None)  # type: Opt[DomainName]
-
-    def dependencies(self):
-        # type: () -> List[Model]
-        resources = []  # type: List[Model]
+    name: str
+    api_gateway_stage: str
+    routes: List[str]
+    connect_function: Opt[LambdaFunction]
+    message_function: Opt[LambdaFunction]
+    disconnect_function: Opt[LambdaFunction]
+    domain_name: Opt[DomainName] = None
+
+    def dependencies(self) -> List[Model]:
+        resources: List[Model] = []
         if self.domain_name is not None:
             resources.append(self.domain_name)
         if self.connect_function is not None:
@@ -325,54 +315,53 @@ def dependencies(self):
         return resources
 
 
-@attrs
+@dataclass
 class S3BucketNotification(FunctionEventSubscriber):
     resource_type = 's3_event'
-    bucket = attrib()           # type: str
-    events = attrib()           # type: List[str]
-    prefix = attrib()           # type: Opt[str]
-    suffix = attrib()           # type: Opt[str]
+    bucket: str
+    events: List[str]
+    prefix: Opt[str]
+    suffix: Opt[str]
 
 
-@attrs
+@dataclass
 class SNSLambdaSubscription(FunctionEventSubscriber):
     resource_type = 'sns_event'
-    topic = attrib()            # type: str
+    topic: str
 
 
-@attrs
+@dataclass
 class QueueARN(object):
-    arn = attrib()  # type: str
+    arn: str
 
     @property
-    def queue_name(self):
-        # type: () -> str
+    def queue_name(self) -> str:
         # Pylint 2.x validates this correctly, but for py27, we have to
-        # use Pylint 1.x which doesn't support attrs.
+        # use Pylint 1.x which doesn't support dataclass.
         return self.arn.rpartition(':')[2]  # pylint: disable=no-member
 
 
-@attrs
+@dataclass
 class SQSEventSource(FunctionEventSubscriber):
     resource_type = 'sqs_event'
-    queue = attrib()                               # type: Union[str, QueueARN]
-    batch_size = attrib()                          # type: int
-    maximum_batching_window_in_seconds = attrib()  # type: int
+    queue: Union[str, QueueARN]
+    batch_size: int
+    maximum_batching_window_in_seconds: int
 
 
-@attrs
+@dataclass
 class KinesisEventSource(FunctionEventSubscriber):
     resource_type = 'kinesis_event'
-    stream = attrib()                               # type: str
-    batch_size = attrib()                           # type: int
-    starting_position = attrib()                    # type: str
-    maximum_batching_window_in_seconds = attrib()   # type: int
+    stream: str
+    batch_size: int
+    starting_position: str
+    maximum_batching_window_in_seconds: int
 
 
-@attrs
+@dataclass
 class DynamoDBEventSource(FunctionEventSubscriber):
     resource_type = 'dynamodb_event'
-    stream_arn = attrib()                           # type: str
-    batch_size = attrib()                           # type: int
-    starting_position = attrib()                    # type: str
-    maximum_batching_window_in_seconds = attrib()   # type: int
+    stream_arn: str
+    batch_size: int
+    starting_position: str
+    maximum_batching_window_in_seconds: int
diff --git a/chalice/logs.py b/chalice/logs.py
index e04dca57b..fc723d192 100644
--- a/chalice/logs.py
+++ b/chalice/logs.py
@@ -4,27 +4,29 @@
 with the logs generated by AWS Lambda.
 
 """
+from __future__ import annotations
 import time
 from datetime import datetime, timedelta
+from dataclasses import dataclass
 from collections import defaultdict
 
 from typing import Any, Optional, Iterator, Dict, IO, Callable, Set  # noqa
 from botocore.session import Session  # noqa
-from attr import attrs, attrib
 
 from chalice.awsclient import TypedAWSClient, CWLogEvent  # noqa
 from chalice.utils import TimestampConverter
 
 
-@attrs
+@dataclass
 class LogRetrieveOptions(object):
-    max_entries = attrib(default=None)               # type: Optional[int]
-    start_time = attrib(default=None)                # type: Optional[datetime]
-    include_lambda_messages = attrib(default=False)  # type: bool
+    max_entries: Optional[int] = None
+    start_time: Optional[datetime] = None
+    include_lambda_messages: bool = False
 
     @classmethod
-    def create(cls, follow=False, since=None, **kwargs):
-        # type: (bool, Optional[str], **Any) -> LogRetrieveOptions
+    def create(
+        cls, follow: bool = False, since: Optional[str] = None, **kwargs: Any
+    ) -> LogRetrieveOptions:
         start_time = None
         if since is not None:
             start_time = TimestampConverter().timestamp_to_datetime(since)
@@ -41,25 +43,34 @@ def create(cls, follow=False, since=None, **kwargs):
         return cls(**kwargs)
 
 
-def display_logs(retriever, stream, retrieve_options):
-    # type: (LogRetriever, IO[str], LogRetrieveOptions) -> None
+def display_logs(
+    retriever: LogRetriever,
+    stream: IO[str],
+    retrieve_options: LogRetrieveOptions,
+) -> None:
     events = retriever.retrieve_logs(retrieve_options)
     for event in events:
-        stream.write('%s %s %s\n' % (
-            event['timestamp'],
-            event['logShortId'],
-            event['message'].strip()))
+        stream.write(
+            '%s %s %s\n'
+            % (
+                event['timestamp'],
+                event['logShortId'],
+                event['message'].strip(),
+            )
+        )
 
 
 class LogRetriever(object):
-    def __init__(self, log_event_generator, log_group_name):
-        # type: (BaseLogEventGenerator, str) -> None
+    def __init__(
+        self, log_event_generator: BaseLogEventGenerator, log_group_name: str
+    ) -> None:
         self._log_event_generator = log_event_generator
         self._log_group_name = log_group_name
 
     @classmethod
-    def create_from_lambda_arn(cls, log_event_generator, lambda_arn):
-        # type: (BaseLogEventGenerator, str) -> LogRetriever
+    def create_from_lambda_arn(
+        cls, log_event_generator: BaseLogEventGenerator, lambda_arn: str
+    ) -> LogRetriever:
         """Create a LogRetriever from a client and lambda arn.
 
         :type client: botocore.client.Logs
@@ -75,8 +86,7 @@ def create_from_lambda_arn(cls, log_event_generator, lambda_arn):
         log_group_name = '/aws/lambda/%s' % lambda_name
         return cls(log_event_generator, log_group_name)
 
-    def _is_lambda_message(self, event):
-        # type: (CWLogEvent) -> bool
+    def _is_lambda_message(self, event: CWLogEvent) -> bool:
         # Lambda will also inject log messages into your log streams.
         # They look like:
         # START RequestId: guid Version: $LATEST
@@ -88,12 +98,13 @@ def _is_lambda_message(self, event):
         # these message so that we only include log messages generated
         # by your chalice app.
         msg = event['message'].strip()
-        return msg.startswith(('START RequestId',
-                               'END RequestId',
-                               'REPORT RequestId'))
+        return msg.startswith(
+            ('START RequestId', 'END RequestId', 'REPORT RequestId')
+        )
 
-    def retrieve_logs(self, retrieve_options):
-        # type: (LogRetrieveOptions) -> Iterator[CWLogEvent]
+    def retrieve_logs(
+        self, retrieve_options: LogRetrieveOptions
+    ) -> Iterator[CWLogEvent]:
         """Retrieve logs from a log group.
 
         :type include_lambda_messages: boolean
@@ -119,10 +130,13 @@ def retrieve_logs(self, retrieve_options):
         max_entries = retrieve_options.max_entries
         shown = 0
         events = self._log_event_generator.iter_log_events(
-            self._log_group_name, retrieve_options)
+            self._log_group_name, retrieve_options
+        )
         for event in events:
-            if not retrieve_options.include_lambda_messages and \
-                    self._is_lambda_message(event):
+            if (
+                not retrieve_options.include_lambda_messages
+                and self._is_lambda_message(event)
+            ):
                 continue
             # logStreamName is: '2016/07/05/[id]hash'
             # We want to extract the hash portion and
@@ -139,20 +153,22 @@ def retrieve_logs(self, retrieve_options):
 
 
 class BaseLogEventGenerator(object):
-    def __init__(self, client):
-        # type: (TypedAWSClient) -> None
+    def __init__(self, client: TypedAWSClient) -> None:
         self._client = client
 
-    def iter_log_events(self, log_group_name, options):
-        # type: (str, LogRetrieveOptions) -> Iterator[CWLogEvent]
+    def iter_log_events(
+        self, log_group_name: str, options: LogRetrieveOptions
+    ) -> Iterator[CWLogEvent]:
         raise NotImplementedError("iter_log_events")
 
 
 class LogEventGenerator(BaseLogEventGenerator):
-    def iter_log_events(self, log_group_name, options):
-        # type: (str, LogRetrieveOptions) -> Iterator[CWLogEvent]
-        logs = self._client.iter_log_events(log_group_name=log_group_name,
-                                            start_time=options.start_time)
+    def iter_log_events(
+        self, log_group_name: str, options: LogRetrieveOptions
+    ) -> Iterator[CWLogEvent]:
+        logs = self._client.iter_log_events(
+            log_group_name=log_group_name, start_time=options.start_time
+        )
         for log_message in logs:
             yield log_message
 
@@ -161,31 +177,37 @@ class FollowLogEventGenerator(BaseLogEventGenerator):
 
     _POLL_TIME = 5
 
-    def __init__(self, client, sleep=time.sleep, poll_time=_POLL_TIME):
-        # type: (TypedAWSClient, Callable[[int], None], int) -> None
+    def __init__(
+        self,
+        client: TypedAWSClient,
+        sleep: Callable[[int], None] = time.sleep,
+        poll_time: int = _POLL_TIME,
+    ) -> None:
         self._client = client
         self._sleep = sleep
-        self._event_id_cache = defaultdict(
-            set)  # type: Dict[datetime, Set[str]]
+        self._event_id_cache: Dict[datetime, Set[str]] = defaultdict(set)
         self._poll_time = poll_time
 
-    def iter_log_events(self, log_group_name, options):
-        # type: (str, LogRetrieveOptions) -> Iterator[CWLogEvent]
+    def iter_log_events(
+        self, log_group_name: str, options: LogRetrieveOptions
+    ) -> Iterator[CWLogEvent]:
         start_time = options.start_time
         try:
-            for event in self._loop_on_filter_log_events(log_group_name,
-                                                         start_time):
+            for event in self._loop_on_filter_log_events(
+                log_group_name, start_time
+            ):
                 yield event
         except KeyboardInterrupt:
             return
 
-    def _loop_on_filter_log_events(self, log_group_name, start_time):
-        # type: (str, Optional[datetime]) -> Iterator[CWLogEvent]
+    def _loop_on_filter_log_events(
+        self, log_group_name: str, start_time: Optional[datetime]
+    ) -> Iterator[CWLogEvent]:
         self._event_id_cache.clear()
-        kwargs = {
+        kwargs: Dict[str, Any] = {
             'log_group_name': log_group_name,
             'start_time': start_time,
-        }  # type: Dict[str, Any]
+        }
         while True:
             response = self._client.filter_log_events(**kwargs)
             for event in response['events']:
@@ -217,16 +239,13 @@ def _loop_on_filter_log_events(self, log_group_name, start_time):
                     self._prune_old_cache_entries(most_recent_start_time)
                     self._sleep(self._poll_time)
 
-    def _in_cache(self, event):
-        # type: (CWLogEvent) -> bool
+    def _in_cache(self, event: CWLogEvent) -> bool:
         return event['eventId'] in self._event_id_cache[event['timestamp']]
 
-    def _add_to_cache(self, event):
-        # type: (CWLogEvent) -> None
+    def _add_to_cache(self, event: CWLogEvent) -> None:
         self._event_id_cache[event['timestamp']].add(event['eventId'])
 
-    def _prune_old_cache_entries(self, timestamp):
-        # type: (datetime) -> None
+    def _prune_old_cache_entries(self, timestamp: datetime) -> None:
         surviving_entries = self._event_id_cache[timestamp]
         self._event_id_cache.clear()
         self._event_id_cache[timestamp] = surviving_entries
diff --git a/requirements-dev.txt b/requirements-dev.txt
index b050df731..e055e3b97 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -10,7 +10,6 @@ astroid==2.12.5
     # via pylint
 attrs==21.4.0
     # via
-    #   -r requirements-test.in
     #   hypothesis
     #   pytest
 babel==2.10.3
diff --git a/setup.py b/setup.py
index aeed0a171..7f05fde25 100644
--- a/setup.py
+++ b/setup.py
@@ -26,7 +26,6 @@ def recursive_include(relative_dir):
     'typing-extensions>=4.0.0,<5.0.0',
     'six>=1.10.0,<2.0.0',
     'pip>=9,<23.1',
-    'attrs>=19.3.0,<21.5.0',
     'jmespath>=0.9.3,<2.0.0',
     'pyyaml>=5.3.1,<7.0.0',
     'inquirer>=2.7.0,<3.0.0',


Test patch:
diff --git a/requirements-test.in b/requirements-test.in
index 1cd5ca13c..b8dfce326 100644
--- a/requirements-test.in
+++ b/requirements-test.in
@@ -6,4 +6,3 @@ websocket-client<2.0.0
 pytest-cov
 requests
 mock
-attrs<21.5.0
diff --git a/requirements-test.txt b/requirements-test.txt
index 2785acce3..64c839acb 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -6,7 +6,6 @@
 #
 attrs==21.4.0
     # via
-    #   -r requirements-test.in
     #   hypothesis
     #   pytest
 boto3==1.26.70
diff --git a/tests/unit/deploy/test_deployer.py b/tests/unit/deploy/test_deployer.py
index 201a1fb8f..4db630391 100644
--- a/tests/unit/deploy/test_deployer.py
+++ b/tests/unit/deploy/test_deployer.py
@@ -1,6 +1,8 @@
+from __future__ import annotations
 import os
-
+from dataclasses import dataclass
 import socket
+
 import botocore.session
 
 import pytest
@@ -20,8 +22,6 @@
 from chalice.utils import UI
 import unittest
 
-from attr import attrs, attrib
-
 from chalice.awsclient import TypedAWSClient
 from chalice.utils import OSUtils, serialize_to_json
 from chalice.deploy import models
@@ -241,10 +241,10 @@ def test_simplifies_error_msg_for_timeout(self):
         )
 
 
-@attrs
+@dataclass
 class FooResource(models.Model):
-    name = attrib()
-    leaf = attrib()
+    name: str
+    leaf: LeafResource
 
     def dependencies(self):
         if not isinstance(self.leaf, list):
@@ -252,9 +252,9 @@ def dependencies(self):
         return self.leaf
 
 
-@attrs
+@dataclass
 class LeafResource(models.Model):
-    name = attrib()
+    name: str
 
 
 @fixture
diff --git a/tests/unit/deploy/test_models.py b/tests/unit/deploy/test_models.py
index 6395acd0a..6ce23f70e 100644
--- a/tests/unit/deploy/test_models.py
+++ b/tests/unit/deploy/test_models.py
@@ -1,4 +1,4 @@
-from attr import evolve
+from dataclasses import replace
 
 from chalice.deploy import models
 
@@ -28,8 +28,8 @@ def test_can_default_to_no_auths_in_rest_api(lambda_function):
 
 
 def test_can_add_authorizers_to_dependencies(lambda_function):
-    auth1 = evolve(lambda_function, resource_name='auth1')
-    auth2 = evolve(lambda_function, resource_name='auth2')
+    auth1 = replace(lambda_function, resource_name='auth1')
+    auth2 = replace(lambda_function, resource_name='auth2')
     rest_api = models.RestAPI(
         resource_name='rest_api',
         swagger_doc={'swagger': '2.0'},
diff --git a/tests/unit/deploy/test_planner.py b/tests/unit/deploy/test_planner.py
index 439e937bf..df02680d2 100644
--- a/tests/unit/deploy/test_planner.py
+++ b/tests/unit/deploy/test_planner.py
@@ -1,7 +1,7 @@
 import mock
 
-import attr
 import pytest
+from dataclasses import replace, dataclass
 from typing import Tuple
 
 from chalice.awsclient import TypedAWSClient, ResourceDoesNotExistError
@@ -555,7 +555,7 @@ def test_can_update_layer(self):
             deployment_package=models.DeploymentPackage(
                 filename='foo')
         )
-        copy_of_layer = attr.evolve(layer)
+        copy_of_layer = replace(layer)
         self.remote_state.declare_resource_exists(
             copy_of_layer,
             layer_version_arn='arn:bar:4'
@@ -675,7 +675,7 @@ def test_create_function_with_layers(self):
 
     def test_can_update_lambda_function_code(self):
         function = create_function_resource('function_name')
-        copy_of_function = attr.evolve(function)
+        copy_of_function = replace(function)
         self.remote_state.declare_resource_exists(copy_of_function)
         # Now let's change the memory size and ensure we
         # get an update.
@@ -721,9 +721,9 @@ def test_can_update_lambda_function_with_managed_layer(self):
             'function_name',
             managed_layer=create_managed_layer(),
         )
-        copy_of_function = attr.evolve(function)
+        copy_of_function = replace(function)
         self.remote_state.declare_resource_exists(copy_of_function)
-        copy_of_layer = attr.evolve(function.managed_layer)
+        copy_of_layer = replace(function.managed_layer)
         self.remote_state.declare_resource_exists(
             copy_of_layer,
             layer_version_arn='arn:bar:4'
@@ -2413,7 +2413,7 @@ def test_dynamically_lookup_iam_role(self):
 
     def test_unknown_model_type_raises_error(self):
 
-        @attr.attrs
+        @dataclass
         class Foo(models.ManagedModel):
             resource_type = 'foo'