From 24a30cb0a43f734eaf6eb7fdebf7100ce19d8145 Mon Sep 17 00:00:00 2001 From: lordlogo2002 Date: Sun, 4 Jan 2026 17:51:43 +0100 Subject: [PATCH] Add initial project files including .gitignore, README, and setup script - Created .gitignore to exclude build artifacts, Python bytecode, and environment files. - Added README.md with project description, features, use cases, and examples. - Implemented Environment class for managing environment variables with mode support. - Defined casting functions for various data types. - Established setup.py for package distribution. --- .gitignore | 22 +++++++ README.md | 105 ++++++++++++++++++++++++++++++ chipenv/__init__.py | 155 ++++++++++++++++++++++++++++++++++++++++++++ setup.py | 20 ++++++ 4 files changed, 302 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 chipenv/__init__.py create mode 100644 setup.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab85757 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Python bytecode +__pycache__/ +*.py[cod] + +# Virtual environments +venv/ +.env/ + +# System files +.DS_Store +Thumbs.db + +# VS Code +.vscode/ + +.env* +test.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..7eca2e5 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +## `chipenv` + +A simple but flexible environment loader. + +Supports `.env` file parsing with optional mode suffixes (e.g. `.env.production`, `.env.testing`, etc.), lazy loading, and dynamic access. + +### Features + +* Mode-based config support (`.env.production`, `.env.testing`, etc.) +* Access via: + + * `env["KEY"]` + * `env.get("KEY", ...)` + * `env.KEY` +* Optional type casting (`str`, `bool`, `list`, `dict`, etc.) +* Sane default handling +* Does **not** mutate `os.environ` + +--- + +### Use cases + +* Loading `.env` files in mode-aware Python projects +* Separating secrets and configs by deployment context +* Dynamically reading values like `env.DB_URL`, `env.get("DEBUG", default=False, cast=cast_bool)` +* Avoiding `os.environ` pollution + +--- + +### Basic Example + +```python +from chipenv import Environment, Modes + +env = Environment(env_file_name=".env", mode=Modes.DEVELOPMENT) + +print(env.get("DEBUG", default=False)) +print(env["API_KEY"]) +print(env.DB_URL) +``` + +Given a `.env.development` file: + +```env +DEBUG=true +API_KEY=secret +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 +) + +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 +``` + +Example `.env`: + +```env +PORT=8080 +PI=3.1415 +ENABLED=yes +NUMBERS=1,2,3 +WORDS=hello,world,test +CONFIG={"timeout": 30, "debug": true} +OPTIONAL=null +``` + +### Included Cast Helpers + +All built-in cast functions handle common edge cases: + +| Cast Function | Description | +| ------------------ | ------------------------------------------- | +| `cast_str` | Ensures string | +| `cast_int` | Converts to integer | +| `cast_float` | Converts to float | +| `cast_bool` | Accepts `1`, `true`, `yes`, `on`, etc. | +| `cast_list` | Comma-split list | +| `cast_tuple` | Comma-split, converted to tuple | +| `cast_dict` | Parses JSON string into dictionary | +| `cast_none_or_str` | Returns `None` if value is `null` or `None` | + +--- + +### EnviromentKeyMissing + +if a key is get from `env.get` and it has no default given it will raise +EnviromentKeyMissing(f"Environment variable '{key}' not found.") + +--- diff --git a/chipenv/__init__.py b/chipenv/__init__.py new file mode 100644 index 0000000..ac25d2c --- /dev/null +++ b/chipenv/__init__.py @@ -0,0 +1,155 @@ +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 + +def version(): + return "0.3.0" diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c1fd91d --- /dev/null +++ b/setup.py @@ -0,0 +1,20 @@ +from setuptools import setup, find_packages + +setup( + name='chipenv', + version='0.0.1', + packages=find_packages(), + install_requires=[ + "python-dotenv>=0.21.0" + ], + author='Chipperfluff', + author_email='contact@chipperfluff.at', + description='a lightweight environment variable management library for Python', + long_description=open('README.md').read(), + long_description_content_type='text/markdown', + url='https://git.chipperfluff.at/MultinutServices/Multinut', + classifiers=[ + 'Programming Language :: Python :: 3' + ], + python_requires='>=3.6', +)