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

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)
 98    def __init__(
 99        self,
100        client: AppConfigDataClient,
101        app_id: str,
102        config_profile_id: str,
103        env_id: str,
104    ) -> None:
105        """Initialize the backend."""
106        logger.debug(
107            "Initialize: %s(client=%s, app_id='%s', conf_id='%s', env_id='%s')",
108            self.__class__.__name__,
109            client,
110            app_id,
111            config_profile_id,
112            env_id,
113        )
114        self.client = client
115
116        self.application_id = app_id
117        self.configuration_profile_id = config_profile_id
118        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:
154    def get(self) -> str:
155        """Retrieve the latest configuration deployment as a string."""
156        logger.debug('Retrieve latest configuration (%s)', self)
157        token = self.client.start_configuration_session(
158            ApplicationIdentifier=self.application_id,
159            EnvironmentIdentifier=self.environment_id,
160            ConfigurationProfileIdentifier=self.configuration_profile_id,
161            RequiredMinimumPollIntervalInSeconds=MINIMUM_POLL_INTERVAL_SECONDS,
162        )['InitialConfigurationToken']
163
164        resp = self.client.get_latest_configuration(ConfigurationToken=token)
165        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:
167    @classmethod
168    def get_application_id(cls, name: str, client: AppConfigClient) -> str:
169        """Retrieve the application ID for the given application name."""
170        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:
172    @classmethod
173    def get_configuration_profile_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
174        """Retrieve the configuration profile ID for the given configuration profile name."""
175        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:
177    @classmethod
178    def get_environment_id(cls, name: str, client: AppConfigClient, application_id: str) -> str:
179        """Retrieve the environment ID for the given environment name & application ID."""
180        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:
182    @classmethod
183    def new(  # pylint: disable=arguments-differ  # pyright: ignore[reportIncompatibleMethodOverride]
184        cls,
185        application_name: str,
186        configuration_profile_name: str,
187        environment_name: str,
188        session: boto3.Session | None = None,
189    ) -> AppConfigBackend:
190        """Create a new instance of the backend.
191
192        ## Usage: `AppConfigBackend.new()`
193
194        <!-- fixture is used for doctest but excluded from documentation
195        >>> session = getfixture('mock_session_with_1_id')
196
197        -->
198
199        Use `boto3` to fetch IDs for based on name:
200
201        >>> backend = AppConfigBackend.new('app-name', 'conf-name', 'env-name', session)
202        >>> print(f"{backend}")
203        boto3.client('appconfigdata').start_configuration_session(ApplicationIdentifier='id-1', ConfigurationProfileIdentifier='id-1', EnvironmentIdentifier='id-1')
204
205        ### Error: No IDs Found
206
207        >>> session = getfixture('mock_session_with_0_ids')  # fixture for doctest
208
209        A `ValueError` is raised if no IDs are found for the given name:
210
211        >>> backend = AppConfigBackend.new('app-name-1', 'conf-name-1', 'env-name-1', session)
212        Traceback (most recent call last):
213        ...
214        ValueError: no "list_applications" results found for Name="app-name-1"
215
216        ### Warning: Multiple IDs Found
217
218        >>> session = getfixture('mock_session_with_2_ids')
219
220        The first ID is used and the others ignored.
221
222        >>> with pytest.warns(RuntimeWarning):
223        ...     backend = AppConfigBackend.new('app-name-2', 'conf-name-2', 'env-name-2', session)
224        """
225        logger.info(
226            'Create new instance: %s(app="%s", conf="%s", env="%s")',
227            cls.__name__,
228            application_name,
229            configuration_profile_name,
230            environment_name,
231        )
232
233        session = session or boto3.Session()
234        appconfig_client = session.client('appconfig')  # pyright: ignore[reportUnknownMemberType]
235        application_id = cls.get_application_id(application_name, appconfig_client)
236        configuration_profile_id = cls.get_configuration_profile_id(
237            configuration_profile_name, appconfig_client, application_id
238        )
239        environment_id = cls.get_environment_id(environment_name, appconfig_client, application_id)
240
241        client: AppConfigDataClient = session.client('appconfigdata')  # pyright: ignore[reportUnknownMemberType]
242
243        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]:
245    async def poll(self, interval: int = MINIMUM_POLL_INTERVAL_SECONDS) -> AsyncIterator[str]:
246        """Poll the AppConfig service for configuration changes.
247
248        .. note::
249            Methods written for `asyncio` need to jump through hoops to run as `doctest` tests.
250            To improve the readability of this documentation, each Python code block corresponds to
251            a `doctest` test defined in a private method.
252
253        ## Usage: `AppConfigBackend.poll()`
254
255        ```py
256        In [1]: async for content in backend.poll():
257           ...:     print(content)  # ← executes each time the configuration changes
258        ```
259        ```yaml
260        key_0: value_0
261        key_1: 1
262        key_2: true
263        key_3:
264            - 1
265            - 2
266            - 3
267        ```
268
269        .. note::
270            If polling is done too quickly, the AWS AppConfig client will raise a
271            `BadRequestException`. This is handled automatically by the backend, which will retry
272            the request after waiting for half the given `interval`.
273        """
274        token = self.client.start_configuration_session(
275            ApplicationIdentifier=self.application_id,
276            EnvironmentIdentifier=self.environment_id,
277            ConfigurationProfileIdentifier=self.configuration_profile_id,
278            RequiredMinimumPollIntervalInSeconds=interval,
279        )['InitialConfigurationToken']
280
281        while True:
282            logger.debug('Poll for configuration changes')
283            try:
284                resp = self.client.get_latest_configuration(ConfigurationToken=token)
285            except self.client.exceptions.BadRequestException as exc:
286                exc_resp: BadRequestExceptionResponse = exc.response  # type: ignore[assignment]
287                if exc_resp['Error']['Message'] != 'Request too early':  # pragma: no cover
288                    raise
289                logger.debug('Request too early; retrying in %d seconds', interval / 2)
290                await asyncio.sleep(interval / 2)
291                continue
292
293            token = resp['NextPollConfigurationToken']
294            if content := resp['Configuration'].read():
295                yield content.decode()
296            else:
297                logger.debug('No configuration changes')
298
299            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.