diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 0bb6185d8..36eb193e4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -32,7 +32,7 @@ To send us a pull request, please:
1. Fork the repository.
2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
3. Ensure local tests pass (`make test-py` and `make test-protocols`).
-4. Run `make lint-py` if you've changed any python sources.
+4. Run `make lint-py` and `make check-py` if you've changed any python sources.
4. Commit to your fork using clear commit messages.
5. Send us a pull request, answering any default questions in the pull request interface.
6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsPythonDependency.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsPythonDependency.java
new file mode 100644
index 000000000..b8d0dc2dc
--- /dev/null
+++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsPythonDependency.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.smithy.python.aws.codegen;
+
+import software.amazon.smithy.python.codegen.PythonDependency;
+import software.amazon.smithy.utils.SmithyUnstableApi;
+
+/**
+ * AWS Dependencies used in the smithy python generator.
+ */
+@SmithyUnstableApi
+public class AwsPythonDependency {
+ /**
+ * The core aws smithy runtime python package.
+ *
+ *
While in development this will use the develop branch.
+ */
+ public static final PythonDependency SMITHY_AWS_CORE = new PythonDependency(
+ "smithy_aws_core",
+ // You'll need to locally install this before we publish
+ "==0.0.1",
+ PythonDependency.Type.DEPENDENCY,
+ false);
+}
diff --git a/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java
new file mode 100644
index 000000000..4434f0c23
--- /dev/null
+++ b/codegen/aws/core/src/main/java/software/amazon/smithy/python/aws/codegen/AwsUserAgentIntegration.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.smithy.python.aws.codegen;
+
+import java.util.Collections;
+import java.util.List;
+import software.amazon.smithy.codegen.core.Symbol;
+import software.amazon.smithy.codegen.core.SymbolReference;
+import software.amazon.smithy.python.codegen.ConfigProperty;
+import software.amazon.smithy.python.codegen.integrations.PythonIntegration;
+import software.amazon.smithy.python.codegen.integrations.RuntimeClientPlugin;
+import software.amazon.smithy.utils.SmithyInternalApi;
+
+/**
+ * Adds a runtime plugin to set user agent.
+ */
+@SmithyInternalApi
+public class AwsUserAgentIntegration implements PythonIntegration {
+ @Override
+ public List getClientPlugins() {
+ return List.of(
+ RuntimeClientPlugin.builder()
+ .addConfigProperty(
+ ConfigProperty.builder()
+ // TODO: This is the name used in boto, but potentially could be user_agent_prefix. Depends on backwards compat strategy.
+ .name("user_agent_extra")
+ .documentation("Additional suffix to be added to the User-Agent header.")
+ .type(Symbol.builder().name("str").build()) // TODO: Should common types like this be defined as constants somewhere?
+ .nullable(true)
+ .build())
+ .addConfigProperty(
+ ConfigProperty.builder()
+ .name("sdk_ua_app_id")
+ .documentation("A unique and opaque application ID that is appended to the User-Agent header.")
+ .type(Symbol.builder().name("str").build())
+ .nullable(true)
+ .build()
+ )
+ .pythonPlugin(
+ SymbolReference.builder()
+ .symbol(Symbol.builder()
+ .namespace(AwsPythonDependency.SMITHY_AWS_CORE.packageName() + ".plugins", ".")
+ .name("user_agent_plugin")
+ .addDependency(AwsPythonDependency.SMITHY_AWS_CORE)
+ .build())
+ .build()
+ )
+ .build()
+ );
+ }
+
+}
diff --git a/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration b/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration
index 5155ed74a..0375294ba 100644
--- a/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration
+++ b/codegen/aws/core/src/main/resources/META-INF/services/software.amazon.smithy.python.codegen.integrations.PythonIntegration
@@ -5,3 +5,4 @@
software.amazon.smithy.python.aws.codegen.AwsAuthIntegration
software.amazon.smithy.python.aws.codegen.AwsProtocolsIntegration
+software.amazon.smithy.python.aws.codegen.AwsUserAgentIntegration
diff --git a/packages/smithy-aws-core/src/smithy_aws_core/interceptors/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/interceptors/__init__.py
new file mode 100644
index 000000000..33cbe867a
--- /dev/null
+++ b/packages/smithy-aws-core/src/smithy_aws_core/interceptors/__init__.py
@@ -0,0 +1,2 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
diff --git a/packages/smithy-aws-core/src/smithy_aws_core/interceptors/user_agent.py b/packages/smithy-aws-core/src/smithy_aws_core/interceptors/user_agent.py
new file mode 100644
index 000000000..b7826fc99
--- /dev/null
+++ b/packages/smithy-aws-core/src/smithy_aws_core/interceptors/user_agent.py
@@ -0,0 +1,41 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from smithy_aws_core.user_agent import UserAgent
+from smithy_core.interceptors import Interceptor, InterceptorContext, Request
+from smithy_http import Field
+from smithy_http.aio import HTTPRequest
+
+
+class UserAgentInterceptor(Interceptor[Request, None, HTTPRequest, None]):
+ """Adds UserAgent header to the Request before signing."""
+
+ def __init__(
+ self,
+ ua_suffix: str | None = None,
+ ua_app_id: str | None = None,
+ sdk_version: str | None = "0.0.1",
+ ) -> None:
+ """Initialize the UserAgentInterceptor.
+
+ :ua_suffix: Additional suffix to be added to the UserAgent header. :ua_app_id:
+ User defined and opaque application ID to be added to the UserAgent header.
+ """
+ super().__init__()
+ self._ua_suffix = ua_suffix
+ self._ua_app_id = ua_app_id
+ self._sdk_version = sdk_version
+
+ def modify_before_signing(
+ self, context: InterceptorContext[Request, None, HTTPRequest, None]
+ ) -> HTTPRequest:
+ user_agent = UserAgent.from_environment().with_config(
+ ua_suffix=self._ua_suffix,
+ ua_app_id=self._ua_app_id,
+ sdk_version=self._sdk_version,
+ )
+ request = context.transport_request
+ request.fields.set_field(
+ Field(name="User-Agent", values=[user_agent.to_string()])
+ )
+ return context.transport_request
diff --git a/packages/smithy-aws-core/src/smithy_aws_core/plugins/__init__.py b/packages/smithy-aws-core/src/smithy_aws_core/plugins/__init__.py
new file mode 100644
index 000000000..a0829f930
--- /dev/null
+++ b/packages/smithy-aws-core/src/smithy_aws_core/plugins/__init__.py
@@ -0,0 +1,16 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+from typing import Any
+
+from smithy_aws_core.interceptors.user_agent import UserAgentInterceptor
+
+
+# TODO: Define a Protocol for Config w/ interceptor method?
+def user_agent_plugin(config: Any) -> None:
+ config.interceptors.append(
+ UserAgentInterceptor(
+ ua_suffix=config.user_agent_extra,
+ ua_app_id=config.sdk_ua_app_id,
+ )
+ )
diff --git a/packages/smithy-aws-core/src/smithy_aws_core/user_agent.py b/packages/smithy-aws-core/src/smithy_aws_core/user_agent.py
new file mode 100644
index 000000000..c15fc25ca
--- /dev/null
+++ b/packages/smithy-aws-core/src/smithy_aws_core/user_agent.py
@@ -0,0 +1,269 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+# pyright: reportMissingTypeStubs=false,reportUnknownMemberType=false
+
+import os
+import platform
+from string import ascii_letters, digits
+from typing import NamedTuple, Optional, Self, Union, List
+
+from smithy_http.aio.crt import HAS_CRT
+
+_USERAGENT_ALLOWED_CHARACTERS = ascii_letters + digits + "!$%&'*+-.^_`|~"
+_USERAGENT_ALLOWED_OS_NAMES = (
+ "windows",
+ "linux",
+ "macos",
+ "android",
+ "ios",
+ "watchos",
+ "tvos",
+ "other",
+)
+_USERAGENT_PLATFORM_NAME_MAPPINGS = {"darwin": "macos"}
+_USERAGENT_SDK_NAME = "aws-sdk-python"
+
+
+class UserAgentComponent(NamedTuple):
+ """Component of a User-Agent header string in the standard format.
+
+ Each component consists of a prefix, a name, and a value. In the string
+ representation these are combined in the format ``prefix/name#value``.
+
+ This class is considered private and is subject to abrupt breaking changes.
+ """
+
+ prefix: str
+ name: str
+ value: Optional[str] = None
+
+ def to_string(self):
+ """Create string like 'prefix/name#value' from a UserAgentComponent."""
+ clean_prefix = sanitize_user_agent_string_component(
+ self.prefix, allow_hash=True
+ )
+ clean_name = sanitize_user_agent_string_component(self.name, allow_hash=False)
+ if self.value is None or self.value == "":
+ return f"{clean_prefix}/{clean_name}"
+ clean_value = sanitize_user_agent_string_component(self.value, allow_hash=True)
+ return f"{clean_prefix}/{clean_name}#{clean_value}"
+
+
+class RawStringUserAgentComponent:
+ """UserAgentComponent interface wrapper around ``str``.
+
+ Use for User-Agent header components that are not constructed from prefix+name+value
+ but instead are provided as strings. No sanitization is performed.
+ """
+
+ def __init__(self, value: str):
+ self._value = value
+
+ def to_string(self) -> str:
+ return self._value
+
+
+_UAComponent = Union[UserAgentComponent, RawStringUserAgentComponent]
+
+
+class UserAgent:
+ def __init__(
+ self,
+ platform_name: str | None,
+ platform_version: str | None,
+ platform_machine: str | None,
+ python_version: str | None,
+ python_implementation: str | None,
+ execution_env: str | None,
+ crt_version: str | None,
+ ) -> None:
+ self._platform_name = platform_name
+ self._platform_version = platform_version
+ self._platform_machine = platform_machine
+ self._python_version = python_version
+ self._python_implementation = python_implementation
+ self._execution_env = execution_env
+ self._crt_version = crt_version
+
+ # Components that can be added with ``set_config``
+ self._user_agent_suffix = None
+ self._user_agent_app_id = None
+ self._sdk_version = None
+
+ @classmethod
+ def from_environment(cls) -> Self:
+ crt_version = None
+ if HAS_CRT:
+ crt_version = _get_crt_version() or "Unknown"
+ return cls(
+ platform_name=platform.system(),
+ platform_version=platform.release(),
+ platform_machine=platform.machine(),
+ python_version=platform.python_version(),
+ python_implementation=platform.python_implementation(),
+ execution_env=os.environ.get("AWS_EXECUTION_ENV"),
+ crt_version=crt_version,
+ )
+
+ def with_config(
+ self,
+ ua_suffix: str | None,
+ ua_app_id: str | None,
+ sdk_version: str | None,
+ ) -> Self:
+ self._user_agent_suffix = ua_suffix
+ self._user_agent_app_id = ua_app_id
+ self._sdk_version = sdk_version
+ return self
+
+ def to_string(self) -> str:
+ """Build User-Agent header string from the object's properties."""
+ components = [
+ *self._build_sdk_metadata(),
+ UserAgentComponent("ua", "2.0"),
+ *self._build_os_metadata(),
+ *self._build_architecture_metadata(),
+ *self._build_language_metadata(),
+ *self._build_execution_env_metadata(),
+ *self._build_feature_metadata(),
+ *self._build_app_id(),
+ *self._build_suffix(),
+ ]
+
+ return " ".join([comp.to_string() for comp in components])
+
+ def _build_sdk_metadata(self) -> List[UserAgentComponent]:
+ """Build the SDK name and version component of the User-Agent header.
+
+ Includes CRT version if available.
+ """
+ sdk_version = self._sdk_version if self._sdk_version else "Unknown"
+ sdk_md: List[UserAgentComponent] = [
+ UserAgentComponent(_USERAGENT_SDK_NAME, sdk_version)
+ ]
+
+ if self._crt_version is not None:
+ sdk_md.append(UserAgentComponent("md", "awscrt", self._crt_version))
+
+ return sdk_md
+
+ def _build_os_metadata(self) -> List[UserAgentComponent]:
+ """Build the OS/platform components of the User-Agent header string.
+
+ For recognized platform names that match or map to an entry in the list
+ of standardized OS names, a single component with prefix "os" is
+ returned. Otherwise, one component "os/other" is returned and a second
+ with prefix "md" and the raw platform name.
+
+ String representations of example return values:
+ * ``os/macos#10.13.6``
+ * ``os/linux``
+ * ``os/other``
+ * ``os/other md/foobar#1.2.3``
+ """
+ if self._platform_name is None:
+ return [UserAgentComponent("os", "other")]
+
+ plt_name_lower = self._platform_name.lower()
+ if plt_name_lower in _USERAGENT_ALLOWED_OS_NAMES:
+ os_family = plt_name_lower
+ elif plt_name_lower in _USERAGENT_PLATFORM_NAME_MAPPINGS:
+ os_family = _USERAGENT_PLATFORM_NAME_MAPPINGS[plt_name_lower]
+ else:
+ os_family = None
+
+ if os_family is not None:
+ return [UserAgentComponent("os", os_family, self._platform_version)]
+ else:
+ return [
+ UserAgentComponent("os", "other"),
+ UserAgentComponent("md", self._platform_name, self._platform_version),
+ ]
+
+ def _build_architecture_metadata(self) -> List[UserAgentComponent]:
+ """Build architecture component of the User-Agent header string.
+
+ Returns the machine type with prefix "md" and name "arch", if one is available.
+ Common values include "x86_64", "arm64", "i386".
+ """
+ if self._platform_machine:
+ return [UserAgentComponent("md", "arch", self._platform_machine.lower())]
+ return []
+
+ def _build_language_metadata(self) -> List[UserAgentComponent]:
+ """Build the language components of the User-Agent header string.
+
+ Returns the Python version in a component with prefix "lang" and name
+ "python". The Python implementation (e.g. CPython, PyPy) is returned as
+ separate metadata component with prefix "md" and name "pyimpl".
+
+ String representation of an example return value:
+ ``lang/python#3.10.4 md/pyimpl#CPython``
+ """
+ lang_md = [
+ UserAgentComponent("lang", "python", self._python_version),
+ ]
+ if self._python_implementation:
+ lang_md.append(
+ UserAgentComponent("md", "pyimpl", self._python_implementation)
+ )
+ return lang_md
+
+ def _build_execution_env_metadata(self) -> List[UserAgentComponent]:
+ """Build the execution environment component of the User-Agent header.
+
+ Returns a single component prefixed with "exec-env", usually sourced from the
+ environment variable AWS_EXECUTION_ENV.
+ """
+ if self._execution_env:
+ return [UserAgentComponent("exec-env", self._execution_env)]
+ else:
+ return []
+
+ def _build_feature_metadata(self) -> List[UserAgentComponent]:
+ """Build the features components of the User-Agent header string.
+
+ TODO: These should be sourced from property bag set on context.
+ """
+ return []
+
+ def _build_app_id(self) -> List[UserAgentComponent]:
+ """Build app component of the User-Agent header string."""
+ if self._user_agent_app_id:
+ return [UserAgentComponent("app", self._user_agent_app_id)]
+ else:
+ return []
+
+ def _build_suffix(self) -> List[_UAComponent]:
+ if self._user_agent_suffix:
+ return [RawStringUserAgentComponent(self._user_agent_suffix)]
+ else:
+ return []
+
+
+def sanitize_user_agent_string_component(raw_str: str, allow_hash: bool = False) -> str:
+ """Replaces all not allowed characters in the string with a dash ("-").
+
+ Allowed characters are ASCII alphanumerics and ``!$%&'*+-.^_`|~``. If
+ ``allow_hash`` is ``True``, "#"``" is also allowed.
+
+ :type raw_str: str
+ :param raw_str: The input string to be sanitized.
+
+ :type allow_hash: bool
+ :param allow_hash: Whether "#" is considered an allowed character.
+ """
+ return "".join(
+ c if c in _USERAGENT_ALLOWED_CHARACTERS or (allow_hash and c == "#") else "-"
+ for c in raw_str
+ )
+
+
+def _get_crt_version() -> str | None:
+ """This function is considered private and is subject to abrupt breaking changes."""
+ try:
+ import awscrt
+
+ return awscrt.__version__
+ except AttributeError:
+ return None