tidyenv 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.
- tidyenv-0.1.0/.github/dependabot.yml +10 -0
- tidyenv-0.1.0/.github/workflows/ci.yml +25 -0
- tidyenv-0.1.0/.github/workflows/release.yml +69 -0
- tidyenv-0.1.0/.gitignore +11 -0
- tidyenv-0.1.0/CHANGELOG.md +7 -0
- tidyenv-0.1.0/LICENSE +21 -0
- tidyenv-0.1.0/PKG-INFO +156 -0
- tidyenv-0.1.0/README.md +131 -0
- tidyenv-0.1.0/pyproject.toml +57 -0
- tidyenv-0.1.0/src/tidyenv/__init__.py +13 -0
- tidyenv-0.1.0/src/tidyenv/_dotenv.py +59 -0
- tidyenv-0.1.0/src/tidyenv/_env.py +322 -0
- tidyenv-0.1.0/src/tidyenv/py.typed +0 -0
- tidyenv-0.1.0/tests/test_dotenv.py +111 -0
- tidyenv-0.1.0/tests/test_env.py +226 -0
- tidyenv-0.1.0/uv.lock +860 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v7
|
|
18
|
+
- uses: astral-sh/setup-uv@v7
|
|
19
|
+
with:
|
|
20
|
+
python-version: ${{ matrix.python-version }}
|
|
21
|
+
- run: uv sync
|
|
22
|
+
- run: uv run ruff check .
|
|
23
|
+
- run: uv run ruff format --check .
|
|
24
|
+
- run: uv run mypy
|
|
25
|
+
- run: uv run pytest
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Publishing, with no tokens (PyPI "trusted publishing"):
|
|
2
|
+
# - Run this workflow by hand (Actions -> Release -> Run workflow) to publish to TestPyPI.
|
|
3
|
+
# - Publish a GitHub release with tag vX.Y.Z to publish to PyPI.
|
|
4
|
+
# One-time setup on pypi.org and test.pypi.org: add a trusted publisher with
|
|
5
|
+
# owner LenaBarretta, repository tidyenv, workflow release.yml and environment
|
|
6
|
+
# `pypi` (on pypi.org) or `testpypi` (on test.pypi.org).
|
|
7
|
+
name: Release
|
|
8
|
+
|
|
9
|
+
on:
|
|
10
|
+
release:
|
|
11
|
+
types: [published]
|
|
12
|
+
workflow_dispatch:
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
test:
|
|
16
|
+
uses: ./.github/workflows/ci.yml
|
|
17
|
+
|
|
18
|
+
build:
|
|
19
|
+
needs: test
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v7
|
|
23
|
+
- uses: astral-sh/setup-uv@v7
|
|
24
|
+
- run: uv build
|
|
25
|
+
- name: Check that the release tag matches the package version
|
|
26
|
+
if: github.event_name == 'release'
|
|
27
|
+
run: |
|
|
28
|
+
version="${GITHUB_REF_NAME#v}"
|
|
29
|
+
test -f "dist/tidyenv-${version}-py3-none-any.whl" || {
|
|
30
|
+
echo "Tag $GITHUB_REF_NAME does not match the version in src/tidyenv/__init__.py"
|
|
31
|
+
ls dist
|
|
32
|
+
exit 1
|
|
33
|
+
}
|
|
34
|
+
- uses: actions/upload-artifact@v7
|
|
35
|
+
with:
|
|
36
|
+
name: dist
|
|
37
|
+
path: dist/
|
|
38
|
+
|
|
39
|
+
testpypi:
|
|
40
|
+
if: github.event_name == 'workflow_dispatch'
|
|
41
|
+
needs: build
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
environment: testpypi
|
|
44
|
+
permissions:
|
|
45
|
+
id-token: write
|
|
46
|
+
steps:
|
|
47
|
+
- uses: actions/download-artifact@v8
|
|
48
|
+
with:
|
|
49
|
+
name: dist
|
|
50
|
+
path: dist/
|
|
51
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
52
|
+
with:
|
|
53
|
+
repository-url: https://test.pypi.org/legacy/
|
|
54
|
+
# A version can be uploaded only once; reruns just re-check the setup.
|
|
55
|
+
skip-existing: true
|
|
56
|
+
|
|
57
|
+
pypi:
|
|
58
|
+
if: github.event_name == 'release'
|
|
59
|
+
needs: build
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
environment: pypi
|
|
62
|
+
permissions:
|
|
63
|
+
id-token: write
|
|
64
|
+
steps:
|
|
65
|
+
- uses: actions/download-artifact@v8
|
|
66
|
+
with:
|
|
67
|
+
name: dist
|
|
68
|
+
path: dist/
|
|
69
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
tidyenv-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-02)
|
|
4
|
+
|
|
5
|
+
First release: `str`, `int`, `float`, `bool`, `list`, `choice`, `path` and `json`
|
|
6
|
+
readers, defaults, `secret=True`, prefixes, `env.collect()` for all-at-once errors,
|
|
7
|
+
and built-in `.env` loading with `env.read_dotenv()` and `parse_dotenv()`.
|
tidyenv-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elena Raikova
|
|
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.
|
tidyenv-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tidyenv
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed environment variables with friendly errors and built-in .env support.
|
|
5
|
+
Project-URL: Homepage, https://github.com/LenaBarretta/tidyenv
|
|
6
|
+
Project-URL: Issues, https://github.com/LenaBarretta/tidyenv/issues
|
|
7
|
+
Author-email: Elena Raikova <lenabarretta@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: 12-factor,config,env,environment,settings
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
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: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<img src="https://raw.githubusercontent.com/LenaBarretta/tidyenv/main/docs/logo.png" alt="tidyenv logo" width="160">
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
# tidyenv
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/tidyenv/)
|
|
33
|
+
[](https://pypi.org/project/tidyenv/)
|
|
34
|
+
[](https://github.com/LenaBarretta/tidyenv/actions/workflows/ci.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
**Typed environment variables with friendly errors.** Built-in `.env` support. Zero dependencies.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from tidyenv import env
|
|
41
|
+
|
|
42
|
+
PORT = env.int("PORT", default=8000)
|
|
43
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
44
|
+
HOSTS = env.list("ALLOWED_HOSTS", default=["localhost"])
|
|
45
|
+
DATABASE_URL = env.str("DATABASE_URL")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each line converts the type, applies the default, and if something is wrong, says exactly what:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
tidyenv.EnvError: 1 environment problem:
|
|
52
|
+
- PORT: expected an integer (got 'eighty')
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why
|
|
56
|
+
|
|
57
|
+
| | Plain `os.environ` | tidyenv |
|
|
58
|
+
| --- | --- | --- |
|
|
59
|
+
| Number with a default | `int(os.environ.get("PORT", "8000"))` | `env.int("PORT", default=8000)` |
|
|
60
|
+
| Bad number | `ValueError: invalid literal for int() with base 10: 'eighty'` (which variable?) | `PORT: expected an integer (got 'eighty')` |
|
|
61
|
+
| Missing variable | `KeyError: 'DB_URL'` | `DB_URL: is not set` |
|
|
62
|
+
| Empty value `DB_URL=` | silently `""` | treated as not set |
|
|
63
|
+
| Boolean | `os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")`, and a typo like `ture` silently means `False` | `env.bool("DEBUG", default=False)`, and `ture` is an error |
|
|
64
|
+
| List | `[h.strip() for h in os.environ.get("HOSTS", "").split(",") if h.strip()]` | `env.list("HOSTS")` |
|
|
65
|
+
| List of numbers | the same, plus `int()` on every item | `env.list("PORTS", of=int)` |
|
|
66
|
+
| One of several values | `if mode not in ("dev", "prod"): raise ...` | `env.choice("MODE", ["dev", "prod"])` |
|
|
67
|
+
| Secrets in errors | the value ends up in your logs | `secret=True` shows `***` |
|
|
68
|
+
| `.env` file | `pip install python-dotenv` + `load_dotenv()` | `env.read_dotenv()`, nothing to install |
|
|
69
|
+
| Types for mypy / IDE | `str \| None`, cast it yourself | `env.int` returns `int` |
|
|
70
|
+
| Several broken variables | crash, fix, redeploy, crash on the next one | `env.collect()` lists them all at once |
|
|
71
|
+
|
|
72
|
+
One line per variable, and every error names the variable and says what is wrong with it.
|
|
73
|
+
|
|
74
|
+
## Install
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install tidyenv
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Python 3.9+.
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
| Reader | Example value | Returns |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `env.str(name)` | `hello` | `str` (stripped) |
|
|
87
|
+
| `env.int(name)` | `8000`, `1_000` | `int` |
|
|
88
|
+
| `env.float(name)` | `0.25` | `float` (`nan` and `inf` are rejected) |
|
|
89
|
+
| `env.bool(name)` | `true/false`, `yes/no`, `on/off`, `1/0`, any case | `bool` |
|
|
90
|
+
| `env.list(name, sep=",", of=str)` | `a, b, c` | `list` (use `of=int` to convert items; `int`, `float` and `bool` follow the rules above) |
|
|
91
|
+
| `env.choice(name, choices)` | `prod` | `str` that must be in `choices` |
|
|
92
|
+
| `env.path(name, must_exist=False)` | `~/data` | `pathlib.Path` with `~` expanded |
|
|
93
|
+
| `env.json(name)` | `{"a": 1}` | parsed JSON |
|
|
94
|
+
|
|
95
|
+
Every reader takes an optional `default`. Without one, a missing or empty variable is
|
|
96
|
+
an error. With one, the default is returned instead, and type checkers know the result
|
|
97
|
+
is `int | <type of default>`.
|
|
98
|
+
|
|
99
|
+
### `.env` files
|
|
100
|
+
|
|
101
|
+
No need for `python-dotenv`:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
env.read_dotenv() # reads ./.env if it exists
|
|
105
|
+
env.read_dotenv("config/dev.env", required=True)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Real environment variables always win over the file, and nothing is written to
|
|
109
|
+
`os.environ`. When you read several files, later ones override earlier ones.
|
|
110
|
+
Supported syntax: `KEY=value`, `export KEY=value`, `# comments`,
|
|
111
|
+
`'single quotes'` (literal) and `"double quotes"` (with `\n`, `\t`, `\"` escapes).
|
|
112
|
+
Each value must fit on one line; for a multi-line value such as a PEM key, write
|
|
113
|
+
`\n` inside double quotes.
|
|
114
|
+
Need just the parser? `tidyenv.parse_dotenv(text)` returns a `dict`.
|
|
115
|
+
|
|
116
|
+
### All errors at once (optional)
|
|
117
|
+
|
|
118
|
+
By default the first bad variable raises `EnvError`. If you'd rather see every
|
|
119
|
+
problem in one go, wrap your reads in `env.collect()`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
with env.collect():
|
|
123
|
+
PORT = env.int("PORT", default=8000)
|
|
124
|
+
DATABASE_URL = env.str("DATABASE_URL")
|
|
125
|
+
API_KEY = env.str("API_KEY", secret=True)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
tidyenv.EnvError: 3 environment problems:
|
|
130
|
+
- PORT: expected an integer (got 'eighty')
|
|
131
|
+
- DATABASE_URL: is not set
|
|
132
|
+
- API_KEY: is not set
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Failed reads return `None` inside the block, so only use the values after it exits.
|
|
136
|
+
|
|
137
|
+
### More
|
|
138
|
+
|
|
139
|
+
**Secrets.** Pass `secret=True` and the raw value is shown as `***` in error messages,
|
|
140
|
+
including errors about single `env.list` items.
|
|
141
|
+
|
|
142
|
+
**Prefixes and custom sources.** Build your own reader:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from tidyenv import Env
|
|
146
|
+
|
|
147
|
+
env = Env(prefix="MYAPP_") # reads MYAPP_PORT for env.int("PORT")
|
|
148
|
+
test_env = Env(environ={"PORT": "1"}) # any mapping, handy in tests
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Handling errors.** `EnvError.problems` is a list of `Problem(name, message)`, so you
|
|
152
|
+
can print them your own way.
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT
|
tidyenv-0.1.0/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/LenaBarretta/tidyenv/main/docs/logo.png" alt="tidyenv logo" width="160">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# tidyenv
|
|
6
|
+
|
|
7
|
+
[](https://pypi.org/project/tidyenv/)
|
|
8
|
+
[](https://pypi.org/project/tidyenv/)
|
|
9
|
+
[](https://github.com/LenaBarretta/tidyenv/actions/workflows/ci.yml)
|
|
10
|
+
[](LICENSE)
|
|
11
|
+
|
|
12
|
+
**Typed environment variables with friendly errors.** Built-in `.env` support. Zero dependencies.
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from tidyenv import env
|
|
16
|
+
|
|
17
|
+
PORT = env.int("PORT", default=8000)
|
|
18
|
+
DEBUG = env.bool("DEBUG", default=False)
|
|
19
|
+
HOSTS = env.list("ALLOWED_HOSTS", default=["localhost"])
|
|
20
|
+
DATABASE_URL = env.str("DATABASE_URL")
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each line converts the type, applies the default, and if something is wrong, says exactly what:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
tidyenv.EnvError: 1 environment problem:
|
|
27
|
+
- PORT: expected an integer (got 'eighty')
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Why
|
|
31
|
+
|
|
32
|
+
| | Plain `os.environ` | tidyenv |
|
|
33
|
+
| --- | --- | --- |
|
|
34
|
+
| Number with a default | `int(os.environ.get("PORT", "8000"))` | `env.int("PORT", default=8000)` |
|
|
35
|
+
| Bad number | `ValueError: invalid literal for int() with base 10: 'eighty'` (which variable?) | `PORT: expected an integer (got 'eighty')` |
|
|
36
|
+
| Missing variable | `KeyError: 'DB_URL'` | `DB_URL: is not set` |
|
|
37
|
+
| Empty value `DB_URL=` | silently `""` | treated as not set |
|
|
38
|
+
| Boolean | `os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")`, and a typo like `ture` silently means `False` | `env.bool("DEBUG", default=False)`, and `ture` is an error |
|
|
39
|
+
| List | `[h.strip() for h in os.environ.get("HOSTS", "").split(",") if h.strip()]` | `env.list("HOSTS")` |
|
|
40
|
+
| List of numbers | the same, plus `int()` on every item | `env.list("PORTS", of=int)` |
|
|
41
|
+
| One of several values | `if mode not in ("dev", "prod"): raise ...` | `env.choice("MODE", ["dev", "prod"])` |
|
|
42
|
+
| Secrets in errors | the value ends up in your logs | `secret=True` shows `***` |
|
|
43
|
+
| `.env` file | `pip install python-dotenv` + `load_dotenv()` | `env.read_dotenv()`, nothing to install |
|
|
44
|
+
| Types for mypy / IDE | `str \| None`, cast it yourself | `env.int` returns `int` |
|
|
45
|
+
| Several broken variables | crash, fix, redeploy, crash on the next one | `env.collect()` lists them all at once |
|
|
46
|
+
|
|
47
|
+
One line per variable, and every error names the variable and says what is wrong with it.
|
|
48
|
+
|
|
49
|
+
## Install
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install tidyenv
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Python 3.9+.
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
| Reader | Example value | Returns |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `env.str(name)` | `hello` | `str` (stripped) |
|
|
62
|
+
| `env.int(name)` | `8000`, `1_000` | `int` |
|
|
63
|
+
| `env.float(name)` | `0.25` | `float` (`nan` and `inf` are rejected) |
|
|
64
|
+
| `env.bool(name)` | `true/false`, `yes/no`, `on/off`, `1/0`, any case | `bool` |
|
|
65
|
+
| `env.list(name, sep=",", of=str)` | `a, b, c` | `list` (use `of=int` to convert items; `int`, `float` and `bool` follow the rules above) |
|
|
66
|
+
| `env.choice(name, choices)` | `prod` | `str` that must be in `choices` |
|
|
67
|
+
| `env.path(name, must_exist=False)` | `~/data` | `pathlib.Path` with `~` expanded |
|
|
68
|
+
| `env.json(name)` | `{"a": 1}` | parsed JSON |
|
|
69
|
+
|
|
70
|
+
Every reader takes an optional `default`. Without one, a missing or empty variable is
|
|
71
|
+
an error. With one, the default is returned instead, and type checkers know the result
|
|
72
|
+
is `int | <type of default>`.
|
|
73
|
+
|
|
74
|
+
### `.env` files
|
|
75
|
+
|
|
76
|
+
No need for `python-dotenv`:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
env.read_dotenv() # reads ./.env if it exists
|
|
80
|
+
env.read_dotenv("config/dev.env", required=True)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Real environment variables always win over the file, and nothing is written to
|
|
84
|
+
`os.environ`. When you read several files, later ones override earlier ones.
|
|
85
|
+
Supported syntax: `KEY=value`, `export KEY=value`, `# comments`,
|
|
86
|
+
`'single quotes'` (literal) and `"double quotes"` (with `\n`, `\t`, `\"` escapes).
|
|
87
|
+
Each value must fit on one line; for a multi-line value such as a PEM key, write
|
|
88
|
+
`\n` inside double quotes.
|
|
89
|
+
Need just the parser? `tidyenv.parse_dotenv(text)` returns a `dict`.
|
|
90
|
+
|
|
91
|
+
### All errors at once (optional)
|
|
92
|
+
|
|
93
|
+
By default the first bad variable raises `EnvError`. If you'd rather see every
|
|
94
|
+
problem in one go, wrap your reads in `env.collect()`:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
with env.collect():
|
|
98
|
+
PORT = env.int("PORT", default=8000)
|
|
99
|
+
DATABASE_URL = env.str("DATABASE_URL")
|
|
100
|
+
API_KEY = env.str("API_KEY", secret=True)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
tidyenv.EnvError: 3 environment problems:
|
|
105
|
+
- PORT: expected an integer (got 'eighty')
|
|
106
|
+
- DATABASE_URL: is not set
|
|
107
|
+
- API_KEY: is not set
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Failed reads return `None` inside the block, so only use the values after it exits.
|
|
111
|
+
|
|
112
|
+
### More
|
|
113
|
+
|
|
114
|
+
**Secrets.** Pass `secret=True` and the raw value is shown as `***` in error messages,
|
|
115
|
+
including errors about single `env.list` items.
|
|
116
|
+
|
|
117
|
+
**Prefixes and custom sources.** Build your own reader:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from tidyenv import Env
|
|
121
|
+
|
|
122
|
+
env = Env(prefix="MYAPP_") # reads MYAPP_PORT for env.int("PORT")
|
|
123
|
+
test_env = Env(environ={"PORT": "1"}) # any mapping, handy in tests
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Handling errors.** `EnvError.problems` is a list of `Problem(name, message)`, so you
|
|
127
|
+
can print them your own way.
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tidyenv"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Typed environment variables with friendly errors and built-in .env support."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Elena Raikova", email = "lenabarretta@gmail.com" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["environment", "env", "config", "settings", "12-factor"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Programming Language :: Python :: 3.14",
|
|
28
|
+
"Typing :: Typed",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://github.com/LenaBarretta/tidyenv"
|
|
33
|
+
Issues = "https://github.com/LenaBarretta/tidyenv/issues"
|
|
34
|
+
|
|
35
|
+
[dependency-groups]
|
|
36
|
+
dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
exclude = ["docs"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.version]
|
|
42
|
+
path = "src/tidyenv/__init__.py"
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
addopts = "-q --cov=tidyenv --cov-report=term-missing --cov-fail-under=100"
|
|
46
|
+
testpaths = ["tests"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py39"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
57
|
+
files = ["src", "tests"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Typed environment variables with friendly errors and built-in .env support."""
|
|
2
|
+
|
|
3
|
+
from tidyenv._dotenv import parse_dotenv
|
|
4
|
+
from tidyenv._env import Env, EnvError, Problem
|
|
5
|
+
|
|
6
|
+
__all__ = ["Env", "EnvError", "Problem", "env", "parse_dotenv"]
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
# Show errors as tidyenv.EnvError, not tidyenv._env.EnvError.
|
|
10
|
+
for _cls in (Env, EnvError, Problem):
|
|
11
|
+
_cls.__module__ = __name__
|
|
12
|
+
|
|
13
|
+
env = Env()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
# The value keeps its leading whitespace: `A= # note` is an empty value plus a comment.
|
|
6
|
+
_LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=(.*?)\s*$")
|
|
7
|
+
_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_dotenv(text: str) -> dict[str, str]:
|
|
11
|
+
"""Parse the contents of a ``.env`` file into a dict.
|
|
12
|
+
|
|
13
|
+
Supports ``KEY=value``, ``export KEY=value``, ``#`` comments, blank lines,
|
|
14
|
+
'single quotes' (taken literally) and "double quotes" (with ``\\n``, ``\\t``,
|
|
15
|
+
``\\"`` and ``\\\\`` escapes). Values must fit on one line.
|
|
16
|
+
Raises ``ValueError`` naming the bad line.
|
|
17
|
+
"""
|
|
18
|
+
values: dict[str, str] = {}
|
|
19
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
20
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
21
|
+
continue
|
|
22
|
+
match = _LINE.match(line)
|
|
23
|
+
if match is None:
|
|
24
|
+
raise ValueError(f"line {lineno}: expected KEY=VALUE")
|
|
25
|
+
key, raw = match.groups()
|
|
26
|
+
try:
|
|
27
|
+
values[key] = _value(raw)
|
|
28
|
+
except ValueError as exc:
|
|
29
|
+
raise ValueError(f"line {lineno}: {exc}") from None
|
|
30
|
+
return values
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _value(raw: str) -> str:
|
|
34
|
+
value = raw.lstrip()
|
|
35
|
+
if value[:1] in ("'", '"'):
|
|
36
|
+
quote = value[0]
|
|
37
|
+
end = _closing_quote(value, quote)
|
|
38
|
+
rest = value[end + 1 :].strip()
|
|
39
|
+
if rest and not rest.startswith("#"):
|
|
40
|
+
raise ValueError(f"unexpected text after closing quote: {rest!r}")
|
|
41
|
+
body = value[1:end]
|
|
42
|
+
if quote == "'":
|
|
43
|
+
return body
|
|
44
|
+
return re.sub(r"\\(.)", lambda m: _ESCAPES.get(m.group(1), m.group(0)), body)
|
|
45
|
+
# Unquoted: an inline comment needs whitespace before the '#'; the space after
|
|
46
|
+
# '=' counts, so `A= # note` is empty while `A=#x` is the literal '#x'.
|
|
47
|
+
return re.split(r"\s+#", raw, maxsplit=1)[0].strip()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _closing_quote(raw: str, quote: str) -> int:
|
|
51
|
+
i = 1
|
|
52
|
+
while i < len(raw):
|
|
53
|
+
if raw[i] == "\\" and quote == '"':
|
|
54
|
+
i += 2
|
|
55
|
+
continue
|
|
56
|
+
if raw[i] == quote:
|
|
57
|
+
return i
|
|
58
|
+
i += 1
|
|
59
|
+
raise ValueError(f"missing closing {quote}")
|