Skip to content

Commit

Permalink
Merge pull request #990 from sirosen/globus-clientinfo
Browse files Browse the repository at this point in the history
Implement the X-Globus-Client-Info header as a feature of transport objects
  • Loading branch information
sirosen authored Jun 25, 2024
2 parents 7964b8d + 41c2c78 commit 89642d8
Show file tree
Hide file tree
Showing 7 changed files with 277 additions and 1 deletion.
7 changes: 7 additions & 0 deletions changelog.d/20240621_130106_sirosen_globus_clientinfo.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Added
~~~~~

- Clients will now emit a ``X-Globus-Client-Info`` header which reports the
version of the ``globus-sdk`` which was used to send a request. Users may
customize this header further by modifying the ``globus_clientinfo`` object
attached to the transport object. (:pr:`NUMBER`)
8 changes: 8 additions & 0 deletions docs/core/transport.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ Transport
:members:
:member-order: bysource

``RequestsTransport`` objects include an attribute, ``globus_client_info`` which
provides the ``X-Globus-Client-Info`` header which is sent to Globus services.
It is an instance of ``GlobusClientInfo``:

.. autoclass:: globus_sdk.transport.GlobusClientInfo
:members:
:member-order: bysource

Retries
~~~~~~~

Expand Down
2 changes: 2 additions & 0 deletions src/globus_sdk/transport/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from ._clientinfo import GlobusClientInfo
from .encoders import FormRequestEncoder, JSONRequestEncoder, RequestEncoder
from .requests import RequestsTransport
from .retry import (
Expand All @@ -20,4 +21,5 @@
"RequestEncoder",
"JSONRequestEncoder",
"FormRequestEncoder",
"GlobusClientInfo",
)
108 changes: 108 additions & 0 deletions src/globus_sdk/transport/_clientinfo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
This module models a read-write object representation of the
X-Globus-Client-Info header.
The spec for X-Globus-Client-Info is documented, in brief, in the GlobusClientInfo class
docstring.
"""

from __future__ import annotations

import typing as t

from globus_sdk import exc
from globus_sdk.version import __version__

_RESERVED_CHARS = ";,="


class GlobusClientInfo:
"""
An implementation of X-Globus-Client-Info as an object. This header encodes a
mapping of multiple products to versions and potentially other information.
Values can be added to a clientinfo object via the ``add()`` method.
The object always initializes itself to start with
product=python-sdk,version=...
using the current package version information.
.. rubric:: ``X-Globus-Client-Info`` Specification
Header Name: ``X-Globus-Client-Info``
Header Value:
- A semicolon (``;``) separated list of client information.
- Client information is a comma-separated list of ``=`` delimited key-value pairs.
Well-known values for client-information are:
- ``product``: A unique identifier of the product.
- ``version``: Relevant version information for the product.
- Based on the above, the characters ``;,=`` should be considered reserved and
should NOT be included in client information values to ensure proper parsing.
.. rubric:: Example Headers
.. code-block:: none
X-Globus-Client-Info: product=python-sdk,version=3.32.1
X-Globus-Client-Info: product=python-sdk,version=3.32.1;product=cli,version=4.0.0a1
.. note::
The ``GlobusClientInfo`` object is not guaranteed to reject all invalid usages.
For example, ``product`` is required to be unique per header, and users are
expected to enforce this in their usage.
""" # noqa: E501

def __init__(self) -> None:
self.infos: list[str] = []
self.add({"product": "python-sdk", "version": __version__})

def __bool__(self) -> bool:
"""Check if there are any values present."""
return bool(self.infos)

def format(self) -> str:
"""Format as a header value."""
return ";".join(self.infos)

def add(self, value: str | dict[str, str]) -> None:
"""
Add an item to the clientinfo. The item is either already formatted
as a string, or is a dict containing values to format.
:param value: The element to add to the client-info. If it is a dict,
it may not contain reserved characters in any keys or values. If it is a
string, it cannot contain the ``;`` separator.
"""
if not isinstance(value, str):
value = ",".join(_format_items(value))
elif ";" in value:
raise exc.GlobusSDKUsageError(
"GlobusClientInfo.add() cannot be used to add multiple items in "
"an already-joined string. Add items separately instead. "
f"Bad usage: '{value}'"
)
self.infos.append(value)


def _format_items(info: dict[str, str]) -> t.Iterable[str]:
"""Format the items in a dict, yielding the contents as an iterable."""
for key, value in info.items():
_check_reserved_chars(key, value)
yield f"{key}={value}"


def _check_reserved_chars(key: str, value: str) -> None:
"""Check a key-value pair to see if it uses reserved chars."""
if any(c in x for c in _RESERVED_CHARS for x in (key, value)):
raise exc.GlobusSDKUsageError(
"X-Globus-Client-Info reserved characters cannot be used in keys or "
f"values. Bad usage: '{key}: {value}'"
)
7 changes: 6 additions & 1 deletion src/globus_sdk/transport/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from globus_sdk.version import __version__

from ._clientinfo import GlobusClientInfo
from .retry import (
RetryCheck,
RetryCheckFlags,
Expand Down Expand Up @@ -114,6 +115,7 @@ def __init__(
self.verify_ssl = config.get_ssl_verify(verify_ssl)
self.http_timeout = config.get_http_timeout(http_timeout)
self._user_agent = self.BASE_USER_AGENT
self.globus_client_info: GlobusClientInfo = GlobusClientInfo()

# retry parameters
self.retry_backoff = retry_backoff
Expand All @@ -135,7 +137,10 @@ def user_agent(self, value: str) -> None:

@property
def _headers(self) -> dict[str, str]:
return {"Accept": "application/json", "User-Agent": self.user_agent}
headers = {"Accept": "application/json", "User-Agent": self.user_agent}
if self.globus_client_info:
headers["X-Globus-Client-Info"] = self.globus_client_info.format()
return headers

@contextlib.contextmanager
def tune(
Expand Down
34 changes: 34 additions & 0 deletions tests/functional/base_client/test_default_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from globus_sdk import __version__
from globus_sdk._testing import RegisteredResponse, get_last_request


def test_clientinfo_header_default(client):
RegisteredResponse(
path="https://foo.api.globus.org/bar",
json={"foo": "bar"},
).add()
res = client.request("GET", "/bar")
assert res.http_status == 200

req = get_last_request()
assert "X-Globus-Client-Info" in req.headers
assert (
req.headers["X-Globus-Client-Info"]
== f"product=python-sdk,version={__version__}"
)


def test_clientinfo_header_can_be_supressed(client):
RegisteredResponse(
path="https://foo.api.globus.org/bar",
json={"foo": "bar"},
).add()

# clear the X-Globus-Client-Info header
client.transport.globus_client_info.infos = []

res = client.request("GET", "/bar")
assert res.http_status == 200

req = get_last_request()
assert "X-Globus-Client-Info" not in req.headers
112 changes: 112 additions & 0 deletions tests/unit/transport/test_clientinfo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import pytest

from globus_sdk import __version__
from globus_sdk.transport import GlobusClientInfo


def make_empty_clientinfo():
# create a clientinfo with no contents, as a starting point for tests
obj = GlobusClientInfo()
obj.infos = []
return obj


def parse_clientinfo(header):
"""
Sample parser.
Including this in the testsuite not only validates the mechanical implementation of
X-Globus-Client-Info, but also acts as a safety check that we've thought through the
ability of consumers to parse this data.
"""
mappings = {}
for segment in header.split(";"):
segment_dict = {}

segment = segment.strip()
elements = segment.split(",")

for element in elements:
if "=" not in element:
raise ValueError(
f"Bad X-Globus-Client-Info element: '{element}' in '{header}'"
)
key, _, value = element.partition("=")
if "=" in value:
raise ValueError(
f"Bad X-Globus-Client-Info element: '{element}' in '{header}'"
)
if key in segment_dict:
raise ValueError(
f"Bad X-Globus-Client-Info element: '{element}' in '{header}'"
)
segment_dict[key] = value
if "product" not in segment_dict:
raise ValueError(
"Bad X-Globus-Client-Info segment missing product: "
f"'{segment}' in '{header}'"
)
product = segment_dict["product"]
if product in mappings:
raise ValueError(
"Bad X-Globus-Client-Info header repeats product: "
f"'{product}' in '{header}'"
)
mappings[product] = segment_dict
return mappings


def test_clientinfo_bool():
# base clientinfo starts with the SDK version and should bool true
info = GlobusClientInfo()
assert bool(info) is True
# but we can clear it and it will bool False
info.infos = []
assert bool(info) is False


@pytest.mark.parametrize(
"value, expect_str",
(
("x=y", "x=y"),
("x=y,omicron=iota", "x=y,omicron=iota"),
({"x": "y"}, "x=y"),
({"x": "y", "alpha": "b01"}, "x=y,alpha=b01"),
),
)
def test_format_of_simple_item(value, expect_str):
info = make_empty_clientinfo()
info.add(value)
assert info.format() == expect_str


@pytest.mark.parametrize(
"values, expect_str",
(
(("x=y",), "x=y"),
(("x=y", "alpha=b01,omicron=iota"), "x=y;alpha=b01,omicron=iota"),
),
)
def test_format_of_multiple_items(values, expect_str):
info = make_empty_clientinfo()
for value in values:
info.add(value)
assert info.format() == expect_str


def test_clientinfo_parses_as_expected():
info = GlobusClientInfo()
info.add("alpha=b01,product=my-cool-tool")
header_str = info.format()

parsed = parse_clientinfo(header_str)
assert parsed == {
"python-sdk": {
"product": "python-sdk",
"version": __version__,
},
"my-cool-tool": {
"product": "my-cool-tool",
"alpha": "b01",
},
}

0 comments on commit 89642d8

Please sign in to comment.