config_ninja.contrib.appconfig

Integrate with the AWS AppConfig service.

Example

The following config-ninja settings file configures the AppConfigBackend to install /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json from the latest version deployed through AWS AppConfig:

---
# the following top-level key is required
CONFIG_NINJA_OBJECTS:
  # each second-level key identifies a config-ninja object
  example-0:
    # set the location that the object is written to
    dest:
      format: json
      path: /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json

    # specify where the object is stored / retrieved from
    source:
      backend: appconfig
      format: json

      # instantiate the backend class using its 'new()' method
      new:
        kwargs:
          application_name: Sample Application
          configuration_profile_name: /dev/amazon-cloudwatch-agent.json
          environment_name: dev
  1"""Integrate with the AWS AppConfig service.
  2
  3## Example
  4
  5The following `config-ninja`_ settings file configures the `AppConfigBackend` to install
  6`/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json` from the latest version deployed
  7through AWS AppConfig:
  8
  9```yaml
 10.. include:: ../../../examples/appconfig-backend.yaml
 11```
 12
 13.. _config-ninja: https://bryant-finney.github.io/config-ninja/config_ninja.html
 14"""
 15
 16from __future__ import annotations
 17
 18import asyncio
 19import functools
 20import logging
 21import warnings
 22from collections.abc import AsyncIterator, Iterator
 23from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypedDict  # type: ignore[attr-defined,unused-ignore]
 24
 25import boto3
 26
 27from config_ninja.backend import Backend
 28
 29if TYPE_CHECKING:  # pragma: no cover
 30    from mypy_boto3_appconfig.client import AppConfigClient
 31    from mypy_boto3_appconfigdata import AppConfigDataClient
 32
 33__all__ = ['AppConfigBackend']
 34
 35MINIMUM_POLL_INTERVAL_SECONDS = 60
 36
 37
 38OperationName: TypeAlias = Literal['list_applications', 'list_configuration_profiles', 'list_environments']
 39
 40logger = logging.getLogger(__name__)
 41
 42
 43@functools.cache
 44def get_session_and_client() -> tuple[boto3.Session, AppConfigClient]:
 45    """Get the default session and AppConfig client."""
 46    session = boto3.Session()
 47    client = session.client('appconfig')  # pyright: ignore[reportUnknownMemberType]
 48    return session, client
 49
 50
 51class ErrorT(TypedDict):
 52    """These properties are returned in the `BadRequestException` response's `Error` object.
 53
 54    # ref: https://botocore.amazonaws.com/v1/documentation/api/latest/reference/services/appconfigdata/client/exceptions/BadRequestException.html#badrequestexception
 55    """
 56
 57    Code: str
 58    Message: str
 59
 60
 61class BadRequestExceptionResponse(TypedDict):
 62    """Type information for the `AppConfigData.Client.exceptions.BadRequestException.response`.
 63
 64    # ref: https://botocore.amazonaws.com/v1/documentation/api/latest/reference/services/appconfigdata/client/exceptions/BadRequestException.html#badrequestexception
 65    """
 66
 67    Error: ErrorT
 68    Message: str
 69
 70
 71class AppConfigBackend(Backend):
 72    """Retrieve the deployed configuration from AWS AppConfig.
 73
 74    ## Usage
 75
 76    To retrieve the configuration, use the `AppConfigBackend.get()` method:
 77
 78    >>> backend = AppConfigBackend(appconfigdata_client, 'app-id', 'conf-id', 'env-id')
 79    >>> print(backend.get())
 80    key_0: value_0
 81    key_1: 1
 82    key_2: true
 83    key_3:
 84        - 1
 85        - 2
 86        - 3
 87    """
 88
 89    client: AppConfigDataClient
 90    """The `boto3` client used to communicate with the AWS AppConfig service."""
 91
 92    application_id: str
 93    """See [Creating a namespace for your application in AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-namespace.html)"""
 94    configuration_profile_id: str
 95    """See [Creating a configuration profile in AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-profile.html)"""
 96    environment_id: str
 97    """See [Creating environments for your application in AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-environment.html)"""
 98
 99    def __init__(
100        self,
101        client: AppConfigDataClient,
102        app_id: str,
103        config_profile_id: str,
104        env_id: str,
105    ) -> None:
106        """Initialize the backend."""
107        logger.debug(
108            "Initialize: %s(client=%s, app_id='%s', conf_id='%s', env_id='%s')",
109            self.__class__.__name__,
110            client,
111            app_id,
112            config_profile_id,
113            env_id,
114        )
115        self.client = client
116
117        self.application_id = app_id
118        self.configuration_profile_id = config_profile_id
119        self.environment_id = env_id
120
121    def __str__(self) -> str:
122        """Include properties in the string representation.
123
124        >>> print(str( AppConfigBackend(appconfigdata_client, 'app-id', 'conf-id', 'env-id') ))
125        boto3.client('appconfigdata').start_configuration_session(ApplicationIdentifier='app-id', ConfigurationProfileIdentifier='conf-id', EnvironmentIdentifier='env-id')
126        """
127        return (
128            "boto3.client('appconfigdata').start_configuration_session("
129            f"ApplicationIdentifier='{self.application_id}', "
130            f"ConfigurationProfileIdentifier='{self.configuration_profile_id}', "
131            f"EnvironmentIdentifier='{self.environment_id}')"
132        )
133
134    @staticmethod
135    @functools.lru_cache(maxsize=127)
136    def _get_id_from_name(name: str, operation_name: OperationName, client: AppConfigClient, **kwargs: Any) -> str:
137        out: Iterator[str] = (
138            client.get_paginator(operation_name).paginate(**kwargs).search(f'Items[?Name == `{name}`].Id')
139        )
140        ids = list(out)
141
142        if not ids:
143            raise ValueError(f'no "{operation_name}" results found for Name="{name}"')
144
145        if len(ids) > 1:
146            warnings.warn(
147                f"'{operation_name}' found {len(ids)} results for Name='{name}'; "
148                f"'{ids[0]}' will be used and the others ignored: {ids[1:]}",
149                category=RuntimeWarning,
150                stacklevel=3,
151            )
152
153        return ids[0]
154
155    def get(self) -> str:
156        """Retrieve the latest configuration deployment as a string."""
157        logger.debug('Retrieve latest configuration (%s)', self)
158        token = self.client.start_configuration_session(
159            ApplicationIdentifier=self.application_id,
160            EnvironmentIdentifier=self.environment_id,
161            ConfigurationProfileIdentifier=self.configuration_profile_id,
162            RequiredMinimumPollIntervalInSeconds=MINIMUM_POLL_INTERVAL_SECONDS,
163        )['InitialConfigurationToken']
164
165        resp = self.client.get_latest_configuration(ConfigurationToken=token)
166        return resp['Configuration'].read().decode()
167
168    @classmethod
169    def get_application_id(cls, name: str, client: AppConfigClient) -> str:
170        """Retrieve the application ID for the given application name."""
171        return cls._get_id_from_name(name, 'list_applications', client)
172
173    @classmethod
174    def get_configuration_profile_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
175        """Retrieve the configuration profile ID for the given configuration profile name."""
176        return cls._get_id_from_name(name, 'list_configuration_profiles', client, ApplicationId=application_id)
177
178    @classmethod
179    def get_environment_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
180        """Retrieve the environment ID for the given environment name & application ID."""
181        return cls._get_id_from_name(name, 'list_environments', client, ApplicationId=application_id)
182
183    @classmethod
184    def new(  # pylint: disable=arguments-differ  # pyright: ignore[reportIncompatibleMethodOverride]
185        cls,
186        application_name: str,
187        configuration_profile_name: str,
188        environment_name: str,
189        session: boto3.Session | None = None,
190    ) -> AppConfigBackend:
191        """Create a new instance of the backend.
192
193        ## Usage: `AppConfigBackend.new()`
194
195        <!-- fixture is used for doctest but excluded from documentation
196        >>> session = getfixture('mock_session_with_1_id')
197
198        -->
199
200        Use `boto3` to fetch IDs for based on name:
201
202        >>> backend = AppConfigBackend.new('app-name', 'conf-name', 'env-name', session)
203        >>> print(f"{backend}")
204        boto3.client('appconfigdata').start_configuration_session(ApplicationIdentifier='id-1', ConfigurationProfileIdentifier='id-1', EnvironmentIdentifier='id-1')
205
206        ### Error: No IDs Found
207
208        >>> session = getfixture('mock_session_with_0_ids')  # fixture for doctest
209
210        A `ValueError` is raised if no IDs are found for the given name:
211
212        >>> backend = AppConfigBackend.new('app-name-1', 'conf-name-1', 'env-name-1', session)
213        Traceback (most recent call last):
214        ...
215        ValueError: no "list_applications" results found for Name="app-name-1"
216
217        ### Warning: Multiple IDs Found
218
219        >>> session = getfixture('mock_session_with_2_ids')
220
221        The first ID is used and the others ignored.
222
223        >>> with pytest.warns(RuntimeWarning):
224        ...     backend = AppConfigBackend.new('app-name-2', 'conf-name-2', 'env-name-2', session)
225        """
226        logger.info(
227            'Create new instance: %s(app="%s", conf="%s", env="%s")',
228            cls.__name__,
229            application_name,
230            configuration_profile_name,
231            environment_name,
232        )
233
234        if session is None:
235            session, appconfig_client = get_session_and_client()
236        else:
237            appconfig_client = session.client('appconfig')  # pyright: ignore[reportUnknownMemberType]
238
239        application_id = cls.get_application_id(application_name, appconfig_client)
240        configuration_profile_id = cls.get_configuration_profile_id(
241            configuration_profile_name, appconfig_client, application_id
242        )
243        environment_id = cls.get_environment_id(environment_name, appconfig_client, application_id)
244
245        client: AppConfigDataClient = session.client('appconfigdata')  # pyright: ignore[reportUnknownMemberType]
246
247        return cls(client, application_id, configuration_profile_id, environment_id)
248
249    async def poll(self, interval: int = MINIMUM_POLL_INTERVAL_SECONDS) -> AsyncIterator[str]:
250        """Poll the AppConfig service for configuration changes.
251
252        .. note::
253            Methods written for `asyncio` need to jump through hoops to run as `doctest` tests.
254            To improve the readability of this documentation, each Python code block corresponds to
255            a `doctest` test defined in a private method.
256
257        ## Usage: `AppConfigBackend.poll()`
258
259        ```py
260        In [1]: async for content in backend.poll():
261           ...:     print(content)  # ← executes each time the configuration changes
262        ```
263        ```yaml
264        key_0: value_0
265        key_1: 1
266        key_2: true
267        key_3:
268            - 1
269            - 2
270            - 3
271        ```
272
273        .. note::
274            If polling is done too quickly, the AWS AppConfig client will raise a
275            `BadRequestException`. This is handled automatically by the backend, which will retry
276            the request after waiting for half the given `interval`.
277        """
278        token = self.client.start_configuration_session(
279            ApplicationIdentifier=self.application_id,
280            EnvironmentIdentifier=self.environment_id,
281            ConfigurationProfileIdentifier=self.configuration_profile_id,
282            RequiredMinimumPollIntervalInSeconds=interval,
283        )['InitialConfigurationToken']
284
285        while True:
286            logger.debug('Poll for configuration changes')
287            try:
288                resp = self.client.get_latest_configuration(ConfigurationToken=token)
289            except self.client.exceptions.BadRequestException as exc:
290                exc_resp: BadRequestExceptionResponse = exc.response  # type: ignore[assignment]
291                if exc_resp['Error']['Message'] != 'Request too early':  # pragma: no cover
292                    raise
293                logger.debug('Request too early; retrying in %d seconds', interval / 2)
294                await asyncio.sleep(interval / 2)
295                continue
296
297            token = resp['NextPollConfigurationToken']
298            if content := resp['Configuration'].read():
299                yield content.decode()
300            else:
301                logger.debug('No configuration changes')
302
303            await asyncio.sleep(resp['NextPollIntervalInSeconds'])
304
305    def _async_doctests(self) -> None:
306        """Define `async` `doctest` tests in this method to improve documentation.
307
308        Verify that an empty response to the `boto3` client is handled and the polling continues:
309        >>> backend = AppConfigBackend(appconfigdata_client_first_empty, 'app-id', 'conf-id', 'env-id')
310        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))
311        >>> print(content)
312        key_0: value_0
313        key_1: 1
314        key_2: true
315        key_3:
316            - 1
317            - 2
318            - 3
319
320
321        >>> client = getfixture('mock_poll_too_early')    # seed a `BadRequestException`
322
323        >>> backend = AppConfigBackend(client, 'app-id', 'conf-id', 'env-id')
324        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))   # it is handled successfully
325        >>> print(content)
326        key_0: value_0
327        key_1: 1
328        key_2: true
329        key_3:
330            - 1
331            - 2
332            - 3
333        """
334
335
336logger.debug('successfully imported %s', __name__)
class AppConfigBackend(config_ninja.backend.Backend):
 72class AppConfigBackend(Backend):
 73    """Retrieve the deployed configuration from AWS AppConfig.
 74
 75    ## Usage
 76
 77    To retrieve the configuration, use the `AppConfigBackend.get()` method:
 78
 79    >>> backend = AppConfigBackend(appconfigdata_client, 'app-id', 'conf-id', 'env-id')
 80    >>> print(backend.get())
 81    key_0: value_0
 82    key_1: 1
 83    key_2: true
 84    key_3:
 85        - 1
 86        - 2
 87        - 3
 88    """
 89
 90    client: AppConfigDataClient
 91    """The `boto3` client used to communicate with the AWS AppConfig service."""
 92
 93    application_id: str
 94    """See [Creating a namespace for your application in AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-namespace.html)"""
 95    configuration_profile_id: str
 96    """See [Creating a configuration profile in AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-configuration-profile.html)"""
 97    environment_id: str
 98    """See [Creating environments for your application in AWS AppConfig](https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-creating-environment.html)"""
 99
100    def __init__(
101        self,
102        client: AppConfigDataClient,
103        app_id: str,
104        config_profile_id: str,
105        env_id: str,
106    ) -> None:
107        """Initialize the backend."""
108        logger.debug(
109            "Initialize: %s(client=%s, app_id='%s', conf_id='%s', env_id='%s')",
110            self.__class__.__name__,
111            client,
112            app_id,
113            config_profile_id,
114            env_id,
115        )
116        self.client = client
117
118        self.application_id = app_id
119        self.configuration_profile_id = config_profile_id
120        self.environment_id = env_id
121
122    def __str__(self) -> str:
123        """Include properties in the string representation.
124
125        >>> print(str( AppConfigBackend(appconfigdata_client, 'app-id', 'conf-id', 'env-id') ))
126        boto3.client('appconfigdata').start_configuration_session(ApplicationIdentifier='app-id', ConfigurationProfileIdentifier='conf-id', EnvironmentIdentifier='env-id')
127        """
128        return (
129            "boto3.client('appconfigdata').start_configuration_session("
130            f"ApplicationIdentifier='{self.application_id}', "
131            f"ConfigurationProfileIdentifier='{self.configuration_profile_id}', "
132            f"EnvironmentIdentifier='{self.environment_id}')"
133        )
134
135    @staticmethod
136    @functools.lru_cache(maxsize=127)
137    def _get_id_from_name(name: str, operation_name: OperationName, client: AppConfigClient, **kwargs: Any) -> str:
138        out: Iterator[str] = (
139            client.get_paginator(operation_name).paginate(**kwargs).search(f'Items[?Name == `{name}`].Id')
140        )
141        ids = list(out)
142
143        if not ids:
144            raise ValueError(f'no "{operation_name}" results found for Name="{name}"')
145
146        if len(ids) > 1:
147            warnings.warn(
148                f"'{operation_name}' found {len(ids)} results for Name='{name}'; "
149                f"'{ids[0]}' will be used and the others ignored: {ids[1:]}",
150                category=RuntimeWarning,
151                stacklevel=3,
152            )
153
154        return ids[0]
155
156    def get(self) -> str:
157        """Retrieve the latest configuration deployment as a string."""
158        logger.debug('Retrieve latest configuration (%s)', self)
159        token = self.client.start_configuration_session(
160            ApplicationIdentifier=self.application_id,
161            EnvironmentIdentifier=self.environment_id,
162            ConfigurationProfileIdentifier=self.configuration_profile_id,
163            RequiredMinimumPollIntervalInSeconds=MINIMUM_POLL_INTERVAL_SECONDS,
164        )['InitialConfigurationToken']
165
166        resp = self.client.get_latest_configuration(ConfigurationToken=token)
167        return resp['Configuration'].read().decode()
168
169    @classmethod
170    def get_application_id(cls, name: str, client: AppConfigClient) -> str:
171        """Retrieve the application ID for the given application name."""
172        return cls._get_id_from_name(name, 'list_applications', client)
173
174    @classmethod
175    def get_configuration_profile_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
176        """Retrieve the configuration profile ID for the given configuration profile name."""
177        return cls._get_id_from_name(name, 'list_configuration_profiles', client, ApplicationId=application_id)
178
179    @classmethod
180    def get_environment_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
181        """Retrieve the environment ID for the given environment name & application ID."""
182        return cls._get_id_from_name(name, 'list_environments', client, ApplicationId=application_id)
183
184    @classmethod
185    def new(  # pylint: disable=arguments-differ  # pyright: ignore[reportIncompatibleMethodOverride]
186        cls,
187        application_name: str,
188        configuration_profile_name: str,
189        environment_name: str,
190        session: boto3.Session | None = None,
191    ) -> AppConfigBackend:
192        """Create a new instance of the backend.
193
194        ## Usage: `AppConfigBackend.new()`
195
196        <!-- fixture is used for doctest but excluded from documentation
197        >>> session = getfixture('mock_session_with_1_id')
198
199        -->
200
201        Use `boto3` to fetch IDs for based on name:
202
203        >>> backend = AppConfigBackend.new('app-name', 'conf-name', 'env-name', session)
204        >>> print(f"{backend}")
205        boto3.client('appconfigdata').start_configuration_session(ApplicationIdentifier='id-1', ConfigurationProfileIdentifier='id-1', EnvironmentIdentifier='id-1')
206
207        ### Error: No IDs Found
208
209        >>> session = getfixture('mock_session_with_0_ids')  # fixture for doctest
210
211        A `ValueError` is raised if no IDs are found for the given name:
212
213        >>> backend = AppConfigBackend.new('app-name-1', 'conf-name-1', 'env-name-1', session)
214        Traceback (most recent call last):
215        ...
216        ValueError: no "list_applications" results found for Name="app-name-1"
217
218        ### Warning: Multiple IDs Found
219
220        >>> session = getfixture('mock_session_with_2_ids')
221
222        The first ID is used and the others ignored.
223
224        >>> with pytest.warns(RuntimeWarning):
225        ...     backend = AppConfigBackend.new('app-name-2', 'conf-name-2', 'env-name-2', session)
226        """
227        logger.info(
228            'Create new instance: %s(app="%s", conf="%s", env="%s")',
229            cls.__name__,
230            application_name,
231            configuration_profile_name,
232            environment_name,
233        )
234
235        if session is None:
236            session, appconfig_client = get_session_and_client()
237        else:
238            appconfig_client = session.client('appconfig')  # pyright: ignore[reportUnknownMemberType]
239
240        application_id = cls.get_application_id(application_name, appconfig_client)
241        configuration_profile_id = cls.get_configuration_profile_id(
242            configuration_profile_name, appconfig_client, application_id
243        )
244        environment_id = cls.get_environment_id(environment_name, appconfig_client, application_id)
245
246        client: AppConfigDataClient = session.client('appconfigdata')  # pyright: ignore[reportUnknownMemberType]
247
248        return cls(client, application_id, configuration_profile_id, environment_id)
249
250    async def poll(self, interval: int = MINIMUM_POLL_INTERVAL_SECONDS) -> AsyncIterator[str]:
251        """Poll the AppConfig service for configuration changes.
252
253        .. note::
254            Methods written for `asyncio` need to jump through hoops to run as `doctest` tests.
255            To improve the readability of this documentation, each Python code block corresponds to
256            a `doctest` test defined in a private method.
257
258        ## Usage: `AppConfigBackend.poll()`
259
260        ```py
261        In [1]: async for content in backend.poll():
262           ...:     print(content)  # ← executes each time the configuration changes
263        ```
264        ```yaml
265        key_0: value_0
266        key_1: 1
267        key_2: true
268        key_3:
269            - 1
270            - 2
271            - 3
272        ```
273
274        .. note::
275            If polling is done too quickly, the AWS AppConfig client will raise a
276            `BadRequestException`. This is handled automatically by the backend, which will retry
277            the request after waiting for half the given `interval`.
278        """
279        token = self.client.start_configuration_session(
280            ApplicationIdentifier=self.application_id,
281            EnvironmentIdentifier=self.environment_id,
282            ConfigurationProfileIdentifier=self.configuration_profile_id,
283            RequiredMinimumPollIntervalInSeconds=interval,
284        )['InitialConfigurationToken']
285
286        while True:
287            logger.debug('Poll for configuration changes')
288            try:
289                resp = self.client.get_latest_configuration(ConfigurationToken=token)
290            except self.client.exceptions.BadRequestException as exc:
291                exc_resp: BadRequestExceptionResponse = exc.response  # type: ignore[assignment]
292                if exc_resp['Error']['Message'] != 'Request too early':  # pragma: no cover
293                    raise
294                logger.debug('Request too early; retrying in %d seconds', interval / 2)
295                await asyncio.sleep(interval / 2)
296                continue
297
298            token = resp['NextPollConfigurationToken']
299            if content := resp['Configuration'].read():
300                yield content.decode()
301            else:
302                logger.debug('No configuration changes')
303
304            await asyncio.sleep(resp['NextPollIntervalInSeconds'])
305
306    def _async_doctests(self) -> None:
307        """Define `async` `doctest` tests in this method to improve documentation.
308
309        Verify that an empty response to the `boto3` client is handled and the polling continues:
310        >>> backend = AppConfigBackend(appconfigdata_client_first_empty, 'app-id', 'conf-id', 'env-id')
311        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))
312        >>> print(content)
313        key_0: value_0
314        key_1: 1
315        key_2: true
316        key_3:
317            - 1
318            - 2
319            - 3
320
321
322        >>> client = getfixture('mock_poll_too_early')    # seed a `BadRequestException`
323
324        >>> backend = AppConfigBackend(client, 'app-id', 'conf-id', 'env-id')
325        >>> content = asyncio.run(anext(backend.poll(interval=0.01)))   # it is handled successfully
326        >>> print(content)
327        key_0: value_0
328        key_1: 1
329        key_2: true
330        key_3:
331            - 1
332            - 2
333            - 3
334        """

Retrieve the deployed configuration from AWS AppConfig.

Usage

To retrieve the configuration, use the AppConfigBackend.get() method:

>>> backend = AppConfigBackend(appconfigdata_client, 'app-id', 'conf-id', 'env-id')
>>> print(backend.get())
key_0: value_0
key_1: 1
key_2: true
key_3:
    - 1
    - 2
    - 3
AppConfigBackend( client: mypy_boto3_appconfigdata.AppConfigDataClient, app_id: str, config_profile_id: str, env_id: str)
100    def __init__(
101        self,
102        client: AppConfigDataClient,
103        app_id: str,
104        config_profile_id: str,
105        env_id: str,
106    ) -> None:
107        """Initialize the backend."""
108        logger.debug(
109            "Initialize: %s(client=%s, app_id='%s', conf_id='%s', env_id='%s')",
110            self.__class__.__name__,
111            client,
112            app_id,
113            config_profile_id,
114            env_id,
115        )
116        self.client = client
117
118        self.application_id = app_id
119        self.configuration_profile_id = config_profile_id
120        self.environment_id = env_id

Initialize the backend.

The boto3 client used to communicate with the AWS AppConfig service.

configuration_profile_id: str
def get(self) -> str:
156    def get(self) -> str:
157        """Retrieve the latest configuration deployment as a string."""
158        logger.debug('Retrieve latest configuration (%s)', self)
159        token = self.client.start_configuration_session(
160            ApplicationIdentifier=self.application_id,
161            EnvironmentIdentifier=self.environment_id,
162            ConfigurationProfileIdentifier=self.configuration_profile_id,
163            RequiredMinimumPollIntervalInSeconds=MINIMUM_POLL_INTERVAL_SECONDS,
164        )['InitialConfigurationToken']
165
166        resp = self.client.get_latest_configuration(ConfigurationToken=token)
167        return resp['Configuration'].read().decode()

Retrieve the latest configuration deployment as a string.

@classmethod
def get_application_id( cls, name: str, client: mypy_boto3_appconfig.AppConfigClient) -> str:
169    @classmethod
170    def get_application_id(cls, name: str, client: AppConfigClient) -> str:
171        """Retrieve the application ID for the given application name."""
172        return cls._get_id_from_name(name, 'list_applications', client)

Retrieve the application ID for the given application name.

@classmethod
def get_configuration_profile_id( cls, name: str, client: mypy_boto3_appconfig.AppConfigClient, application_id: str) -> str:
174    @classmethod
175    def get_configuration_profile_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
176        """Retrieve the configuration profile ID for the given configuration profile name."""
177        return cls._get_id_from_name(name, 'list_configuration_profiles', client, ApplicationId=application_id)

Retrieve the configuration profile ID for the given configuration profile name.

@classmethod
def get_environment_id( cls, name: str, client: mypy_boto3_appconfig.AppConfigClient, application_id: str) -> str:
179    @classmethod
180    def get_environment_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
181        """Retrieve the environment ID for the given environment name & application ID."""
182        return cls._get_id_from_name(name, 'list_environments', client, ApplicationId=application_id)

Retrieve the environment ID for the given environment name & application ID.

@classmethod
def new( cls, application_name: str, configuration_profile_name: str, environment_name: str, session: boto3.session.Session | None = None) -> AppConfigBackend:
184    @classmethod
185    def new(  # pylint: disable=arguments-differ  # pyright: ignore[reportIncompatibleMethodOverride]
186        cls,
187        application_name: str,
188        configuration_profile_name: str,
189        environment_name: str,
190        session: boto3.Session | None = None,
191    ) -> AppConfigBackend:
192        """Create a new instance of the backend.
193
194        ## Usage: `AppConfigBackend.new()`
195
196        <!-- fixture is used for doctest but excluded from documentation
197        >>> session = getfixture('mock_session_with_1_id')
198
199        -->
200
201        Use `boto3` to fetch IDs for based on name:
202
203        >>> backend = AppConfigBackend.new('app-name', 'conf-name', 'env-name', session)
204        >>> print(f"{backend}")
205        boto3.client('appconfigdata').start_configuration_session(ApplicationIdentifier='id-1', ConfigurationProfileIdentifier='id-1', EnvironmentIdentifier='id-1')
206
207        ### Error: No IDs Found
208
209        >>> session = getfixture('mock_session_with_0_ids')  # fixture for doctest
210
211        A `ValueError` is raised if no IDs are found for the given name:
212
213        >>> backend = AppConfigBackend.new('app-name-1', 'conf-name-1', 'env-name-1', session)
214        Traceback (most recent call last):
215        ...
216        ValueError: no "list_applications" results found for Name="app-name-1"
217
218        ### Warning: Multiple IDs Found
219
220        >>> session = getfixture('mock_session_with_2_ids')
221
222        The first ID is used and the others ignored.
223
224        >>> with pytest.warns(RuntimeWarning):
225        ...     backend = AppConfigBackend.new('app-name-2', 'conf-name-2', 'env-name-2', session)
226        """
227        logger.info(
228            'Create new instance: %s(app="%s", conf="%s", env="%s")',
229            cls.__name__,
230            application_name,
231            configuration_profile_name,
232            environment_name,
233        )
234
235        if session is None:
236            session, appconfig_client = get_session_and_client()
237        else:
238            appconfig_client = session.client('appconfig')  # pyright: ignore[reportUnknownMemberType]
239
240        application_id = cls.get_application_id(application_name, appconfig_client)
241        configuration_profile_id = cls.get_configuration_profile_id(
242            configuration_profile_name, appconfig_client, application_id
243        )
244        environment_id = cls.get_environment_id(environment_name, appconfig_client, application_id)
245
246        client: AppConfigDataClient = session.client('appconfigdata')  # pyright: ignore[reportUnknownMemberType]
247
248        return cls(client, application_id, configuration_profile_id, environment_id)

Create a new instance of the backend.

Usage: AppConfigBackend.new()

Use boto3 to fetch IDs for based on name:

>>> backend = AppConfigBackend.new('app-name', 'conf-name', 'env-name', session)
>>> print(f"{backend}")
boto3.client('appconfigdata').start_configuration_session(ApplicationIdentifier='id-1', ConfigurationProfileIdentifier='id-1', EnvironmentIdentifier='id-1')

Error: No IDs Found

>>> session = getfixture('mock_session_with_0_ids')  # fixture for doctest

A ValueError is raised if no IDs are found for the given name:

>>> backend = AppConfigBackend.new('app-name-1', 'conf-name-1', 'env-name-1', session)
Traceback (most recent call last):
...
ValueError: no "list_applications" results found for Name="app-name-1"

Warning: Multiple IDs Found

>>> session = getfixture('mock_session_with_2_ids')

The first ID is used and the others ignored.

>>> with pytest.warns(RuntimeWarning):
...     backend = AppConfigBackend.new('app-name-2', 'conf-name-2', 'env-name-2', session)
async def poll(self, interval: int = 60) -> AsyncIterator[str]:
250    async def poll(self, interval: int = MINIMUM_POLL_INTERVAL_SECONDS) -> AsyncIterator[str]:
251        """Poll the AppConfig service for configuration changes.
252
253        .. note::
254            Methods written for `asyncio` need to jump through hoops to run as `doctest` tests.
255            To improve the readability of this documentation, each Python code block corresponds to
256            a `doctest` test defined in a private method.
257
258        ## Usage: `AppConfigBackend.poll()`
259
260        ```py
261        In [1]: async for content in backend.poll():
262           ...:     print(content)  # ← executes each time the configuration changes
263        ```
264        ```yaml
265        key_0: value_0
266        key_1: 1
267        key_2: true
268        key_3:
269            - 1
270            - 2
271            - 3
272        ```
273
274        .. note::
275            If polling is done too quickly, the AWS AppConfig client will raise a
276            `BadRequestException`. This is handled automatically by the backend, which will retry
277            the request after waiting for half the given `interval`.
278        """
279        token = self.client.start_configuration_session(
280            ApplicationIdentifier=self.application_id,
281            EnvironmentIdentifier=self.environment_id,
282            ConfigurationProfileIdentifier=self.configuration_profile_id,
283            RequiredMinimumPollIntervalInSeconds=interval,
284        )['InitialConfigurationToken']
285
286        while True:
287            logger.debug('Poll for configuration changes')
288            try:
289                resp = self.client.get_latest_configuration(ConfigurationToken=token)
290            except self.client.exceptions.BadRequestException as exc:
291                exc_resp: BadRequestExceptionResponse = exc.response  # type: ignore[assignment]
292                if exc_resp['Error']['Message'] != 'Request too early':  # pragma: no cover
293                    raise
294                logger.debug('Request too early; retrying in %d seconds', interval / 2)
295                await asyncio.sleep(interval / 2)
296                continue
297
298            token = resp['NextPollConfigurationToken']
299            if content := resp['Configuration'].read():
300                yield content.decode()
301            else:
302                logger.debug('No configuration changes')
303
304            await asyncio.sleep(resp['NextPollIntervalInSeconds'])

Poll the AppConfig service for configuration changes.

Methods written for asyncio need to jump through hoops to run as doctest tests. To improve the readability of this documentation, each Python code block corresponds to a doctest test defined in a private method.

Usage: AppConfigBackend.poll()

In [1]: async for content in backend.poll():
   ...:     print(content)  # ← executes each time the configuration changes
key_0: value_0
key_1: 1
key_2: true
key_3:
    - 1
    - 2
    - 3

If polling is done too quickly, the AWS AppConfig client will raise a BadRequestException. This is handled automatically by the backend, which will retry the request after waiting for half the given interval.