envbase 1.0.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.
- envbase-1.0.0/.github/workflows/ci.yml +40 -0
- envbase-1.0.0/.github/workflows/publish.yml +58 -0
- envbase-1.0.0/.gitignore +7 -0
- envbase-1.0.0/PKG-INFO +112 -0
- envbase-1.0.0/README.md +94 -0
- envbase-1.0.0/pyproject.toml +35 -0
- envbase-1.0.0/sample/example.py +25 -0
- envbase-1.0.0/sample/resources/.env +3 -0
- envbase-1.0.0/sample/resources/.env.prod +4 -0
- envbase-1.0.0/src/envbase/__init__.py +8 -0
- envbase-1.0.0/src/envbase/env_base.py +86 -0
- envbase-1.0.0/tests/test_env_base.py +123 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call:
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
name: pytest (python ${{ matrix.python }})
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
strategy:
|
|
17
|
+
fail-fast: false
|
|
18
|
+
matrix:
|
|
19
|
+
python: ["3.12", "3.13"]
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- uses: astral-sh/setup-uv@v6
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python }}
|
|
26
|
+
activate-environment: true
|
|
27
|
+
|
|
28
|
+
- name: Install
|
|
29
|
+
run: uv pip install -e ".[dev]"
|
|
30
|
+
|
|
31
|
+
- name: Test
|
|
32
|
+
run: pytest --cov --cov-report=xml --cov-report=term-missing
|
|
33
|
+
|
|
34
|
+
- name: Upload coverage
|
|
35
|
+
if: matrix.python == '3.13'
|
|
36
|
+
uses: codecov/codecov-action@v5
|
|
37
|
+
with:
|
|
38
|
+
files: coverage.xml
|
|
39
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
40
|
+
fail_ci_if_error: false
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
uses: ./.github/workflows/ci.yml
|
|
13
|
+
secrets: inherit
|
|
14
|
+
|
|
15
|
+
build:
|
|
16
|
+
name: Build distributions
|
|
17
|
+
needs: test
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- uses: astral-sh/setup-uv@v6
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.13"
|
|
25
|
+
|
|
26
|
+
- name: Check the tag matches the version
|
|
27
|
+
run: |
|
|
28
|
+
version=$(uv run --no-project python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
|
|
29
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
30
|
+
if [ "$version" != "$tag" ]; then
|
|
31
|
+
echo "release tag $GITHUB_REF_NAME does not match pyproject version $version"
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
- name: Build
|
|
36
|
+
run: uv build
|
|
37
|
+
|
|
38
|
+
- uses: actions/upload-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: dist
|
|
41
|
+
path: dist/
|
|
42
|
+
|
|
43
|
+
publish:
|
|
44
|
+
name: Publish to PyPI
|
|
45
|
+
needs: build
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
environment:
|
|
48
|
+
name: pypi
|
|
49
|
+
url: https://pypi.org/p/envbase
|
|
50
|
+
permissions:
|
|
51
|
+
id-token: write
|
|
52
|
+
steps:
|
|
53
|
+
- uses: actions/download-artifact@v4
|
|
54
|
+
with:
|
|
55
|
+
name: dist
|
|
56
|
+
path: dist/
|
|
57
|
+
|
|
58
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
envbase-1.0.0/.gitignore
ADDED
envbase-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: envbase
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Pydantic settings with class name prefixes and per environment dotenv files
|
|
5
|
+
Project-URL: Homepage, https://github.com/parkermmr/envbase
|
|
6
|
+
Project-URL: Issues, https://github.com/parkermmr/envbase/issues
|
|
7
|
+
Author: Parker
|
|
8
|
+
Classifier: Framework :: Pydantic :: 2
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Requires-Dist: pydantic-settings>=2.4
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
[![CI Status][ci-shield]][ci-url]
|
|
20
|
+
[![Coverage][coverage-shield]][coverage-url]
|
|
21
|
+
[![Contributors][contributors-shield]][contributors-url]
|
|
22
|
+
[![Forks][forks-shield]][forks-url]
|
|
23
|
+
[![Stargazers][stars-shield]][stars-url]
|
|
24
|
+
[![Issues][issues-shield]][issues-url]
|
|
25
|
+
|
|
26
|
+
# Envbase
|
|
27
|
+
|
|
28
|
+
Pydantic settings with class name prefixes and per environment dotenv
|
|
29
|
+
files.
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
pip install envbase
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from envbase import EnvBase
|
|
39
|
+
|
|
40
|
+
class RedisConfig(EnvBase):
|
|
41
|
+
uri: str = "localhost"
|
|
42
|
+
port: int = 9010
|
|
43
|
+
ssl: bool = False
|
|
44
|
+
|
|
45
|
+
config = RedisConfig()
|
|
46
|
+
config.uri # REDIS_CONFIG_URI, else resources/.env, else "localhost"
|
|
47
|
+
config.dump() # {"uri": "localhost", "port": 9010, "ssl": False}
|
|
48
|
+
config.json() # same, json safe values (secrets masked, paths as strings)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Prefixes
|
|
52
|
+
|
|
53
|
+
Each field reads `PREFIX_FIELD`. The prefix is the snake case class name
|
|
54
|
+
unless `_prefix` is set. Matching is case insensitive.
|
|
55
|
+
|
|
56
|
+
| Class | `uri` reads |
|
|
57
|
+
|---------------------------------|--------------------|
|
|
58
|
+
| `class RedisConfig(EnvBase)` | `REDIS_CONFIG_URI` |
|
|
59
|
+
| `class HTTPServer(EnvBase)` | `HTTP_SERVER_URI` |
|
|
60
|
+
| `_prefix = "redis"` | `REDIS_URI` |
|
|
61
|
+
| `_prefix = ""` | `URI` |
|
|
62
|
+
|
|
63
|
+
Nested models read `PREFIX_FIELD__SUBFIELD`. Subclasses inherit `_prefix`.
|
|
64
|
+
|
|
65
|
+
## Files
|
|
66
|
+
|
|
67
|
+
Values are loaded from a dotenv file in the resources directory:
|
|
68
|
+
|
|
69
|
+
| `EXECUTION_ENVIRONMENT` | File |
|
|
70
|
+
|-------------------------|--------------------------|
|
|
71
|
+
| unset | `./resources/.env` |
|
|
72
|
+
| `prod` | `./resources/.env.prod` |
|
|
73
|
+
|
|
74
|
+
A missing file is skipped. Keys in the file that belong to other classes
|
|
75
|
+
are ignored, so one file can hold every class's settings.
|
|
76
|
+
|
|
77
|
+
Precedence, highest first: keyword arguments, environment variables, the
|
|
78
|
+
dotenv file, field defaults.
|
|
79
|
+
|
|
80
|
+
## Options
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
class RedisConfig(EnvBase):
|
|
84
|
+
uri: str = "localhost"
|
|
85
|
+
|
|
86
|
+
_prefix = "redis" # default: snake case class name
|
|
87
|
+
_resources = "./config" # default: ./resources (relative to cwd)
|
|
88
|
+
_variable = "STAGE" # default: EXECUTION_ENVIRONMENT
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`RedisConfig.prefix()` and `RedisConfig.file()` show what a class resolves
|
|
92
|
+
to. Everything else is plain pydantic settings: validators, `SecretStr`,
|
|
93
|
+
`model_config`, and the `_env_file` keyword all work as usual.
|
|
94
|
+
|
|
95
|
+
## Tests
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
pytest --cov
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
[ci-shield]: https://img.shields.io/github/actions/workflow/status/parkermmr/envbase/ci.yml?branch=main&style=for-the-badge&label=CI
|
|
102
|
+
[ci-url]: https://github.com/parkermmr/envbase/actions/workflows/ci.yml
|
|
103
|
+
[coverage-shield]: https://img.shields.io/codecov/c/github/parkermmr/envbase?style=for-the-badge
|
|
104
|
+
[coverage-url]: https://codecov.io/gh/parkermmr/envbase
|
|
105
|
+
[contributors-shield]: https://img.shields.io/github/contributors/parkermmr/envbase.svg?style=for-the-badge
|
|
106
|
+
[contributors-url]: https://github.com/parkermmr/envbase/graphs/contributors
|
|
107
|
+
[forks-shield]: https://img.shields.io/github/forks/parkermmr/envbase.svg?style=for-the-badge
|
|
108
|
+
[forks-url]: https://github.com/parkermmr/envbase/network/members
|
|
109
|
+
[stars-shield]: https://img.shields.io/github/stars/parkermmr/envbase.svg?style=for-the-badge
|
|
110
|
+
[stars-url]: https://github.com/parkermmr/envbase/stargazers
|
|
111
|
+
[issues-shield]: https://img.shields.io/github/issues/parkermmr/envbase.svg?style=for-the-badge
|
|
112
|
+
[issues-url]: https://github.com/parkermmr/envbase/issues
|
envbase-1.0.0/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
[![CI Status][ci-shield]][ci-url]
|
|
2
|
+
[![Coverage][coverage-shield]][coverage-url]
|
|
3
|
+
[![Contributors][contributors-shield]][contributors-url]
|
|
4
|
+
[![Forks][forks-shield]][forks-url]
|
|
5
|
+
[![Stargazers][stars-shield]][stars-url]
|
|
6
|
+
[![Issues][issues-shield]][issues-url]
|
|
7
|
+
|
|
8
|
+
# Envbase
|
|
9
|
+
|
|
10
|
+
Pydantic settings with class name prefixes and per environment dotenv
|
|
11
|
+
files.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
pip install envbase
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from envbase import EnvBase
|
|
21
|
+
|
|
22
|
+
class RedisConfig(EnvBase):
|
|
23
|
+
uri: str = "localhost"
|
|
24
|
+
port: int = 9010
|
|
25
|
+
ssl: bool = False
|
|
26
|
+
|
|
27
|
+
config = RedisConfig()
|
|
28
|
+
config.uri # REDIS_CONFIG_URI, else resources/.env, else "localhost"
|
|
29
|
+
config.dump() # {"uri": "localhost", "port": 9010, "ssl": False}
|
|
30
|
+
config.json() # same, json safe values (secrets masked, paths as strings)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Prefixes
|
|
34
|
+
|
|
35
|
+
Each field reads `PREFIX_FIELD`. The prefix is the snake case class name
|
|
36
|
+
unless `_prefix` is set. Matching is case insensitive.
|
|
37
|
+
|
|
38
|
+
| Class | `uri` reads |
|
|
39
|
+
|---------------------------------|--------------------|
|
|
40
|
+
| `class RedisConfig(EnvBase)` | `REDIS_CONFIG_URI` |
|
|
41
|
+
| `class HTTPServer(EnvBase)` | `HTTP_SERVER_URI` |
|
|
42
|
+
| `_prefix = "redis"` | `REDIS_URI` |
|
|
43
|
+
| `_prefix = ""` | `URI` |
|
|
44
|
+
|
|
45
|
+
Nested models read `PREFIX_FIELD__SUBFIELD`. Subclasses inherit `_prefix`.
|
|
46
|
+
|
|
47
|
+
## Files
|
|
48
|
+
|
|
49
|
+
Values are loaded from a dotenv file in the resources directory:
|
|
50
|
+
|
|
51
|
+
| `EXECUTION_ENVIRONMENT` | File |
|
|
52
|
+
|-------------------------|--------------------------|
|
|
53
|
+
| unset | `./resources/.env` |
|
|
54
|
+
| `prod` | `./resources/.env.prod` |
|
|
55
|
+
|
|
56
|
+
A missing file is skipped. Keys in the file that belong to other classes
|
|
57
|
+
are ignored, so one file can hold every class's settings.
|
|
58
|
+
|
|
59
|
+
Precedence, highest first: keyword arguments, environment variables, the
|
|
60
|
+
dotenv file, field defaults.
|
|
61
|
+
|
|
62
|
+
## Options
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
class RedisConfig(EnvBase):
|
|
66
|
+
uri: str = "localhost"
|
|
67
|
+
|
|
68
|
+
_prefix = "redis" # default: snake case class name
|
|
69
|
+
_resources = "./config" # default: ./resources (relative to cwd)
|
|
70
|
+
_variable = "STAGE" # default: EXECUTION_ENVIRONMENT
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`RedisConfig.prefix()` and `RedisConfig.file()` show what a class resolves
|
|
74
|
+
to. Everything else is plain pydantic settings: validators, `SecretStr`,
|
|
75
|
+
`model_config`, and the `_env_file` keyword all work as usual.
|
|
76
|
+
|
|
77
|
+
## Tests
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
pytest --cov
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
[ci-shield]: https://img.shields.io/github/actions/workflow/status/parkermmr/envbase/ci.yml?branch=main&style=for-the-badge&label=CI
|
|
84
|
+
[ci-url]: https://github.com/parkermmr/envbase/actions/workflows/ci.yml
|
|
85
|
+
[coverage-shield]: https://img.shields.io/codecov/c/github/parkermmr/envbase?style=for-the-badge
|
|
86
|
+
[coverage-url]: https://codecov.io/gh/parkermmr/envbase
|
|
87
|
+
[contributors-shield]: https://img.shields.io/github/contributors/parkermmr/envbase.svg?style=for-the-badge
|
|
88
|
+
[contributors-url]: https://github.com/parkermmr/envbase/graphs/contributors
|
|
89
|
+
[forks-shield]: https://img.shields.io/github/forks/parkermmr/envbase.svg?style=for-the-badge
|
|
90
|
+
[forks-url]: https://github.com/parkermmr/envbase/network/members
|
|
91
|
+
[stars-shield]: https://img.shields.io/github/stars/parkermmr/envbase.svg?style=for-the-badge
|
|
92
|
+
[stars-url]: https://github.com/parkermmr/envbase/stargazers
|
|
93
|
+
[issues-shield]: https://img.shields.io/github/issues/parkermmr/envbase.svg?style=for-the-badge
|
|
94
|
+
[issues-url]: https://github.com/parkermmr/envbase/issues
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "envbase"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Pydantic settings with class name prefixes and per environment dotenv files"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [{ name = "Parker" }]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.12",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Framework :: Pydantic :: 2",
|
|
17
|
+
]
|
|
18
|
+
dependencies = ["pydantic-settings>=2.4"]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = ["pytest>=8", "pytest-cov>=5"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/parkermmr/envbase"
|
|
25
|
+
Issues = "https://github.com/parkermmr/envbase/issues"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/envbase"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
|
|
33
|
+
[tool.coverage.run]
|
|
34
|
+
source = ["envbase"]
|
|
35
|
+
branch = true
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from pydantic import SecretStr
|
|
2
|
+
|
|
3
|
+
from envbase import EnvBase
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RedisConfig(EnvBase):
|
|
7
|
+
"""Reads REDIS_CONFIG_URI, REDIS_CONFIG_PORT and REDIS_CONFIG_SSL."""
|
|
8
|
+
|
|
9
|
+
uri: str = "localhost"
|
|
10
|
+
port: int = 9010
|
|
11
|
+
ssl: bool = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Postgres(EnvBase):
|
|
15
|
+
"""Reads PG_HOST and PG_PASSWORD."""
|
|
16
|
+
|
|
17
|
+
host: str = "localhost"
|
|
18
|
+
password: SecretStr = SecretStr("")
|
|
19
|
+
|
|
20
|
+
_prefix = "pg"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
print(RedisConfig.file(), RedisConfig().dump())
|
|
25
|
+
print(Postgres.file(), Postgres().json())
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
EnvBase is a pydantic settings model with class name prefixes and
|
|
3
|
+
per environment dotenv files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from os import environ
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from re import sub
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def snake(name: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
Converts a class name to snake case, keeping acronyms together.
|
|
17
|
+
"""
|
|
18
|
+
return sub(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])", "_", name).lower()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EnvBase(BaseSettings):
|
|
22
|
+
"""
|
|
23
|
+
Settings read from the environment and a dotenv file.
|
|
24
|
+
|
|
25
|
+
Each field reads the variable PREFIX_FIELD, where the prefix is the
|
|
26
|
+
snake case class name (RedisConfig.uri reads REDIS_CONFIG_URI) or
|
|
27
|
+
_prefix when set (_prefix = "redis" reads REDIS_URI). An empty
|
|
28
|
+
_prefix reads bare field names.
|
|
29
|
+
|
|
30
|
+
The dotenv file is .env in the _resources directory, or
|
|
31
|
+
.env.{name} when the variable named by _variable holds a name.
|
|
32
|
+
Keyword arguments win over the environment, the environment wins
|
|
33
|
+
over the file, and the file wins over defaults.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
model_config = SettingsConfigDict(
|
|
37
|
+
extra="ignore",
|
|
38
|
+
env_nested_delimiter="__",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
_prefix: str | None = None
|
|
42
|
+
_resources: str = "./resources"
|
|
43
|
+
_variable: str = "EXECUTION_ENVIRONMENT"
|
|
44
|
+
|
|
45
|
+
def __init__(self, **values: Any) -> None:
|
|
46
|
+
values.setdefault("_env_prefix", type(self).prefix())
|
|
47
|
+
values.setdefault("_env_file", type(self).file())
|
|
48
|
+
super().__init__(**values)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def option(cls, name: str) -> Any:
|
|
52
|
+
"""
|
|
53
|
+
Returns the class level value of an underscore option.
|
|
54
|
+
"""
|
|
55
|
+
return cls.__private_attributes__[name].default
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def prefix(cls) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Returns the variable prefix, with its trailing underscore.
|
|
61
|
+
"""
|
|
62
|
+
prefix = cls.option("_prefix")
|
|
63
|
+
if prefix is None:
|
|
64
|
+
prefix = snake(cls.__name__)
|
|
65
|
+
return f"{prefix.upper()}_" if prefix else ""
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def file(cls) -> Path:
|
|
69
|
+
"""
|
|
70
|
+
Returns the dotenv file for the current execution environment.
|
|
71
|
+
"""
|
|
72
|
+
name = environ.get(cls.option("_variable"))
|
|
73
|
+
resources = Path(cls.option("_resources"))
|
|
74
|
+
return resources / (f".env.{name}" if name else ".env")
|
|
75
|
+
|
|
76
|
+
def dump(self, **options: Any) -> dict[str, Any]:
|
|
77
|
+
"""
|
|
78
|
+
Returns the fields as a dictionary of python values.
|
|
79
|
+
"""
|
|
80
|
+
return self.model_dump(**options)
|
|
81
|
+
|
|
82
|
+
def json(self, **options: Any) -> dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Returns the fields as a dictionary of json safe values.
|
|
85
|
+
"""
|
|
86
|
+
return self.model_dump(mode="json", **options)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from pydantic import SecretStr
|
|
5
|
+
|
|
6
|
+
from envbase import EnvBase
|
|
7
|
+
from envbase.env_base import snake
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RedisConfig(EnvBase):
|
|
11
|
+
uri: str = "localhost"
|
|
12
|
+
port: int = 9010
|
|
13
|
+
ssl: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Redis(EnvBase):
|
|
17
|
+
uri: str = "localhost"
|
|
18
|
+
port: int = 9010
|
|
19
|
+
_prefix = "redis"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Bare(EnvBase):
|
|
23
|
+
uri: str = "localhost"
|
|
24
|
+
_prefix = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Custom(EnvBase):
|
|
28
|
+
uri: str = "localhost"
|
|
29
|
+
password: SecretStr = SecretStr("")
|
|
30
|
+
path: Path = Path("/tmp")
|
|
31
|
+
_prefix = "custom"
|
|
32
|
+
_resources = "config"
|
|
33
|
+
_variable = "STAGE"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture(autouse=True)
|
|
37
|
+
def workspace(tmp_path, monkeypatch):
|
|
38
|
+
for name in ("EXECUTION_ENVIRONMENT", "STAGE", "REDIS_URI", "REDIS_CONFIG_URI", "URI"):
|
|
39
|
+
monkeypatch.delenv(name, raising=False)
|
|
40
|
+
monkeypatch.chdir(tmp_path)
|
|
41
|
+
(tmp_path / "resources").mkdir()
|
|
42
|
+
return tmp_path
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write(path: Path, text: str) -> None:
|
|
46
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
path.write_text(text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_snake():
|
|
51
|
+
assert snake("RedisConfig") == "redis_config"
|
|
52
|
+
assert snake("HTTPServer") == "http_server"
|
|
53
|
+
assert snake("S3Store") == "s3_store"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_defaults():
|
|
57
|
+
assert RedisConfig().dump() == {"uri": "localhost", "port": 9010, "ssl": False}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_class_name_prefix(monkeypatch):
|
|
61
|
+
monkeypatch.setenv("REDIS_CONFIG_URI", "cache")
|
|
62
|
+
monkeypatch.setenv("REDIS_CONFIG_PORT", "6379")
|
|
63
|
+
monkeypatch.setenv("REDIS_CONFIG_SSL", "true")
|
|
64
|
+
assert RedisConfig().dump() == {"uri": "cache", "port": 6379, "ssl": True}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_explicit_prefix(monkeypatch):
|
|
68
|
+
monkeypatch.setenv("REDIS_URI", "cache")
|
|
69
|
+
assert Redis().uri == "cache"
|
|
70
|
+
assert RedisConfig().uri == "localhost"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_empty_prefix(monkeypatch):
|
|
74
|
+
monkeypatch.setenv("URI", "cache")
|
|
75
|
+
assert Bare().uri == "cache"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_default_file(workspace):
|
|
79
|
+
write(workspace / "resources/.env", "REDIS_URI=fromfile\nOTHER_KEY=ignored\n")
|
|
80
|
+
assert Redis().uri == "fromfile"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_environment_file(workspace, monkeypatch):
|
|
84
|
+
write(workspace / "resources/.env", "REDIS_URI=base\n")
|
|
85
|
+
write(workspace / "resources/.env.prod", "REDIS_URI=prod\n")
|
|
86
|
+
monkeypatch.setenv("EXECUTION_ENVIRONMENT", "prod")
|
|
87
|
+
assert Redis().uri == "prod"
|
|
88
|
+
assert Redis.file() == Path("resources/.env.prod")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_missing_file(monkeypatch):
|
|
92
|
+
monkeypatch.setenv("EXECUTION_ENVIRONMENT", "nowhere")
|
|
93
|
+
assert Redis().uri == "localhost"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_precedence(workspace, monkeypatch):
|
|
97
|
+
write(workspace / "resources/.env", "REDIS_URI=file\nREDIS_PORT=1\n")
|
|
98
|
+
monkeypatch.setenv("REDIS_URI", "environ")
|
|
99
|
+
assert Redis().dump() == {"uri": "environ", "port": 1}
|
|
100
|
+
assert Redis(uri="keyword").uri == "keyword"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_custom_options(workspace, monkeypatch):
|
|
104
|
+
write(workspace / "config/.env.dev", "CUSTOM_URI=dev\n")
|
|
105
|
+
monkeypatch.setenv("STAGE", "dev")
|
|
106
|
+
assert Custom().uri == "dev"
|
|
107
|
+
assert Custom()._prefix == "custom"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_subclass_inherits_options(monkeypatch):
|
|
111
|
+
class Replica(Redis):
|
|
112
|
+
replicas: int = 1
|
|
113
|
+
|
|
114
|
+
monkeypatch.setenv("REDIS_REPLICAS", "3")
|
|
115
|
+
assert Replica().replicas == 3
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_dump_and_json():
|
|
119
|
+
config = Custom(password="secret")
|
|
120
|
+
assert config.dump()["password"].get_secret_value() == "secret"
|
|
121
|
+
assert config.dump()["path"] == Path("/tmp")
|
|
122
|
+
assert config.json() == {"uri": "localhost", "password": "**********", "path": "/tmp"}
|
|
123
|
+
assert config.json(include={"uri"}) == {"uri": "localhost"}
|