Skip to content
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

[Showstopper] Cannot use continuation token in API 7.0 and above (thus limiting number of results) #461

Open
jfthuong opened this issue May 7, 2023 · 7 comments

Comments

@jfthuong
Copy link

jfthuong commented May 7, 2023

Hi, in earlier versions of the API (until 6.0.0b4), when making a request on some items (e.g. WorkItems, Test Suites, ...), you had a response object with a value and a continuation_token that you could use to make a new request and continue parsing.

For example, here is the prototype of such function:

    def get_test_suites_for_plan(self, project, plan_id, expand=None, continuation_token=None, as_tree_view=None):
        """GetTestSuitesForPlan.
        [Preview API] Get test suites for plan.
        :param str project: Project ID or project name
        :param int plan_id: ID of the test plan for which suites are requested.
        :param str expand: Include the children suites and testers details.
        :param str continuation_token: If the list of suites returned is not complete, a continuation token to query next batch of suites is included in the response header as "x-ms-continuationtoken". Omit this parameter to get the first batch of test suites.
        :param bool as_tree_view: If the suites returned should be in a tree structure.
        :rtype: :class:`<GetTestSuitesForPlanResponseValue>`

So you could do something like:

resp = client.get_test_suites_for_plan(project, my_plan_id)
suites = resp.value
while resp.continuation_token:
    resp = client.get_test_suites_for_plan(project, my_plan_id)
    suites += resp.value

With more recent versions (in particular 7.0), you now get a list returned (but with the limit of size imposed by the API).

For example, a version of similar function would be:

    def get_test_suites_for_plan(self, project, plan_id, expand=None, continuation_token=None, as_tree_view=None):
        """GetTestSuitesForPlan.
        [Preview API] Get test suites for plan.
        :param str project: Project ID or project name
        :param int plan_id: ID of the test plan for which suites are requested.
        :param str expand: Include the children suites and testers details.
        :param str continuation_token: If the list of suites returned is not complete, a continuation token to query next batch of suites is included in the response header as "x-ms-continuationtoken". Omit this parameter to get the first batch of test suites.
        :param bool as_tree_view: If the suites returned should be in a tree structure.
        :rtype: :class:`<[TestSuite]> <azure.devops.v6_0.test_plan.models.[TestSuite]>`
        """

How to retrieve the continuation token to continue parsing the other results?

@jfthuong
Copy link
Author

jfthuong commented May 7, 2023

An idea of how to proceed would be to patch the _send method of the base Client (in client.py) by storing the continuation token of the last request.

Something like:

class Client(object):
    """Client.
    :param str base_url: Service URL
    :param Authentication creds: Authenticated credentials.
    """

    def __init__(self, base_url=None, creds=None):
        ...
        self.continuation_token_last_request = None


    def _send(self, http_method, location_id, version, route_values=None,
              query_parameters=None, content=None, media_type='application/json', accept_media_type='application/json',
              additional_headers=None):
        ...
        response = self._send_request(request=request, headers=headers, content=content, media_type=media_type)
        ...
        # Patch: Workaround to be able to see the continuation token of the response
        self.continuation_token_last_request = self._get_continuation_token(response)

        return response

And we could use as such:

>>> suite_plan_id = 68185
>>> suites = test_plan_client.get_test_suites_for_plan(project, suite_plan_id)
>>> len(suites), test_plan_client.continuation_token_last_request
(200, '339901;0')
>>> while test_plan_client.continuation_token_last_request is not None:
...     suites += test_plan_client.get_test_suites_for_plan(project, suite_plan_id, continuation_token=test_plan_client.continuation_token_last_request)
>>> len(suites), test_plan_client.continuation_token_last_request
(214, None)

Any better way or idea?

Let's note that there is already a method Client._get_continuation_token but it does not seem to be used in the current version of the API.

@jfthuong
Copy link
Author

jfthuong commented May 9, 2023

FYI... I have done a function to temporarily patch as I described above:

"""Patching ADO Client to retrieve continuation token

Related to question in following issue:
https://github.com/microsoft/azure-devops-python-api/issues/461
"""
import logging
from typing import Optional, cast

from azure.devops import _models
from azure.devops.client import Client
from azure.devops.client_configuration import ClientConfiguration
from msrest import Deserializer, Serializer
from msrest.service_client import ServiceClient

logger = logging.getLogger("azure.devops.client")


# pylint: disable=super-init-not-called

class ClientPatch(Client):
    """Client.
    :param str base_url: Service URL
    :param Authentication creds: Authenticated credentials.
    """

    def __init__(self, base_url=None, creds=None):
        self.config = ClientConfiguration(base_url)
        self.config.credentials = creds
        self._client = ServiceClient(creds, config=self.config)
        _base_client_models = {
            k: v for k, v in _models.__dict__.items() if isinstance(v, type)
        }
        self._base_deserialize = Deserializer(_base_client_models)
        self._base_serialize = Serializer(_base_client_models)
        self._all_host_types_locations = {}
        self._locations = {}
        self._suppress_fedauth_redirect = True
        self._force_msa_pass_through = True
        self.normalized_url = Client._normalize_url(base_url)
        self.continuation_token_last_request: Optional[str] = None

    def _send(
        self,
        http_method,
        location_id,
        version,
        route_values=None,
        query_parameters=None,
        content=None,
        media_type="application/json",
        accept_media_type="application/json",
        additional_headers=None,
    ):
        request = self._create_request_message(
            http_method=http_method,
            location_id=location_id,
            route_values=route_values,
            query_parameters=query_parameters,
        )
        negotiated_version = self._negotiate_request_version(
            self._get_resource_location(self.normalized_url, location_id), version
        )
        negotiated_version = cast(str, negotiated_version)

        if version != negotiated_version:
            logger.info(
                "Negotiated api version from '%s' down to '%s'."
                " This means the client is newer than the server.",
                version,
                negotiated_version,
            )
        else:
            logger.debug("Api version '%s'", negotiated_version)

        # Construct headers
        headers = {
            "Content-Type": media_type + "; charset=utf-8",
            "Accept": accept_media_type + ";api-version=" + negotiated_version,
        }
        if additional_headers is not None:
            for key in additional_headers:
                headers[key] = str(additional_headers[key])
        if self.config.additional_headers is not None:
            for key in self.config.additional_headers:
                headers[key] = self.config.additional_headers[key]
        if self._suppress_fedauth_redirect:
            headers["X-TFS-FedAuthRedirect"] = "Suppress"
        if self._force_msa_pass_through:
            headers["X-VSS-ForceMsaPassThrough"] = "true"
        if (
            Client._session_header_key in Client._session_data
            and Client._session_header_key not in headers
        ):
            headers[Client._session_header_key] = Client._session_data[
                Client._session_header_key
            ]
        response = self._send_request(
            request=request, headers=headers, content=content, media_type=media_type
        )
        if Client._session_header_key in response.headers:
            Client._session_data[Client._session_header_key] = response.headers[
                Client._session_header_key
            ]

        # Patch: Workaround to be able to see the continuation token of the response
        self.continuation_token_last_request = self._get_continuation_token(response)

        return response



def patch_azure_devops_client():
    """Patch the Azure DevOps client to see the continuation token of the response"""
    # pylint: disable=protected-access
    Client.__init__ = ClientPatch.__init__  # type: ignore
    Client._send = ClientPatch._send  # type: ignore

Just importing and calling the patch_azure_devops_client before creating a client will add a continuation_token_last_request to the client. Not ideal but at least it works for me.

I'm really curious to know the real way to find the token though...

@jfthuong
Copy link
Author

Maybe it is related to an old and closed issue:
#152

@jfthuong jfthuong changed the title How to retrieve continuation token in API 7.0 and above? Continuation token in API 7.0 and above have disappeared May 22, 2023
@jeffyoungstrom
Copy link

The fact that the sample code in the project README does not run due to this issue and there has been no response here from the team does not instill a lot of confidence, does it?

@jfthuong
Copy link
Author

jfthuong commented Jun 5, 2023

From one Jeff to another... yes, you are right.

And no much more luck on StackOverflow, even though I launched a bounty.

@treyBohon
Copy link

I just tried updating my team's stack and ran into the same issue. I'm confused how it's been this way for so long. Either we're both missing something, or this has in fact been a problem in v6.0.0 for a long time, but you can bypass it by using 6.0.0b4 and specify the v5.1. But that gets back to sanity checking - has it really been broken for about 4 years since 6.0.0 came out and everyone is collectively just working around it by using the v5.1 API?

I'm guessing v6.0.0b4 will work for at least a couple of more years, so we're just planning on checking on this later.

@jfthuong jfthuong changed the title Continuation token in API 7.0 and above have disappeared [Showstopper] Continuation token in API 7.0 and above have disappeared Sep 20, 2023
@jfthuong jfthuong changed the title [Showstopper] Continuation token in API 7.0 and above have disappeared [Showstopper] Cannot use continuation token in API 7.0 and above (thus limiting number of results) Sep 20, 2023
PPRiphagen added a commit to PPRiphagen/azure-devops-python-api that referenced this issue Dec 7, 2023
This functionality was removed after v5.1, but this prohibts getting a full list of builds.
See bug report microsoft#461
PPRiphagen added a commit to PPRiphagen/azure-devops-python-api that referenced this issue Dec 7, 2023
This functionality was present in v5.1, but removed afterwards.
See microsoft#461
PPRiphagen added a commit to PPRiphagen/azure-devops-python-api that referenced this issue Dec 7, 2023
This functionality was present in v5.1, but removed afterwards.
See microsoft#461
PPRiphagen added a commit to PPRiphagen/azure-devops-python-api that referenced this issue Dec 7, 2023
This functionality was present in v5.1, but removed afterwards.
See microsoft#461
@natescherer
Copy link

Worth mentioning that you now have the choose between using Python 3.12 OR having functional pagination as only 7.1.0b4 works on Python 3.12.

Would love to see some TLC from Microsoft on this. Contemplating just using the azure cli's devops extension via subprocess.run since this project appears to be more or less broken and unmaintained.

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

No branches or pull requests

4 participants