Add environment management classes and casting functions
- Introduced `Environment`, `EnvShared`, and `TestEnv` classes for managing environment variables. - Added casting functions for various data types in a new `cast.py` module. - Updated README with usage examples and class descriptions.
This commit is contained in:
parent
24a30cb0a4
commit
5bd10d222e
79
README.md
79
README.md
@ -27,6 +27,14 @@ Supports `.env` file parsing with optional mode suffixes (e.g. `.env.production`
|
||||
|
||||
---
|
||||
|
||||
### When to use which class
|
||||
|
||||
* `Environment`: default choice for most apps; new instance per config.
|
||||
* `EnvShared`: use when you want a single shared instance across the process (legacy singleton behavior).
|
||||
* `TestEnv`: use for tests/fixtures when you want to inject a dict and skip file I/O.
|
||||
|
||||
---
|
||||
|
||||
### Basic Example
|
||||
|
||||
```python
|
||||
@ -52,20 +60,39 @@ DB_URL=https://example.com
|
||||
### Smart Casts Example
|
||||
|
||||
```python
|
||||
from chipenv import (
|
||||
Environment, cast_bool, cast_int, cast_float,
|
||||
cast_list, cast_tuple, cast_dict, cast_none_or_str
|
||||
)
|
||||
from chipenv import Environment, cast
|
||||
|
||||
env = Environment()
|
||||
|
||||
print("INT:", env.get("PORT", cast=cast_int)) # -> int
|
||||
print("FLOAT:", env.get("PI", cast=cast_float)) # -> float
|
||||
print("BOOL:", env.get("ENABLED", cast=cast_bool)) # -> bool
|
||||
print("LIST:", env.get("NUMBERS", cast=cast_list)) # -> list[str]
|
||||
print("TUPLE:", env.get("WORDS", cast=cast_tuple)) # -> tuple[str]
|
||||
print("DICT:", env.get("CONFIG", cast=cast_dict)) # -> dict
|
||||
print("NONE_OR_STR:", env.get("OPTIONAL", cast=cast_none_or_str)) # -> None or str
|
||||
print("INT:", env.get("PORT", cast=cast.cast_int)) # -> int
|
||||
print("FLOAT:", env.get("PI", cast=cast.cast_float)) # -> float
|
||||
print("BOOL:", env.get("ENABLED", cast=cast.cast_bool)) # -> bool
|
||||
print("LIST:", env.get("NUMBERS", cast=cast.cast_list)) # -> list[str]
|
||||
print("TUPLE:", env.get("WORDS", cast=cast.cast_tuple)) # -> tuple[str]
|
||||
print("DICT:", env.get("CONFIG", cast=cast.cast_dict)) # -> dict
|
||||
print("NONE_OR_STR:", env.get("OPTIONAL", cast=cast.cast_none_or_str)) # -> None or str
|
||||
```
|
||||
|
||||
### Singleton Example
|
||||
|
||||
```python
|
||||
from chipenv import EnvShared, Modes
|
||||
|
||||
env_a = EnvShared(env_file_name=".env", mode=Modes.LOCAL)
|
||||
env_b = EnvShared(env_file_name=".env", mode=Modes.LOCAL)
|
||||
|
||||
print(env_a is env_b) # -> True
|
||||
```
|
||||
|
||||
### Test/Fixture Example
|
||||
|
||||
```python
|
||||
from chipenv import TestEnv
|
||||
|
||||
env = TestEnv({"DEBUG": "true", "PORT": "8080"})
|
||||
|
||||
print(env.get("DEBUG")) # -> "true"
|
||||
print(env["PORT"]) # -> "8080"
|
||||
```
|
||||
|
||||
Example `.env`:
|
||||
@ -80,8 +107,38 @@ CONFIG={"timeout": 30, "debug": true}
|
||||
OPTIONAL=null
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Common errors and handling
|
||||
|
||||
Missing key with no default:
|
||||
|
||||
```python
|
||||
from chipenv import Environment, EnviromentKeyMissing
|
||||
|
||||
env = Environment(suppress_file_not_found=True)
|
||||
|
||||
try:
|
||||
env.get("MISSING_KEY")
|
||||
except EnviromentKeyMissing as exc:
|
||||
print(exc)
|
||||
```
|
||||
|
||||
Missing `.env` file:
|
||||
|
||||
```python
|
||||
from chipenv import Environment
|
||||
|
||||
try:
|
||||
env = Environment(env_file_name=".env", mode="missing")
|
||||
except FileNotFoundError as exc:
|
||||
print(exc)
|
||||
```
|
||||
|
||||
### Included Cast Helpers
|
||||
|
||||
Import them via `from chipenv import cast` and use `cast.cast_int(...)`, etc.
|
||||
|
||||
All built-in cast functions handle common edge cases:
|
||||
|
||||
| Cast Function | Description |
|
||||
|
||||
@ -1,155 +1,41 @@
|
||||
import os.path as op
|
||||
from typing import Optional, Callable, Any
|
||||
from enum import Enum
|
||||
from dotenv import dotenv_values
|
||||
import json
|
||||
|
||||
NO_DEFAULT = object() # Used to indicate no default value was provided
|
||||
|
||||
class EnviromentKeyMissing(Exception):
|
||||
def __init__(self, key: str):
|
||||
super().__init__(f"Environment variable '{key}' not found.")
|
||||
|
||||
class Modes(Enum):
|
||||
"""
|
||||
Enum for different environment modes.
|
||||
Note: they are what i commonly use, feel free to use a own enum or directly use strings.
|
||||
"""
|
||||
PRODUCTION = ".production"
|
||||
DEVELOPMENT = ".development"
|
||||
TESTING = ".testing"
|
||||
STAGING = ".staging"
|
||||
LOCAL = ""
|
||||
|
||||
class Environment:
|
||||
"""
|
||||
Environment class for managing environment variables.
|
||||
This class loads environment variables from a file and provides access to them.
|
||||
It supports different modes (e.g., production, development) by appending the mode to the file name.
|
||||
It uses a singleton pattern to ensure only one instance exists.
|
||||
The environment variables can be accessed directly or through a `get` method that allows for casting and default values.
|
||||
|
||||
Usage:
|
||||
```python
|
||||
env = Environment(env_file_name='.env', mode=Modes.DEVELOPMENT)
|
||||
value = env.get('MY_VARIABLE', default='default_value', cast=str)
|
||||
```
|
||||
|
||||
This will load the environment variables from '.env.development' and return the value of 'MY_VARIABLE'.
|
||||
If 'MY_VARIABLE' is not found, it will return 'default_value'.
|
||||
If the environment file does not exist, it raises a FileNotFoundError.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, *, env_file_name: Optional[str] = None, env_file_path: Optional[str] = None, mode: Optional[Enum|str] = Modes.LOCAL, suppress_file_not_found: bool = False):
|
||||
self.__initialize(env_file_name, env_file_path, mode, suppress_file_not_found)
|
||||
|
||||
def __initialize(self, env_file_name: str, env_file_path: str, mode: Enum|str, suppress_file_not_found: bool):
|
||||
if hasattr(self, '__initialized') and self.__initialized:
|
||||
return
|
||||
|
||||
self.__env_file_path = self.__resolve_file_path(env_file_name, env_file_path, mode)
|
||||
|
||||
if not op.exists(self.__env_file_path) and not suppress_file_not_found:
|
||||
raise FileNotFoundError(f"Environment file '{self.__env_file_path}' does not exist.")
|
||||
|
||||
self.__values = dotenv_values(self.__env_file_path)
|
||||
self.__initialized = True
|
||||
|
||||
def __resolve_file_path(self, env_file_name: str, env_file_path: str, mode: Enum|str) -> str:
|
||||
"""
|
||||
Resolves the file path for the environment file.
|
||||
"""
|
||||
if isinstance(mode, Enum):
|
||||
mode = mode.value
|
||||
else:
|
||||
mode = f".{mode}" if mode else ""
|
||||
|
||||
if mode != "" and not mode.startswith('.'):
|
||||
mode = f".{mode}"
|
||||
|
||||
file = (env_file_name or '.env') + mode
|
||||
|
||||
if env_file_path:
|
||||
return op.join(env_file_path, file)
|
||||
|
||||
return file
|
||||
|
||||
@property
|
||||
def env_file_path(self) -> str:
|
||||
"""
|
||||
Returns the path to the environment file.
|
||||
This is the file from which environment variables are loaded.
|
||||
If no file was specified, it defaults to '.env'.
|
||||
If a mode is specified, it appends the mode to the file name.
|
||||
Example: '.env.production', '.env.development', etc.
|
||||
If the file was not found, it raises a FileNotFoundError unless suppressed.
|
||||
Returns None if no file was specified or found.
|
||||
"""
|
||||
return self.__env_file_path if hasattr(self, '_Environment__env_file_path') else None
|
||||
|
||||
@property
|
||||
def values(self) -> dict:
|
||||
"""
|
||||
Direct access to environment variables.
|
||||
Returns a dictionary of all environment variables loaded.
|
||||
Use with caution as it bypasses any casting or default values.
|
||||
Prefer using `get` method for safer access.
|
||||
"""
|
||||
return self.__values
|
||||
|
||||
def get(self, key: str, default: Optional[Any] = NO_DEFAULT, cast: Optional[Callable] = lambda x: x) -> Any:
|
||||
"""
|
||||
Get the value of an environment variable.
|
||||
If the variable is not found, it returns the default value.
|
||||
If no default is provided, it raises a KeyError.
|
||||
If a cast function is provided, it applies the cast to the value before returning.
|
||||
"""
|
||||
|
||||
if default is NO_DEFAULT and key not in self.__values:
|
||||
raise EnviromentKeyMissing(key)
|
||||
|
||||
return cast(self.__values.get(key, default))
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
if name.startswith("_") or name in super().__dir__():
|
||||
return super().__getattribute__(name)
|
||||
return self.get(name)
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
return self.get(key)
|
||||
|
||||
|
||||
def cast_bool(value: str) -> bool:
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
def cast_int(value: str) -> int:
|
||||
return int(value.strip())
|
||||
|
||||
def cast_float(value: str) -> float:
|
||||
return float(value.strip())
|
||||
|
||||
def cast_str(value: str) -> str:
|
||||
return str(value)
|
||||
|
||||
def cast_list(value: str) -> list:
|
||||
return [item.strip() for item in value.split(",")]
|
||||
|
||||
def cast_tuple(value: str) -> tuple:
|
||||
return tuple(item.strip() for item in value.split(","))
|
||||
|
||||
def cast_dict(value: str) -> dict:
|
||||
return json.loads(value)
|
||||
|
||||
def cast_none_or_str(value: str):
|
||||
if value.strip().lower() in ("null", "none"):
|
||||
return None
|
||||
return value
|
||||
from .env import (
|
||||
Environment,
|
||||
EnvShared,
|
||||
TestEnv,
|
||||
Modes,
|
||||
EnviromentKeyMissing,
|
||||
NO_DEFAULT,
|
||||
)
|
||||
from .cast import (
|
||||
cast_bool,
|
||||
cast_int,
|
||||
cast_float,
|
||||
cast_str,
|
||||
cast_list,
|
||||
cast_tuple,
|
||||
cast_dict,
|
||||
cast_none_or_str,
|
||||
)
|
||||
from . import cast
|
||||
|
||||
def version():
|
||||
return "0.3.0"
|
||||
return "0.0.1"
|
||||
|
||||
__all__ = [
|
||||
"Environment",
|
||||
"EnvShared",
|
||||
"TestEnv",
|
||||
"Modes",
|
||||
"EnviromentKeyMissing",
|
||||
"NO_DEFAULT",
|
||||
"cast",
|
||||
"cast_bool",
|
||||
"cast_int",
|
||||
"cast_float",
|
||||
"cast_str",
|
||||
"cast_list",
|
||||
"cast_tuple",
|
||||
"cast_dict",
|
||||
"cast_none_or_str",
|
||||
"version",
|
||||
]
|
||||
|
||||
27
chipenv/cast.py
Normal file
27
chipenv/cast.py
Normal file
@ -0,0 +1,27 @@
|
||||
import json
|
||||
|
||||
def cast_bool(value: str) -> bool:
|
||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
def cast_int(value: str) -> int:
|
||||
return int(value.strip())
|
||||
|
||||
def cast_float(value: str) -> float:
|
||||
return float(value.strip())
|
||||
|
||||
def cast_str(value: str) -> str:
|
||||
return str(value)
|
||||
|
||||
def cast_list(value: str) -> list:
|
||||
return [item.strip() for item in value.split(",")]
|
||||
|
||||
def cast_tuple(value: str) -> tuple:
|
||||
return tuple(item.strip() for item in value.split(","))
|
||||
|
||||
def cast_dict(value: str) -> dict:
|
||||
return json.loads(value)
|
||||
|
||||
def cast_none_or_str(value: str):
|
||||
if value.strip().lower() in ("null", "none"):
|
||||
return None
|
||||
return value
|
||||
151
chipenv/env.py
Normal file
151
chipenv/env.py
Normal file
@ -0,0 +1,151 @@
|
||||
import os.path as op
|
||||
from typing import Optional, Callable, Any
|
||||
from enum import Enum
|
||||
from dotenv import dotenv_values
|
||||
|
||||
NO_DEFAULT = object() # Used to indicate no default value was provided
|
||||
|
||||
|
||||
class EnviromentKeyMissing(Exception):
|
||||
def __init__(self, key: str):
|
||||
super().__init__(f"Environment variable '{key}' not found.")
|
||||
|
||||
|
||||
class Modes(Enum):
|
||||
"""
|
||||
Enum for different environment modes.
|
||||
Note: they are what i commonly use, feel free to use a own enum or directly use strings.
|
||||
"""
|
||||
PRODUCTION = ".production"
|
||||
DEVELOPMENT = ".development"
|
||||
TESTING = ".testing"
|
||||
STAGING = ".staging"
|
||||
LOCAL = ""
|
||||
|
||||
|
||||
class Environment:
|
||||
"""
|
||||
Environment class for managing environment variables.
|
||||
This class loads environment variables from a file and provides access to them.
|
||||
It supports different modes (e.g., production, development) by appending the mode to the file name.
|
||||
The environment variables can be accessed directly or through a `get` method that allows for casting and default values.
|
||||
|
||||
Usage:
|
||||
```python
|
||||
env = Environment(env_file_name='.env', mode=Modes.DEVELOPMENT)
|
||||
value = env.get('MY_VARIABLE', default='default_value', cast=str)
|
||||
```
|
||||
|
||||
This will load the environment variables from '.env.development' and return the value of 'MY_VARIABLE'.
|
||||
If 'MY_VARIABLE' is not found, it will return 'default_value'.
|
||||
If the environment file does not exist, it raises a FileNotFoundError.
|
||||
"""
|
||||
def __init__(self, *, env_file_name: Optional[str] = None, env_file_path: Optional[str] = None, mode: Optional[Enum | str] = Modes.LOCAL, suppress_file_not_found: bool = False):
|
||||
self.__env_file_path = self.__resolve_file_path(env_file_name, env_file_path, mode)
|
||||
self.__values = self.__load_values(suppress_file_not_found)
|
||||
|
||||
def __load_values(self, suppress_file_not_found: bool) -> dict:
|
||||
if not op.exists(self.__env_file_path) and not suppress_file_not_found:
|
||||
raise FileNotFoundError(f"Environment file '{self.__env_file_path}' does not exist.")
|
||||
return dotenv_values(self.__env_file_path)
|
||||
|
||||
def __resolve_file_path(self, env_file_name: str, env_file_path: str, mode: Enum | str) -> str:
|
||||
"""
|
||||
Resolves the file path for the environment file.
|
||||
"""
|
||||
if isinstance(mode, Enum):
|
||||
mode = mode.value
|
||||
else:
|
||||
mode = f".{mode}" if mode else ""
|
||||
|
||||
if mode != "" and not mode.startswith('.'):
|
||||
mode = f".{mode}"
|
||||
|
||||
file = (env_file_name or '.env') + mode
|
||||
|
||||
if env_file_path:
|
||||
return op.join(env_file_path, file)
|
||||
|
||||
return file
|
||||
|
||||
def _set_values(self, values: dict):
|
||||
self.__values = values
|
||||
|
||||
def _set_env_file_path(self, env_file_path: Optional[str]):
|
||||
self.__env_file_path = env_file_path
|
||||
|
||||
@property
|
||||
def env_file_path(self) -> str:
|
||||
"""
|
||||
Returns the path to the environment file.
|
||||
This is the file from which environment variables are loaded.
|
||||
If no file was specified, it defaults to '.env'.
|
||||
If a mode is specified, it appends the mode to the file name.
|
||||
Example: '.env.production', '.env.development', etc.
|
||||
If the file was not found, it raises a FileNotFoundError unless suppressed.
|
||||
Returns None if no file was specified or found.
|
||||
"""
|
||||
return self.__env_file_path if hasattr(self, '_Environment__env_file_path') else None
|
||||
|
||||
@property
|
||||
def values(self) -> dict:
|
||||
"""
|
||||
Direct access to environment variables.
|
||||
Returns a dictionary of all environment variables loaded.
|
||||
Use with caution as it bypasses any casting or default values.
|
||||
Prefer using `get` method for safer access.
|
||||
"""
|
||||
return self.__values
|
||||
|
||||
def get(self, key: str, default: Optional[Any] = NO_DEFAULT, cast: Optional[Callable] = lambda x: x) -> Any:
|
||||
"""
|
||||
Get the value of an environment variable.
|
||||
If the variable is not found, it returns the default value.
|
||||
If no default is provided, it raises a KeyError.
|
||||
If a cast function is provided, it applies the cast to the value before returning.
|
||||
"""
|
||||
|
||||
if default is NO_DEFAULT and key not in self.__values:
|
||||
raise EnviromentKeyMissing(key)
|
||||
|
||||
return cast(self.__values.get(key, default))
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
if name.startswith("_") or name in super().__dir__():
|
||||
return super().__getattribute__(name)
|
||||
return self.get(name)
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
return self.get(key)
|
||||
|
||||
|
||||
class EnvShared(Environment):
|
||||
"""
|
||||
Environment subclass that preserves singleton behavior.
|
||||
"""
|
||||
_instance = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self, *, env_file_name: Optional[str] = None, env_file_path: Optional[str] = None, mode: Optional[Enum | str] = Modes.LOCAL, suppress_file_not_found: bool = False):
|
||||
if hasattr(self, '__initialized') and self.__initialized:
|
||||
return
|
||||
super().__init__(
|
||||
env_file_name=env_file_name,
|
||||
env_file_path=env_file_path,
|
||||
mode=mode,
|
||||
suppress_file_not_found=suppress_file_not_found,
|
||||
)
|
||||
self.__initialized = True
|
||||
|
||||
|
||||
class TestEnv(Environment):
|
||||
"""
|
||||
Environment subclass for tests that uses a provided dict and skips file loading.
|
||||
"""
|
||||
def __init__(self, values: dict):
|
||||
self._set_env_file_path(None)
|
||||
self._set_values(values)
|
||||
Loading…
x
Reference in New Issue
Block a user