Repository: getmoto/moto
Pull request #6342 (2023-05-25 00:06:44)

ECR: Implement ImageAlreadyExistsException
I hit this error running some boto code and wanted to handle it in a unit test but then noticed that moto didn't implement it so I thought I'd give it a shot.


Patch:
diff --git a/.github/workflows/payment_notification.yml b/.github/workflows/payment_notification.yml
index b892611fa..57f92311b 100644
--- a/.github/workflows/payment_notification.yml
+++ b/.github/workflows/payment_notification.yml
@@ -15,6 +15,16 @@ jobs:
     - name: Leave comment
       uses: peter-evans/create-or-update-comment@v3
       with:
-        issue-number: ${{ context.issue.number }}
+        issue-number: ${{ github.event.pull_request.number }}
         body: |
-          Test message
+          Hi @${{ github.event.pull_request.user.login }},
+
+          Thank you for contributing to Moto!
+
+          To show our thanks, we'd like to share some of the donations that we've received with you. PR's like this are the big reason that Moto is as successful as it is - so it's only fair that you, as a contributor, gets to share the spoils.
+
+          We've created a companion website with more information:
+          https://payments.getmoto.org/
+
+          This website is still in beta, but everything should work. Feel free to open a bug or discussion if you run into any problems:
+          https://github.com/getmoto/payments
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 6243655d0..a741212a0 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -1197,7 +1197,7 @@
 
 ## comprehend
 <details>
-<summary>9% implemented</summary>
+<summary>13% implemented</summary>
 
 - [ ] batch_detect_dominant_language
 - [ ] batch_detect_entities
@@ -1235,9 +1235,9 @@
 - [ ] describe_topics_detection_job
 - [ ] detect_dominant_language
 - [ ] detect_entities
-- [ ] detect_key_phrases
-- [ ] detect_pii_entities
-- [ ] detect_sentiment
+- [X] detect_key_phrases
+- [X] detect_pii_entities
+- [X] detect_sentiment
 - [ ] detect_syntax
 - [ ] detect_targeted_sentiment
 - [ ] import_model
diff --git a/docs/docs/services/appsync.rst b/docs/docs/services/appsync.rst
index 861670d28..a68f03c94 100644
--- a/docs/docs/services/appsync.rst
+++ b/docs/docs/services/appsync.rst
@@ -54,7 +54,7 @@ appsync
 - [ ] get_domain_name
 - [ ] get_function
 - [X] get_graphql_api
-- [ ] get_introspection_schema
+- [X] get_introspection_schema
 - [ ] get_resolver
 - [X] get_schema_creation_status
 - [X] get_type
diff --git a/docs/docs/services/comprehend.rst b/docs/docs/services/comprehend.rst
index 47963412b..4b477a9c7 100644
--- a/docs/docs/services/comprehend.rst
+++ b/docs/docs/services/comprehend.rst
@@ -67,9 +67,9 @@ comprehend
 - [ ] describe_topics_detection_job
 - [ ] detect_dominant_language
 - [ ] detect_entities
-- [ ] detect_key_phrases
-- [ ] detect_pii_entities
-- [ ] detect_sentiment
+- [X] detect_key_phrases
+- [X] detect_pii_entities
+- [X] detect_sentiment
 - [ ] detect_syntax
 - [ ] detect_targeted_sentiment
 - [ ] import_model
diff --git a/moto/appsync/exceptions.py b/moto/appsync/exceptions.py
index 289d5eb77..e387052f5 100644
--- a/moto/appsync/exceptions.py
+++ b/moto/appsync/exceptions.py
@@ -12,3 +12,16 @@ class GraphqlAPINotFound(AppSyncExceptions):
     def __init__(self, api_id: str):
         super().__init__("NotFoundException", f"GraphQL API {api_id} not found.")
         self.description = json.dumps({"message": self.message})
+
+
+class GraphQLSchemaException(AppSyncExceptions):
+    code = 400
+
+    def __init__(self, message: str):
+        super().__init__("GraphQLSchemaException", message)
+        self.description = json.dumps({"message": self.message})
+
+
+class BadRequestException(AppSyncExceptions):
+    def __init__(self, message: str):
+        super().__init__("BadRequestException", message)
diff --git a/moto/appsync/models.py b/moto/appsync/models.py
index 819c659e2..7718f1f5b 100644
--- a/moto/appsync/models.py
+++ b/moto/appsync/models.py
@@ -1,4 +1,5 @@
 import base64
+import json
 from datetime import timedelta, datetime, timezone
 from typing import Any, Dict, Iterable, List, Optional, Tuple
 from moto.core import BaseBackend, BackendDict, BaseModel
@@ -6,7 +7,34 @@ from moto.core.utils import unix_time
 from moto.moto_api._internal import mock_random
 from moto.utilities.tagging_service import TaggingService
 
-from .exceptions import GraphqlAPINotFound
+from .exceptions import GraphqlAPINotFound, GraphQLSchemaException, BadRequestException
+
+# AWS custom scalars and directives
+# https://github.com/dotansimha/graphql-code-generator/discussions/4311#discussioncomment-2921796
+AWS_CUSTOM_GRAPHQL = """scalar AWSTime
+scalar AWSDateTime
+scalar AWSTimestamp
+scalar AWSEmail
+scalar AWSJSON
+scalar AWSURL
+scalar AWSPhone
+scalar AWSIPAddress
+scalar BigInt
+scalar Double
+
+directive @aws_subscribe(mutations: [String!]!) on FIELD_DEFINITION
+
+# Allows transformer libraries to deprecate directive arguments.
+directive @deprecated(reason: String!) on INPUT_FIELD_DEFINITION | ENUM
+
+directive @aws_auth(cognito_groups: [String!]!) on FIELD_DEFINITION
+directive @aws_api_key on FIELD_DEFINITION | OBJECT
+directive @aws_iam on FIELD_DEFINITION | OBJECT
+directive @aws_oidc on FIELD_DEFINITION | OBJECT
+directive @aws_cognito_user_pools(
+  cognito_groups: [String!]
+) on FIELD_DEFINITION | OBJECT
+"""
 
 
 class GraphqlSchema(BaseModel):
@@ -49,6 +77,27 @@ class GraphqlSchema(BaseModel):
             self.status = "FAILED"
             self.parse_error = str(e)
 
+    def get_introspection_schema(self, format_: str, include_directives: bool) -> str:
+        from graphql import (
+            print_schema,
+            build_client_schema,
+            introspection_from_schema,
+            build_schema,
+        )
+
+        schema = build_schema(self.definition + AWS_CUSTOM_GRAPHQL)
+        introspection_data = introspection_from_schema(schema, descriptions=False)
+
+        if not include_directives:
+            introspection_data["__schema"]["directives"] = []
+
+        if format_ == "SDL":
+            return print_schema(build_client_schema(introspection_data))
+        elif format_ == "JSON":
+            return json.dumps(introspection_data)
+        else:
+            raise BadRequestException(message=f"Invalid format {format_} given")
+
 
 class GraphqlAPIKey(BaseModel):
     def __init__(self, description: str, expires: Optional[int]):
@@ -254,6 +303,15 @@ class AppSyncBackend(BaseBackend):
             raise GraphqlAPINotFound(api_id)
         return self.graphql_apis[api_id]
 
+    def get_graphql_schema(self, api_id: str) -> GraphqlSchema:
+        graphql_api = self.get_graphql_api(api_id)
+        if not graphql_api.graphql_schema:
+            # When calling get_introspetion_schema without a graphql schema
+            # the response GraphQLSchemaException exception includes InvalidSyntaxError
+            # in the message. This might not be the case for other methods.
+            raise GraphQLSchemaException(message="InvalidSyntaxError")
+        return graphql_api.graphql_schema
+
     def delete_graphql_api(self, api_id: str) -> None:
         self.graphql_apis.pop(api_id)
 
diff --git a/moto/appsync/responses.py b/moto/appsync/responses.py
index 0a51bf8b1..c815e6d76 100644
--- a/moto/appsync/responses.py
+++ b/moto/appsync/responses.py
@@ -69,6 +69,11 @@ class AppSyncResponse(BaseResponse):
         if request.method == "GET":
             return self.get_type()
 
+    def schema(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
+        self.setup_class(request, full_url, headers)
+        if request.method == "GET":
+            return self.get_introspection_schema()
+
     def create_graphql_api(self) -> TYPE_RESPONSE:
         params = json.loads(self.body)
         name = params.get("name")
@@ -230,3 +235,19 @@ class AppSyncResponse(BaseResponse):
             api_id=api_id, type_name=type_name, type_format=type_format
         )
         return 200, {}, json.dumps(dict(type=graphql_type))
+
+    def get_introspection_schema(self) -> TYPE_RESPONSE:  # type: ignore[return]
+        api_id = self.path.split("/")[-2]
+        format_ = self.querystring.get("format")[0]  # type: ignore[index]
+        if self.querystring.get("includeDirectives"):
+            include_directives = (
+                self.querystring.get("includeDirectives")[0].lower() == "true"  # type: ignore[index]
+            )
+        else:
+            include_directives = True
+        graphql_schema = self.appsync_backend.get_graphql_schema(api_id=api_id)
+
+        schema = graphql_schema.get_introspection_schema(
+            format_=format_, include_directives=include_directives
+        )
+        return 200, {}, schema
diff --git a/moto/appsync/urls.py b/moto/appsync/urls.py
index 1b078f720..0b04b48d2 100644
--- a/moto/appsync/urls.py
+++ b/moto/appsync/urls.py
@@ -15,6 +15,7 @@ url_paths = {
     "{0}/v1/apis/(?P<api_id>[^/]+)/apikeys$": response.api_key,
     "{0}/v1/apis/(?P<api_id>[^/]+)/apikeys/(?P<api_key_id>[^/]+)$": response.api_key_individual,
     "{0}/v1/apis/(?P<api_id>[^/]+)/schemacreation$": response.schemacreation,
+    "{0}/v1/apis/(?P<api_id>[^/]+)/schema$": response.schema,
     "{0}/v1/tags/(?P<resource_arn>.+)$": response.tags,
     "{0}/v1/tags/(?P<resource_arn_pt1>.+)/(?P<resource_arn_pt2>.+)$": response.tags,
     "{0}/v1/apis/(?P<api_id>[^/]+)/types/(?P<type_name>.+)$": response.types,
diff --git a/moto/comprehend/exceptions.py b/moto/comprehend/exceptions.py
index c4031b203..0530214af 100644
--- a/moto/comprehend/exceptions.py
+++ b/moto/comprehend/exceptions.py
@@ -1,5 +1,6 @@
 """Exceptions raised by the comprehend service."""
 from moto.core.exceptions import JsonRESTError
+from typing import List
 
 
 class ResourceNotFound(JsonRESTError):
@@ -8,3 +9,22 @@ class ResourceNotFound(JsonRESTError):
             "ResourceNotFoundException",
             "RESOURCE_NOT_FOUND: Could not find specified resource.",
         )
+
+
+class DetectPIIValidationException(JsonRESTError):
+    def __init__(self, language: str, all_languages: List[str]) -> None:
+        all_languages_str = str(all_languages).replace("'", "")
+        super().__init__(
+            "ValidationException",
+            f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+            f"Member must satisfy enum value set: {all_languages_str}",
+        )
+
+
+class TextSizeLimitExceededException(JsonRESTError):
+    def __init__(self, size: int) -> None:
+        super().__init__(
+            "TextSizeLimitExceededException",
+            "Input text size exceeds limit. Max length of request text allowed is 100000 bytes while in "
+            f"this request the text size is {size} bytes",
+        )
diff --git a/moto/comprehend/models.py b/moto/comprehend/models.py
index c33b72b4c..c06dac041 100644
--- a/moto/comprehend/models.py
+++ b/moto/comprehend/models.py
@@ -2,9 +2,62 @@
 
 from moto.core import BaseBackend, BackendDict, BaseModel
 from moto.utilities.tagging_service import TaggingService
-from .exceptions import ResourceNotFound
+from .exceptions import (
+    ResourceNotFound,
+    DetectPIIValidationException,
+    TextSizeLimitExceededException,
+)
 from typing import Any, Dict, List, Iterable
 
+CANNED_DETECT_RESPONSE = [
+    {
+        "Score": 0.9999890923500061,
+        "Type": "NAME",
+        "BeginOffset": 50,
+        "EndOffset": 58,
+    },
+    {
+        "Score": 0.9999966621398926,
+        "Type": "EMAIL",
+        "BeginOffset": 230,
+        "EndOffset": 259,
+    },
+    {
+        "Score": 0.9999954700469971,
+        "Type": "BANK_ACCOUNT_NUMBER",
+        "BeginOffset": 334,
+        "EndOffset": 349,
+    },
+]
+
+CANNED_PHRASES_RESPONSE = [
+    {
+        "Score": 0.9999890923500061,
+        "BeginOffset": 50,
+        "EndOffset": 58,
+    },
+    {
+        "Score": 0.9999966621398926,
+        "BeginOffset": 230,
+        "EndOffset": 259,
+    },
+    {
+        "Score": 0.9999954700469971,
+        "BeginOffset": 334,
+        "EndOffset": 349,
+    },
+]
+
+CANNED_SENTIMENT_RESPONSE = {
+    "Sentiment": "NEUTRAL",
+    "SentimentScore": {
+        "Positive": 0.008101312443614006,
+        "Negative": 0.0002824589901138097,
+        "Neutral": 0.9916020035743713,
+        "Mixed": 1.4156351426208857e-05,
+    },
+}
+
 
 class EntityRecognizer(BaseModel):
     def __init__(
@@ -53,6 +106,24 @@ class EntityRecognizer(BaseModel):
 class ComprehendBackend(BaseBackend):
     """Implementation of Comprehend APIs."""
 
+    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/comprehend/client/detect_key_phrases.html
+    detect_key_phrases_languages = [
+        "ar",
+        "hi",
+        "ko",
+        "zh-TW",
+        "ja",
+        "zh",
+        "de",
+        "pt",
+        "en",
+        "it",
+        "fr",
+        "es",
+    ]
+    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/comprehend/client/detect_pii_entities.html
+    detect_pii_entities_languages = ["en"]
+
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
         self.recognizers: Dict[str, EntityRecognizer] = dict()
@@ -130,5 +201,35 @@ class ComprehendBackend(BaseBackend):
     def untag_resource(self, resource_arn: str, tag_keys: List[str]) -> None:
         self.tagger.untag_resource_using_names(resource_arn, tag_keys)
 
+    def detect_pii_entities(self, text: str, language: str) -> List[Dict[str, Any]]:
+        if language not in self.detect_pii_entities_languages:
+            raise DetectPIIValidationException(
+                language, self.detect_pii_entities_languages
+            )
+        text_size = len(text)
+        if text_size > 100000:
+            raise TextSizeLimitExceededException(text_size)
+        return CANNED_DETECT_RESPONSE
+
+    def detect_key_phrases(self, text: str, language: str) -> List[Dict[str, Any]]:
+        if language not in self.detect_key_phrases_languages:
+            raise DetectPIIValidationException(
+                language, self.detect_key_phrases_languages
+            )
+        text_size = len(text)
+        if text_size > 100000:
+            raise TextSizeLimitExceededException(text_size)
+        return CANNED_PHRASES_RESPONSE
+
+    def detect_sentiment(self, text: str, language: str) -> Dict[str, Any]:
+        if language not in self.detect_key_phrases_languages:
+            raise DetectPIIValidationException(
+                language, self.detect_key_phrases_languages
+            )
+        text_size = len(text)
+        if text_size > 5000:
+            raise TextSizeLimitExceededException(text_size)
+        return CANNED_SENTIMENT_RESPONSE
+
 
 comprehend_backends = BackendDict(ComprehendBackend, "comprehend")
diff --git a/moto/comprehend/responses.py b/moto/comprehend/responses.py
index 6e1d7b0b2..d439d853d 100644
--- a/moto/comprehend/responses.py
+++ b/moto/comprehend/responses.py
@@ -95,3 +95,24 @@ class ComprehendResponse(BaseResponse):
         tag_keys = params.get("TagKeys")
         self.comprehend_backend.untag_resource(resource_arn, tag_keys)
         return "{}"
+
+    def detect_pii_entities(self) -> str:
+        params = json.loads(self.body)
+        text = params.get("Text")
+        language = params.get("LanguageCode")
+        resp = self.comprehend_backend.detect_pii_entities(text, language)
+        return json.dumps(dict(Entities=resp))
+
+    def detect_key_phrases(self) -> str:
+        params = json.loads(self.body)
+        text = params.get("Text")
+        language = params.get("LanguageCode")
+        resp = self.comprehend_backend.detect_key_phrases(text, language)
+        return json.dumps(dict(KeyPhrases=resp))
+
+    def detect_sentiment(self) -> str:
+        params = json.loads(self.body)
+        text = params.get("Text")
+        language = params.get("LanguageCode")
+        resp = self.comprehend_backend.detect_sentiment(text, language)
+        return json.dumps(resp)
diff --git a/moto/dynamodb/parsing/partiql.py b/moto/dynamodb/parsing/partiql.py
index 78a92c345..b148921f1 100644
--- a/moto/dynamodb/parsing/partiql.py
+++ b/moto/dynamodb/parsing/partiql.py
@@ -1,4 +1,7 @@
-from typing import Any, Dict, List
+from typing import Any, Dict, List, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from py_partiql_parser import QueryMetadata
 
 
 def query(
@@ -9,7 +12,7 @@ def query(
     return DynamoDBStatementParser(source_data).parse(statement, parameters)
 
 
-def get_query_metadata(statement: str) -> Any:
+def get_query_metadata(statement: str) -> "QueryMetadata":
     from py_partiql_parser import DynamoDBStatementParser
 
     return DynamoDBStatementParser.get_query_metadata(query=statement)
diff --git a/moto/ec2/models/key_pairs.py b/moto/ec2/models/key_pairs.py
index b2dab1235..b480b037d 100644
--- a/moto/ec2/models/key_pairs.py
+++ b/moto/ec2/models/key_pairs.py
@@ -1,4 +1,5 @@
 from typing import Any, Dict, List
+from datetime import datetime
 
 from moto.core import BaseModel
 from ..exceptions import (
@@ -14,6 +15,7 @@ from ..utils import (
     generic_filter,
     random_key_pair_id,
 )
+from moto.core.utils import iso_8601_datetime_with_milliseconds
 
 
 class KeyPair(BaseModel):
@@ -22,6 +24,11 @@ class KeyPair(BaseModel):
         self.name = name
         self.fingerprint = fingerprint
         self.material = material
+        self.create_time = datetime.utcnow()
+
+    @property
+    def created_iso_8601(self) -> str:
+        return iso_8601_datetime_with_milliseconds(self.create_time)
 
     def get_filter_value(self, filter_name: str) -> str:
         if filter_name == "key-name":
diff --git a/moto/ec2/responses/key_pairs.py b/moto/ec2/responses/key_pairs.py
index 343f20bde..65f6d2842 100644
--- a/moto/ec2/responses/key_pairs.py
+++ b/moto/ec2/responses/key_pairs.py
@@ -37,6 +37,7 @@ DESCRIBE_KEY_PAIRS_RESPONSE = """<DescribeKeyPairsResponse xmlns="http://ec2.ama
     <keySet>
     {% for keypair in keypairs %}
       <item>
+           <createTime>{{ keypair.created_iso_8601 }}</createTime>
            <keyPairId>{{ keypair.id }}</keyPairId>
            <keyName>{{ keypair.name }}</keyName>
            <keyFingerprint>{{ keypair.fingerprint }}</keyFingerprint>
diff --git a/moto/ecr/exceptions.py b/moto/ecr/exceptions.py
index 07b9c3742..9150bd6be 100644
--- a/moto/ecr/exceptions.py
+++ b/moto/ecr/exceptions.py
@@ -105,6 +105,26 @@ class ImageNotFoundException(JsonRESTError):
         )
 
 
+class ImageAlreadyExistsException(JsonRESTError):
+    code = 400
+
+    def __init__(
+        self,
+        repository_name: str,
+        registry_id: str,
+        digest: str,
+        image_tag: str,
+    ):
+        super().__init__(
+            error_type="ImageAlreadyExistsException",
+            message=(
+                f"Image with digest '{digest}' and tag '{image_tag}' already exists "
+                f"in the repository with name '{repository_name}' "
+                f"in registry with id '{registry_id}'"
+            ),
+        )
+
+
 class InvalidParameterException(JsonRESTError):
     code = 400
 
diff --git a/moto/ecr/models.py b/moto/ecr/models.py
index ba80a78a2..b4a7256a6 100644
--- a/moto/ecr/models.py
+++ b/moto/ecr/models.py
@@ -15,6 +15,7 @@ from moto.ecr.exceptions import (
     RepositoryAlreadyExistsException,
     RepositoryNotEmptyException,
     InvalidParameterException,
+    ImageAlreadyExistsException,
     RepositoryPolicyNotFoundException,
     LifecyclePolicyNotFoundException,
     RegistryPolicyNotFoundException,
@@ -566,19 +567,28 @@ class ECRBackend(BaseBackend):
             else:
                 image_manifest_mediatype = parsed_image_manifest["mediaType"]
 
-        # Tags are unique, so delete any existing image with this tag first
-        self.batch_delete_image(
-            repository_name=repository_name, image_ids=[{"imageTag": image_tag}]
-        )
-
-        existing_images = list(
+        existing_images_with_matching_manifest = list(
             filter(
                 lambda x: x.response_object["imageManifest"] == image_manifest,
                 repository.images,
             )
         )
 
-        if not existing_images:
+        # if an image with a matching manifest exists and it is tagged,
+        # trying to put the same image with the same tag will result in an
+        # ImageAlreadyExistsException
+
+        try:
+            existing_images_with_matching_tag = list(
+                filter(
+                    lambda x: x.response_object["imageTag"] == image_tag,
+                    repository.images,
+                )
+            )
+        except KeyError:
+            existing_images_with_matching_tag = []
+
+        if not existing_images_with_matching_manifest:
             # this image is not in ECR yet
             image = Image(
                 self.account_id,
@@ -588,11 +598,27 @@ class ECRBackend(BaseBackend):
                 image_manifest_mediatype,
             )
             repository.images.append(image)
+            if existing_images_with_matching_tag:
+                # Tags are unique, so delete any existing image with this tag first
+                # (or remove the tag if the image has more than one tag)
+                self.batch_delete_image(
+                    repository_name=repository_name, image_ids=[{"imageTag": image_tag}]
+                )
             return image
         else:
-            # update existing image
-            existing_images[0].update_tag(image_tag)
-            return existing_images[0]
+            # this image is in ECR
+            image = existing_images_with_matching_manifest[0]
+            if image.image_tag == image_tag:
+                raise ImageAlreadyExistsException(
+                    registry_id=repository.registry_id,
+                    image_tag=image_tag,
+                    digest=image.get_image_digest(),
+                    repository_name=repository_name,
+                )
+            else:
+                # update existing image
+                image.update_tag(image_tag)
+                return image
 
     def batch_get_image(
         self,
diff --git a/moto/s3/models.py b/moto/s3/models.py
index c7f4e9527..ae744e532 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -2486,9 +2486,14 @@ class S3Backend(BaseBackend, CloudWatchMetricProvider):
 
             use_headers = input_details["CSV"].get("FileHeaderInfo", "") == "USE"
             query_input = csv_to_json(query_input, use_headers)
+        query_result = parse_query(query_input, select_query)
+        from py_partiql_parser import SelectEncoder
+
         return [
-            json.dumps(x, indent=None, separators=(",", ":")).encode("utf-8")
-            for x in parse_query(query_input, select_query)
+            json.dumps(x, indent=None, separators=(",", ":"), cls=SelectEncoder).encode(
+                "utf-8"
+            )
+            for x in query_result
         ]
 
 
diff --git a/setup.cfg b/setup.cfg
index 6d37533a8..d807a85df 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -52,7 +52,7 @@ all =
     openapi-spec-validator>=0.2.8
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
     aws-xray-sdk!=0.96,>=0.93
     setuptools
 server =
@@ -66,7 +66,7 @@ server =
     openapi-spec-validator>=0.2.8
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
     aws-xray-sdk!=0.96,>=0.93
     setuptools
     flask!=2.2.0,!=2.2.1
@@ -100,7 +100,7 @@ cloudformation =
     openapi-spec-validator>=0.2.8
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
     aws-xray-sdk!=0.96,>=0.93
     setuptools
 cloudfront =
@@ -123,10 +123,10 @@ dms =
 ds = sshpubkeys>=3.1.0
 dynamodb =
     docker>=3.0.0
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
 dynamodbstreams =
     docker>=3.0.0
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
 ebs = sshpubkeys>=3.1.0
 ec2 = sshpubkeys>=3.1.0
 ec2instanceconnect =
@@ -183,7 +183,7 @@ route53 =
 route53resolver = sshpubkeys>=3.1.0
 s3 =
     PyYAML>=5.1
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
 s3control =
 sagemaker =
 sdb =


Test patch:
diff --git a/tests/test_appsync/test_appsync_schema.py b/tests/test_appsync/test_appsync_schema.py
index 0934687e8..dfc15e7c4 100644
--- a/tests/test_appsync/test_appsync_schema.py
+++ b/tests/test_appsync/test_appsync_schema.py
@@ -1,6 +1,8 @@
 import boto3
 import sure  # noqa # pylint: disable=unused-import
-
+import json
+from botocore.exceptions import ClientError
+import pytest
 from moto import mock_appsync
 
 schema = """type Mutation {
@@ -23,6 +25,33 @@ schema {
 
 }"""
 
+schema_with_directives = """type Mutation {
+    putPost(id: ID!, title: String!): Post
+}
+
+"My custom post type"
+type Post {
+    id: ID!
+    title: String!
+    createdAt: AWSDateTime!
+}
+
+type Query {
+    singlePost(id: ID!): Post
+}
+
+schema {
+    query: Query
+    mutation: Mutation
+    subscription: Subscription
+}
+
+type Subscription {
+    onPostCreated(id: ID!): Post @aws_subscribe(mutations: ["putPost"])
+}
+
+"""
+
 
 @mock_appsync
 def test_start_schema_creation():
@@ -86,3 +115,116 @@ def test_get_type_from_schema():
     query_type = client.get_type(apiId=api_id, typeName="Query", format="SDL")["type"]
     query_type.should.have.key("name").equals("Query")
     query_type.shouldnt.have.key("description")
+
+
+@mock_appsync
+def test_get_introspection_schema_raise_gql_schema_error_if_no_schema():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    with pytest.raises(ClientError) as exc:
+        client.get_introspection_schema(apiId=api_id, format="SDL")
+    err = exc.value.response["Error"]
+    err["Code"].should.equal("GraphQLSchemaException")
+    # AWS API appears to return InvalidSyntaxError if no schema exists
+    err["Message"].should.equal("InvalidSyntaxError")
+
+
+@mock_appsync
+def test_get_introspection_schema_sdl():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(apiId=api_id, definition=schema.encode("utf-8"))
+
+    resp = client.get_introspection_schema(apiId=api_id, format="SDL")
+    schema_sdl = resp["schema"].read().decode("utf-8")
+    schema_sdl.should.contain("putPost(")
+    schema_sdl.should.contain("singlePost(id: ID!): Post")
+
+
+@mock_appsync
+def test_get_introspection_schema_json():
+
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(apiId=api_id, definition=schema.encode("utf-8"))
+
+    resp = client.get_introspection_schema(apiId=api_id, format="JSON")
+    schema_json = json.loads(resp["schema"].read().decode("utf-8"))
+    schema_json.should.have.key("__schema")
+    schema_json["__schema"].should.have.key("queryType")
+    schema_json["__schema"].should.have.key("mutationType")
+    schema_json["__schema"].should.have.key("subscriptionType")
+    schema_json["__schema"].should.have.key("types")
+    schema_json["__schema"].should.have.key("directives")
+
+
+@mock_appsync
+def test_get_introspection_schema_bad_format():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(apiId=api_id, definition=schema.encode("utf-8"))
+
+    with pytest.raises(ClientError) as exc:
+        client.get_introspection_schema(apiId=api_id, format="NotAFormat")
+    err = exc.value.response["Error"]
+
+    err["Code"].should.equal("BadRequestException")
+    err["Message"].should.equal("Invalid format NotAFormat given")
+
+
+@mock_appsync
+def test_get_introspection_schema_include_directives_true():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(
+        apiId=api_id, definition=schema_with_directives.encode("utf-8")
+    )
+
+    resp = client.get_introspection_schema(
+        apiId=api_id, format="SDL", includeDirectives=True
+    )
+
+    schema_sdl = resp["schema"].read().decode("utf-8")
+
+    schema_sdl.should.contain("@aws_subscribe")
+
+
+@mock_appsync
+def test_get_introspection_schema_include_directives_false():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(
+        apiId=api_id, definition=schema_with_directives.encode("utf-8")
+    )
+
+    resp = client.get_introspection_schema(
+        apiId=api_id, format="SDL", includeDirectives=False
+    )
+
+    schema_sdl = resp["schema"].read().decode("utf-8")
+
+    schema_sdl.shouldnt.contain("@aws_subscribe")
diff --git a/tests/test_comprehend/test_comprehend.py b/tests/test_comprehend/test_comprehend.py
index d5f4aab8b..321a6eff1 100644
--- a/tests/test_comprehend/test_comprehend.py
+++ b/tests/test_comprehend/test_comprehend.py
@@ -4,6 +4,11 @@ import pytest
 import sure  # noqa # pylint: disable=unused-import
 from botocore.exceptions import ClientError
 from moto import mock_comprehend
+from moto.comprehend.models import (
+    CANNED_DETECT_RESPONSE,
+    CANNED_PHRASES_RESPONSE,
+    CANNED_SENTIMENT_RESPONSE,
+)
 
 # See our Development Tips on writing tests for hints on how to write good tests:
 # http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html
@@ -198,3 +203,174 @@ def test_delete_entity_recognizer():
     err["Message"].should.equal(
         "RESOURCE_NOT_FOUND: Could not find specified resource."
     )
+
+
+@mock_comprehend
+def test_detect_pii_entities():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+
+    # Execute
+    result = client.detect_pii_entities(Text=sample_text, LanguageCode="en")
+
+    # Verify
+    assert "Entities" in result
+    assert result["Entities"] == CANNED_DETECT_RESPONSE
+
+
+@mock_comprehend
+def test_detect_pii_entities_invalid_languages():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+    language = "es"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_pii_entities(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+        f"Member must satisfy enum value set: [en]"
+    )
+
+
+@mock_comprehend
+def test_detect_pii_entities_text_too_large():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    size = 100001
+    sample_text = "x" * size
+    language = "en"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_pii_entities(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "TextSizeLimitExceededException"
+    assert (
+        err["Message"]
+        == "Input text size exceeds limit. Max length of request text allowed is 100000 bytes "
+        f"while in this request the text size is {size} bytes"
+    )
+
+
+@mock_comprehend
+def test_detect_key_phrases():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+
+    # Execute
+    result = client.detect_key_phrases(Text=sample_text, LanguageCode="en")
+
+    # Verify
+    assert "KeyPhrases" in result
+    assert result["KeyPhrases"] == CANNED_PHRASES_RESPONSE
+
+
+@mock_comprehend
+def test_detect_key_phrases_invalid_languages():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+    language = "blah"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_key_phrases(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+        f"Member must satisfy enum value set: [ar, hi, ko, zh-TW, ja, zh, de, pt, en, it, fr, es]"
+    )
+
+
+@mock_comprehend
+def test_detect_detect_key_phrases_text_too_large():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    size = 100002
+    sample_text = "x" * size
+    language = "en"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_key_phrases(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "TextSizeLimitExceededException"
+    assert (
+        err["Message"]
+        == "Input text size exceeds limit. Max length of request text allowed is 100000 bytes "
+        f"while in this request the text size is {size} bytes"
+    )
+
+
+@mock_comprehend
+def test_detect_sentiment():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+
+    # Execute
+    result = client.detect_sentiment(Text=sample_text, LanguageCode="en")
+
+    # Verify
+    del result["ResponseMetadata"]
+    assert result == CANNED_SENTIMENT_RESPONSE
+
+
+@mock_comprehend
+def test_detect_sentiment_invalid_languages():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+    language = "blah"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_sentiment(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+        "Member must satisfy enum value set: [ar, hi, ko, zh-TW, ja, zh, de, pt, en, it, fr, es]"
+    )
+
+
+@mock_comprehend
+def test_detect_sentiment_text_too_large():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    size = 5001
+    sample_text = "x" * size
+    language = "en"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_sentiment(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "TextSizeLimitExceededException"
+    assert (
+        err["Message"]
+        == "Input text size exceeds limit. Max length of request text allowed is 100000 bytes while "
+        f"in this request the text size is {size} bytes"
+    )
diff --git a/tests/test_ec2/test_key_pairs.py b/tests/test_ec2/test_key_pairs.py
index 5826dc500..c0af777dc 100644
--- a/tests/test_ec2/test_key_pairs.py
+++ b/tests/test_ec2/test_key_pairs.py
@@ -1,4 +1,5 @@
 import pytest
+from datetime import datetime
 
 import sure  # noqa # pylint: disable=unused-import
 import boto3
@@ -101,6 +102,7 @@ def test_key_pairs_create_boto3():
     kps[0].should.have.key("KeyPairId")
     kps[0].should.have.key("KeyName").equal(key_name)
     kps[0].should.have.key("KeyFingerprint")
+    kps[0].should.have.key("CreateTime").should.be.a(datetime)
 
 
 @mock_ec2
diff --git a/tests/test_ecr/test_ecr_boto3.py b/tests/test_ecr/test_ecr_boto3.py
index 8f7deaa4b..6249ab867 100644
--- a/tests/test_ecr/test_ecr_boto3.py
+++ b/tests/test_ecr/test_ecr_boto3.py
@@ -506,6 +506,7 @@ def test_put_image_with_multiple_tags():
 def test_put_multiple_images_with_same_tag():
     repo_name = "testrepo"
     image_tag = "my-tag"
+    manifest = json.dumps(_create_image_manifest())
 
     client = boto3.client("ecr", "us-east-1")
     client.create_repository(repositoryName=repo_name)
@@ -513,10 +514,12 @@ def test_put_multiple_images_with_same_tag():
     image_1 = client.put_image(
         repositoryName=repo_name,
         imageTag=image_tag,
-        imageManifest=json.dumps(_create_image_manifest()),
+        imageManifest=manifest,
     )["image"]["imageId"]["imageDigest"]
 
-    # We should overwrite the first image
+    # We should overwrite the first image because the first image
+    # only has one tag
+
     image_2 = client.put_image(
         repositoryName=repo_name,
         imageTag=image_tag,
@@ -530,11 +533,11 @@ def test_put_multiple_images_with_same_tag():
     images.should.have.length_of(1)
     images[0]["imageDigest"].should.equal(image_2)
 
-    # Image with different tags are allowed
+    # Same image with different tags is allowed
     image_3 = client.put_image(
         repositoryName=repo_name,
         imageTag="different-tag",
-        imageManifest=json.dumps(_create_image_manifest()),
+        imageManifest=manifest,
     )["image"]["imageId"]["imageDigest"]
 
     images = client.describe_images(repositoryName=repo_name)["imageDetails"]
@@ -542,6 +545,42 @@ def test_put_multiple_images_with_same_tag():
     set([img["imageDigest"] for img in images]).should.equal({image_2, image_3})
 
 
+@mock_ecr
+def test_put_same_image_with_same_tag():
+    repo_name = "testrepo"
+    image_tag = "my-tag"
+    manifest = json.dumps(_create_image_manifest())
+
+    client = boto3.client("ecr", "us-east-1")
+    client.create_repository(repositoryName=repo_name)
+
+    image_1 = client.put_image(
+        repositoryName=repo_name,
+        imageTag=image_tag,
+        imageManifest=manifest,
+    )["image"]["imageId"]["imageDigest"]
+
+    with pytest.raises(ClientError) as e:
+        client.put_image(
+            repositoryName=repo_name,
+            imageTag=image_tag,
+            imageManifest=manifest,
+        )["image"]["imageId"]["imageDigest"]
+
+    ex = e.value
+    ex.operation_name.should.equal("PutImage")
+    ex.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.response["Error"]["Code"].should.contain("ImageAlreadyExistsException")
+    ex.response["Error"]["Message"].should.equal(
+        f"Image with digest '{image_1}' and tag '{image_tag}' already exists "
+        f"in the repository with name '{repo_name}' in registry with id '{ACCOUNT_ID}'"
+    )
+
+    images = client.describe_images(repositoryName=repo_name)["imageDetails"]
+
+    images.should.have.length_of(1)
+
+
 @mock_ecr
 def test_list_images():
     client = boto3.client("ecr", region_name="us-east-1")
diff --git a/tests/test_s3/test_s3_select.py b/tests/test_s3/test_s3_select.py
index aea6cc9eb..8b9d77a3f 100644
--- a/tests/test_s3/test_s3_select.py
+++ b/tests/test_s3/test_s3_select.py
@@ -6,7 +6,7 @@ from unittest import TestCase
 from uuid import uuid4
 
 
-SIMPLE_JSON = {"a1": "b1", "a2": "b2"}
+SIMPLE_JSON = {"a1": "b1", "a2": "b2", "a3": None}
 SIMPLE_JSON2 = {"a1": "b2", "a3": "b3"}
 SIMPLE_LIST = [SIMPLE_JSON, SIMPLE_JSON2]
 SIMPLE_CSV = """a,b,c
@@ -47,7 +47,14 @@ class TestS3Select(TestCase):
             OutputSerialization={"JSON": {"RecordDelimiter": ","}},
         )
         result = list(x["Payload"])
-        result.should.contain({"Records": {"Payload": b'{"a1":"b1","a2":"b2"},'}})
+        result.should.contain(
+            {"Records": {"Payload": b'{"a1":"b1","a2":"b2","a3":null},'}}
+        )
+
+        # Verify result is valid JSON
+        json.loads(result[0]["Records"]["Payload"][0:-1].decode("utf-8"))
+
+        # Verify result contains metadata
         result.should.contain(
             {
                 "Stats": {