Skip to content

Commit

Permalink
feature: allow to disable lru_caching when needed in testing context (#…
Browse files Browse the repository at this point in the history
…68)



---------

Co-authored-by: Ran Isenberg <ran.isenberg@ranthebuilder.cloud>
  • Loading branch information
ran-isenberg and Ran Isenberg authored Apr 5, 2024
1 parent 8f52c5f commit 2d58df9
Show file tree
Hide file tree
Showing 14 changed files with 753 additions and 546 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,4 @@ cdk.out
.vscode
lib_requirements.txt
.dccache
.ruff_cache
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ class MyEnvVariables(BaseModel):
API_URL: HttpUrl
```

You must first use the `@init_environment_variables` decorator to automatically validate and initialize the environment variables before executing a function:
Before executing a function, you must use the `@init_environment_variables` decorator to validate and initialize the environment variables automatically.

The decorator guarantees that the function will run with the correct variable configuration.

Then, you can fetch the environment variables using the global getter function, 'get_environment_variables,' and use them just like a data class. At this point, they are parsed and validated.

```python
from aws_lambda_env_modeler import init_environment_variables
Expand All @@ -93,6 +97,51 @@ env_vars = get_environment_variables(MyEnvVariables)
print(env_vars.DB_HOST)
```

## Disabling Cache for Testing

By default, the modeler uses cache - the parsed model is cached for performance improvement for multiple 'get' calls.

In some cases, such as during testing, you may want to turn off the cache. You can do this by setting the `LAMBDA_ENV_MODELER_DISABLE_CACHE` environment variable to 'True.'

This is especially useful in tests where you want to run multiple tests concurrently, each with a different set of environment variables.

Here's an example of how you can use this in a pytest test:

```python
import json
from http import HTTPStatus
from typing import Any, Dict
from unittest.mock import patch

from pydantic import BaseModel
from typing_extensions import Literal

from aws_lambda_env_modeler import LAMBDA_ENV_MODELER_DISABLE_CACHE, get_environment_variables, init_environment_variables


class MyHandlerEnvVars(BaseModel):
LOG_LEVEL: Literal['DEBUG', 'INFO', 'ERROR', 'CRITICAL', 'WARNING', 'EXCEPTION']


@init_environment_variables(model=MyHandlerEnvVars)
def my_handler(event: Dict[str, Any], context) -> Dict[str, Any]:
env_vars = get_environment_variables(model=MyHandlerEnvVars) # noqa: F841
# can access directly env_vars.LOG_LEVEL as dataclass
return {
'statusCode': HTTPStatus.OK,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({'message': 'success'}),
}


@patch.dict('os.environ', {LAMBDA_ENV_MODELER_DISABLE_CACHE: 'true', 'LOG_LEVEL': 'DEBUG'})
def test_my_handler():
response = my_handler({}, None)
assert response['statusCode'] == HTTPStatus.OK
assert response['headers'] == {'Content-Type': 'application/json'}
assert json.loads(response['body']) == {'message': 'success'}
```

## Code Contributions
Code contributions are welcomed. Read this [guide.](https://github.com/ran-isenberg/aws-lambda-env-modeler/blob/main/CONTRIBUTING.md)

Expand Down
14 changes: 4 additions & 10 deletions aws_lambda_env_modeler/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
"""Advanced event_parser utility
"""
from pydantic import BaseModel

from .modeler import get_environment_variables, init_environment_variables
from .types import Model
from aws_lambda_env_modeler.modeler import get_environment_variables, init_environment_variables
from aws_lambda_env_modeler.modeler_impl import LAMBDA_ENV_MODELER_DISABLE_CACHE
from aws_lambda_env_modeler.types import Annotated, Model

__all__ = [
'Model',
'BaseModel',
'init_environment_variables',
'get_environment_variables',
]
__all__ = ['Model', 'BaseModel', 'init_environment_variables', 'get_environment_variables', 'LAMBDA_ENV_MODELER_DISABLE_CACHE', 'Annotated']
52 changes: 19 additions & 33 deletions aws_lambda_env_modeler/modeler.py
Original file line number Diff line number Diff line change
@@ -1,64 +1,50 @@
import os
from functools import lru_cache, wraps
from functools import wraps
from typing import Any, Callable, Dict, Type

from aws_lambda_env_modeler.modeler_impl import __get_environment_variables_impl
from aws_lambda_env_modeler.types import Model


def get_environment_variables(model: Type[Model]) -> Model:
"""
This function receives a model of type Model, uses it to validate the environment variables, and returns the
validated model.
Args:
model (Type[Model]): A Pydantic model that defines the structure and types of the expected environment variables.
Returns:
Model: An instance of the provided model filled with the values of the validated environment variables.
"""
return __parse_model(model)


def init_environment_variables(model: Type[Model]):
"""
A decorator function for AWS Lambda handler functions that initializes environment variables based on the given Pydantic model before executing
the decorated function. The decorator validates the environment variables according to the model structure before
running the handler.
A decorator for AWS Lambda handler functions. It initializes and validates environment variables based on the provided Pydantic model before the execution of the decorated function.
It uses LRU Cache by model class type to optimize parsing time. Cache can be disabled by setting the environment variable 'LAMBDA_ENV_MODELER_DISABLE_CACHE' to FALSE (default: cache is enabled)
Args:
model (Type[Model]): A Pydantic model that defines the structure and types of the expected environment variables.
model (Type[Model]): A Pydantic model that outlines the structure and types of the expected environment variables.
Returns:
Callable: A decorated function that first initializes the environment variables and then runs the function.
Callable: A decorated function that first initializes and validates the environment variables, then executes the original function.
Raises:
ValueError: If the environment variables do not align with the model's structure or fail validation.
"""

def decorator(lambda_handler_function: Callable):
@wraps(lambda_handler_function)
def wrapper(event: Dict[str, Any], context, **kwargs):
__parse_model(model)
# Initialize and validate environment variables before executing the lambda handler function
__get_environment_variables_impl(model)
return lambda_handler_function(event, context, **kwargs)

return wrapper

return decorator


@lru_cache
def __parse_model(model: Type[Model]) -> Model:
def get_environment_variables(model: Type[Model]) -> Model:
"""
A helper function to validate and parse environment variables based on a given Pydantic model. This function is
also cached to improve performance in successive calls.
Retrieves and validates environment variables based on the provided Pydantic model.
It uses LRU Cache by model class type to optimize parsing time. Cache can be disabled by setting the environment variable 'LAMBDA_ENV_MODELER_DISABLE_CACHE' to FALSE (default: cache is enabled)
It's recommended to use anywhere in the function's after init_environment_variables decorator was used on the handler function.
Args:
model (Type[Model]): A Pydantic model that defines the structure and types of the expected environment variables.
model (Type[Model]): A Pydantic model that outlines the structure and types of the expected environment variables.
Returns:
Model: An instance of the provided model filled with the values of the validated environment variables.
Model: An instance of the provided model populated with the values of the validated environment variables.
Raises:
ValueError: If the environment variables do not match the structure of the model or cannot be validated.
ValueError: If the environment variables do not align with the model's structure or fail validation.
"""
try:
return model.model_validate(os.environ)
except Exception as exc:
raise ValueError(f'failed to load environment variables, exception={str(exc)}') from exc
return __get_environment_variables_impl(model)
33 changes: 33 additions & 0 deletions aws_lambda_env_modeler/modeler_impl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os
from functools import lru_cache
from typing import Type

from aws_lambda_env_modeler.types import Model

# Environment variable to control caching
LAMBDA_ENV_MODELER_DISABLE_CACHE = 'LAMBDA_ENV_MODELER_DISABLE_CACHE'


def __get_environment_variables_impl(model: Type[Model]) -> Model:
# Check if the environment variable for disabling cache is set to true
disable_cache = True if os.getenv(LAMBDA_ENV_MODELER_DISABLE_CACHE, 'false').lower() == 'true' else False
if disable_cache:
# If LAMBDA_ENV_MODELER_DISABLE_CACHE is true, parse the model without cache
return __parse_model_impl(model)
# If LAMBDA_ENV_MODELER_DISABLE_CACHE is not true, parse the model with cache
return __parse_model_with_cache(model)


@lru_cache
def __parse_model_with_cache(model: Type[Model]) -> Model:
# Parse the model with cache enabled
return __parse_model_impl(model)


def __parse_model_impl(model: Type[Model]) -> Model:
try:
# Validate the model with the environment variables
return model.model_validate(os.environ)
except Exception as exc:
# If validation fails, raise an exception with the error message
raise ValueError(f'failed to load environment variables, exception={str(exc)}') from exc
9 changes: 8 additions & 1 deletion aws_lambda_env_modeler/types.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import sys
from typing import TypeVar

from pydantic import BaseModel

Model = TypeVar('Model', bound=BaseModel)

__all__ = ['Model', 'BaseModel']

if sys.version_info >= (3, 9):
from typing import Annotated
else:
from typing_extensions import Annotated

__all__ = ['Model', 'BaseModel', 'Annotated']
38 changes: 27 additions & 11 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,42 @@ pip install aws-lambda-env-modeler

## Usage

First, define a Pydantic model for your environment variables:
### Schema Definition

```python
from pydantic import BaseModel
First, define a Pydantic model for your environment variables:

class MyEnvVariables(BaseModel):
DB_HOST: str
DB_PORT: int
DB_USER: str
DB_PASS: str
```python title="schema.py"
--8<-- "docs/snippets/schema.py"
```

You must first use the `@init_environment_variables` decorator to automatically validate and initialize the environment variables before executing a function:
Notice how you can use advanced types and value assertions and not just plain strings.

### Decorator

Then, you can fetch and validate the environment variables with your model:
Before executing a function, you must use the `@init_environment_variables` decorator to validate and initialize the environment variables automatically.

```python hl_lines="8 18 20" title="my_handler.py"
The decorator guarantees that the function will run with the correct variable configuration.

Then, you can fetch the environment variables using the global getter function, 'get_environment_variables,' and use them just like a data class. At this point, they are parsed and validated.

```python hl_lines="7 18 20" title="my_handler.py"
--8<-- "docs/snippets/my_handler.py"
```

## Disabling Cache for Testing

By default, the modeler uses cache - the parsed model is cached for performance improvement for multiple 'get' calls.

In some cases, such as during testing, you may want to turn off the cache. You can do this by setting the `LAMBDA_ENV_MODELER_DISABLE_CACHE` environment variable to 'True.'

This is especially useful in tests where you want to run multiple tests concurrently, each with a different set of environment variables.

Here's an example of how you can use this in a pytest test:

```python hl_lines="8 26" title="pytest.py"
--8<-- "docs/snippets/pytest.py"
```

## License

This library is licensed under the MIT License. See the [LICENSE](https://github.com/ran-isenberg/aws-lambda-env-modeler/blob/main/LICENSE) file.
4 changes: 2 additions & 2 deletions docs/snippets/my_handler.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import json
from http import HTTPStatus
from typing import Any, Dict
from typing import Any, Dict, Literal

from pydantic import BaseModel, Field, HttpUrl
from typing_extensions import Annotated, Literal

from aws_lambda_env_modeler import get_environment_variables, init_environment_variables
from aws_lambda_env_modeler.types import Annotated


class MyHandlerEnvVars(BaseModel):
Expand Down
31 changes: 31 additions & 0 deletions docs/snippets/pytest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import json
from http import HTTPStatus
from typing import Any, Dict, Literal
from unittest.mock import patch

from pydantic import BaseModel

from aws_lambda_env_modeler import LAMBDA_ENV_MODELER_DISABLE_CACHE, get_environment_variables, init_environment_variables


class MyHandlerEnvVars(BaseModel):
LOG_LEVEL: Literal['DEBUG', 'INFO', 'ERROR', 'CRITICAL', 'WARNING', 'EXCEPTION']


@init_environment_variables(model=MyHandlerEnvVars)
def my_handler(event: Dict[str, Any], context) -> Dict[str, Any]:
env_vars = get_environment_variables(model=MyHandlerEnvVars) # noqa: F841
# can access directly env_vars.LOG_LEVEL as dataclass
return {
'statusCode': HTTPStatus.OK,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({'message': 'success'}),
}


@patch.dict('os.environ', {LAMBDA_ENV_MODELER_DISABLE_CACHE: 'true', 'LOG_LEVEL': 'DEBUG'})
def test_my_handler():
response = my_handler({}, None)
assert response['statusCode'] == HTTPStatus.OK
assert response['headers'] == {'Content-Type': 'application/json'}
assert json.loads(response['body']) == {'message': 'success'}
12 changes: 12 additions & 0 deletions docs/snippets/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from typing import Literal

from pydantic import BaseModel, Field, HttpUrl

from aws_lambda_env_modeler.types import Annotated


class MyEnvVariables(BaseModel):
REST_API: HttpUrl
ROLE_ARN: Annotated[str, Field(min_length=20, max_length=2048)]
POWERTOOLS_SERVICE_NAME: Annotated[str, Field(min_length=1)]
LOG_LEVEL: Literal['DEBUG', 'INFO', 'ERROR', 'CRITICAL', 'WARNING', 'EXCEPTION']
Loading

0 comments on commit 2d58df9

Please sign in to comment.