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.
@@ -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
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ coverage.xml
6
+ .coverage
7
+ dist/
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
@@ -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,3 @@
1
+ REDIS_CONFIG_URI=redis.local
2
+ PG_HOST=db.local
3
+ PG_PASSWORD=local
@@ -0,0 +1,4 @@
1
+ REDIS_CONFIG_URI=redis.prod
2
+ REDIS_CONFIG_SSL=true
3
+ PG_HOST=db.prod
4
+ PG_PASSWORD=prod
@@ -0,0 +1,8 @@
1
+ """
2
+ envbase: pydantic settings with class name prefixes and per
3
+ environment dotenv files.
4
+ """
5
+
6
+ from envbase.env_base import EnvBase
7
+
8
+ __all__ = ["EnvBase"]
@@ -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"}