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:
Chipperfluff 2026-01-04 18:27:57 +01:00
parent 24a30cb0a4
commit 5bd10d222e
4 changed files with 285 additions and 164 deletions

View File

@ -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 ### Basic Example
```python ```python
@ -52,20 +60,39 @@ DB_URL=https://example.com
### Smart Casts Example ### Smart Casts Example
```python ```python
from chipenv import ( from chipenv import Environment, cast
Environment, cast_bool, cast_int, cast_float,
cast_list, cast_tuple, cast_dict, cast_none_or_str
)
env = Environment() env = Environment()
print("INT:", env.get("PORT", cast=cast_int)) # -> int print("INT:", env.get("PORT", cast=cast.cast_int)) # -> int
print("FLOAT:", env.get("PI", cast=cast_float)) # -> float print("FLOAT:", env.get("PI", cast=cast.cast_float)) # -> float
print("BOOL:", env.get("ENABLED", cast=cast_bool)) # -> bool print("BOOL:", env.get("ENABLED", cast=cast.cast_bool)) # -> bool
print("LIST:", env.get("NUMBERS", cast=cast_list)) # -> list[str] print("LIST:", env.get("NUMBERS", cast=cast.cast_list)) # -> list[str]
print("TUPLE:", env.get("WORDS", cast=cast_tuple)) # -> tuple[str] print("TUPLE:", env.get("WORDS", cast=cast.cast_tuple)) # -> tuple[str]
print("DICT:", env.get("CONFIG", cast=cast_dict)) # -> dict print("DICT:", env.get("CONFIG", cast=cast.cast_dict)) # -> dict
print("NONE_OR_STR:", env.get("OPTIONAL", cast=cast_none_or_str)) # -> None or str 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`: Example `.env`:
@ -80,8 +107,38 @@ CONFIG={"timeout": 30, "debug": true}
OPTIONAL=null 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 ### 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: All built-in cast functions handle common edge cases:
| Cast Function | Description | | Cast Function | Description |

View File

@ -1,155 +1,41 @@
import os.path as op from .env import (
from typing import Optional, Callable, Any Environment,
from enum import Enum EnvShared,
from dotenv import dotenv_values TestEnv,
import json Modes,
EnviromentKeyMissing,
NO_DEFAULT = object() # Used to indicate no default value was provided NO_DEFAULT,
)
class EnviromentKeyMissing(Exception): from .cast import (
def __init__(self, key: str): cast_bool,
super().__init__(f"Environment variable '{key}' not found.") cast_int,
cast_float,
class Modes(Enum): cast_str,
""" cast_list,
Enum for different environment modes. cast_tuple,
Note: they are what i commonly use, feel free to use a own enum or directly use strings. cast_dict,
""" cast_none_or_str,
PRODUCTION = ".production" )
DEVELOPMENT = ".development" from . import cast
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
def version(): 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
View 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
View 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)