duckenv 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ __pycache__/
2
+ dist/
3
+ .coverage
@@ -0,0 +1,13 @@
1
+ steps:
2
+ lint:
3
+ image: python:3.13-slim
4
+ environment:
5
+ PIP_ROOT_USER_ACTION: ignore
6
+ commands:
7
+ - pip install .[dev]
8
+ - black . --check
9
+ - mypy .
10
+ when:
11
+ - event: pull_request
12
+ - event: push
13
+ branch: main
@@ -0,0 +1,20 @@
1
+ steps:
2
+ release:
3
+ image: python:3.11-slim
4
+ commands:
5
+ - pip install build twine
6
+ - python -m build
7
+ - twine upload --disable-progress-bar dist/*
8
+ environment:
9
+ TWINE_REPOSITORY:
10
+ from_secret: twine_repository
11
+ TWINE_USERNAME:
12
+ from_secret: twine_username
13
+ TWINE_PASSWORD:
14
+ from_secret: twine_password
15
+ when:
16
+ event: tag
17
+
18
+ depends_on:
19
+ - lint
20
+ - tests
@@ -0,0 +1,20 @@
1
+ matrix:
2
+ PY_VERSION:
3
+ - 3.11
4
+ - 3.12
5
+ - 3.13
6
+ - 3.14
7
+
8
+ steps:
9
+ tests:
10
+ image: python:${PY_VERSION}-slim
11
+ environment:
12
+ PIP_ROOT_USER_ACTION: ignore
13
+ commands:
14
+ - pip install .[tests]
15
+ - coverage run -m unittest -v tests.py
16
+ - coverage report -m
17
+ when:
18
+ - event: pull_request
19
+ - event: push
20
+ branch: main
duckenv-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: duckenv
3
+ Version: 0.1.0
4
+ Summary: A minimalist .env loader
5
+ Project-URL: Homepage, https://codeberg.org/canarduck/duckenv
6
+ Project-URL: Issues, https://codeberg.org/canarduck/duckenv/issues
7
+ Author-email: Renaud Canarduck <renaud@canarduck.com>
8
+ License-Expression: MIT
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.11
12
+ Provides-Extra: dev
13
+ Requires-Dist: black; extra == 'dev'
14
+ Requires-Dist: mypy; extra == 'dev'
15
+ Provides-Extra: tests
16
+ Requires-Dist: coverage; extra == 'tests'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # duckenv
20
+
21
+ A minimalist dotenv loader for Python, inspired by [environs](https://github.com/sloria/environs) but with fewer features and dependencies.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ pip install duckenv
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ Create a `.env` file:
32
+ ```env
33
+ DEBUG=on
34
+ TTL=30
35
+ NAME=Jean-Michel
36
+ MY_LIST=apple,banana,orange
37
+ ```
38
+
39
+ Load and parse environment variables:
40
+
41
+ ```python
42
+ from duckenv import Env
43
+
44
+ env = Env() # reads `.env` by default
45
+
46
+ env.str("NAME") # "Jean-Michel"
47
+ env.int("AGE") # 30
48
+ env.bool("DEBUG") # True
49
+ env.list("MY_LIST") # ["apple", "banana", "orange"]
50
+ env.str("MISSING_VAR", "default") # "default"
51
+ env.str("MISSING_VAR") # Raises KeyError
52
+ env.int("NAME") # Raises ValueError
53
+ ```
54
+
55
+ ## Supported Methods
56
+
57
+ - `str(name, default=...)`: Returns a string or `None`.
58
+ - `int(name, default=...)`: Returns an integer or `None`.
59
+ - `bool(name, default=...)`: Accepts `true/false`, `on/off`, `1/0`, returns `True/False` or `None`.
60
+ - `list(name, default=...)`: Parses comma-separated strings into a list of stripped strings, or accepts a list directly.
61
+
62
+ If a variable is not set and no default is provided, a `KeyError` is raised.
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,48 @@
1
+ # duckenv
2
+
3
+ A minimalist dotenv loader for Python, inspired by [environs](https://github.com/sloria/environs) but with fewer features and dependencies.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install duckenv
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Create a `.env` file:
14
+ ```env
15
+ DEBUG=on
16
+ TTL=30
17
+ NAME=Jean-Michel
18
+ MY_LIST=apple,banana,orange
19
+ ```
20
+
21
+ Load and parse environment variables:
22
+
23
+ ```python
24
+ from duckenv import Env
25
+
26
+ env = Env() # reads `.env` by default
27
+
28
+ env.str("NAME") # "Jean-Michel"
29
+ env.int("AGE") # 30
30
+ env.bool("DEBUG") # True
31
+ env.list("MY_LIST") # ["apple", "banana", "orange"]
32
+ env.str("MISSING_VAR", "default") # "default"
33
+ env.str("MISSING_VAR") # Raises KeyError
34
+ env.int("NAME") # Raises ValueError
35
+ ```
36
+
37
+ ## Supported Methods
38
+
39
+ - `str(name, default=...)`: Returns a string or `None`.
40
+ - `int(name, default=...)`: Returns an integer or `None`.
41
+ - `bool(name, default=...)`: Accepts `true/false`, `on/off`, `1/0`, returns `True/False` or `None`.
42
+ - `list(name, default=...)`: Parses comma-separated strings into a list of stripped strings, or accepts a list directly.
43
+
44
+ If a variable is not set and no default is provided, a `KeyError` is raised.
45
+
46
+ ## License
47
+
48
+ MIT
@@ -0,0 +1,158 @@
1
+ """duckenv."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ DEFAULT = object()
8
+
9
+
10
+ class Env:
11
+ """A Minimalist dotenv loader.
12
+
13
+ Inspired by [environs](https://github.com/sloria/environs) with less features and
14
+ dependencies.
15
+ """
16
+
17
+ implemented_methods = ["str", "int", "bool", "list", "bool"]
18
+
19
+ def __init__(self, env_file: Path = Path(".env")) -> None:
20
+ """Loads `env_file` in the environnement variables.
21
+
22
+ Args:
23
+ env_file: the dotenv file
24
+ """
25
+ if not env_file.is_file():
26
+ raise FileNotFoundError(f"{env_file} does not exist")
27
+ for idx, line in enumerate(env_file.read_text().splitlines(), 1):
28
+ try:
29
+ name, value = line.split("=", 1)
30
+ except ValueError:
31
+ raise ValueError(
32
+ f"Unable to parse line {idx} of {env_file}, must be NAME=value"
33
+ )
34
+ os.environ[name] = value
35
+
36
+ def __getattr__(self, method: str) -> None:
37
+ """Aliases implemented methods to their `get_` equivalent.
38
+
39
+ To avoid using defining `str`, `int` methods (which makes mypy unhappy, we use
40
+ `get_str`, `get_int` and handle their `natural` aliases here.
41
+
42
+ Args:
43
+ method: A method name (str, list, ...)
44
+
45
+ Raises:
46
+ NotImplementedError: the method (for example `dict`) is not implemented
47
+ """
48
+ if method in self.implemented_methods:
49
+ return getattr(self, f"get_{method}")
50
+ else:
51
+ raise NotImplementedError(f"Method {method} is not supported")
52
+
53
+ def _get(self, name: str, default: Any) -> Any:
54
+ """Get the value of `name` from the env variables.
55
+
56
+ Args:
57
+ name: variable's name
58
+ default: default value
59
+
60
+ Returns:
61
+ `name`'s value or `default`
62
+
63
+ Raises:
64
+ KeyError: `name` is not defined in the env and no `default` to use
65
+ """
66
+ if default == DEFAULT and name not in os.environ:
67
+ raise KeyError(f"Env var {name} is required")
68
+ return os.environ.get(name, default)
69
+
70
+ def get_str(self, name: str, default: Any = DEFAULT) -> str | None:
71
+ """Cast `name` in a string.
72
+
73
+ Args:
74
+ name: variable's name
75
+ default: default value if `name` is not defined in the env
76
+
77
+ Returns:
78
+ A string or None
79
+
80
+ Raises:
81
+ KeyError: `name` is not defined in the env and no `default` to use
82
+ """
83
+ value = self._get(name, default)
84
+ if value is None:
85
+ return None
86
+ return str(value)
87
+
88
+ def get_list(self, name: str, default: Any = DEFAULT) -> list | None:
89
+ """Cast `name` in a list.
90
+
91
+ This only handles a list of strings.
92
+
93
+ Args:
94
+ name: variable's name
95
+ default: default value if `name` is not defined in the env
96
+
97
+ Returns:
98
+ A list or None
99
+
100
+ Raises:
101
+ ValueError: `name`'s value is nor a list nor a comma-separated string
102
+ KeyError: `name` is not defined in the env and no `default` to use
103
+ """
104
+ value = self._get(name, default)
105
+ if value is None or isinstance(value, list):
106
+ return value
107
+ if not isinstance(value, str):
108
+ raise ValueError(
109
+ f"{name} must be a list or a string of comma-separated values, not {value} ({type(value)})"
110
+ )
111
+ return [v.strip() for v in value.split(",")]
112
+
113
+ def get_int(self, name: str, default: Any = DEFAULT) -> int | None:
114
+ """Cast `name` in an integer.
115
+
116
+ Args:
117
+ name: variable's name
118
+ default: default value if `name` is not defined in the env
119
+
120
+ Returns:
121
+ An integer or None
122
+
123
+ Raises:
124
+ KeyError: `name` is not defined in the env and no `default` to use
125
+ """
126
+ value = self._get(name, default)
127
+ if value is None or isinstance(value, int):
128
+ return value
129
+ try:
130
+ return int(value)
131
+ except ValueError:
132
+ raise ValueError(f"{name} is not int-able, « {value} » ({type(value)})")
133
+
134
+ def get_bool(self, name: str, default: Any = DEFAULT) -> bool | None:
135
+ """Cast `name` in a bool.
136
+
137
+ Args:
138
+ name: variable's name
139
+ default: default value if `name` is not defined in the env
140
+
141
+ Returns:
142
+ A boolean value or None
143
+
144
+ Raises:
145
+ ValueError: `name`'s value is not bool-able
146
+ KeyError: `name` is not defined in the env and no `default` to use
147
+ """
148
+ value = self._get(name, default)
149
+ if value is None or isinstance(value, bool):
150
+ return value
151
+ if value in [0, 1]:
152
+ return bool(value)
153
+ possible_values = {"on": True, "off": False, "1": True, "0": False}
154
+ if isinstance(value, str) and value in possible_values:
155
+ return possible_values[value]
156
+ raise ValueError(
157
+ f"{name} must be a bool, 0/1 or one of {', '.join(possible_values.keys())}, not « {value} » ({type(value)})"
158
+ )
@@ -0,0 +1,36 @@
1
+ [project]
2
+ name = "duckenv"
3
+ version = "0.1.0"
4
+ authors = [
5
+ { name="Renaud Canarduck", email="renaud@canarduck.com" },
6
+ ]
7
+ description = "A minimalist .env loader"
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Operating System :: OS Independent",
13
+ ]
14
+ license = "MIT"
15
+
16
+ [project.urls]
17
+ Homepage = "https://codeberg.org/canarduck/duckenv"
18
+ Issues = "https://codeberg.org/canarduck/duckenv/issues"
19
+
20
+ [project.optional-dependencies]
21
+ tests = ["coverage"]
22
+ dev = [
23
+ "black",
24
+ "mypy",
25
+ ]
26
+
27
+ [build-system]
28
+ requires = ["hatchling >= 1.26"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.coverage.report]
32
+ fail_under = 90
33
+
34
+ [tool.coverage.run]
35
+ branch = true
36
+ source = ["."]
duckenv-0.1.0/tests.py ADDED
@@ -0,0 +1,152 @@
1
+ import os
2
+ import tempfile
3
+ import unittest
4
+ from pathlib import Path
5
+
6
+ from duckenv import Env, DEFAULT
7
+
8
+ test_env = """\
9
+ NAME=Jean-Michel
10
+ TTL=30
11
+ DJANGO_DEBUG=on
12
+ THIEVES=Margaret,Brittany, Prudence
13
+ EMPTY=
14
+ """
15
+
16
+
17
+ class TestEnv(unittest.TestCase):
18
+ def setUp(self):
19
+ self.temp_env_file = Path(tempfile.mktemp())
20
+ self.temp_env_file.write_text(test_env)
21
+ os.environ.clear()
22
+ self.env = Env(self.temp_env_file)
23
+
24
+ def tearDown(self):
25
+ self.temp_env_file.unlink()
26
+
27
+
28
+ class TestInit(TestEnv):
29
+ def test_env_file_loaded(self):
30
+ self.assertEqual(os.environ["NAME"], "Jean-Michel")
31
+ self.assertEqual(os.environ["TTL"], "30")
32
+
33
+ def test_file_does_not_exist(self):
34
+ with self.assertRaisesRegex(FileNotFoundError, ".env does not exist"):
35
+ Env() # .env does not exist by default
36
+
37
+ def test_invalid_file(self):
38
+ self.temp_env_file.write_text("\n".join(["OK=ok", "KO≃KO"]))
39
+ with self.assertRaisesRegex(ValueError, "Unable to parse line 2"):
40
+ Env(self.temp_env_file)
41
+
42
+
43
+ class TestGetAttr(TestEnv):
44
+ def test_not_implemented(self):
45
+ with self.assertRaises(NotImplementedError):
46
+ self.env.dict("YO")
47
+
48
+ def test_handle_dunders(self):
49
+ self.assertIn("duckenv.Env", self.env.__str__())
50
+
51
+
52
+ class TestGet(TestEnv):
53
+ def test_required_var_exists(self):
54
+ self.assertEqual(self.env._get("NAME", DEFAULT), "Jean-Michel")
55
+
56
+ def test_required_var_missing(self):
57
+ with self.assertRaisesRegex(KeyError, "Env var UNKNOWN is required"):
58
+ self.env._get("UNKNOWN", DEFAULT)
59
+
60
+ def test_with_default(self):
61
+ self.assertEqual(self.env._get("UNKNOWN", "default"), "default")
62
+
63
+
64
+ class TestStr(TestEnv):
65
+ def test_existing_var(self):
66
+ self.assertEqual(self.env.str("NAME"), "Jean-Michel")
67
+
68
+ def test_empty_var(self):
69
+ self.assertEqual(self.env.str("EMPTY"), "")
70
+
71
+ def test_missing_with_default(self):
72
+ self.assertEqual(self.env.str("UNKNOWN", "default"), "default")
73
+ self.assertEqual(self.env.str("UNKNOWN", None), None)
74
+
75
+ def test_missing(self):
76
+ with self.assertRaisesRegex(KeyError, "Env var UNKNOWN is required"):
77
+ self.env.str("UNKNOWN")
78
+
79
+
80
+ class TestList(TestEnv):
81
+ def test_from_comma_separated_string(self):
82
+ self.assertEqual(self.env.list("THIEVES"), ["Margaret", "Brittany", "Prudence"])
83
+
84
+ def test_from_string_no_commas(self):
85
+ self.assertEqual(self.env.list("NAME"), ["Jean-Michel"])
86
+
87
+ def test_from_list(self):
88
+ self.assertEqual(self.env.list("TEST_LIST", ["x", "y"]), ["x", "y"])
89
+
90
+ def test_invalid_type(self):
91
+ with self.assertRaises(ValueError):
92
+ self.env.list("TEST_DICT", {"a": 1})
93
+
94
+ def test_missing_with_default(self):
95
+ self.assertEqual(self.env.list("UNKNOWN", ["default"]), ["default"])
96
+
97
+ def test_missing(self):
98
+ with self.assertRaisesRegex(KeyError, "Env var UNKNOWN is required"):
99
+ self.env.list("UNKNOWN")
100
+
101
+
102
+ class TestInt(TestEnv):
103
+ def test_conversion(self):
104
+ self.assertEqual(self.env.int("TTL"), 30)
105
+
106
+ def test_missing_with_default(self):
107
+ self.assertEqual(self.env.int("UNKNOWN", 42), 42)
108
+
109
+ def test_missing(self):
110
+ with self.assertRaisesRegex(KeyError, "Env var UNKNOWN is required"):
111
+ self.env.int("UNKNOWN")
112
+
113
+ def test_invalid_string(self):
114
+ os.environ["INVALID_INT"] = "not_a_number"
115
+ with self.assertRaisesRegex(ValueError, "INVALID_INT is not int-able"):
116
+ self.env.int("INVALID_INT")
117
+
118
+
119
+ class TestBool(TestEnv):
120
+ def test_conversion(self):
121
+ self.assertTrue(self.env.bool("DJANGO_DEBUG"))
122
+
123
+ def test_possible_values(self):
124
+ os.environ["ON"] = "on"
125
+ os.environ["OFF"] = "off"
126
+ self.assertTrue(self.env.bool("ON"))
127
+ self.assertFalse(self.env.bool("OFF"))
128
+
129
+ def test_int(self):
130
+ os.environ["ZERO"] = "0"
131
+ os.environ["ONE"] = "1"
132
+ self.assertTrue(self.env.bool("ONE"))
133
+ self.assertFalse(self.env.bool("ZERO"))
134
+
135
+ def test_bool(self):
136
+ self.assertTrue(self.env.bool("BOOL_TRUE", True))
137
+ self.assertFalse(self.env.bool("BOOL_FALSE", False))
138
+ self.assertTrue(self.env.bool("BOOL_TRUE", 1))
139
+ self.assertFalse(self.env.bool("BOOL_FALSE", 0))
140
+
141
+ def test_invalid(self):
142
+ os.environ["INVALID_BOOL"] = "maybe"
143
+ with self.assertRaisesRegex(ValueError, "INVALID_BOOL must be a bool"):
144
+ self.env.bool("INVALID_BOOL")
145
+
146
+ def test_missing(self):
147
+ with self.assertRaisesRegex(KeyError, "Env var UNKNOWN is required"):
148
+ self.env.bool("UNKNOWN")
149
+
150
+ def test_missing_with_default(self):
151
+ self.assertIsNone(self.env.bool("UNKNOWN", None))
152
+ self.assertTrue(self.env.bool("UNKNOWN", True))