Skip to content

fix: mocking errors for /product-projections/search #146

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from

Conversation

pshiu
Copy link

@pshiu pshiu commented Dec 21, 2023

Hi! Don't know if this project accepts PRs, but in case it does:

Description

On tests of commercetools-python-sdk SDK calls to Product Projection Search, we see error:

marshmallow.exceptions.ValidationError: {'filter': ['Unknown field.'], 'markmatchingvariants': ['Unknown field.']}

In the first 2 commits, this PR attempts to correct the schema and urls used in commercetools.testing.product_projections's ProductProjectionsBackend.search() to squash this error.

In the last 2 commits, this PR proposes minor changes to the coverage GitHub Action job to squash bugs from the breaking changes in tooling dependencies, such as one in actions/upload-artifact@3 to actions/upload-artifact@4 of not being able to upload artifacts with the same name.

Additional Information

See commit messages for detailed explanation of changes.

Full error logs that prompted change are below. (But see commit messages first.)

Output of error resolved by a5b5f27

Note in the full logs below how this mocker matches to to get_by_id() instead of search():

<...>
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/product_projections.py:85: in get_by_id
    params = utils.parse_request_params(_ProductProjectionQuerySchema, request)
<...>

Full logs, starting with where search() is called:

% pytest
<...truncated...>
commerce_coordinator/apps/commercetools/clients.py:240: in get_product_variant_by_course_run
    results = self.base_client.product_projections.search(False, filter=f"variants.sku:\"{cr_id}\"").results
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/services/product_projections.py:294: in search
    return self._client._get(
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/client.py:36: in _get
    response = self._http_client.get(self._base_url + endpoint, params=params)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests/sessions.py:602: in get
    return self.request("GET", url, **kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests_oauthlib/oauth2_session.py:521: in request
    return super(OAuth2Session, self).request(
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests/sessions.py:589: in request
    resp = self.send(prep, **send_kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests_mock/mocker.py:185: in _fake_send
    return _original_send(session, request, **kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests/sessions.py:703: in send
    r = adapter.send(request, **kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests_mock/adapter.py:248: in send
    resp = matcher(request)
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/abstract.py:174: in _matcher
    response = callback(request, **path_match.groupdict())
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/product_projections.py:85: in get_by_id
    params = utils.parse_request_params(_ProductProjectionQuerySchema, request)
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/utils.py:32: in parse_request_params
    obj = schema().load(params)
../../.virtualenvs/workarea/lib/python3.8/site-packages/marshmallow/schema.py:722: in load
    return self._do_load(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <_ProductProjectionQuerySchema(many=False)>
data = {'filter': 'variants.sku:"course-v1:michiganx+injurypreventionx+1t2021"', 'markmatchingvariants': 'false', 'predicate_var': {}}

    def _do_load(
        self,
        data: (
            typing.Mapping[str, typing.Any]
            | typing.Iterable[typing.Mapping[str, typing.Any]]
        ),
        *,
        many: bool | None = None,
        partial: bool | types.StrSequenceOrSet | None = None,
        unknown: str | None = None,
        postprocess: bool = True,
    ):
        """Deserialize `data`, returning the deserialized result.
        This method is private API.
    
        :param data: The data to deserialize.
        :param many: Whether to deserialize `data` as a collection. If `None`, the
            value for `self.many` is used.
        :param partial: Whether to validate required fields. If its
            value is an iterable, only fields listed in that iterable will be
            ignored will be allowed missing. If `True`, all fields will be allowed missing.
            If `None`, the value for `self.partial` is used.
        :param unknown: Whether to exclude, include, or raise an error for unknown
            fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
            If `None`, the value for `self.unknown` is used.
        :param postprocess: Whether to run post_load methods..
        :return: Deserialized data
        """
        error_store = ErrorStore()
        errors = {}  # type: dict[str, list[str]]
        many = self.many if many is None else bool(many)
        unknown = (
            self.unknown
            if unknown is None
            else validate_unknown_parameter_value(unknown)
        )
        if partial is None:
            partial = self.partial
        # Run preprocessors
        if self._has_processors(PRE_LOAD):
            try:
                processed_data = self._invoke_load_processors(
                    PRE_LOAD, data, many=many, original_data=data, partial=partial
                )
            except ValidationError as err:
                errors = err.normalized_messages()
                result = None  # type: list | dict | None
        else:
            processed_data = data
        if not errors:
            # Deserialize data
            result = self._deserialize(
                processed_data,
                error_store=error_store,
                many=many,
                partial=partial,
                unknown=unknown,
            )
            # Run field-level validation
            self._invoke_field_validators(
                error_store=error_store, data=result, many=many
            )
            # Run schema-level validation
            if self._has_processors(VALIDATES_SCHEMA):
                field_errors = bool(error_store.errors)
                self._invoke_schema_validators(
                    error_store=error_store,
                    pass_many=True,
                    data=result,
                    original_data=data,
                    many=many,
                    partial=partial,
                    field_errors=field_errors,
                )
                self._invoke_schema_validators(
                    error_store=error_store,
                    pass_many=False,
                    data=result,
                    original_data=data,
                    many=many,
                    partial=partial,
                    field_errors=field_errors,
                )
            errors = error_store.errors
            # Run post processors
            if not errors and postprocess and self._has_processors(POST_LOAD):
                try:
                    result = self._invoke_load_processors(
                        POST_LOAD,
                        result,
                        many=many,
                        original_data=data,
                        partial=partial,
                    )
                except ValidationError as err:
                    errors = err.normalized_messages()
        if errors:
            exc = ValidationError(errors, data=data, valid_data=result)
            self.handle_error(exc, data, many=many, partial=partial)
>           raise exc
E           marshmallow.exceptions.ValidationError: {'filter': ['Unknown field.'], 'markmatchingvariants': ['Unknown field.']}

../../.virtualenvs/workarea/lib/python3.8/site-packages/marshmallow/schema.py:909: ValidationError

Output of error resolved by 59c7623

Note in full logs below that now call is to search():

<...>
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/product_projections.py:57: in search
    params = utils.parse_request_params(_ProductProjectionQuerySchema, request)
<...>

but _ProductProjectionQuerySchema is used instead of _ProductProjectionSearchSchema:

<...>
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/product_projections.py:57: in search
    params = utils.parse_request_params(_ProductProjectionQuerySchema, request)
<...>

Full logs:

% pytest
<...truncated...>
commerce_coordinator/apps/commercetools/clients.py:240: in get_product_variant_by_course_run
    results = self.base_client.product_projections.search(False, filter=f"variants.sku:\"{cr_id}\"").results
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/services/product_projections.py:294: in search
    return self._client._get(
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/client.py:36: in _get
    response = self._http_client.get(self._base_url + endpoint, params=params)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests/sessions.py:602: in get
    return self.request("GET", url, **kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests_oauthlib/oauth2_session.py:521: in request
    return super(OAuth2Session, self).request(
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests/sessions.py:589: in request
    resp = self.send(prep, **send_kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests_mock/mocker.py:185: in _fake_send
    return _original_send(session, request, **kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests/sessions.py:703: in send
    r = adapter.send(request, **kwargs)
../../.virtualenvs/workarea/lib/python3.8/site-packages/requests_mock/adapter.py:248: in send
    resp = matcher(request)
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/abstract.py:174: in _matcher
    response = callback(request, **path_match.groupdict())
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/product_projections.py:57: in search
    params = utils.parse_request_params(_ProductProjectionQuerySchema, request)
../../.virtualenvs/workarea/lib/python3.8/site-packages/commercetools/testing/utils.py:32: in parse_request_params
    obj = schema().load(params)
../../.virtualenvs/workarea/lib/python3.8/site-packages/marshmallow/schema.py:722: in load
    return self._do_load(
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

self = <_ProductProjectionQuerySchema(many=False)>
data = {'filter': 'variants.sku:"course-v1:michiganx+injurypreventionx+1t2021"', 'markmatchingvariants': 'false', 'predicate_var': {}}

    def _do_load(
        self,
        data: (
            typing.Mapping[str, typing.Any]
            | typing.Iterable[typing.Mapping[str, typing.Any]]
        ),
        *,
        many: bool | None = None,
        partial: bool | types.StrSequenceOrSet | None = None,
        unknown: str | None = None,
        postprocess: bool = True,
    ):
        """Deserialize `data`, returning the deserialized result.
        This method is private API.
    
        :param data: The data to deserialize.
        :param many: Whether to deserialize `data` as a collection. If `None`, the
            value for `self.many` is used.
        :param partial: Whether to validate required fields. If its
            value is an iterable, only fields listed in that iterable will be
            ignored will be allowed missing. If `True`, all fields will be allowed missing.
            If `None`, the value for `self.partial` is used.
        :param unknown: Whether to exclude, include, or raise an error for unknown
            fields in the data. Use `EXCLUDE`, `INCLUDE` or `RAISE`.
            If `None`, the value for `self.unknown` is used.
        :param postprocess: Whether to run post_load methods..
        :return: Deserialized data
        """
        error_store = ErrorStore()
        errors = {}  # type: dict[str, list[str]]
        many = self.many if many is None else bool(many)
        unknown = (
            self.unknown
            if unknown is None
            else validate_unknown_parameter_value(unknown)
        )
        if partial is None:
            partial = self.partial
        # Run preprocessors
        if self._has_processors(PRE_LOAD):
            try:
                processed_data = self._invoke_load_processors(
                    PRE_LOAD, data, many=many, original_data=data, partial=partial
                )
            except ValidationError as err:
                errors = err.normalized_messages()
                result = None  # type: list | dict | None
        else:
            processed_data = data
        if not errors:
            # Deserialize data
            result = self._deserialize(
                processed_data,
                error_store=error_store,
                many=many,
                partial=partial,
                unknown=unknown,
            )
            # Run field-level validation
            self._invoke_field_validators(
                error_store=error_store, data=result, many=many
            )
            # Run schema-level validation
            if self._has_processors(VALIDATES_SCHEMA):
                field_errors = bool(error_store.errors)
                self._invoke_schema_validators(
                    error_store=error_store,
                    pass_many=True,
                    data=result,
                    original_data=data,
                    many=many,
                    partial=partial,
                    field_errors=field_errors,
                )
                self._invoke_schema_validators(
                    error_store=error_store,
                    pass_many=False,
                    data=result,
                    original_data=data,
                    many=many,
                    partial=partial,
                    field_errors=field_errors,
                )
            errors = error_store.errors
            # Run post processors
            if not errors and postprocess and self._has_processors(POST_LOAD):
                try:
                    result = self._invoke_load_processors(
                        POST_LOAD,
                        result,
                        many=many,
                        original_data=data,
                        partial=partial,
                    )
                except ValidationError as err:
                    errors = err.normalized_messages()
        if errors:
            exc = ValidationError(errors, data=data, valid_data=result)
            self.handle_error(exc, data, many=many, partial=partial)
>           raise exc
E           marshmallow.exceptions.ValidationError: {'filter': ['Unknown field.'], 'markmatchingvariants': ['Unknown field.']}

../../.virtualenvs/workarea/lib/python3.8/site-packages/marshmallow/schema.py:909: ValidationError

Testing Information

We tested these changes by verifying that the test errors in edx/commerce-coordinator#135 (see this comment) turned green on our locals when manually loading this branch of the SDK in our code.

We verified format/tests/coverage jobs ran successfully in edx#1.

Commercetools' /product-projections/search endpoint allows both GET and
POST methods:

    https://docs.commercetools.com/api/projects/products-search#product-projection-search

labd/commercetools-python-sdk uses a GET method when using
/product-projections/search:

    https://github.com/labd/commercetools-python-sdk/blob/3bd9fd1d94c8640c28f0fc82cdd4796a6dc6e97c/src/commercetools/services/product_projections.py#L294

However, its testing package only recognizes POST methods for
/product-projections/search.

This commit adds GET as a supported method so the testing package will
mock product projection searches from the services package of this
repository.
….search()

_ProductProjectionSearchSchema is defined here:

    https://github.com/labd/commercetools-python-sdk/blob/3bd9fd1d94c8640c28f0fc82cdd4796a6dc6e97c/src/commercetools/services/product_projections.py#L32C7-L32C37

It generally matches the valid parameters of Commercetools'
/product-projections/search endpoint:

    https://docs.commercetools.com/api/projects/products-search#query-parameters

commercetools.services.ProductProjectionService.search() uses
_ProductProjectionSearchSchema:

    https://github.com/labd/commercetools-python-sdk/blob/3bd9fd1d94c8640c28f0fc82cdd4796a6dc6e97c/src/commercetools/services/product_projections.py#L292

However, commercetools.testing.ProductProjectionsBackend.search() uses
_ProductProjectionQuerySchema instead of _ProductProjectionSearchSchema.

This commit changes the schema used by
commercetools.testing.ProductProjectionsBackend.search() so that
marshmellow will validate search() requests against the correct schema.
A GitHub Action matrix lets you run a workflow multiple times using
different parameters:

    https://docs.github.com/en/actions/using-jobs/using-a-matrix-for-your-jobs

The matrix of the python-test.yml's GitHub Action is uploading a
coverage file per python-version in the matrix.

This is no longer allowed by actions/upload-artifact. See its README:

    https://github.com/actions/upload-artifact/blob/cf8714cfeaba5687a442b9bcb85b29e23f468dfa/README.md#not-uploading-to-the-same-artifact

Fixes errors like:

    ```
    Run actions/upload-artifact@master
    With the provided path, there will be 1 file uploaded
    Artifact name is valid!
    Root directory input is valid!
    Error: Failed to CreateArtifact: Received non-retryable error: Failed request: (409) Conflict: an artifact with this name already exists on the workflow run
    ```

The example above from:

    https://github.com/edx/commercetools-python-sdk/actions/runs/7289547220/job/19864387471?pr=1_
From GitHub Actions:

    ```
    Run tox -e coverage-report
    coverage-report: install_deps> python -I -m pip install 'coverage[toml]'
    coverage-report: freeze> python -m pip freeze --all
    coverage-report: coverage==7.3.4,pip==23.3.1,setuptools==69.0.2,tomli==2.0.1,wheel==0.42.0
    coverage-report: commands[0]> coverage combine
    Combined data file .coverage.fv-az1116-506.1828.XNaBMOPx
    coverage-report: commands[1]> coverage xml
    No source for code: '/home/runner/work/commercetools-python-sdk/commercetools-python-sdk/.tox/py310/lib/python3.10/site-packages/commercetools/__init__.py'.
    coverage-report: exit 1 (0.19 seconds) /home/runner/work/commercetools-python-sdk/commercetools-python-sdk> coverage xml pid=1847
      coverage-report: FAIL code 1 (2.56=setup[2.25]+cmd[0.12,0.19] seconds)
      evaluation failed :( (3.03 seconds)
    Error: Process completed with exit code 1.
    ```

This commit hypothesizes that coverage.py is unable to find the source
file because the `*` glob may not cover the `py310/lib/python3.10/` in
between the directories `.tox/` and `site-packages/`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant