Compare commits
No commits in common. "1e700a485d2a4d7ddf5694243d9619ac4c7a0d32" and "b1675c6a62e1d188ec785ae89670da488bad82b9" have entirely different histories.
1e700a485d
...
b1675c6a62
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,6 +17,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# VS Code
|
# VS Code
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
.env*
|
|
||||||
test.py
|
|
||||||
|
|||||||
105
README.md
105
README.md
@ -1,104 +1,3 @@
|
|||||||
# `multinut`
|
# multinut
|
||||||
|
|
||||||
The multitool nobody asked for. Includes stuff and so.
|
The multitool nobody asked for. Includes stuff like `greet()`, `add()`, and other functions you'll forget are here.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## `multinut.env`
|
|
||||||
|
|
||||||
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 multinut.env 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 multinut.env 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` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|||||||
@ -1,2 +1,8 @@
|
|||||||
def add(x: int, y: int) -> int:
|
def greet(name):
|
||||||
|
return f"Hello, {name}! You're using chipi."
|
||||||
|
|
||||||
|
def add(x, y):
|
||||||
return x + y
|
return x + y
|
||||||
|
|
||||||
|
def reverse_string(s):
|
||||||
|
return s[::-1]
|
||||||
|
|||||||
148
multinut/env.py
148
multinut/env.py
@ -1,148 +0,0 @@
|
|||||||
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 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:
|
|
||||||
return cast(self.__values.get(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
|
|
||||||
7
setup.py
7
setup.py
@ -2,9 +2,9 @@ from setuptools import setup, find_packages
|
|||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='multinut',
|
name='multinut',
|
||||||
version='0.2.2',
|
version='0.1.0',
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
install_requires=["dotenv"],
|
install_requires=[],
|
||||||
author='Chipperfluff',
|
author='Chipperfluff',
|
||||||
author_email='i96774080@gmail.com',
|
author_email='i96774080@gmail.com',
|
||||||
description='A completely unnecessary multitool module.',
|
description='A completely unnecessary multitool module.',
|
||||||
@ -12,7 +12,8 @@ setup(
|
|||||||
long_description_content_type='text/markdown',
|
long_description_content_type='text/markdown',
|
||||||
url='https://github.com/ChipperFluff/multinut',
|
url='https://github.com/ChipperFluff/multinut',
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Programming Language :: Python :: 3'
|
'Programming Language :: Python :: 3',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
],
|
],
|
||||||
python_requires='>=3.6',
|
python_requires='>=3.6',
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
import unittest
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from multinut.env import Environment, Modes
|
|
||||||
|
|
||||||
class TestEnvironment(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
# Create a temporary directory and file
|
|
||||||
self.temp_dir = tempfile.TemporaryDirectory()
|
|
||||||
self.base_path = Path(self.temp_dir.name)
|
|
||||||
self.default_file = self.base_path / ".env"
|
|
||||||
self.mode_file = self.base_path / ".env.testing"
|
|
||||||
self.named_file = self.base_path / "custom.env"
|
|
||||||
self.named_mode_file = self.base_path / "custom.production"
|
|
||||||
|
|
||||||
for f in [self.default_file, self.mode_file, self.named_file, self.named_mode_file]:
|
|
||||||
f.write_text("KEY=value\n")
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self.temp_dir.cleanup()
|
|
||||||
|
|
||||||
def reset_environment_singleton(self):
|
|
||||||
Environment._instance = None
|
|
||||||
|
|
||||||
def test_default_env(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
env = Environment(env_file_path=str(self.base_path))
|
|
||||||
self.assertTrue(str(self.default_file) in env.env_file_path)
|
|
||||||
|
|
||||||
def test_with_named_file(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
env = Environment(env_file_name="custom.env", env_file_path=str(self.base_path))
|
|
||||||
self.assertTrue(str(self.named_file) in env.env_file_path)
|
|
||||||
|
|
||||||
def test_with_mode_enum(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
env = Environment(mode=Modes.TESTING, env_file_path=str(self.base_path))
|
|
||||||
self.assertTrue(str(self.mode_file) in env.env_file_path)
|
|
||||||
|
|
||||||
def test_with_mode_string(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
env = Environment(mode="testing", env_file_path=str(self.base_path))
|
|
||||||
self.assertTrue(str(self.mode_file) in env.env_file_path)
|
|
||||||
|
|
||||||
def test_with_named_file_and_mode_enum(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
env = Environment(env_file_name="custom", mode=Modes.PRODUCTION, env_file_path=str(self.base_path))
|
|
||||||
self.assertTrue(str(self.named_mode_file) in env.env_file_path)
|
|
||||||
|
|
||||||
def test_missing_file_raises(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
with self.assertRaises(FileNotFoundError):
|
|
||||||
Environment(env_file_name="notfound.env", env_file_path=str(self.base_path))
|
|
||||||
|
|
||||||
def test_missing_file_suppressed(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
env = Environment(env_file_name="notfound.env", env_file_path=str(self.base_path), suppress_file_not_found=True)
|
|
||||||
self.assertIn("notfound.env", env.env_file_path)
|
|
||||||
|
|
||||||
def test_singleton_behavior(self):
|
|
||||||
self.reset_environment_singleton()
|
|
||||||
a = Environment(env_file_path=str(self.base_path), suppress_file_not_found=True)
|
|
||||||
b = Environment(env_file_path="should/be/ignored", suppress_file_not_found=True)
|
|
||||||
self.assertIs(a, b)
|
|
||||||
self.assertEqual(a.env_file_path, b.env_file_path)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
unittest.main()
|
|
||||||
2
tests/test.py
Normal file
2
tests/test.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
def say():
|
||||||
|
print("lol")
|
||||||
Loading…
x
Reference in New Issue
Block a user