envcaster 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 YoungAlpaccino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: envcaster
3
+ Version: 0.1.0
4
+ Summary: Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors.
5
+ Author: YoungAlpaccino
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/YoungAlpaccino/envcast
8
+ Project-URL: Repository, https://github.com/YoungAlpaccino/envcast
9
+ Project-URL: Issues, https://github.com/YoungAlpaccino/envcast/issues
10
+ Keywords: env,environment,config,dotenv,settings,12factor,typed,configuration
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: build; extra == "dev"
29
+ Requires-Dist: twine; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # envcaster ⚙️
33
+
34
+ > Read environment variables as the **type you actually want** — `int`, `bool`, `list`, `json`, `Path` — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.
35
+
36
+ [![CI](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml/badge.svg)](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/envcaster.svg)](https://pypi.org/project/envcaster/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/envcaster.svg)](https://pypi.org/project/envcaster/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+
41
+ `os.environ` only ever gives you strings. So every project grows the same little
42
+ pile of `int(os.environ.get("PORT", "8000"))` and hand-rolled truthy checks that
43
+ quietly treat `"False"` as `True`. **envcaster** is that pile, done once and done right.
44
+
45
+ - 🪶 **Zero required dependencies** — pure standard library.
46
+ - 🎯 **Typed getters** — `str · int · float · bool · list · json · path` (+ custom `cast`).
47
+ - 🧯 **Loud, precise errors** — missing or malformed values tell you *which* variable and *why*.
48
+ - 🧪 **Fully tested** across Python 3.9–3.12.
49
+ - 🧩 **Drop-in `.env` loader** that never clobbers real environment config.
50
+
51
+ ---
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install envcaster
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ from envcaster import env
65
+
66
+ PORT = env.int("PORT", default=8000)
67
+ DEBUG = env.bool("DEBUG", default=False)
68
+ HOSTS = env.list("ALLOWED_HOSTS", sep=",") # ["a", "b"] from "a,b"
69
+ TIMEOUT = env.float("TIMEOUT", default=1.5)
70
+ SECRET = env.str("SECRET_KEY", required=True) # raises if not set
71
+ DATA = env.path("DATA_DIR", default="/var/data") # -> pathlib.Path
72
+ FLAGS = env.json("FEATURE_FLAGS", default={}) # parsed JSON
73
+ ```
74
+
75
+ > **A variable is required unless you give it a `default`.** Missing required
76
+ > variables raise `MissingEnvError`; bad values raise `CastError`. Both subclass
77
+ > `EnvError` — catch broadly or narrowly.
78
+
79
+ ---
80
+
81
+ ## Usage
82
+
83
+ ### Booleans that actually behave
84
+
85
+ ```python
86
+ env.bool("DEBUG") # 1 true t yes y on -> True (case-insensitive)
87
+ # 0 false f no n off -> False
88
+ # anything else -> CastError
89
+ ```
90
+
91
+ No more `bool("False") == True` bugs.
92
+
93
+ ### Lists (and lists of other types)
94
+
95
+ ```python
96
+ env.list("ALLOWED_HOSTS") # "a, b ,, c" -> ["a", "b", "c"] (trims, drops empties)
97
+ env.list("PORTS", sep=":", cast=int) # "80:443" -> [80, 443]
98
+ env.list("TAGS", default=[]) # missing -> []
99
+ ```
100
+
101
+ ### JSON and paths
102
+
103
+ ```python
104
+ env.json("LIMITS") # '{"rpm": 60}' -> {"rpm": 60}
105
+ env.path("LOG_DIR") # "/var/log" -> PosixPath("/var/log")
106
+ ```
107
+
108
+ ### Anything else — bring your own cast
109
+
110
+ ```python
111
+ from decimal import Decimal
112
+
113
+ env.cast("PRICE", Decimal) # "9.99" -> Decimal("9.99")
114
+ env.cast("COLOR", lambda v: int(v, 16)) # "ff0000" -> 16711680
115
+ # exceptions from your function are wrapped in CastError, naming the variable
116
+ ```
117
+
118
+ ### Scoped readers with a prefix
119
+
120
+ ```python
121
+ from envcaster import Env
122
+
123
+ app = Env(prefix="APP_")
124
+ app.int("PORT") # reads APP_PORT
125
+ app.bool("DEBUG") # reads APP_DEBUG
126
+ ```
127
+
128
+ ### Read from somewhere other than `os.environ`
129
+
130
+ ```python
131
+ cfg = Env(source={"PORT": "9000"}) # great for tests — no global state
132
+ cfg.int("PORT") # 9000
133
+ ```
134
+
135
+ ### Load a `.env` file (no dependency)
136
+
137
+ ```python
138
+ from envcaster import load_dotenv, env
139
+
140
+ load_dotenv() # reads ./.env into os.environ (won't override real env vars)
141
+ load_dotenv(".env.local", override=True)
142
+
143
+ PORT = env.int("PORT")
144
+
145
+ # Or parse without touching the environment:
146
+ from envcaster import read_dotenv
147
+ values = read_dotenv(".env") # -> {"PORT": "8000", ...}
148
+ ```
149
+
150
+ Handles `KEY=value`, `export KEY=value`, `# comments`, and quoted values. For
151
+ interpolation or multiline values, use [python-dotenv](https://github.com/theskumar/python-dotenv).
152
+
153
+ ---
154
+
155
+ ## API reference
156
+
157
+ | Call | Returns | Notes |
158
+ |---|---|---|
159
+ | `env.str(name, default=…, required=False)` | `str` | The raw value, unchanged |
160
+ | `env.int(name, …)` | `int` | Base-10, whitespace stripped |
161
+ | `env.float(name, …)` | `float` | |
162
+ | `env.bool(name, …)` | `bool` | `1/true/t/yes/y/on` ↔ `0/false/f/no/n/off` |
163
+ | `env.list(name, …, sep=",", cast=str)` | `list` | Trims items, drops empties, per-item `cast` |
164
+ | `env.json(name, …)` | `Any` | `json.loads` of the value |
165
+ | `env.path(name, …)` | `pathlib.Path` | Not resolved/validated |
166
+ | `env.cast(name, func, …)` | `Any` | Apply any callable; errors wrapped in `CastError` |
167
+ | `Env(source=None, prefix="")` | `Env` | Custom mapping and/or name prefix |
168
+ | `read_dotenv(path=".env")` | `dict` | Parse a `.env` file; `{}` if absent |
169
+ | `load_dotenv(path=".env", override=False)` | `dict` | Inject into `os.environ` |
170
+
171
+ **Errors:** `EnvError` (base) · `MissingEnvError` (also `KeyError`) · `CastError` (also `ValueError`).
172
+
173
+ ---
174
+
175
+ ## Why not just `os.environ`?
176
+
177
+ ```python
178
+ # Before
179
+ import os
180
+ PORT = int(os.environ.get("PORT", "8000"))
181
+ DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
182
+ HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]
183
+
184
+ # After
185
+ from envcaster import env
186
+ PORT = env.int("PORT", default=8000)
187
+ DEBUG = env.bool("DEBUG", default=False)
188
+ HOSTS = env.list("ALLOWED_HOSTS", default=[])
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ git clone https://github.com/YoungAlpaccino/envcast
197
+ cd envcast
198
+ pip install -e ".[dev]"
199
+ pytest # run tests
200
+ ruff check . # lint
201
+ ```
202
+
203
+ ---
204
+
205
+ ## License
206
+
207
+ MIT — see [LICENSE](./LICENSE). Use it anywhere, including commercially.
@@ -0,0 +1,176 @@
1
+ # envcaster ⚙️
2
+
3
+ > Read environment variables as the **type you actually want** — `int`, `bool`, `list`, `json`, `Path` — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.
4
+
5
+ [![CI](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml/badge.svg)](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/envcaster.svg)](https://pypi.org/project/envcaster/)
7
+ [![Python](https://img.shields.io/pypi/pyversions/envcaster.svg)](https://pypi.org/project/envcaster/)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9
+
10
+ `os.environ` only ever gives you strings. So every project grows the same little
11
+ pile of `int(os.environ.get("PORT", "8000"))` and hand-rolled truthy checks that
12
+ quietly treat `"False"` as `True`. **envcaster** is that pile, done once and done right.
13
+
14
+ - 🪶 **Zero required dependencies** — pure standard library.
15
+ - 🎯 **Typed getters** — `str · int · float · bool · list · json · path` (+ custom `cast`).
16
+ - 🧯 **Loud, precise errors** — missing or malformed values tell you *which* variable and *why*.
17
+ - 🧪 **Fully tested** across Python 3.9–3.12.
18
+ - 🧩 **Drop-in `.env` loader** that never clobbers real environment config.
19
+
20
+ ---
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install envcaster
26
+ ```
27
+
28
+ ---
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ from envcaster import env
34
+
35
+ PORT = env.int("PORT", default=8000)
36
+ DEBUG = env.bool("DEBUG", default=False)
37
+ HOSTS = env.list("ALLOWED_HOSTS", sep=",") # ["a", "b"] from "a,b"
38
+ TIMEOUT = env.float("TIMEOUT", default=1.5)
39
+ SECRET = env.str("SECRET_KEY", required=True) # raises if not set
40
+ DATA = env.path("DATA_DIR", default="/var/data") # -> pathlib.Path
41
+ FLAGS = env.json("FEATURE_FLAGS", default={}) # parsed JSON
42
+ ```
43
+
44
+ > **A variable is required unless you give it a `default`.** Missing required
45
+ > variables raise `MissingEnvError`; bad values raise `CastError`. Both subclass
46
+ > `EnvError` — catch broadly or narrowly.
47
+
48
+ ---
49
+
50
+ ## Usage
51
+
52
+ ### Booleans that actually behave
53
+
54
+ ```python
55
+ env.bool("DEBUG") # 1 true t yes y on -> True (case-insensitive)
56
+ # 0 false f no n off -> False
57
+ # anything else -> CastError
58
+ ```
59
+
60
+ No more `bool("False") == True` bugs.
61
+
62
+ ### Lists (and lists of other types)
63
+
64
+ ```python
65
+ env.list("ALLOWED_HOSTS") # "a, b ,, c" -> ["a", "b", "c"] (trims, drops empties)
66
+ env.list("PORTS", sep=":", cast=int) # "80:443" -> [80, 443]
67
+ env.list("TAGS", default=[]) # missing -> []
68
+ ```
69
+
70
+ ### JSON and paths
71
+
72
+ ```python
73
+ env.json("LIMITS") # '{"rpm": 60}' -> {"rpm": 60}
74
+ env.path("LOG_DIR") # "/var/log" -> PosixPath("/var/log")
75
+ ```
76
+
77
+ ### Anything else — bring your own cast
78
+
79
+ ```python
80
+ from decimal import Decimal
81
+
82
+ env.cast("PRICE", Decimal) # "9.99" -> Decimal("9.99")
83
+ env.cast("COLOR", lambda v: int(v, 16)) # "ff0000" -> 16711680
84
+ # exceptions from your function are wrapped in CastError, naming the variable
85
+ ```
86
+
87
+ ### Scoped readers with a prefix
88
+
89
+ ```python
90
+ from envcaster import Env
91
+
92
+ app = Env(prefix="APP_")
93
+ app.int("PORT") # reads APP_PORT
94
+ app.bool("DEBUG") # reads APP_DEBUG
95
+ ```
96
+
97
+ ### Read from somewhere other than `os.environ`
98
+
99
+ ```python
100
+ cfg = Env(source={"PORT": "9000"}) # great for tests — no global state
101
+ cfg.int("PORT") # 9000
102
+ ```
103
+
104
+ ### Load a `.env` file (no dependency)
105
+
106
+ ```python
107
+ from envcaster import load_dotenv, env
108
+
109
+ load_dotenv() # reads ./.env into os.environ (won't override real env vars)
110
+ load_dotenv(".env.local", override=True)
111
+
112
+ PORT = env.int("PORT")
113
+
114
+ # Or parse without touching the environment:
115
+ from envcaster import read_dotenv
116
+ values = read_dotenv(".env") # -> {"PORT": "8000", ...}
117
+ ```
118
+
119
+ Handles `KEY=value`, `export KEY=value`, `# comments`, and quoted values. For
120
+ interpolation or multiline values, use [python-dotenv](https://github.com/theskumar/python-dotenv).
121
+
122
+ ---
123
+
124
+ ## API reference
125
+
126
+ | Call | Returns | Notes |
127
+ |---|---|---|
128
+ | `env.str(name, default=…, required=False)` | `str` | The raw value, unchanged |
129
+ | `env.int(name, …)` | `int` | Base-10, whitespace stripped |
130
+ | `env.float(name, …)` | `float` | |
131
+ | `env.bool(name, …)` | `bool` | `1/true/t/yes/y/on` ↔ `0/false/f/no/n/off` |
132
+ | `env.list(name, …, sep=",", cast=str)` | `list` | Trims items, drops empties, per-item `cast` |
133
+ | `env.json(name, …)` | `Any` | `json.loads` of the value |
134
+ | `env.path(name, …)` | `pathlib.Path` | Not resolved/validated |
135
+ | `env.cast(name, func, …)` | `Any` | Apply any callable; errors wrapped in `CastError` |
136
+ | `Env(source=None, prefix="")` | `Env` | Custom mapping and/or name prefix |
137
+ | `read_dotenv(path=".env")` | `dict` | Parse a `.env` file; `{}` if absent |
138
+ | `load_dotenv(path=".env", override=False)` | `dict` | Inject into `os.environ` |
139
+
140
+ **Errors:** `EnvError` (base) · `MissingEnvError` (also `KeyError`) · `CastError` (also `ValueError`).
141
+
142
+ ---
143
+
144
+ ## Why not just `os.environ`?
145
+
146
+ ```python
147
+ # Before
148
+ import os
149
+ PORT = int(os.environ.get("PORT", "8000"))
150
+ DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
151
+ HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]
152
+
153
+ # After
154
+ from envcaster import env
155
+ PORT = env.int("PORT", default=8000)
156
+ DEBUG = env.bool("DEBUG", default=False)
157
+ HOSTS = env.list("ALLOWED_HOSTS", default=[])
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ git clone https://github.com/YoungAlpaccino/envcast
166
+ cd envcast
167
+ pip install -e ".[dev]"
168
+ pytest # run tests
169
+ ruff check . # lint
170
+ ```
171
+
172
+ ---
173
+
174
+ ## License
175
+
176
+ MIT — see [LICENSE](./LICENSE). Use it anywhere, including commercially.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "envcaster"
7
+ version = "0.1.0"
8
+ description = "Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "YoungAlpaccino" }]
13
+ keywords = ["env", "environment", "config", "dotenv", "settings", "12factor", "typed", "configuration"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=7", "ruff", "build", "twine"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/YoungAlpaccino/envcast"
34
+ Repository = "https://github.com/YoungAlpaccino/envcast"
35
+ Issues = "https://github.com/YoungAlpaccino/envcast/issues"
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+
40
+ [tool.setuptools.package-data]
41
+ envcaster = ["py.typed"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,29 @@
1
+ """envcaster — typed, dependency-free environment variable loading.
2
+
3
+ Read environment variables as the type you actually want — ``int``, ``float``,
4
+ ``bool``, ``list``, ``json``, ``Path`` — with defaults, required-checks, and
5
+ error messages that tell you exactly which variable was wrong and why. Plus a
6
+ tiny ``.env`` loader. Zero dependencies, pure standard library.
7
+
8
+ from envcaster import env
9
+
10
+ PORT = env.int("PORT", default=8000)
11
+ DEBUG = env.bool("DEBUG", default=False)
12
+ HOSTS = env.list("ALLOWED_HOSTS", sep=",")
13
+ SECRET = env.str("SECRET_KEY", required=True)
14
+ """
15
+
16
+ from envcaster.core import CastError, Env, EnvError, MissingEnvError, env
17
+ from envcaster.dotenv import load_dotenv, read_dotenv
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "env",
23
+ "Env",
24
+ "EnvError",
25
+ "MissingEnvError",
26
+ "CastError",
27
+ "load_dotenv",
28
+ "read_dotenv",
29
+ ]
@@ -0,0 +1,210 @@
1
+ """Typed reads from the environment with defaults, requirements, and clear errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import json as _json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Callable, List, Mapping, Optional, TypeVar
10
+
11
+ __all__ = [
12
+ "Env",
13
+ "EnvError",
14
+ "MissingEnvError",
15
+ "CastError",
16
+ "env",
17
+ ]
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ class EnvError(Exception):
23
+ """Base class for all envcaster errors."""
24
+
25
+
26
+ class MissingEnvError(EnvError, KeyError):
27
+ """A required environment variable was not set."""
28
+
29
+ def __init__(self, name: str) -> None:
30
+ self.name = name
31
+ super().__init__(f"Required environment variable {name!r} is not set.")
32
+
33
+
34
+ class CastError(EnvError, ValueError):
35
+ """An environment variable could not be cast to the requested type."""
36
+
37
+ def __init__(self, name: str, value: str, type_name: str, hint: str = "") -> None:
38
+ self.name = name
39
+ self.value = value
40
+ self.type_name = type_name
41
+ msg = f"Environment variable {name!r}={value!r} is not a valid {type_name}."
42
+ if hint:
43
+ msg += f" {hint}"
44
+ super().__init__(msg)
45
+
46
+
47
+ # Sentinels — distinct from any user value (including None).
48
+ class _Unset:
49
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
50
+ return "<unset>"
51
+
52
+
53
+ _UNSET: Any = _Unset()
54
+ _USE_DEFAULT: Any = object()
55
+
56
+ _TRUE = {"1", "true", "t", "yes", "y", "on"}
57
+ _FALSE = {"0", "false", "f", "no", "n", "off"}
58
+
59
+
60
+ class Env:
61
+ """A typed reader over a mapping of environment variables.
62
+
63
+ By default it reads the live process environment (``os.environ``). Pass a
64
+ ``source`` mapping (e.g. a plain ``dict``) to read from somewhere else —
65
+ handy in tests. A ``prefix`` is prepended to every name you look up, so
66
+ ``Env(prefix="APP_").int("PORT")`` reads ``APP_PORT``.
67
+
68
+ A variable is **required unless you pass a ``default``**. Missing required
69
+ variables raise :class:`MissingEnvError`; bad values raise :class:`CastError`.
70
+ Both subclass :class:`EnvError` (and the matching builtin), so you can catch
71
+ broadly or narrowly.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ source: Optional[Mapping[str, str]] = None,
77
+ *,
78
+ prefix: str = "",
79
+ ) -> None:
80
+ self._source = source
81
+ self._prefix = prefix
82
+
83
+ # -- internals ---------------------------------------------------------
84
+
85
+ def _mapping(self) -> Mapping[str, str]:
86
+ # Read os.environ lazily so changes after construction are seen.
87
+ return os.environ if self._source is None else self._source
88
+
89
+ def _raw(self, name: str, default: Any, required: bool) -> str:
90
+ key = self._prefix + name
91
+ value = self._mapping().get(key)
92
+ if value is None:
93
+ if required or default is _UNSET:
94
+ raise MissingEnvError(key)
95
+ return _USE_DEFAULT
96
+ return value
97
+
98
+ # -- typed getters -----------------------------------------------------
99
+
100
+ def str(self, name: str, default: Any = _UNSET, *, required: bool = False) -> str:
101
+ """Return the variable as a string (the raw value, unchanged)."""
102
+ raw = self._raw(name, default, required)
103
+ return default if raw is _USE_DEFAULT else raw
104
+
105
+ def int(self, name: str, default: Any = _UNSET, *, required: bool = False) -> int:
106
+ """Return the variable parsed as an ``int`` (base-10, whitespace ignored)."""
107
+ raw = self._raw(name, default, required)
108
+ if raw is _USE_DEFAULT:
109
+ return default
110
+ try:
111
+ return int(raw.strip())
112
+ except ValueError:
113
+ raise CastError(self._prefix + name, raw, "integer") from None
114
+
115
+ def float(self, name: str, default: Any = _UNSET, *, required: bool = False) -> float:
116
+ """Return the variable parsed as a ``float``."""
117
+ raw = self._raw(name, default, required)
118
+ if raw is _USE_DEFAULT:
119
+ return default
120
+ try:
121
+ return float(raw.strip())
122
+ except ValueError:
123
+ raise CastError(self._prefix + name, raw, "float") from None
124
+
125
+ def bool(self, name: str, default: Any = _UNSET, *, required: bool = False) -> bool:
126
+ """Return the variable as a ``bool``.
127
+
128
+ Truthy: ``1 true t yes y on``. Falsy: ``0 false f no n off``
129
+ (case-insensitive). Anything else raises :class:`CastError`.
130
+ """
131
+ raw = self._raw(name, default, required)
132
+ if raw is _USE_DEFAULT:
133
+ return default
134
+ token = raw.strip().lower()
135
+ if token in _TRUE:
136
+ return True
137
+ if token in _FALSE:
138
+ return False
139
+ raise CastError(
140
+ self._prefix + name,
141
+ raw,
142
+ "boolean",
143
+ hint=f"Use one of: {', '.join(sorted(_TRUE | _FALSE))}.",
144
+ )
145
+
146
+ def list(
147
+ self,
148
+ name: str,
149
+ default: Any = _UNSET,
150
+ *,
151
+ sep: str = ",",
152
+ cast: Callable[[str], T] = builtins.str, # type: ignore[assignment]
153
+ required: bool = False,
154
+ ) -> List[T]:
155
+ """Split the variable on ``sep`` into a list.
156
+
157
+ Items are stripped of surrounding whitespace and empty items dropped.
158
+ Pass ``cast`` to convert each item (e.g. ``cast=int``).
159
+ """
160
+ raw = self._raw(name, default, required)
161
+ if raw is _USE_DEFAULT:
162
+ return default
163
+ items = [piece.strip() for piece in raw.split(sep)]
164
+ items = [piece for piece in items if piece]
165
+ try:
166
+ return [cast(piece) for piece in items]
167
+ except (ValueError, TypeError):
168
+ raise CastError(self._prefix + name, raw, "list", hint="An item failed to cast.") from None
169
+
170
+ def json(self, name: str, default: Any = _UNSET, *, required: bool = False) -> Any:
171
+ """Parse the variable as JSON and return the resulting object."""
172
+ raw = self._raw(name, default, required)
173
+ if raw is _USE_DEFAULT:
174
+ return default
175
+ try:
176
+ return _json.loads(raw)
177
+ except _json.JSONDecodeError:
178
+ raise CastError(self._prefix + name, raw, "JSON") from None
179
+
180
+ def path(self, name: str, default: Any = _UNSET, *, required: bool = False) -> Path:
181
+ """Return the variable as a :class:`pathlib.Path` (not resolved/validated)."""
182
+ raw = self._raw(name, default, required)
183
+ if raw is _USE_DEFAULT:
184
+ return default
185
+ return Path(raw)
186
+
187
+ def cast(
188
+ self,
189
+ name: str,
190
+ func: Callable[[str], T],
191
+ default: Any = _UNSET,
192
+ *,
193
+ required: bool = False,
194
+ ) -> T:
195
+ """Apply an arbitrary ``func`` to the raw string value.
196
+
197
+ Any exception from ``func`` is wrapped in :class:`CastError`.
198
+ """
199
+ raw = self._raw(name, default, required)
200
+ if raw is _USE_DEFAULT:
201
+ return default
202
+ try:
203
+ return func(raw)
204
+ except Exception as exc: # noqa: BLE001 - re-raised as CastError
205
+ type_name = getattr(func, "__name__", "value")
206
+ raise CastError(self._prefix + name, raw, type_name, hint=str(exc)) from None
207
+
208
+
209
+ # A ready-to-use instance bound to the live process environment.
210
+ env = Env()
@@ -0,0 +1,67 @@
1
+ """A tiny, dependency-free ``.env`` reader.
2
+
3
+ Just enough to load a local ``.env`` in development. For complex needs
4
+ (variable interpolation, multiline values) reach for python-dotenv.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Dict, Union
12
+
13
+ __all__ = ["read_dotenv", "load_dotenv"]
14
+
15
+ _PathLike = Union[str, "os.PathLike[str]"]
16
+
17
+
18
+ def _unquote(value: str) -> str:
19
+ value = value.strip()
20
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
21
+ return value[1:-1]
22
+ return value
23
+
24
+
25
+ def read_dotenv(path: _PathLike = ".env") -> Dict[str, str]:
26
+ """Parse a ``.env`` file into a dict. Returns ``{}`` if the file is absent.
27
+
28
+ Supports ``KEY=value``, ``export KEY=value``, ``#`` comments, blank lines,
29
+ and single/double-quoted values. Does **not** touch ``os.environ``.
30
+ """
31
+ file = Path(path)
32
+ if not file.is_file():
33
+ return {}
34
+
35
+ result: Dict[str, str] = {}
36
+ for raw_line in file.read_text(encoding="utf-8").splitlines():
37
+ line = raw_line.strip()
38
+ if not line or line.startswith("#"):
39
+ continue
40
+ if line.startswith("export "):
41
+ line = line[len("export ") :].lstrip()
42
+ if "=" not in line:
43
+ continue
44
+ key, _, value = line.partition("=")
45
+ key = key.strip()
46
+ if not key:
47
+ continue
48
+ # Strip an inline comment only when the value is not quoted.
49
+ stripped = value.strip()
50
+ if stripped[:1] not in ("'", '"') and " #" in value:
51
+ value = value.split(" #", 1)[0]
52
+ result[key] = _unquote(value)
53
+ return result
54
+
55
+
56
+ def load_dotenv(path: _PathLike = ".env", *, override: bool = False) -> Dict[str, str]:
57
+ """Read a ``.env`` file and inject its values into ``os.environ``.
58
+
59
+ By default existing environment variables win (``override=False``), so real
60
+ environment configuration is never clobbered by the file. Returns the dict
61
+ that was parsed from the file.
62
+ """
63
+ values = read_dotenv(path)
64
+ for key, value in values.items():
65
+ if override or key not in os.environ:
66
+ os.environ[key] = value
67
+ return values
File without changes
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: envcaster
3
+ Version: 0.1.0
4
+ Summary: Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors.
5
+ Author: YoungAlpaccino
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/YoungAlpaccino/envcast
8
+ Project-URL: Repository, https://github.com/YoungAlpaccino/envcast
9
+ Project-URL: Issues, https://github.com/YoungAlpaccino/envcast/issues
10
+ Keywords: env,environment,config,dotenv,settings,12factor,typed,configuration
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: build; extra == "dev"
29
+ Requires-Dist: twine; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # envcaster ⚙️
33
+
34
+ > Read environment variables as the **type you actually want** — `int`, `bool`, `list`, `json`, `Path` — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.
35
+
36
+ [![CI](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml/badge.svg)](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/envcaster.svg)](https://pypi.org/project/envcaster/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/envcaster.svg)](https://pypi.org/project/envcaster/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+
41
+ `os.environ` only ever gives you strings. So every project grows the same little
42
+ pile of `int(os.environ.get("PORT", "8000"))` and hand-rolled truthy checks that
43
+ quietly treat `"False"` as `True`. **envcaster** is that pile, done once and done right.
44
+
45
+ - 🪶 **Zero required dependencies** — pure standard library.
46
+ - 🎯 **Typed getters** — `str · int · float · bool · list · json · path` (+ custom `cast`).
47
+ - 🧯 **Loud, precise errors** — missing or malformed values tell you *which* variable and *why*.
48
+ - 🧪 **Fully tested** across Python 3.9–3.12.
49
+ - 🧩 **Drop-in `.env` loader** that never clobbers real environment config.
50
+
51
+ ---
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install envcaster
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ from envcaster import env
65
+
66
+ PORT = env.int("PORT", default=8000)
67
+ DEBUG = env.bool("DEBUG", default=False)
68
+ HOSTS = env.list("ALLOWED_HOSTS", sep=",") # ["a", "b"] from "a,b"
69
+ TIMEOUT = env.float("TIMEOUT", default=1.5)
70
+ SECRET = env.str("SECRET_KEY", required=True) # raises if not set
71
+ DATA = env.path("DATA_DIR", default="/var/data") # -> pathlib.Path
72
+ FLAGS = env.json("FEATURE_FLAGS", default={}) # parsed JSON
73
+ ```
74
+
75
+ > **A variable is required unless you give it a `default`.** Missing required
76
+ > variables raise `MissingEnvError`; bad values raise `CastError`. Both subclass
77
+ > `EnvError` — catch broadly or narrowly.
78
+
79
+ ---
80
+
81
+ ## Usage
82
+
83
+ ### Booleans that actually behave
84
+
85
+ ```python
86
+ env.bool("DEBUG") # 1 true t yes y on -> True (case-insensitive)
87
+ # 0 false f no n off -> False
88
+ # anything else -> CastError
89
+ ```
90
+
91
+ No more `bool("False") == True` bugs.
92
+
93
+ ### Lists (and lists of other types)
94
+
95
+ ```python
96
+ env.list("ALLOWED_HOSTS") # "a, b ,, c" -> ["a", "b", "c"] (trims, drops empties)
97
+ env.list("PORTS", sep=":", cast=int) # "80:443" -> [80, 443]
98
+ env.list("TAGS", default=[]) # missing -> []
99
+ ```
100
+
101
+ ### JSON and paths
102
+
103
+ ```python
104
+ env.json("LIMITS") # '{"rpm": 60}' -> {"rpm": 60}
105
+ env.path("LOG_DIR") # "/var/log" -> PosixPath("/var/log")
106
+ ```
107
+
108
+ ### Anything else — bring your own cast
109
+
110
+ ```python
111
+ from decimal import Decimal
112
+
113
+ env.cast("PRICE", Decimal) # "9.99" -> Decimal("9.99")
114
+ env.cast("COLOR", lambda v: int(v, 16)) # "ff0000" -> 16711680
115
+ # exceptions from your function are wrapped in CastError, naming the variable
116
+ ```
117
+
118
+ ### Scoped readers with a prefix
119
+
120
+ ```python
121
+ from envcaster import Env
122
+
123
+ app = Env(prefix="APP_")
124
+ app.int("PORT") # reads APP_PORT
125
+ app.bool("DEBUG") # reads APP_DEBUG
126
+ ```
127
+
128
+ ### Read from somewhere other than `os.environ`
129
+
130
+ ```python
131
+ cfg = Env(source={"PORT": "9000"}) # great for tests — no global state
132
+ cfg.int("PORT") # 9000
133
+ ```
134
+
135
+ ### Load a `.env` file (no dependency)
136
+
137
+ ```python
138
+ from envcaster import load_dotenv, env
139
+
140
+ load_dotenv() # reads ./.env into os.environ (won't override real env vars)
141
+ load_dotenv(".env.local", override=True)
142
+
143
+ PORT = env.int("PORT")
144
+
145
+ # Or parse without touching the environment:
146
+ from envcaster import read_dotenv
147
+ values = read_dotenv(".env") # -> {"PORT": "8000", ...}
148
+ ```
149
+
150
+ Handles `KEY=value`, `export KEY=value`, `# comments`, and quoted values. For
151
+ interpolation or multiline values, use [python-dotenv](https://github.com/theskumar/python-dotenv).
152
+
153
+ ---
154
+
155
+ ## API reference
156
+
157
+ | Call | Returns | Notes |
158
+ |---|---|---|
159
+ | `env.str(name, default=…, required=False)` | `str` | The raw value, unchanged |
160
+ | `env.int(name, …)` | `int` | Base-10, whitespace stripped |
161
+ | `env.float(name, …)` | `float` | |
162
+ | `env.bool(name, …)` | `bool` | `1/true/t/yes/y/on` ↔ `0/false/f/no/n/off` |
163
+ | `env.list(name, …, sep=",", cast=str)` | `list` | Trims items, drops empties, per-item `cast` |
164
+ | `env.json(name, …)` | `Any` | `json.loads` of the value |
165
+ | `env.path(name, …)` | `pathlib.Path` | Not resolved/validated |
166
+ | `env.cast(name, func, …)` | `Any` | Apply any callable; errors wrapped in `CastError` |
167
+ | `Env(source=None, prefix="")` | `Env` | Custom mapping and/or name prefix |
168
+ | `read_dotenv(path=".env")` | `dict` | Parse a `.env` file; `{}` if absent |
169
+ | `load_dotenv(path=".env", override=False)` | `dict` | Inject into `os.environ` |
170
+
171
+ **Errors:** `EnvError` (base) · `MissingEnvError` (also `KeyError`) · `CastError` (also `ValueError`).
172
+
173
+ ---
174
+
175
+ ## Why not just `os.environ`?
176
+
177
+ ```python
178
+ # Before
179
+ import os
180
+ PORT = int(os.environ.get("PORT", "8000"))
181
+ DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
182
+ HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]
183
+
184
+ # After
185
+ from envcaster import env
186
+ PORT = env.int("PORT", default=8000)
187
+ DEBUG = env.bool("DEBUG", default=False)
188
+ HOSTS = env.list("ALLOWED_HOSTS", default=[])
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ git clone https://github.com/YoungAlpaccino/envcast
197
+ cd envcast
198
+ pip install -e ".[dev]"
199
+ pytest # run tests
200
+ ruff check . # lint
201
+ ```
202
+
203
+ ---
204
+
205
+ ## License
206
+
207
+ MIT — see [LICENSE](./LICENSE). Use it anywhere, including commercially.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/envcaster/__init__.py
5
+ src/envcaster/core.py
6
+ src/envcaster/dotenv.py
7
+ src/envcaster/py.typed
8
+ src/envcaster.egg-info/PKG-INFO
9
+ src/envcaster.egg-info/SOURCES.txt
10
+ src/envcaster.egg-info/dependency_links.txt
11
+ src/envcaster.egg-info/requires.txt
12
+ src/envcaster.egg-info/top_level.txt
13
+ tests/test_core.py
14
+ tests/test_dotenv.py
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ pytest>=7
4
+ ruff
5
+ build
6
+ twine
@@ -0,0 +1 @@
1
+ envcaster
@@ -0,0 +1,151 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from envcaster import CastError, Env, MissingEnvError
6
+
7
+
8
+ def make(**values):
9
+ return Env(source=dict(values))
10
+
11
+
12
+ # -- str ------------------------------------------------------------------
13
+
14
+
15
+ def test_str_present_and_default():
16
+ e = make(NAME="alice")
17
+ assert e.str("NAME") == "alice"
18
+ assert e.str("MISSING", default="fallback") == "fallback"
19
+
20
+
21
+ def test_empty_string_is_a_value_not_missing():
22
+ e = make(NAME="")
23
+ assert e.str("NAME") == ""
24
+
25
+
26
+ # -- int / float ----------------------------------------------------------
27
+
28
+
29
+ def test_int_parses_and_strips_whitespace():
30
+ assert make(PORT=" 8000 ").int("PORT") == 8000
31
+
32
+
33
+ def test_int_bad_value_raises_casterror_with_name():
34
+ with pytest.raises(CastError) as exc:
35
+ make(PORT="abc").int("PORT")
36
+ assert exc.value.name == "PORT"
37
+
38
+
39
+ def test_float_parses():
40
+ assert make(RATE="1.5").float("RATE") == 1.5
41
+
42
+
43
+ # -- bool -----------------------------------------------------------------
44
+
45
+
46
+ @pytest.mark.parametrize("raw", ["1", "true", "TRUE", "t", "yes", "Y", "on"])
47
+ def test_bool_truthy(raw):
48
+ assert make(FLAG=raw).bool("FLAG") is True
49
+
50
+
51
+ @pytest.mark.parametrize("raw", ["0", "false", "F", "no", "n", "OFF"])
52
+ def test_bool_falsy(raw):
53
+ assert make(FLAG=raw).bool("FLAG") is False
54
+
55
+
56
+ def test_bool_invalid_raises():
57
+ with pytest.raises(CastError):
58
+ make(FLAG="maybe").bool("FLAG")
59
+
60
+
61
+ def test_bool_default():
62
+ assert make().bool("FLAG", default=False) is False
63
+
64
+
65
+ # -- list -----------------------------------------------------------------
66
+
67
+
68
+ def test_list_splits_strips_and_drops_empty():
69
+ e = make(HOSTS="a, b ,, c")
70
+ assert e.list("HOSTS") == ["a", "b", "c"]
71
+
72
+
73
+ def test_list_custom_sep_and_cast():
74
+ e = make(NUMS="1|2|3")
75
+ assert e.list("NUMS", sep="|", cast=int) == [1, 2, 3]
76
+
77
+
78
+ def test_list_cast_failure_raises():
79
+ with pytest.raises(CastError):
80
+ make(NUMS="1,x,3").list("NUMS", cast=int)
81
+
82
+
83
+ def test_list_default():
84
+ assert make().list("HOSTS", default=[]) == []
85
+
86
+
87
+ # -- json -----------------------------------------------------------------
88
+
89
+
90
+ def test_json_parses_object():
91
+ e = make(CONF=json.dumps({"a": 1, "b": [2, 3]}))
92
+ assert e.json("CONF") == {"a": 1, "b": [2, 3]}
93
+
94
+
95
+ def test_json_invalid_raises():
96
+ with pytest.raises(CastError):
97
+ make(CONF="{not json}").json("CONF")
98
+
99
+
100
+ # -- path -----------------------------------------------------------------
101
+
102
+
103
+ def test_path_returns_pathlib():
104
+ from pathlib import Path
105
+
106
+ assert make(DIR="/tmp/x").path("DIR") == Path("/tmp/x")
107
+
108
+
109
+ # -- cast -----------------------------------------------------------------
110
+
111
+
112
+ def test_custom_cast():
113
+ e = make(COLOR="ff0000")
114
+ assert e.cast("COLOR", lambda v: int(v, 16)) == 0xFF0000
115
+
116
+
117
+ def test_custom_cast_wraps_errors():
118
+ with pytest.raises(CastError):
119
+ make(COLOR="zzz").cast("COLOR", lambda v: int(v, 16))
120
+
121
+
122
+ # -- required / missing ---------------------------------------------------
123
+
124
+
125
+ def test_missing_without_default_raises():
126
+ with pytest.raises(MissingEnvError):
127
+ make().str("SECRET")
128
+
129
+
130
+ def test_required_true_raises_even_with_default():
131
+ with pytest.raises(MissingEnvError):
132
+ make().str("SECRET", default="x", required=True)
133
+
134
+
135
+ def test_missing_error_is_keyerror_subclass():
136
+ assert issubclass(MissingEnvError, KeyError)
137
+
138
+
139
+ # -- prefix & live os.environ --------------------------------------------
140
+
141
+
142
+ def test_prefix():
143
+ e = Env(source={"APP_PORT": "9000"}, prefix="APP_")
144
+ assert e.int("PORT") == 9000
145
+
146
+
147
+ def test_default_env_reads_live_os_environ(monkeypatch):
148
+ from envcaster import env
149
+
150
+ monkeypatch.setenv("ENVCAST_TEST_X", "42")
151
+ assert env.int("ENVCAST_TEST_X") == 42
@@ -0,0 +1,57 @@
1
+ from envcaster import load_dotenv, read_dotenv
2
+
3
+ SAMPLE = """\
4
+ # a comment
5
+ NAME=alice
6
+ export TOKEN=secret
7
+ QUOTED="hello world"
8
+ SINGLE='single quoted'
9
+ WITH_COMMENT=value # trailing comment
10
+ EMPTY=
11
+
12
+ # indented comment
13
+ PORT=8000
14
+ """
15
+
16
+
17
+ def write(tmp_path, text=SAMPLE):
18
+ f = tmp_path / ".env"
19
+ f.write_text(text, encoding="utf-8")
20
+ return f
21
+
22
+
23
+ def test_read_dotenv_parses_all_forms(tmp_path):
24
+ data = read_dotenv(write(tmp_path))
25
+ assert data["NAME"] == "alice"
26
+ assert data["TOKEN"] == "secret" # export prefix stripped
27
+ assert data["QUOTED"] == "hello world" # double quotes stripped
28
+ assert data["SINGLE"] == "single quoted" # single quotes stripped
29
+ assert data["WITH_COMMENT"] == "value" # inline comment dropped
30
+ assert data["EMPTY"] == ""
31
+ assert data["PORT"] == "8000"
32
+
33
+
34
+ def test_read_dotenv_missing_file_returns_empty(tmp_path):
35
+ assert read_dotenv(tmp_path / "nope.env") == {}
36
+
37
+
38
+ def test_quoted_value_keeps_hash(tmp_path):
39
+ f = write(tmp_path, 'URL="http://x/#frag"\n')
40
+ assert read_dotenv(f)["URL"] == "http://x/#frag"
41
+
42
+
43
+ def test_load_dotenv_does_not_override_by_default(tmp_path, monkeypatch):
44
+ monkeypatch.setenv("NAME", "real")
45
+ load_dotenv(write(tmp_path))
46
+ import os
47
+
48
+ assert os.environ["NAME"] == "real" # existing env wins
49
+ assert os.environ["TOKEN"] == "secret" # new key injected
50
+
51
+
52
+ def test_load_dotenv_override(tmp_path, monkeypatch):
53
+ monkeypatch.setenv("NAME", "real")
54
+ load_dotenv(write(tmp_path), override=True)
55
+ import os
56
+
57
+ assert os.environ["NAME"] == "alice"