Metadata-Version: 2.4
Name: os-env-injection
Version: 2.0.0
Summary: Utilities to handle OS environment variables
Author-email: Francesco Calcavecchia <francesco.calcavecchia@gmail.com>
License: MIT
Project-URL: Homepage, https://gitlab.com/francesco-calcavecchia/py-os-env-injection
Classifier: Programming Language :: Python
Classifier: Operating System :: OS Independent
Requires-Python: >=3.5
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Provides-Extra: dev
Requires-Dist: black; extra == "dev"
Requires-Dist: flake8; extra == "dev"
Requires-Dist: isort; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: pre-commit; extra == "dev"
Requires-Dist: pre-commit-hooks; extra == "dev"
Requires-Dist: pylint; extra == "dev"
Requires-Dist: pytest; extra == "dev"

# os-env-injection
Utilities to handle OS environment variables

## Why this library?

You have a function which requires several arguments that typically depend on the system on when the function is running
```python
def f(url: str, user: str, password: str, subdomain: str):
    ...
```

You would like to have the possibility to have its values to be read from the OS env, so that, at will, you could invoke
it as
```python
f()
```

If you simply write
```python
import os

def f(
    url: str = os.environ["URL"],
    user: str = os.environ["USER"],
    password: str = os.environ["PASSWORD"],
    subdomain: str = os.environ["SUBDOMAIN"],
):
    ...
```
then you will encounter a problem whenever the OS env variables are not set, because the defaults are evaluated at import time.
This means in practice, that you will be forced to use them, instead of passing the values directly.

A workaround is
```python
import os

def f(
    url: str = os.environ.get("URL", None),
    user: str = os.environ.get("USER", None),
    password: str = os.environ.get("PASSWORD", None),
    subdomain: str = os.environ.get("SUBDOMAIN", None),
):
    ...
```
In this way, there are still a couple of drawbacks:
* it is necessary to write code to check the values of each variable to raise an exception if values are missing (`None`)
* the default values will be evaluated when the function is first imported.
  In case you are setting the OS env dynamically (e.g. by executing a shell script with `export`s) you could end up in troubles.



## The nice solution - Quickstart

First, install the library
```shell
pip install os-env-injection
```

From the previous example, say that all variables are required except for `subdomain` which can stay `None`.
You can leverage on this library in two ways, depending on your favorite style.

#### Imperative style

```python
from os_env_injection import inject_var


def f(
	url: str = "",
	user: str = "",
	password: str = "",
	subdomain: str | None = None,
) -> None:
	url = inject_var(var_value=url, os_env_key="OS_ENV_URL")
	user = inject_var(var_value=user, os_env_key="OS_ENV_USER")
	password = inject_var(var_value=password, os_env_key="OS_ENV_PASSWORD")
	subdomain = inject_var(
		var_value=subdomain, os_env_key="OS_ENV_SUBDOMAIN", is_required=False
	)
	...

```

**Note**: `inject_var(var_value=url, os_env_key="OS_ENV_URL")` is the same as `inject_var(var_value=url, os_env_key="OS_ENV_URL", is_required=True)`.


#### Functional style

```python
from os_env_injection import inject_os_env, Injection


@inject_os_env(
	injections=[
		Injection(var_name="url", os_env_key="OS_ENV_URL"),
		Injection(var_name="user", os_env_key="OS_ENV_USER"),
		Injection(var_name="password", os_env_key="OS_ENV_PASSWORD"),
		Injection(
			var_name="subdomain",
			os_env_key="OS_ENV_SUBDOMAIN",
			is_required=False,
		),
	]
)
def f(
	url: str = "",
	user: str = "",
	password: str = "",
	subdomain: str | None = None,
) -> None:
    ...
```

**Note**: `Injection(var_name="url")` is the same as `Injection(var_name="url", os_env_key="url", is_required=True)`.


#### What will happen?

* If you explicitly pass a valid value when you call `f`, it will be used.
* If no value is passed (or the value matches the invalid sentinel, which defaults to `""` or `None`), it will try to read it from the OS environment variable specified in `os_env_key`.
* If the value is still unset (or the environment variable itself is missing or set to the invalid sentinel), it will raise an exception if `is_required` is `True`.
* If `is_required` is `False`, it will not raise an exception and simply keep the invalid sentinel (e.g., `None`).

#### Why set a default value at all?

When you decorate a function with `@inject_os_env` and expect the OS environment to supply the values, you usually want to call the function without checking or passing those arguments yourself (e.g., just `f()`).

If your function signature doesn't provide default values (i.e., `def f(url: str):`), calling `f()` will cause Python to raise a `TypeError` at runtime for missing arguments, and type checkers (`mypy`, `pyright`, etc.) will also complain. Assigning a default value (like `url: str = ""`) satisfies both Python and the type checker, allowing you to invoke `f()` cleanly while trusting the decorator to inject the real values.

#### Understanding Sentinel Values (`value_to_consider_invalid`)

By default, both `None` and the empty string `""` are treated as "missing" values that trigger the fallback to the environment variable.

#### Why is this useful? Eliminating `None`-checks

When working with type checkers like `mypy` or `pyright`, using `None` as a default value forces you to make the type optional, which can clutter downstream code:

```python
# Before: using `None` as the default value
@inject_os_env([Injection("url")])
def f(url: str | None = None):
    # Type checkers will complain here because `url` might be None!
    # Even though `inject_os_env` guarantees `url` will be populated from the OS var,
    # the type signature `str | None` says otherwise.
    processed_url = url.strip()  # Error: Item "None" of "str | None" has no attribute "strip"

    # You are forced to add redundant checks just to satisfy the type checker:
    assert url is not None
    processed_url = url.strip()
```

By using `""` as the invalid sentinel, you can keep strict typing without the boilerplate:

```python
# After: using `""` as the default invalid sentinel
@inject_os_env([Injection("url")])
def f(url: str = ""):
    # No type checker complaints!
    # `url` is guaranteed to be a string.
    # If the env var is missing, the decorator raises an error before we even reach this point.
    processed_url = url.strip()
```

#### Custom Sentinels

If your environment variable might legitimately be an empty string, or you want to use a different sentinel, you can explicitly set `value_to_consider_invalid`:

```python
url = inject_var(
    var_value=url,
    os_env_key="OS_ENV_URL",
    value_to_consider_invalid="__MISSING__"
)
# or functionally:
Injection(
    var_name="url",
    value_to_consider_invalid="__MISSING__"
)
```


## Setup development environment (for contributors only)

* Create a virtual environment and activate it
  ```shell
  python -m venv venv
  source venv/bin/activate
  ```

* Install the developer dependencies you will need
  ```shell
  pip install -U pip wheel setuptools
  pip install -e .[dev]
  ```

* Set black as pre-commit package (will automatically apply [black](https://github.com/psf/black) before committing)
  ```shell
  pre-commit install
  ```

* To run the tests
  ```shell
  pytest
  ```
