Skip to content

Commit 91e8208

Browse files
committed
Working implementation with basic user agent
1 parent cb47f65 commit 91e8208

File tree

4 files changed

+328
-10
lines changed

4 files changed

+328
-10
lines changed

codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,22 @@ public class AwsUserAgentIntegration implements PythonIntegration {
2222
public List<RuntimeClientPlugin> getClientPlugins() {
2323
return List.of(
2424
RuntimeClientPlugin.builder()
25-
.addConfigProperty(ConfigProperty.builder()
25+
.addConfigProperty(
26+
ConfigProperty.builder()
2627
// TODO: This is the name used in boto, but potentially could be user_agent_prefix. Depends on backwards compat strategy.
2728
.name("user_agent_extra")
28-
.documentation("Additional suffix to be added to the user agent")
29+
.documentation("Additional suffix to be added to the User-Agent header.")
2930
.type(Symbol.builder().name("str").build()) // TODO: Should common types like this be defined as constants somewhere?
3031
.nullable(true)
3132
.build())
33+
.addConfigProperty(
34+
ConfigProperty.builder()
35+
.name("sdk_ua_app_id")
36+
.documentation("A unique and opaque application ID that is appended to the User-Agent header.")
37+
.type(Symbol.builder().name("str").build())
38+
.nullable(true)
39+
.build()
40+
)
3241
.pythonPlugin(
3342
SymbolReference.builder()
3443
.symbol(Symbol.builder()

packages/smithy-aws-core/src/smithy_aws_core/interceptors/user_agent.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,42 @@
1010
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
13-
from smithy_core.interceptors import Interceptor, InterceptorContext, Request, TransportRequest
13+
14+
from smithy_aws_core.user_agent import UserAgent
15+
from smithy_core.interceptors import Interceptor, InterceptorContext, Request
16+
from smithy_http import Field
1417
from smithy_http.aio import HTTPRequest
1518

1619

1720
class UserAgentInterceptor(Interceptor):
18-
"""Adds UserAgent header to the Request before signing.
19-
"""
21+
"""Adds UserAgent header to the Request before signing."""
22+
23+
def __init__(
24+
self,
25+
ua_suffix: str | None = None,
26+
ua_app_id: str | None = None,
27+
sdk_version: str | None = "0.0.1",
28+
) -> None:
29+
"""Initialize the UserAgentInterceptor.
30+
31+
:ua_suffix: Additional suffix to be added to the UserAgent header. :ua_app_id:
32+
User defined and opaque application ID to be added to the UserAgent header.
33+
"""
34+
super().__init__()
35+
self._ua_suffix = ua_suffix
36+
self._ua_app_id = ua_app_id
37+
self._sdk_version = sdk_version
38+
2039
def modify_before_signing(
21-
self, context: InterceptorContext[Request, None, HTTPRequest, None]
40+
self, context: InterceptorContext[Request, None, HTTPRequest, None]
2241
) -> HTTPRequest:
23-
print("Oh Hello here I am!")
24-
return context.transport_request
42+
user_agent = UserAgent.from_environment().with_config(
43+
ua_suffix=self._ua_suffix,
44+
ua_app_id=self._ua_app_id,
45+
sdk_version=self._sdk_version,
46+
)
47+
request = context.transport_request
48+
request.fields.set_field(
49+
Field(name="User-Agent", values=[user_agent.to_string()])
50+
)
51+
return context.transport_request

packages/smithy-aws-core/src/smithy_aws_core/plugins/__init__.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@
1010
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
13+
from typing import Any
14+
15+
from smithy_aws_core.interceptors.user_agent import UserAgentInterceptor
16+
1317

1418
# TODO: Define a Protocol for Config w/ interceptor method?
15-
def user_agent_plugin(config: any) -> None:
16-
config.interceptors.append()
19+
def user_agent_plugin(config: Any) -> None:
20+
config.interceptors.append(
21+
UserAgentInterceptor(
22+
ua_suffix=config.user_agent_extra,
23+
ua_app_id=config.sdk_ua_app_id,
24+
)
25+
)
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
14+
import os
15+
import platform
16+
from string import ascii_letters, digits
17+
from typing import NamedTuple, Optional, Self
18+
19+
from smithy_http.aio.crt import HAS_CRT
20+
21+
_USERAGENT_ALLOWED_CHARACTERS = ascii_letters + digits + "!$%&'*+-.^_`|~"
22+
_USERAGENT_ALLOWED_OS_NAMES = (
23+
"windows",
24+
"linux",
25+
"macos",
26+
"android",
27+
"ios",
28+
"watchos",
29+
"tvos",
30+
"other",
31+
)
32+
_USERAGENT_PLATFORM_NAME_MAPPINGS = {"darwin": "macos"}
33+
_USERAGENT_SDK_NAME = "aws-sdk-python"
34+
35+
36+
class UserAgent:
37+
def __init__(
38+
self,
39+
platform_name,
40+
platform_version,
41+
platform_machine,
42+
python_version,
43+
python_implementation,
44+
execution_env,
45+
crt_version=None,
46+
) -> None:
47+
self._platform_name = platform_name
48+
self._platform_version = platform_version
49+
self._platform_machine = platform_machine
50+
self._python_version = python_version
51+
self._python_implementation = python_implementation
52+
self._execution_env = execution_env
53+
self._crt_version = crt_version
54+
55+
# Components that can be added with ``set_config``
56+
self._user_agent_suffix = None
57+
self._user_agent_app_id = None
58+
self._sdk_version = None
59+
60+
@classmethod
61+
def from_environment(cls) -> Self:
62+
crt_version = None
63+
if HAS_CRT:
64+
crt_version = _get_crt_version() or "Unknown"
65+
return cls(
66+
platform_name=platform.system(),
67+
platform_version=platform.release(),
68+
platform_machine=platform.machine(),
69+
python_version=platform.python_version(),
70+
python_implementation=platform.python_implementation(),
71+
execution_env=os.environ.get("AWS_EXECUTION_ENV"),
72+
crt_version=crt_version,
73+
)
74+
75+
def with_config(
76+
self,
77+
ua_suffix: str | None = None,
78+
ua_app_id: str | None = None,
79+
sdk_version: str | None = None,
80+
) -> Self:
81+
self._user_agent_suffix = ua_suffix
82+
self._user_agent_app_id = ua_app_id
83+
self._sdk_version = sdk_version
84+
return self
85+
86+
def to_string(self):
87+
"""Build User-Agent header string from the object's properties."""
88+
components = [
89+
*self._build_sdk_metadata(),
90+
UserAgentComponent("ua", "2.0"),
91+
*self._build_os_metadata(),
92+
*self._build_architecture_metadata(),
93+
*self._build_language_metadata(),
94+
*self._build_execution_env_metadata(),
95+
*self._build_feature_metadata(),
96+
*self._build_app_id(),
97+
*self._build_suffix(),
98+
]
99+
100+
return " ".join([comp.to_string() for comp in components])
101+
102+
def _build_sdk_metadata(self):
103+
"""Build the SDK name and version component of the User-Agent header.
104+
105+
Includes CRT version if available.
106+
"""
107+
sdk_md = []
108+
sdk_md.append(UserAgentComponent(_USERAGENT_SDK_NAME, self._sdk_version))
109+
110+
if self._crt_version is not None:
111+
sdk_md.append(UserAgentComponent("md", "awscrt", self._crt_version))
112+
113+
return sdk_md
114+
115+
def _build_os_metadata(self):
116+
"""Build the OS/platform components of the User-Agent header string.
117+
118+
For recognized platform names that match or map to an entry in the list
119+
of standardized OS names, a single component with prefix "os" is
120+
returned. Otherwise, one component "os/other" is returned and a second
121+
with prefix "md" and the raw platform name.
122+
123+
String representations of example return values:
124+
* ``os/macos#10.13.6``
125+
* ``os/linux``
126+
* ``os/other``
127+
* ``os/other md/foobar#1.2.3``
128+
"""
129+
if self._platform_name is None:
130+
return [UserAgentComponent("os", "other")]
131+
132+
plt_name_lower = self._platform_name.lower()
133+
if plt_name_lower in _USERAGENT_ALLOWED_OS_NAMES:
134+
os_family = plt_name_lower
135+
elif plt_name_lower in _USERAGENT_PLATFORM_NAME_MAPPINGS:
136+
os_family = _USERAGENT_PLATFORM_NAME_MAPPINGS[plt_name_lower]
137+
else:
138+
os_family = None
139+
140+
if os_family is not None:
141+
return [UserAgentComponent("os", os_family, self._platform_version)]
142+
else:
143+
return [
144+
UserAgentComponent("os", "other"),
145+
UserAgentComponent("md", self._platform_name, self._platform_version),
146+
]
147+
148+
def _build_architecture_metadata(self):
149+
"""Build architecture component of the User-Agent header string.
150+
151+
Returns the machine type with prefix "md" and name "arch", if one is available.
152+
Common values include "x86_64", "arm64", "i386".
153+
"""
154+
if self._platform_machine:
155+
return [UserAgentComponent("md", "arch", self._platform_machine.lower())]
156+
return []
157+
158+
def _build_language_metadata(self):
159+
"""Build the language components of the User-Agent header string.
160+
161+
Returns the Python version in a component with prefix "lang" and name
162+
"python". The Python implementation (e.g. CPython, PyPy) is returned as
163+
separate metadata component with prefix "md" and name "pyimpl".
164+
165+
String representation of an example return value:
166+
``lang/python#3.10.4 md/pyimpl#CPython``
167+
"""
168+
lang_md = [
169+
UserAgentComponent("lang", "python", self._python_version),
170+
]
171+
if self._python_implementation:
172+
lang_md.append(
173+
UserAgentComponent("md", "pyimpl", self._python_implementation)
174+
)
175+
return lang_md
176+
177+
def _build_execution_env_metadata(self):
178+
"""Build the execution environment component of the User-Agent header.
179+
180+
Returns a single component prefixed with "exec-env", usually sourced from the
181+
environment variable AWS_EXECUTION_ENV.
182+
"""
183+
if self._execution_env:
184+
return [UserAgentComponent("exec-env", self._execution_env)]
185+
else:
186+
return []
187+
188+
def _build_feature_metadata(self):
189+
"""Build the features components of the User-Agent header string.
190+
191+
TODO: These should be sourced from property bag set on context.
192+
"""
193+
return []
194+
195+
def _build_app_id(self):
196+
"""Build app component of the User-Agent header string."""
197+
if self._user_agent_app_id:
198+
return [UserAgentComponent("app", self._user_agent_app_id)]
199+
else:
200+
return []
201+
202+
def _build_suffix(self):
203+
if self._user_agent_suffix:
204+
return [RawStringUserAgentComponent(self._user_agent_suffix)]
205+
else:
206+
return []
207+
208+
209+
def sanitize_user_agent_string_component(raw_str, allow_hash):
210+
"""Replaces all not allowed characters in the string with a dash ("-").
211+
212+
Allowed characters are ASCII alphanumerics and ``!$%&'*+-.^_`|~``. If
213+
``allow_hash`` is ``True``, "#"``" is also allowed.
214+
215+
:type raw_str: str
216+
:param raw_str: The input string to be sanitized.
217+
218+
:type allow_hash: bool
219+
:param allow_hash: Whether "#" is considered an allowed character.
220+
"""
221+
return "".join(
222+
c if c in _USERAGENT_ALLOWED_CHARACTERS or (allow_hash and c == "#") else "-"
223+
for c in raw_str
224+
)
225+
226+
227+
class UserAgentComponent(NamedTuple):
228+
"""Component of a User-Agent header string in the standard format.
229+
230+
Each component consists of a prefix, a name, and a value. In the string
231+
representation these are combined in the format ``prefix/name#value``.
232+
233+
This class is considered private and is subject to abrupt breaking changes.
234+
"""
235+
236+
prefix: str
237+
name: str
238+
value: Optional[str] = None
239+
240+
def to_string(self):
241+
"""Create string like 'prefix/name#value' from a UserAgentComponent."""
242+
clean_prefix = sanitize_user_agent_string_component(
243+
self.prefix, allow_hash=True
244+
)
245+
clean_name = sanitize_user_agent_string_component(self.name, allow_hash=False)
246+
if self.value is None or self.value == "":
247+
return f"{clean_prefix}/{clean_name}"
248+
clean_value = sanitize_user_agent_string_component(self.value, allow_hash=True)
249+
return f"{clean_prefix}/{clean_name}#{clean_value}"
250+
251+
252+
class RawStringUserAgentComponent:
253+
"""UserAgentComponent interface wrapper around ``str``.
254+
255+
Use for User-Agent header components that are not constructed from prefix+name+value
256+
but instead are provided as strings. No sanitization is performed.
257+
"""
258+
259+
def __init__(self, value):
260+
self._value = value
261+
262+
def to_string(self):
263+
return self._value
264+
265+
266+
def _get_crt_version():
267+
"""This function is considered private and is subject to abrupt breaking changes."""
268+
try:
269+
import awscrt
270+
271+
return awscrt.__version__
272+
except AttributeError:
273+
return None

0 commit comments

Comments
 (0)