diff --git a/README.md b/README.md index 7eca2e5..02dacec 100644 --- a/README.md +++ b/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 | diff --git a/chipenv/__init__.py b/chipenv/__init__.py index ac25d2c..604ec87 100644 --- a/chipenv/__init__.py +++ b/chipenv/__init__.py @@ -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", +] diff --git a/chipenv/cast.py b/chipenv/cast.py new file mode 100644 index 0000000..b7db130 --- /dev/null +++ b/chipenv/cast.py @@ -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 diff --git a/chipenv/env.py b/chipenv/env.py new file mode 100644 index 0000000..a41b132 --- /dev/null +++ b/chipenv/env.py @@ -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)