Repository: getmoto/moto
Pull request #6457 (2023-06-28 21:50:08)

Techdebt: Replace sure with regular asserts in Budgets
See #6035 


Patch:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 3a8ac6584..d2def447b 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -204,7 +204,7 @@
 
 ## apigatewayv2
 <details>
-<summary>69% implemented</summary>
+<summary>75% implemented</summary>
 
 - [X] create_api
 - [X] create_api_mapping
@@ -216,7 +216,7 @@
 - [X] create_model
 - [X] create_route
 - [X] create_route_response
-- [ ] create_stage
+- [X] create_stage
 - [X] create_vpc_link
 - [ ] delete_access_log_settings
 - [X] delete_api
@@ -232,7 +232,7 @@
 - [X] delete_route_request_parameter
 - [X] delete_route_response
 - [ ] delete_route_settings
-- [ ] delete_stage
+- [X] delete_stage
 - [X] delete_vpc_link
 - [ ] export_api
 - [X] get_api
@@ -256,8 +256,8 @@
 - [X] get_route_response
 - [ ] get_route_responses
 - [X] get_routes
-- [ ] get_stage
-- [ ] get_stages
+- [X] get_stage
+- [X] get_stages
 - [X] get_tags
 - [X] get_vpc_link
 - [X] get_vpc_links
diff --git a/docs/docs/services/apigatewayv2.rst b/docs/docs/services/apigatewayv2.rst
index 452254ce5..7be34f0ff 100644
--- a/docs/docs/services/apigatewayv2.rst
+++ b/docs/docs/services/apigatewayv2.rst
@@ -46,7 +46,7 @@ apigatewayv2
         The following parameters are not yet implemented: ResponseModels, ResponseParameters
         
 
-- [ ] create_stage
+- [X] create_stage
 - [X] create_vpc_link
 - [ ] delete_access_log_settings
 - [X] delete_api
@@ -62,7 +62,7 @@ apigatewayv2
 - [X] delete_route_request_parameter
 - [X] delete_route_response
 - [ ] delete_route_settings
-- [ ] delete_stage
+- [X] delete_stage
 - [X] delete_vpc_link
 - [ ] export_api
 - [X] get_api
@@ -102,8 +102,8 @@ apigatewayv2
         Pagination is not yet implemented
         
 
-- [ ] get_stage
-- [ ] get_stages
+- [X] get_stage
+- [X] get_stages
 - [X] get_tags
 - [X] get_vpc_link
 - [X] get_vpc_links
diff --git a/moto/apigatewayv2/exceptions.py b/moto/apigatewayv2/exceptions.py
index 1a63b65a9..44ef021d6 100644
--- a/moto/apigatewayv2/exceptions.py
+++ b/moto/apigatewayv2/exceptions.py
@@ -123,3 +123,13 @@ class ApiMappingNotFound(APIGatewayV2Error):
             "NotFoundException",
             "The api mapping resource specified in the request was not found.",
         )
+
+
+class StageNotFound(APIGatewayV2Error):
+    code = 404
+
+    def __init__(self) -> None:
+        super().__init__(
+            "NotFoundException",
+            "Invalid stage identifier specified",
+        )
diff --git a/moto/apigatewayv2/models.py b/moto/apigatewayv2/models.py
index 7842e5a2b..5ec2b3992 100644
--- a/moto/apigatewayv2/models.py
+++ b/moto/apigatewayv2/models.py
@@ -22,9 +22,57 @@ from .exceptions import (
     VpcLinkNotFound,
     DomainNameNotFound,
     DomainNameAlreadyExists,
+    StageNotFound,
 )
 
 
+class Stage(BaseModel):
+    def __init__(self, api: "Api", config: Dict[str, Any]):
+        self.config = config
+        self.name = config["stageName"]
+        if api.protocol_type == "HTTP":
+            self.default_route_settings = config.get(
+                "defaultRouteSettings", {"detailedMetricsEnabled": False}
+            )
+        elif api.protocol_type == "WEBSOCKET":
+            self.default_route_settings = config.get(
+                "defaultRouteSettings",
+                {
+                    "dataTraceEnabled": False,
+                    "detailedMetricsEnabled": False,
+                    "loggingLevel": "OFF",
+                },
+            )
+        self.access_log_settings = config.get("accessLogSettings")
+        self.auto_deploy = config.get("autoDeploy")
+        self.client_certificate_id = config.get("clientCertificateId")
+        self.description = config.get("description")
+        self.route_settings = config.get("routeSettings", {})
+        self.stage_variables = config.get("stageVariables", {})
+        self.tags = config.get("tags", {})
+        self.created = self.updated = unix_time()
+
+    def to_json(self) -> Dict[str, Any]:
+        dct = {
+            "stageName": self.name,
+            "defaultRouteSettings": self.default_route_settings,
+            "createdDate": self.created,
+            "lastUpdatedDate": self.updated,
+            "routeSettings": self.route_settings,
+            "stageVariables": self.stage_variables,
+            "tags": self.tags,
+        }
+        if self.access_log_settings:
+            dct["accessLogSettings"] = self.access_log_settings
+        if self.auto_deploy is not None:
+            dct["autoDeploy"] = self.auto_deploy
+        if self.client_certificate_id:
+            dct["clientCertificateId"] = self.client_certificate_id
+        if self.description:
+            dct["description"] = self.description
+        return dct
+
+
 class Authorizer(BaseModel):
     def __init__(
         self,
@@ -541,6 +589,7 @@ class Api(BaseModel):
         self.integrations: Dict[str, Integration] = dict()
         self.models: Dict[str, Model] = dict()
         self.routes: Dict[str, Route] = dict()
+        self.stages: Dict[str, Stage] = dict()
 
         self.arn = f"arn:aws:apigateway:{region}::/apis/{self.api_id}"
         self.backend.tag_resource(self.arn, tags)
@@ -550,6 +599,7 @@ class Api(BaseModel):
         self.integrations.clear()
         self.models.clear()
         self.routes.clear()
+        self.stages.clear()
 
     def delete_cors_configuration(self) -> None:
         self.cors_configuration = None
@@ -962,6 +1012,19 @@ class Api(BaseModel):
         route = self.get_route(route_id)
         return route.get_route_response(route_response_id)
 
+    def create_stage(self, config: Dict[str, Any]) -> Stage:
+        stage = Stage(api=self, config=config)
+        self.stages[stage.name] = stage
+        return stage
+
+    def get_stage(self, stage_name: str) -> Stage:
+        if stage_name not in self.stages:
+            raise StageNotFound
+        return self.stages[stage_name]
+
+    def delete_stage(self, stage_name: str) -> None:
+        self.stages.pop(stage_name, None)
+
     def to_json(self) -> Dict[str, Any]:
         return {
             "apiId": self.api_id,
@@ -1599,7 +1662,6 @@ class ApiGatewayV2Backend(BaseBackend):
         mutual_tls_authentication: Dict[str, str],
         tags: Dict[str, str],
     ) -> DomainName:
-
         if domain_name in self.domain_names.keys():
             raise DomainNameAlreadyExists
 
@@ -1701,5 +1763,21 @@ class ApiGatewayV2Backend(BaseBackend):
 
         del self.api_mappings[api_mapping_id]
 
+    def create_stage(self, api_id: str, config: Dict[str, Any]) -> Stage:
+        api = self.get_api(api_id)
+        return api.create_stage(config)
+
+    def get_stage(self, api_id: str, stage_name: str) -> Stage:
+        api = self.get_api(api_id)
+        return api.get_stage(stage_name)
+
+    def delete_stage(self, api_id: str, stage_name: str) -> None:
+        api = self.get_api(api_id)
+        api.delete_stage(stage_name)
+
+    def get_stages(self, api_id: str) -> List[Stage]:
+        api = self.get_api(api_id)
+        return list(api.stages.values())
+
 
 apigatewayv2_backends = BackendDict(ApiGatewayV2Backend, "apigatewayv2")
diff --git a/moto/apigatewayv2/responses.py b/moto/apigatewayv2/responses.py
index 91ac11669..82f1c143c 100644
--- a/moto/apigatewayv2/responses.py
+++ b/moto/apigatewayv2/responses.py
@@ -212,6 +212,22 @@ class ApiGatewayV2Response(BaseResponse):
         if request.method == "DELETE":
             return self.delete_api_mapping()
 
+    def stages(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
+        self.setup_class(request, full_url, headers)
+
+        if self.method == "POST":
+            return self.create_stage()
+        if self.method == "GET":
+            return self.get_stages()
+
+    def stage(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
+        self.setup_class(request, full_url, headers)
+
+        if self.method == "GET":
+            return self.get_stage()
+        if self.method == "DELETE":
+            return self.delete_stage()
+
     def create_api(self) -> TYPE_RESPONSE:
         params = json.loads(self.body)
 
@@ -854,3 +870,26 @@ class ApiGatewayV2Response(BaseResponse):
             domain_name=domain_name,
         )
         return 204, {}, ""
+
+    def create_stage(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-2]
+        config = json.loads(self.body)
+        stage = self.apigatewayv2_backend.create_stage(api_id, config)
+        return 200, {}, json.dumps(stage.to_json())
+
+    def get_stage(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-3]
+        stage_name = self.path.split("/")[-1]
+        stage = self.apigatewayv2_backend.get_stage(api_id, stage_name)
+        return 200, {}, json.dumps(stage.to_json())
+
+    def delete_stage(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-3]
+        stage_name = self.path.split("/")[-1]
+        self.apigatewayv2_backend.delete_stage(api_id, stage_name)
+        return 200, {}, "{}"
+
+    def get_stages(self) -> TYPE_RESPONSE:
+        api_id = self.path.split("/")[-2]
+        stages = self.apigatewayv2_backend.get_stages(api_id)
+        return 200, {}, json.dumps({"items": [st.to_json() for st in stages]})
diff --git a/moto/apigatewayv2/urls.py b/moto/apigatewayv2/urls.py
index b737112e8..7826904a6 100644
--- a/moto/apigatewayv2/urls.py
+++ b/moto/apigatewayv2/urls.py
@@ -26,6 +26,8 @@ url_paths = {
     "{0}/v2/apis/(?P<api_id>[^/]+)/routes/(?P<route_id>[^/]+)/routeresponses$": response_v2.route_responses,
     "{0}/v2/apis/(?P<api_id>[^/]+)/routes/(?P<route_id>[^/]+)/routeresponses/(?P<route_response_id>[^/]+)$": response_v2.route_response,
     "{0}/v2/apis/(?P<api_id>[^/]+)/routes/(?P<route_id>[^/]+)/requestparameters/(?P<request_parameter>[^/]+)$": response_v2.route_request_parameter,
+    "{0}/v2/apis/(?P<api_id>[^/]+)/stages$": response_v2.stages,
+    "{0}/v2/apis/(?P<api_id>[^/]+)/stages/(?P<stage_name>[^/]+)$": response_v2.stage,
     "{0}/v2/tags/(?P<resource_arn>[^/]+)$": response_v2.tags,
     "{0}/v2/tags/(?P<resource_arn_pt1>[^/]+)/apis/(?P<resource_arn_pt2>[^/]+)$": response_v2.tags,
     "{0}/v2/tags/(?P<resource_arn_pt1>[^/]+)/vpclinks/(?P<resource_arn_pt2>[^/]+)$": response_v2.tags,
diff --git a/moto/ec2/models/security_groups.py b/moto/ec2/models/security_groups.py
index 1e458e751..0e3c91665 100644
--- a/moto/ec2/models/security_groups.py
+++ b/moto/ec2/models/security_groups.py
@@ -550,16 +550,19 @@ class SecurityGroupBackend:
 
     def describe_security_group_rules(
         self, group_ids: Optional[List[str]] = None, filters: Any = None
-    ) -> List[SecurityRule]:
+    ) -> Dict[str, List[SecurityRule]]:
         matches = self.describe_security_groups(group_ids=group_ids, filters=filters)
         if not matches:
             raise InvalidSecurityGroupNotFoundError(
                 "No security groups found matching the filters provided."
             )
-        rules = []
+        rules = {}
         for group in matches:
-            rules.extend(group.ingress_rules)
-            rules.extend(group.egress_rules)
+            group_rules = []
+            group_rules.extend(group.ingress_rules)
+            group_rules.extend(group.egress_rules)
+            if group_rules:
+                rules[group.group_id] = group_rules
 
         return rules
 
diff --git a/moto/ec2/responses/security_groups.py b/moto/ec2/responses/security_groups.py
index 2742f6518..04f2f8b54 100644
--- a/moto/ec2/responses/security_groups.py
+++ b/moto/ec2/responses/security_groups.py
@@ -254,22 +254,25 @@ DESCRIBE_SECURITY_GROUP_RULES_RESPONSE = """
 <DescribeSecurityGroupRulesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
   <requestId>{{ request_id }}</requestId>
   <securityGroupRuleSet>
-    {% for rule in rules %}
-    <item>
-        {% if rule.from_port is not none %}
-        <fromPort>{{ rule.from_port }}</fromPort>
-        {% endif %}
-        {% if rule.to_port is not none %}
-          <toPort>{{ rule.to_port }}</toPort>
-        {% endif %}
-        {% if rule.ip_ranges %}
-          <cidrIpv4>{{ rule.ip_ranges[0]['CidrIp'] }}</cidrIpv4>
-        {% endif %}
-        <ipProtocol>{{ rule.ip_protocol }}</ipProtocol>
-        <groupOwnerId>{{ rule.owner_id }}</groupOwnerId>
-        <isEgress>{{ 'true' if rule.is_egress else 'false' }}</isEgress>
-        <securityGroupRuleId>{{ rule.id }}</securityGroupRuleId>
-    </item>
+    {% for group, rule_list in rules.items() %}
+        {% for rule in rule_list %}
+            <item>
+                {% if rule.from_port is not none %}
+                <fromPort>{{ rule.from_port }}</fromPort>
+                {% endif %}
+                {% if rule.to_port is not none %}
+                  <toPort>{{ rule.to_port }}</toPort>
+                {% endif %}
+                {% if rule.ip_ranges %}
+                  <cidrIpv4>{{ rule.ip_ranges[0]['CidrIp'] }}</cidrIpv4>
+                {% endif %}
+                <ipProtocol>{{ rule.ip_protocol }}</ipProtocol>
+                <groupId>{{ group }}</groupId>
+                <groupOwnerId>{{ rule.owner_id }}</groupOwnerId>
+                <isEgress>{{ 'true' if rule.is_egress else 'false' }}</isEgress>
+                <securityGroupRuleId>{{ rule.id }}</securityGroupRuleId>
+            </item>
+        {% endfor %}
     {% endfor %}
   </securityGroupRuleSet>
 </DescribeSecurityGroupRulesResponse>"""


Test patch:
diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt
index bd41fd494..4a5430eb5 100644
--- a/tests/terraformtests/terraform-tests.success.txt
+++ b/tests/terraformtests/terraform-tests.success.txt
@@ -30,6 +30,9 @@ apigatewayv2:
   - TestAccAPIGatewayV2IntegrationResponse
   - TestAccAPIGatewayV2Model
   - TestAccAPIGatewayV2Route
+  - TestAccAPIGatewayV2Stage_basicHTTP
+  - TestAccAPIGatewayV2Stage_basicWebSocket
+  - TestAccAPIGatewayV2Stage_disappears
   - TestAccAPIGatewayV2VPCLink
 appconfig:
   - TestAccAppConfigConfigurationProfileDataSource_basic
diff --git a/tests/test_apigatewayv2/test_apigatewayv2_stages.py b/tests/test_apigatewayv2/test_apigatewayv2_stages.py
new file mode 100644
index 000000000..3dfe47332
--- /dev/null
+++ b/tests/test_apigatewayv2/test_apigatewayv2_stages.py
@@ -0,0 +1,95 @@
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+from moto import mock_apigatewayv2
+
+
+@mock_apigatewayv2
+def test_create_stage__defaults():
+    client = boto3.client("apigatewayv2", "us-east-2")
+
+    api_id = client.create_api(Name="test-api", ProtocolType="HTTP")["ApiId"]
+
+    stages = client.get_stages(ApiId=api_id)
+    assert stages["Items"] == []
+
+    stage = client.create_stage(ApiId=api_id, StageName="my_stage")
+    assert stage["StageName"] == "my_stage"
+
+    resp = client.get_stage(ApiId=api_id, StageName="my_stage")
+    assert resp["StageName"] == "my_stage"
+
+    stages = client.get_stages(ApiId=api_id)
+    assert len(stages["Items"]) == 1
+
+    assert stages["Items"][0]["StageName"] == "my_stage"
+    assert stages["Items"][0]["DefaultRouteSettings"] == {
+        "DetailedMetricsEnabled": False
+    }
+    assert stages["Items"][0]["CreatedDate"]
+    assert stages["Items"][0]["LastUpdatedDate"]
+    assert stages["Items"][0]["RouteSettings"] == {}
+    assert stages["Items"][0]["StageVariables"] == {}
+    assert stages["Items"][0]["Tags"] == {}
+
+    client.delete_stage(ApiId=api_id, StageName="my_stage")
+
+    with pytest.raises(ClientError) as exc:
+        client.get_stage(ApiId=api_id, StageName="my_stage")
+    err = exc.value.response["Error"]
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "Invalid stage identifier specified"
+
+
+@mock_apigatewayv2
+def test_create_stage__defaults_for_websocket_api():
+    client = boto3.client("apigatewayv2", "us-east-2")
+
+    api_id = client.create_api(Name="test-api", ProtocolType="WEBSOCKET")["ApiId"]
+
+    client.create_stage(ApiId=api_id, StageName="my_stage")
+
+    stage = client.get_stage(ApiId=api_id, StageName="my_stage")
+    assert stage["StageName"] == "my_stage"
+    assert stage["DefaultRouteSettings"] == {
+        "DataTraceEnabled": False,
+        "DetailedMetricsEnabled": False,
+        "LoggingLevel": "OFF",
+    }
+
+
+@mock_apigatewayv2
+def test_create_stage():
+    client = boto3.client("apigatewayv2", "us-east-2")
+
+    api_id = client.create_api(Name="test-api", ProtocolType="HTTP")["ApiId"]
+
+    stage = client.create_stage(
+        ApiId=api_id,
+        StageName="my_stage",
+        AccessLogSettings={"Format": "JSON"},
+        AutoDeploy=True,
+        ClientCertificateId="ccid",
+        DefaultRouteSettings={"LoggingLevel": "INFO", "ThrottlingBurstLimit": 9000},
+        Description="my shiny stage",
+        RouteSettings={
+            "route1": {"DataTraceEnabled": True},
+        },
+        StageVariables={"sv1": "val1"},
+        Tags={"k1": "v1"},
+    )
+
+    assert stage["StageName"] == "my_stage"
+    assert stage["AccessLogSettings"] == {"Format": "JSON"}
+    assert stage["AutoDeploy"] is True
+    assert stage["ClientCertificateId"] == "ccid"
+    assert stage["DefaultRouteSettings"] == {
+        "LoggingLevel": "INFO",
+        "ThrottlingBurstLimit": 9000,
+    }
+    assert stage["Description"] == "my shiny stage"
+    assert stage["CreatedDate"]
+    assert stage["LastUpdatedDate"]
+    assert stage["RouteSettings"] == {"route1": {"DataTraceEnabled": True}}
+    assert stage["StageVariables"] == {"sv1": "val1"}
+    assert stage["Tags"] == {"k1": "v1"}
diff --git a/tests/test_budgets/test_budgets.py b/tests/test_budgets/test_budgets.py
index 90e2f8715..a58c7093a 100644
--- a/tests/test_budgets/test_budgets.py
+++ b/tests/test_budgets/test_budgets.py
@@ -2,8 +2,6 @@ import boto3
 import pytest
 
 from botocore.exceptions import ClientError
-import sure  # noqa # pylint: disable=unused-import
-from datetime import datetime
 from moto import mock_budgets
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 
@@ -20,7 +18,7 @@ def test_create_and_describe_budget_minimal_params():
             "BudgetType": "COST",
         },
     )
-    resp["ResponseMetadata"]["HTTPStatusCode"].should.equal(200)
+    assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200
 
     try:
         budget = client.describe_budget(AccountId=ACCOUNT_ID, BudgetName="testbudget")[
@@ -28,42 +26,41 @@ def test_create_and_describe_budget_minimal_params():
         ]
     except OverflowError:
         pytest.skip("This test requires 64-bit time_t")
-    budget.should.have.key("BudgetLimit")
-    budget["BudgetLimit"].should.have.key("Amount")
-    budget["BudgetLimit"]["Amount"].should.equal("10")
-    budget["BudgetLimit"].should.have.key("Unit")
-    budget["BudgetLimit"]["Unit"].should.equal("USD")
-    budget.should.have.key("BudgetName").equals("testbudget")
-    budget.should.have.key("TimeUnit").equals("DAILY")
-    budget.should.have.key("BudgetType").equals("COST")
-    budget.should.have.key("CalculatedSpend")
-    budget["CalculatedSpend"].should.have.key("ActualSpend")
-    budget["CalculatedSpend"]["ActualSpend"].should.equal(
-        {"Amount": "0", "Unit": "USD"}
-    )
-    budget["CalculatedSpend"].should.have.key("ForecastedSpend")
-    budget["CalculatedSpend"]["ForecastedSpend"].should.equal(
-        {"Amount": "0", "Unit": "USD"}
-    )
-    budget.should.have.key("CostTypes")
-    budget["CostTypes"].should.have.key("IncludeCredit").equals(True)
-    budget["CostTypes"].should.have.key("IncludeDiscount").equals(True)
-    budget["CostTypes"].should.have.key("IncludeOtherSubscription").equals(True)
-    budget["CostTypes"].should.have.key("IncludeRecurring").equals(True)
-    budget["CostTypes"].should.have.key("IncludeRefund").equals(True)
-    budget["CostTypes"].should.have.key("IncludeSubscription").equals(True)
-    budget["CostTypes"].should.have.key("IncludeSupport").equals(True)
-    budget["CostTypes"].should.have.key("IncludeTax").equals(True)
-    budget["CostTypes"].should.have.key("IncludeUpfront").equals(True)
-    budget["CostTypes"].should.have.key("UseAmortized").equals(False)
-    budget["CostTypes"].should.have.key("UseBlended").equals(False)
-    budget.should.have.key("LastUpdatedTime").should.be.a(datetime)
-    budget.should.have.key("TimePeriod")
-    budget["TimePeriod"].should.have.key("Start")
-    budget["TimePeriod"]["Start"].should.be.a(datetime)
-    budget["TimePeriod"].should.have.key("End")
-    budget["TimePeriod"]["End"].should.be.a(datetime)
-    budget.should.have.key("TimeUnit").equals("DAILY")
+    assert "BudgetLimit" in budget
+    assert "Amount" in budget["BudgetLimit"]
+    assert budget["BudgetLimit"]["Amount"] == "10"
+    assert "Unit" in budget["BudgetLimit"]
+    assert budget["BudgetLimit"]["Unit"] == "USD"
+    assert budget["BudgetName"] == "testbudget"
+    assert budget["TimeUnit"] == "DAILY"
+    assert budget["BudgetType"] == "COST"
+    assert "CalculatedSpend" in budget
+    assert "ActualSpend" in budget["CalculatedSpend"]
+    assert budget["CalculatedSpend"]["ActualSpend"] == {"Amount": "0", "Unit": "USD"}
+    assert "ForecastedSpend" in budget["CalculatedSpend"]
+    assert budget["CalculatedSpend"]["ForecastedSpend"] == {
+        "Amount": "0",
+        "Unit": "USD",
+    }
+    assert "CostTypes" in budget
+    assert budget["CostTypes"]["IncludeCredit"] is True
+    assert budget["CostTypes"]["IncludeDiscount"] is True
+    assert budget["CostTypes"]["IncludeOtherSubscription"] is True
+    assert budget["CostTypes"]["IncludeRecurring"] is True
+    assert budget["CostTypes"]["IncludeRefund"] is True
+    assert budget["CostTypes"]["IncludeSubscription"] is True
+    assert budget["CostTypes"]["IncludeSupport"] is True
+    assert budget["CostTypes"]["IncludeTax"] is True
+    assert budget["CostTypes"]["IncludeUpfront"] is True
+    assert budget["CostTypes"]["UseAmortized"] is False
+    assert budget["CostTypes"]["UseBlended"] is False
+    assert "LastUpdatedTime" in budget
+    assert "TimePeriod" in budget
+    assert "Start" in budget["TimePeriod"]
+    assert budget["TimePeriod"]["Start"]
+    assert "End" in budget["TimePeriod"]
+    assert budget["TimePeriod"]["End"]
+    assert budget["TimeUnit"] == "DAILY"
 
 
 @mock_budgets
@@ -90,10 +87,8 @@ def test_create_existing_budget():
             },
         )
     err = exc.value.response["Error"]
-    err["Code"].should.equal("DuplicateRecordException")
-    err["Message"].should.equal(
-        "Error creating budget: testb - the budget already exists."
-    )
+    assert err["Code"] == "DuplicateRecordException"
+    assert err["Message"] == "Error creating budget: testb - the budget already exists."
 
 
 @mock_budgets
@@ -105,9 +100,10 @@ def test_create_budget_without_limit_param():
             Budget={"BudgetName": "testb", "TimeUnit": "DAILY", "BudgetType": "COST"},
         )
     err = exc.value.response["Error"]
-    err["Code"].should.equal("InvalidParameterException")
-    err["Message"].should.equal(
-        "Unable to create/update budget - please provide one of the followings: Budget Limit/ Planned Budget Limit/ Auto Adjust Data"
+    assert err["Code"] == "InvalidParameterException"
+    assert (
+        err["Message"]
+        == "Unable to create/update budget - please provide one of the followings: Budget Limit/ Planned Budget Limit/ Auto Adjust Data"
     )
 
 
@@ -117,17 +113,15 @@ def test_describe_unknown_budget():
     with pytest.raises(ClientError) as exc:
         client.describe_budget(AccountId=ACCOUNT_ID, BudgetName="unknown")
     err = exc.value.response["Error"]
-    err["Code"].should.equal("NotFoundException")
-    err["Message"].should.equal(
-        "Unable to get budget: unknown - the budget doesn't exist."
-    )
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "Unable to get budget: unknown - the budget doesn't exist."
 
 
 @mock_budgets
 def test_describe_no_budgets():
     client = boto3.client("budgets", region_name="us-east-1")
     resp = client.describe_budgets(AccountId=ACCOUNT_ID)
-    resp.should.have.key("Budgets").equals([])
+    assert resp["Budgets"] == []
 
 
 @mock_budgets
@@ -147,7 +141,7 @@ def test_create_and_describe_all_budgets():
         res = client.describe_budgets(AccountId=ACCOUNT_ID)
     except OverflowError:
         pytest.skip("This test requires 64-bit time_t")
-    res["Budgets"].should.have.length_of(1)
+    assert len(res["Budgets"]) == 1
 
 
 @mock_budgets
@@ -164,10 +158,10 @@ def test_delete_budget():
     )
 
     resp = client.delete_budget(AccountId=ACCOUNT_ID, BudgetName="b1")
-    resp["ResponseMetadata"]["HTTPStatusCode"].should.equal(200)
+    assert resp["ResponseMetadata"]["HTTPStatusCode"] == 200
 
     res = client.describe_budgets(AccountId=ACCOUNT_ID)
-    res["Budgets"].should.have.length_of(0)
+    assert len(res["Budgets"]) == 0
 
 
 @mock_budgets
@@ -176,7 +170,8 @@ def test_delete_unknown_budget():
     with pytest.raises(ClientError) as exc:
         client.delete_budget(AccountId=ACCOUNT_ID, BudgetName="unknown")
     err = exc.value.response["Error"]
-    err["Code"].should.equal("NotFoundException")
-    err["Message"].should.equal(
-        "Unable to delete budget: unknown - the budget doesn't exist. Try creating it first. "
+    assert err["Code"] == "NotFoundException"
+    assert (
+        err["Message"]
+        == "Unable to delete budget: unknown - the budget doesn't exist. Try creating it first. "
     )
diff --git a/tests/test_budgets/test_notifications.py b/tests/test_budgets/test_notifications.py
index 7e1de0747..f9a306f25 100644
--- a/tests/test_budgets/test_notifications.py
+++ b/tests/test_budgets/test_notifications.py
@@ -2,7 +2,6 @@ import boto3
 import pytest
 
 from botocore.exceptions import ClientError
-import sure  # noqa # pylint: disable=unused-import
 from moto import mock_budgets
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 
@@ -37,13 +36,13 @@ def test_create_and_describe_notification():
     res = client.describe_notifications_for_budget(
         AccountId=ACCOUNT_ID, BudgetName="testbudget"
     )
-    res.should.have.key("Notifications").length_of(1)
+    assert len(res["Notifications"]) == 1
     notification = res["Notifications"][0]
-    notification.should.have.key("NotificationType").should.equal("ACTUAL")
-    notification.should.have.key("ComparisonOperator").should.equal("EQUAL_TO")
-    notification.should.have.key("Threshold").should.equal(123)
-    notification.should.have.key("ThresholdType").should.equal("ABSOLUTE_VALUE")
-    notification.should.have.key("NotificationState").should.equal("ALARM")
+    assert notification["NotificationType"] == "ACTUAL"
+    assert notification["ComparisonOperator"] == "EQUAL_TO"
+    assert notification["Threshold"] == 123
+    assert notification["ThresholdType"] == "ABSOLUTE_VALUE"
+    assert notification["NotificationState"] == "ALARM"
 
 
 @mock_budgets
@@ -85,24 +84,24 @@ def test_create_notification():
         },
         Subscribers=[{"SubscriptionType": "SNS", "Address": "arn:sns:topic:mytopic"}],
     )
-    res["ResponseMetadata"]["HTTPStatusCode"].should.equal(200)
+    assert res["ResponseMetadata"]["HTTPStatusCode"] == 200
 
     res = client.describe_notifications_for_budget(
         AccountId=ACCOUNT_ID, BudgetName="testbudget"
     )
-    res.should.have.key("Notifications").length_of(2)
+    assert len(res["Notifications"]) == 2
     n_1 = res["Notifications"][0]
-    n_1.should.have.key("NotificationType").should.equal("ACTUAL")
-    n_1.should.have.key("ComparisonOperator").should.equal("EQUAL_TO")
-    n_1.should.have.key("Threshold").should.equal(123)
-    n_1.should.have.key("ThresholdType").should.equal("ABSOLUTE_VALUE")
-    n_1.should.have.key("NotificationState").should.equal("ALARM")
+    assert n_1["NotificationType"] == "ACTUAL"
+    assert n_1["ComparisonOperator"] == "EQUAL_TO"
+    assert n_1["Threshold"] == 123
+    assert n_1["ThresholdType"] == "ABSOLUTE_VALUE"
+    assert n_1["NotificationState"] == "ALARM"
     n_2 = res["Notifications"][1]
-    n_2.should.have.key("NotificationType").should.equal("ACTUAL")
-    n_2.should.have.key("ComparisonOperator").should.equal("GREATER_THAN")
-    n_2.should.have.key("Threshold").should.equal(0)
-    n_2.should.have.key("ThresholdType").should.equal("ABSOLUTE_VALUE")
-    n_2.should.have.key("NotificationState").should.equal("OK")
+    assert n_2["NotificationType"] == "ACTUAL"
+    assert n_2["ComparisonOperator"] == "GREATER_THAN"
+    assert n_2["Threshold"] == 0
+    assert n_2["ThresholdType"] == "ABSOLUTE_VALUE"
+    assert n_2["NotificationState"] == "OK"
 
 
 @mock_budgets
@@ -123,10 +122,8 @@ def test_create_notification_unknown_budget():
             Subscribers=[{"SubscriptionType": "EMAIL", "Address": "admin@moto.com"}],
         )
     err = exc.value.response["Error"]
-    err["Code"].should.equal("NotFoundException")
-    err["Message"].should.equal(
-        "Unable to create notification - the budget doesn't exist."
-    )
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "Unable to create notification - the budget doesn't exist."
 
 
 @mock_budgets
@@ -171,7 +168,7 @@ def test_delete_notification():
     res = client.describe_notifications_for_budget(
         AccountId=ACCOUNT_ID, BudgetName="testbudget"
     )
-    res.should.have.key("Notifications").length_of(0)
+    assert len(res["Notifications"]) == 0
 
 
 @mock_budgets
@@ -191,7 +188,5 @@ def test_delete_notification_unknown_budget():
             },
         )
     err = exc.value.response["Error"]
-    err["Code"].should.equal("NotFoundException")
-    err["Message"].should.equal(
-        "Unable to delete notification - the budget doesn't exist."
-    )
+    assert err["Code"] == "NotFoundException"
+    assert err["Message"] == "Unable to delete notification - the budget doesn't exist."
diff --git a/tests/test_budgets/test_server.py b/tests/test_budgets/test_server.py
index 3bfec7297..3f7333939 100644
--- a/tests/test_budgets/test_server.py
+++ b/tests/test_budgets/test_server.py
@@ -1,5 +1,4 @@
 import json
-import sure  # noqa # pylint: disable=unused-import
 
 import moto.server as server
 from moto import mock_budgets
@@ -12,5 +11,5 @@ def test_budgets_describe_budgets():
 
     headers = {"X-Amz-Target": "AWSBudgetServiceGateway.DescribeBudgets"}
     resp = test_client.post("/", headers=headers, json={})
-    resp.status_code.should.equal(200)
-    json.loads(resp.data).should.equal({"Budgets": [], "nextToken": None})
+    assert resp.status_code == 200
+    assert json.loads(resp.data) == {"Budgets": [], "nextToken": None}
diff --git a/tests/test_ec2/test_security_groups.py b/tests/test_ec2/test_security_groups.py
index c7cc6dc87..70d859d8e 100644
--- a/tests/test_ec2/test_security_groups.py
+++ b/tests/test_ec2/test_security_groups.py
@@ -586,6 +586,7 @@ def test_create_and_describe_security_grp_rule():
     assert rules[0]["IsEgress"] is True
     assert rules[0]["IpProtocol"] == "-1"
     assert rules[0]["CidrIpv4"] == "0.0.0.0/0"
+    assert "GroupId" in rules[0]
 
 
 @mock_ec2