-
Notifications
You must be signed in to change notification settings - Fork 38
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #990 from sirosen/globus-clientinfo
Implement the X-Globus-Client-Info header as a feature of transport objects
- Loading branch information
Showing
7 changed files
with
277 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}'" | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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", | ||
}, | ||
} |