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

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