django-environment-config 0.0.2__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,51 @@
1
+ MIT License
2
+
3
+ Copyright (c) Matti Lamppu
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.
22
+
23
+ ---------------- Original Licence for `django-configurations` ----------------
24
+
25
+ Copyright (c) 2012-2023, Jannis Leidel and other contributors.
26
+ All rights reserved.
27
+
28
+ Redistribution and use in source and binary forms, with or without modification,
29
+ are permitted provided that the following conditions are met:
30
+
31
+ 1. Redistributions of source code must retain the above copyright notice,
32
+ this list of conditions and the following disclaimer.
33
+
34
+ 2. Redistributions in binary form must reproduce the above copyright
35
+ notice, this list of conditions and the following disclaimer in the
36
+ documentation and/or other materials provided with the distribution.
37
+
38
+ 3. Neither the name of django-configurations nor the names of its
39
+ contributors may be used to endorse or promote products derived from
40
+ this software without specific prior written permission.
41
+
42
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
43
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
44
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
45
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
46
+ ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
47
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
48
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
49
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
50
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
51
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.1
2
+ Name: django-environment-config
3
+ Version: 0.0.2
4
+ Summary: Configure django settings for multiple environments.
5
+ Home-page: https://mrthearman.github.io/django-environment-config
6
+ License: MIT
7
+ Keywords: django,configuration,settings,environment,env,config
8
+ Author: Matti Lamppu
9
+ Author-email: lamppu.matti.akseli@gmail.com
10
+ Requires-Python: >=3.10,<4
11
+ Classifier: Environment :: Web Environment
12
+ Classifier: Framework :: Django :: 4.2
13
+ Classifier: Framework :: Django :: 5.0
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Natural Language :: English
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3 :: Only
24
+ Provides-Extra: cache
25
+ Provides-Extra: db
26
+ Requires-Dist: Django (>=4.2)
27
+ Requires-Dist: dj-database-url (>=2.1.0,<3.0.0) ; extra == "db"
28
+ Requires-Dist: django-cache-url (>=3.4.5,<4.0.0) ; extra == "cache"
29
+ Requires-Dist: python-dotenv (>=1.0.1)
30
+ Requires-Dist: typing-extensions (>=4.11.0) ; python_version < "3.12"
31
+ Project-URL: Bug Tracker, https://github.com/MrThearMan/django-environment-config/issues
32
+ Project-URL: Repository, https://github.com/MrThearMan/django-environment-config
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Django Environment Config
36
+
37
+ [![Coverage Status][coverage-badge]][coverage]
38
+ [![GitHub Workflow Status][status-badge]][status]
39
+ [![PyPI][pypi-badge]][pypi]
40
+ [![GitHub][licence-badge]][licence]
41
+ [![GitHub Last Commit][repo-badge]][repo]
42
+ [![GitHub Issues][issues-badge]][issues]
43
+ [![Downloads][downloads-badge]][pypi]
44
+ [![Python Version][version-badge]][pypi]
45
+
46
+ ```shell
47
+ pip install django-environment-config
48
+ ```
49
+
50
+ ---
51
+
52
+ **Documentation**: [https://mrthearman.github.io/django-environment-config/](https://mrthearman.github.io/django-environment-config/)
53
+
54
+ **Source Code**: [https://github.com/MrThearMan/django-environment-config/](https://github.com/MrThearMan/django-environment-config/)
55
+
56
+ **Contributing**: [https://github.com/MrThearMan/django-environment-config/blob/main/CONTRIBUTING.md](https://github.com/MrThearMan/django-environment-config/blob/main/CONTRIBUTING.md)
57
+
58
+ ---
59
+
60
+ Configure django settings for multiple environments.
61
+
62
+ [coverage-badge]: https://coveralls.io/repos/github/MrThearMan/django-environment-config/badge.svg?branch=main
63
+ [status-badge]: https://img.shields.io/github/actions/workflow/status/MrThearMan/django-environment-config/test.yml?branch=main
64
+ [pypi-badge]: https://img.shields.io/pypi/v/django-environment-config
65
+ [licence-badge]: https://img.shields.io/github/license/MrThearMan/django-environment-config
66
+ [repo-badge]: https://img.shields.io/github/last-commit/MrThearMan/django-environment-config
67
+ [issues-badge]: https://img.shields.io/github/issues-raw/MrThearMan/django-environment-config
68
+ [version-badge]: https://img.shields.io/pypi/pyversions/django-environment-config
69
+ [downloads-badge]: https://img.shields.io/pypi/dm/django-environment-config
70
+
71
+ [coverage]: https://coveralls.io/github/MrThearMan/django-environment-config?branch=main
72
+ [status]: https://github.com/MrThearMan/django-environment-config/actions/workflows/test.yml
73
+ [pypi]: https://pypi.org/project/django-environment-config
74
+ [licence]: https://github.com/MrThearMan/django-environment-config/blob/main/LICENSE
75
+ [repo]: https://github.com/MrThearMan/django-environment-config/commits/main
76
+ [issues]: https://github.com/MrThearMan/django-environment-config/issues
77
+
@@ -0,0 +1,42 @@
1
+ # Django Environment Config
2
+
3
+ [![Coverage Status][coverage-badge]][coverage]
4
+ [![GitHub Workflow Status][status-badge]][status]
5
+ [![PyPI][pypi-badge]][pypi]
6
+ [![GitHub][licence-badge]][licence]
7
+ [![GitHub Last Commit][repo-badge]][repo]
8
+ [![GitHub Issues][issues-badge]][issues]
9
+ [![Downloads][downloads-badge]][pypi]
10
+ [![Python Version][version-badge]][pypi]
11
+
12
+ ```shell
13
+ pip install django-environment-config
14
+ ```
15
+
16
+ ---
17
+
18
+ **Documentation**: [https://mrthearman.github.io/django-environment-config/](https://mrthearman.github.io/django-environment-config/)
19
+
20
+ **Source Code**: [https://github.com/MrThearMan/django-environment-config/](https://github.com/MrThearMan/django-environment-config/)
21
+
22
+ **Contributing**: [https://github.com/MrThearMan/django-environment-config/blob/main/CONTRIBUTING.md](https://github.com/MrThearMan/django-environment-config/blob/main/CONTRIBUTING.md)
23
+
24
+ ---
25
+
26
+ Configure django settings for multiple environments.
27
+
28
+ [coverage-badge]: https://coveralls.io/repos/github/MrThearMan/django-environment-config/badge.svg?branch=main
29
+ [status-badge]: https://img.shields.io/github/actions/workflow/status/MrThearMan/django-environment-config/test.yml?branch=main
30
+ [pypi-badge]: https://img.shields.io/pypi/v/django-environment-config
31
+ [licence-badge]: https://img.shields.io/github/license/MrThearMan/django-environment-config
32
+ [repo-badge]: https://img.shields.io/github/last-commit/MrThearMan/django-environment-config
33
+ [issues-badge]: https://img.shields.io/github/issues-raw/MrThearMan/django-environment-config
34
+ [version-badge]: https://img.shields.io/pypi/pyversions/django-environment-config
35
+ [downloads-badge]: https://img.shields.io/pypi/dm/django-environment-config
36
+
37
+ [coverage]: https://coveralls.io/github/MrThearMan/django-environment-config?branch=main
38
+ [status]: https://github.com/MrThearMan/django-environment-config/actions/workflows/test.yml
39
+ [pypi]: https://pypi.org/project/django-environment-config
40
+ [licence]: https://github.com/MrThearMan/django-environment-config/blob/main/LICENSE
41
+ [repo]: https://github.com/MrThearMan/django-environment-config/commits/main
42
+ [issues]: https://github.com/MrThearMan/django-environment-config/issues
@@ -0,0 +1,5 @@
1
+ from .base import Environment
2
+
3
+ __all__ = [
4
+ "Environment",
5
+ ]
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import os
5
+ from typing import TYPE_CHECKING
6
+
7
+ from django.utils.functional import classproperty
8
+ from dotenv import dotenv_values
9
+
10
+ from .constants import Undefined
11
+
12
+ if TYPE_CHECKING:
13
+ from dotenv.main import StrPath
14
+
15
+ from .typing import Any
16
+
17
+ __all__ = [
18
+ "Environment",
19
+ ]
20
+
21
+
22
+ class Environment:
23
+ """Configures a single environment."""
24
+
25
+ def __init_subclass__(
26
+ cls,
27
+ *,
28
+ dotenv_path: StrPath | None | Undefined = Undefined,
29
+ ) -> None:
30
+ env: str | None = os.environ.get("DJANGO_SETTINGS_ENVIRONMENT")
31
+ if env is None: # pragma: no cover
32
+ msg = f"Environment variable 'DJANGO_SETTINGS_ENVIRONMENT' is not set before subclassing {cls.__name__!r}"
33
+ raise ValueError(msg)
34
+
35
+ if cls.__name__ != env:
36
+ return
37
+
38
+ # If not given, set it to `None` so that `python-dotenv` will use
39
+ # `dotenv.main.find_dotenv` to find the `.env` file automatically.
40
+ if dotenv_path is Undefined:
41
+ dotenv_path = None
42
+ # If set to `None` explicitly, do not load a `.env` file.
43
+ elif dotenv_path is None:
44
+ dotenv_path = Undefined
45
+
46
+ dotenv = cls.load_dotenv(dotenv_path=dotenv_path) if dotenv_path is not Undefined else Undefined
47
+
48
+ # Do name mangling to avoid overriding the attribute from a parent class.
49
+ # This way, we can have multiple environments with different `.env` files,
50
+ # and allow using values from a parent `.env` file as defaults (if desired).
51
+ setattr(cls, f"_{cls.__name__}__dotenv", dotenv)
52
+ setattr(cls, f"_{cls.__name__}__dotenv_path", dotenv_path)
53
+
54
+ cls.setup(stack_level=2)
55
+ cls.post_setup()
56
+
57
+ @staticmethod
58
+ def load_dotenv(*, dotenv_path: StrPath | None = None) -> dict[str, str]:
59
+ """Load the `.env` file and return the values."""
60
+ return dotenv_values(dotenv_path=dotenv_path)
61
+
62
+ @classmethod
63
+ def setup(cls, *, stack_level: int = 1) -> None:
64
+ """Load settings and set them in the module globals where the environment is defined."""
65
+ settings = cls.load_settings()
66
+ stack = inspect.stack()
67
+ caller_globals: dict[str, Any] = stack[stack_level].frame.f_globals
68
+ caller_globals.update(**settings)
69
+
70
+ @classmethod
71
+ def post_setup(cls) -> None:
72
+ """Hook for doing additional setup after the settings have been loaded."""
73
+
74
+ @classmethod
75
+ def load_settings(cls) -> dict[str, Any]:
76
+ """Load the settings from the environment, validating and returning them."""
77
+ return {name: getattr(cls, name) for name in dir(cls) if name.isupper() and not name.startswith("_")}
78
+
79
+ @classproperty
80
+ def dotenv(cls) -> dict[str, str] | Undefined: # noqa: N805
81
+ return getattr(cls, f"_{cls.__name__}__dotenv", Undefined)
82
+
83
+ @classproperty
84
+ def dotenv_path(cls) -> str | None | Undefined: # noqa: N805
85
+ return getattr(cls, f"_{cls.__name__}__dotenv_path", Undefined)
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ __all__ = [
4
+ "Undefined",
5
+ ]
6
+
7
+
8
+ class Undefined: # pragma: no cover
9
+ """Value to represent an undefined value."""
10
+
11
+ def __repr__(self) -> str:
12
+ return self.__class__.__name__
13
+
14
+ def __bool__(self) -> bool:
15
+ return False
16
+
17
+
18
+ Undefined = Undefined()
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from env_config.constants import Undefined
6
+
7
+ if TYPE_CHECKING:
8
+ from env_config import Environment
9
+
10
+
11
+ __all__ = [
12
+ "DjangoEnvConfigError",
13
+ "MissingEnvValueError",
14
+ ]
15
+
16
+
17
+ class DjangoEnvConfigError(Exception):
18
+ """Base class for all Django Environment Config errors."""
19
+
20
+
21
+ class MissingEnvValueError(DjangoEnvConfigError):
22
+ """Error raised when a value is not found in the .env file, and a default is not provided."""
23
+
24
+ def __init__(self, *, name: str, env: type[Environment]) -> None:
25
+ msg = f"Value {name!r} in environment {env.__name__!r}"
26
+ if env.dotenv_path is not Undefined:
27
+ msg += " not defined in the .env file and value does not have a default"
28
+ else:
29
+ msg += " needs a default value since environment does not define a `dotenv_path`"
30
+
31
+ super().__init__(msg)
File without changes
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import Any, Callable, Generator, Generic, Mapping, ParamSpec, Sequence, TypedDict, TypeVar
5
+
6
+ if sys.version_info < (3, 12): # pragma: no cover
7
+ from typing_extensions import Unpack
8
+ else:
9
+ from typing import Unpack
10
+
11
+ __all__ = [
12
+ "Any",
13
+ "CacheConfig",
14
+ "Callable",
15
+ "DBConfig",
16
+ "DBConfigExtra",
17
+ "Generator",
18
+ "Generic",
19
+ "Mapping",
20
+ "ParamSpec",
21
+ "Sequence",
22
+ "TypeVar",
23
+ "Unpack",
24
+ ]
25
+
26
+
27
+ class DBConfig(TypedDict, total=False):
28
+ ATOMIC_REQUESTS: bool
29
+ AUTOCOMMIT: bool
30
+ CONN_MAX_AGE: int | None
31
+ CONN_HEALTH_CHECKS: bool
32
+ DISABLE_SERVER_SIDE_CURSORS: bool
33
+ ENGINE: str
34
+ HOST: str
35
+ NAME: str
36
+ OPTIONS: dict[str, Any] | None
37
+ PASSWORD: str
38
+ PORT: str | int
39
+ TEST: dict[str, Any]
40
+ TIME_ZONE: str
41
+ USER: str
42
+
43
+
44
+ class DBConfigExtra(TypedDict, total=False):
45
+ engine: str | None
46
+ conn_max_age: int | None
47
+ conn_health_checks: bool
48
+ ssl_require: bool
49
+ test_options: dict | None
50
+
51
+
52
+ class CacheConfig(TypedDict, total=False):
53
+ BACKEND: str
54
+ KEY_FUNCTION: str
55
+ KEY_PREFIX: str
56
+ LOCATION: str
57
+ OPTIONS: dict[str, Any]
58
+ TIMEOUT: int
59
+ VERSION: int
@@ -0,0 +1,379 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from abc import ABC, abstractmethod
5
+ from collections import defaultdict
6
+ from decimal import Decimal
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django.utils.module_loading import import_string
11
+
12
+ from .constants import Undefined
13
+ from .errors import MissingEnvValueError
14
+ from .typing import Any, CacheConfig, DBConfig, DBConfigExtra, Generator, Generic, Mapping, Sequence, TypeVar, Unpack
15
+
16
+ if TYPE_CHECKING:
17
+ from .base import Environment
18
+
19
+
20
+ __all__ = [
21
+ "BooleanValue",
22
+ "CacheURLValue",
23
+ "DatabaseURLValue",
24
+ "DecimalValue",
25
+ "DictValue",
26
+ "EmailValue",
27
+ "FloatValue",
28
+ "IPValue",
29
+ "ImportStringValue",
30
+ "IntegerValue",
31
+ "JsonValue",
32
+ "ListValue",
33
+ "MappingValue",
34
+ "PathValue",
35
+ "PositiveIntegerValue",
36
+ "RegexValue",
37
+ "SequenceValue",
38
+ "SetValue",
39
+ "StringValue",
40
+ "TupleValue",
41
+ "URLValue",
42
+ "Value",
43
+ ]
44
+
45
+
46
+ T = TypeVar("T")
47
+
48
+
49
+ class Value(ABC, Generic[T]):
50
+ def __init__(
51
+ self,
52
+ *,
53
+ default: T | None = Undefined,
54
+ env_name: str | Undefined = Undefined,
55
+ nullable: bool = False,
56
+ ) -> None:
57
+ """
58
+ Value descriptor for an environment variable.
59
+
60
+ :param default: The default value to use if the environment variable is not set.
61
+ :param env_name: The name of the environment variable to use. If not given, the name of the field is used.
62
+ """
63
+ self.default: T | None = default
64
+ self.name: str = env_name
65
+ self.nullable: bool = nullable
66
+
67
+ # Use a map to store the value per environment so that we can have
68
+ # different values for environments what inherit from each other.
69
+ self.value_by_environment: dict[type[Environment], Any] = defaultdict(lambda: Undefined)
70
+ super().__init__()
71
+
72
+ def __set_name__(self, env: type[Environment], name: str) -> None:
73
+ """Called after the owner Environment-class is created with this field as a class attribute."""
74
+ if self.name is Undefined:
75
+ self.name = name
76
+
77
+ def __get__(self, _: Environment | None, env: type[Environment]) -> T:
78
+ """Called when accessing the field on the class or an instance of the class."""
79
+ if self.value_by_environment[env] is not Undefined:
80
+ return self.value_by_environment[env]
81
+
82
+ self.value_by_environment[env] = self.get_for_environment(env)
83
+ return self.value_by_environment[env]
84
+
85
+ def get_for_environment(self, env: type[Environment]) -> T:
86
+ value = self.default if env.dotenv is Undefined else env.dotenv.get(self.name, self.default)
87
+ if value is Undefined:
88
+ raise MissingEnvValueError(name=self.name, env=env)
89
+
90
+ if value is None:
91
+ if self.nullable:
92
+ return None
93
+ msg = f"Value for {self.name!r} cannot be None"
94
+ raise ValueError(msg)
95
+
96
+ return self.convert(value)
97
+
98
+ @abstractmethod
99
+ def convert(self, value: str | T) -> T: # pragma: no cover
100
+ """Convert the given value into the proper representation."""
101
+ raise NotImplementedError
102
+
103
+
104
+ class StringValue(Value[str]):
105
+ """Parses env variables into a string value."""
106
+
107
+ def convert(self, value: str) -> str:
108
+ return value
109
+
110
+
111
+ class BooleanValue(Value[bool]):
112
+ """Parses env variables into a boolean value."""
113
+
114
+ def convert(self, value: str | bool) -> bool: # noqa: FBT001
115
+ if isinstance(value, bool):
116
+ return value
117
+ normalized_value = value.strip().lower()
118
+ if normalized_value in ("yes", "y", "true", "1"):
119
+ return True
120
+ if normalized_value in ("no", "n", "false", "0", ""):
121
+ return False
122
+ msg = f"Cannot interpret {value!r} as a boolean value"
123
+ raise ValueError(msg)
124
+
125
+
126
+ class IntegerValue(Value[int]):
127
+ """Parses env variables into an integer value."""
128
+
129
+ def convert(self, value: str | int) -> int:
130
+ return int(value)
131
+
132
+
133
+ class PositiveIntegerValue(IntegerValue):
134
+ """Parses env variables into an integer value, and validates that the value is positive."""
135
+
136
+ def convert(self, value: str | int) -> int:
137
+ val = super().convert(value)
138
+ if val < 0:
139
+ msg = f"Value must be positive, got {val}"
140
+ raise ValueError(msg)
141
+ return val
142
+
143
+
144
+ class FloatValue(Value[float]):
145
+ """Parses env variables into a float value."""
146
+
147
+ def convert(self, value: str | float) -> float:
148
+ return float(value)
149
+
150
+
151
+ class DecimalValue(Value[Decimal], Decimal):
152
+ """Parses env variables into a Decimal value."""
153
+
154
+ def convert(self, value: str | Decimal) -> Decimal:
155
+ return Decimal(value)
156
+
157
+
158
+ class ImportStringValue(Value[str]):
159
+ """Parses env variables into a string value, and validates that the value is an importable string."""
160
+
161
+ def convert(self, value: str) -> str:
162
+ import_string(value)
163
+ return value
164
+
165
+
166
+ class SequenceValue(Value, ABC, Generic[T]):
167
+ def __init__( # noqa: PLR0913
168
+ self,
169
+ child: Value[T] | None = None,
170
+ *,
171
+ default: Sequence[T] | None = Undefined,
172
+ env_name: str | Undefined = Undefined,
173
+ nullable: bool = False,
174
+ delimiter: str = ",",
175
+ ) -> None:
176
+ self.child = child or StringValue()
177
+ self.delimiter = delimiter
178
+ super().__init__(default=default, env_name=env_name, nullable=nullable)
179
+
180
+ def iterate(self, value: str | Sequence[Any]) -> Generator[T, None, None]:
181
+ seq = value.split(self.delimiter) if isinstance(value, str) else value
182
+ for item in seq:
183
+ if not item:
184
+ continue
185
+
186
+ yield self.child.convert(item.strip())
187
+
188
+
189
+ class ListValue(SequenceValue):
190
+ """Parses env variables like `item1,item2,item3` into a list."""
191
+
192
+ def convert(self, value: str | list[Any]) -> list[Any]:
193
+ return list(self.iterate(value))
194
+
195
+
196
+ class TupleValue(SequenceValue):
197
+ """Parses env variables like `item1,item2,item3` into a tuple."""
198
+
199
+ def convert(self, value: str | tuple[Any, ...]) -> tuple[Any, ...]:
200
+ return tuple(self.iterate(value))
201
+
202
+
203
+ class SetValue(SequenceValue):
204
+ """Parses env variables like `item1,item2,item3` into a set."""
205
+
206
+ def convert(self, value: str | set[str]) -> set[Any]:
207
+ return set(self.iterate(value))
208
+
209
+
210
+ class MappingValue(Value, ABC, Generic[T]):
211
+ def __init__( # noqa: PLR0913
212
+ self,
213
+ child: Value[T] | None = None,
214
+ *,
215
+ default: Mapping[str, T] | None = Undefined,
216
+ env_name: str | Undefined = Undefined,
217
+ nullable: bool = False,
218
+ kv_delimiter: str = "=",
219
+ item_delimiter: str = ";",
220
+ ) -> None:
221
+ self.child = child or StringValue()
222
+ self.kv_delimiter = kv_delimiter
223
+ self.item_delimiter = item_delimiter
224
+ super().__init__(default=default, env_name=env_name, nullable=nullable)
225
+
226
+ def iterate(self, value: str | Mapping[str, Any]) -> Generator[tuple[str, Any], None, None]:
227
+ seq = value.split(self.item_delimiter) if isinstance(value, str) else value.items()
228
+
229
+ for item in seq:
230
+ if not item:
231
+ continue
232
+
233
+ if isinstance(item, str):
234
+ kv = item.split(self.kv_delimiter, 1)
235
+ if len(kv) != 2: # noqa: PLR2004
236
+ msg = f"Cannot split key-value pair from {item!r}"
237
+ raise ValueError(msg)
238
+ else:
239
+ kv = item
240
+
241
+ yield kv[0].strip(), self.child.convert(kv[1].strip())
242
+
243
+
244
+ class DictValue(MappingValue):
245
+ """Parses env variables like `key1=value1;key2=value2` into a dict."""
246
+
247
+ def convert(self, value: str | dict[str, Any]) -> dict[str, Any]:
248
+ return dict(self.iterate(value))
249
+
250
+
251
+ class JsonValue(Value[dict | list]):
252
+ """Parses env variables from a json string to a python list or dict."""
253
+
254
+ def convert(self, value: str | list | dict) -> list | dict:
255
+ if isinstance(value, (list, dict)):
256
+ return value
257
+ return json.loads(value)
258
+
259
+
260
+ class EmailValue(StringValue):
261
+ """Parses env variables into a string value, and validates that it's a valid email address."""
262
+
263
+ def convert(self, value: str) -> str:
264
+ from django.core.validators import validate_email
265
+
266
+ validate_email(value)
267
+ return value
268
+
269
+
270
+ class URLValue(StringValue):
271
+ """Parses env variables into a string value, and validates that it's a valid URL."""
272
+
273
+ def convert(self, value: str) -> str:
274
+ from django.core.validators import URLValidator
275
+
276
+ URLValidator()(value)
277
+ return value
278
+
279
+
280
+ class IPValue(StringValue):
281
+ """Parses env variables into a string value, and validates that it's a valid IP address."""
282
+
283
+ def convert(self, value: str) -> str:
284
+ from django.core.validators import validate_ipv46_address
285
+
286
+ validate_ipv46_address(value)
287
+ return value
288
+
289
+
290
+ class RegexValue(StringValue):
291
+ """Parses env variables into a string value, and validates that it matches the given regex."""
292
+
293
+ def __init__(
294
+ self,
295
+ *,
296
+ regex: str,
297
+ default: str | None = Undefined,
298
+ nullable: bool = False,
299
+ env_name: str | Undefined = Undefined,
300
+ ) -> None:
301
+ self.regex = regex
302
+ super().__init__(default=default, env_name=env_name, nullable=nullable)
303
+
304
+ def convert(self, value: str) -> str:
305
+ from django.core.validators import RegexValidator
306
+
307
+ RegexValidator(regex=self.regex)(value)
308
+ return value
309
+
310
+
311
+ class PathValue(StringValue):
312
+ """Parses env variable into a string value, and can optionally validate that the path exists."""
313
+
314
+ def __init__(
315
+ self,
316
+ *,
317
+ default: str | None = Undefined,
318
+ env_name: str | Undefined = Undefined,
319
+ nullable: bool = False,
320
+ check_exists: bool = True,
321
+ ) -> None:
322
+ self.check_exists = check_exists
323
+ super().__init__(default=default, env_name=env_name, nullable=nullable)
324
+
325
+ def convert(self, value: str) -> str:
326
+ path = Path(value).absolute()
327
+ if self.check_exists and not path.exists():
328
+ msg = f"Path '{path}' does not exist"
329
+ raise ValueError(msg)
330
+
331
+ return str(path)
332
+
333
+
334
+ class DatabaseURLValue(Value[DBConfig | str]):
335
+ """Load a database configuration from a URL."""
336
+
337
+ def __init__(
338
+ self,
339
+ *,
340
+ db_alias: str = "default",
341
+ default: DBConfig | str = Undefined,
342
+ env_name: str = "DATABASE_URL",
343
+ **params: Unpack[DBConfigExtra],
344
+ ) -> None:
345
+ self.db_alias = db_alias
346
+ self.params = params
347
+ super().__init__(default=default, env_name=env_name)
348
+
349
+ def convert(self, value: str | DBConfig) -> dict[str, DBConfig]:
350
+ if not isinstance(value, str):
351
+ return {self.db_alias: value}
352
+
353
+ from dj_database_url import parse
354
+
355
+ config = parse(value, **self.params)
356
+ return {self.db_alias: config}
357
+
358
+
359
+ class CacheURLValue(Value[CacheConfig | str]):
360
+ """Load a cache configuration from a URL."""
361
+
362
+ def __init__(
363
+ self,
364
+ *,
365
+ cache_alias: str = "default",
366
+ default: CacheConfig | str = Undefined,
367
+ env_name: str = "CACHE_URL",
368
+ ) -> None:
369
+ self.cache_alias = cache_alias
370
+ super().__init__(default=default, env_name=env_name)
371
+
372
+ def convert(self, value: str | CacheConfig) -> dict[str, CacheConfig]:
373
+ if not isinstance(value, str):
374
+ return {self.cache_alias: value}
375
+
376
+ from django_cache_url import parse
377
+
378
+ config = parse(value)
379
+ return {self.cache_alias: config}
@@ -0,0 +1,314 @@
1
+ [tool.poetry]
2
+ name = "django-environment-config"
3
+ version = "0.0.2"
4
+ description = "Configure django settings for multiple environments."
5
+ authors = [
6
+ "Matti Lamppu <lamppu.matti.akseli@gmail.com>",
7
+ ]
8
+ packages = [
9
+ { include = "env_config" },
10
+ ]
11
+ license = "MIT"
12
+ readme = "README.md"
13
+ homepage = "https://mrthearman.github.io/django-environment-config"
14
+ repository = "https://github.com/MrThearMan/django-environment-config"
15
+ keywords = [
16
+ "django",
17
+ "configuration",
18
+ "settings",
19
+ "environment",
20
+ "env",
21
+ "config",
22
+ ]
23
+ classifiers = [
24
+ "Environment :: Web Environment",
25
+ "Operating System :: OS Independent",
26
+ "Intended Audience :: Developers",
27
+ "Natural Language :: English",
28
+ "Programming Language :: Python",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3 :: Only",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Framework :: Django :: 4.2",
35
+ "Framework :: Django :: 5.0",
36
+ ]
37
+ include = [
38
+ "README.md",
39
+ "LICENSE",
40
+ ]
41
+ exclude = [
42
+ "tests",
43
+ ]
44
+
45
+ [tool.poetry.urls]
46
+ "Bug Tracker" = "https://github.com/MrThearMan/django-environment-config/issues"
47
+
48
+ [tool.poetry.dependencies]
49
+ python = ">=3.10,<4"
50
+ Django = ">=4.2"
51
+ python-dotenv = ">=1.0.1"
52
+ typing-extensions = { version = ">=4.11.0", python = "<3.12" }
53
+ dj-database-url = { version = "^2.1.0", optional = true }
54
+ django-cache-url = { version = "^3.4.5", optional = true }
55
+
56
+ [tool.poetry.group.test.dependencies]
57
+ pytest = "8.2.0"
58
+ coverage = "7.5.0"
59
+ pytest-django = "4.8.0"
60
+ pre-commit = "3.7.0"
61
+ tox = "4.15.0"
62
+ tox-gh-actions = "3.2.0"
63
+
64
+ [tool.poetry.group.docs.dependencies]
65
+ mkdocs = "1.6.0"
66
+ pymdown-extensions = "10.8.1"
67
+ mkdocs-mermaid2-plugin = "1.1.1"
68
+
69
+ [tool.poetry.group.lint.dependencies]
70
+ mypy = "1.10.0"
71
+ django-stubs = "4.2.7"
72
+
73
+ [tool.poetry.extras]
74
+ db = ["dj-database-url"]
75
+ cache = ["django-cache-url"]
76
+
77
+ [tool.ruff]
78
+ fix = true
79
+ unsafe-fixes = true
80
+ line-length = 120
81
+ extend-exclude = [
82
+ "tests/*",
83
+ ]
84
+ lint.typing-modules = [
85
+ "env_config.typing",
86
+ ]
87
+ lint.explicit-preview-rules = true
88
+ lint.preview = true
89
+ lint.select = [
90
+ "A", # flake8-builtins
91
+ "ANN", # flake8-annotations
92
+ "ARG", # flake8-unused-arguments
93
+ "B", # flake8-bugbear
94
+ "BLE", # flake8-blind-except
95
+ "C90", # mccabe
96
+ "C4", # flake8-comprehensions
97
+ "COM", # flake8-commas
98
+ "D200", # pydocstyle: One-line docstring should fit on one line
99
+ "D201", # pydocstyle: No blank lines allowed before function docstring (found {num_lines})
100
+ "D202", # pydocstyle: No blank lines allowed after function docstring (found {num_lines})
101
+ "D209", # pydocstyle: Multi-line docstring closing quotes should be on a separate line
102
+ "D213", # pydocstyle: Multi-line docstring summary should start at the second line
103
+ "DJ", # flake8-django
104
+ "DTZ", # flake8-datetimez
105
+ "E", # pycodestyle errors
106
+ "EM", # flake8-errmsg
107
+ "F", # pyflakes
108
+ "FA", # flake8-future-annotations
109
+ "FBT", # flake8-boolean-trap
110
+ "FLY", # flynt
111
+ "G", # flake8-logging-format
112
+ "I", # isort
113
+ "ICN", # flake8-import-conventions
114
+ "INP", # flake8-no-pep420
115
+ "INT", # flake8-gettext
116
+ "ISC", # flake8-implicit-str-concat
117
+ "LOG", # flake8-logging
118
+ "N", # pep8-naming
119
+ "PERF", # perflint
120
+ "PGH", # pygrep-hooks
121
+ "PIE", # flake8-pie
122
+ "PL", # pylint
123
+ "PT", # flake8-pytest-style
124
+ "PTH", # flake8-use-pathlib
125
+ "PYI", # flake8-pyi
126
+ "Q", # flake8-quotes
127
+ "R", # Refactor
128
+ "RET", # flake8-return
129
+ "RSE", # flake8-raise
130
+ "RUF", # ruff-specific rules
131
+ "S", # flake8-bandit
132
+ "SIM", # flake8-simplify
133
+ "SLF", # flake8-self
134
+ "T20", # flake8-print
135
+ "TCH", # flake8-type-checking
136
+ "TID", # flake8-tidy-imports
137
+ "TRY", # tryceratops
138
+ "UP", # pyupgrade
139
+ "W", # pycodestyle warnings
140
+ ]
141
+ # Preview rules
142
+ lint.extend-select = [
143
+ "B909", # loop-iterator-mutation
144
+ "FURB110", # if-exp-instead-of-or-operator
145
+ "FURB142", # for-loop-set-mutations
146
+ "FURB145", # slice-copy
147
+ "FURB171", # single-item-membership-test
148
+ "FURB187", # list-reverse-copy
149
+ "PLE0307", # invalid-str-return-type
150
+ "PLR0916", # too-many-boolean-expressions
151
+ "PLR1730", # if-stmt-min-max
152
+ "PLR1733", # unnecessary-dict-index-lookup
153
+ "PLR1736", # unnecessary-list-index-lookup
154
+ "PLR6104", # non-augmented-assignment
155
+ "PLW0211", # bad-staticmethod-argument
156
+ "PLW0642", # self-or-cls-assignment
157
+ "RUF021", # parenthesize-chained-operators
158
+ "RUF022", # unsorted-dunder-all
159
+ "UP042", # replace-str-enum
160
+ ]
161
+ lint.ignore = [
162
+ "ANN101", # Missing type annotation for `self` in method
163
+ "ANN102", # Missing type annotation for `cls` in method
164
+ "ANN401", # Any-typing allowed
165
+ "ARG001", # Unused function argument
166
+ "ARG002", # Unused method argument
167
+ "ARG003", # Unused class method argument
168
+ "RUF012", # No need for ClassVar
169
+ "G004", # Logging statement uses f-string
170
+ #
171
+ # Conflicting with ruff-format
172
+ #
173
+ "COM812", # missing-trailing-comma
174
+ "COM819", # prohibited-trailing-comma
175
+ "D206", # indent-with-spaces
176
+ "D300", # triple-single-quotes
177
+ "E111", # indentation-with-invalid-multiple
178
+ "E114", # indentation-with-invalid-multiple-comment
179
+ "E117", # over-indented
180
+ "ISC001", # single-line-implicit-string-concatenation
181
+ "ISC002", # multi-line-implicit-string-concatenation
182
+ "Q000", # bad-quotes-inline-string
183
+ "Q001", # bad-quotes-multiline-string
184
+ "Q002", # bad-quotes-docstring
185
+ "Q003", # avoidable-escaped-quote
186
+ "W191", # tab-indentation
187
+ ]
188
+
189
+ [tool.ruff.lint.extend-per-file-ignores]
190
+ "test_*.py" = [
191
+ "ANN", # No need to annotate tests
192
+ "N801", # Allow whatever class names in tests
193
+ "N802", # Allow whatever function names in tests
194
+ "N815", # Allow whatever variable names in tests
195
+ "PLR0915", # Can have as many statements as needed
196
+ "PLR2004", # Magic value comparisons are allowed in tests
197
+ "S101", # Assert is fine
198
+ "S105", # Hardcoded passwords are fine in tests
199
+ "S106", # Hardcoded passwords are fine in tests
200
+ "S108", # No need to care about insecure temp file usages in tests
201
+ "S311", # Pseudo-random generators are fine here
202
+ "SLF", # Allow accessing private members in tests
203
+ "UP", # No upgrade rules
204
+ ]
205
+ "conftest.py" = [
206
+ "ARG", # Fixtures can be unused
207
+ "ANN", # No need to annotate tests
208
+ "UP", # No upgrade rules
209
+ ]
210
+
211
+ [tool.ruff.lint.isort]
212
+ combine-as-imports = false
213
+ split-on-trailing-comma = false
214
+ known-third-party = [
215
+ "django",
216
+ ]
217
+ known-first-party = [
218
+ "env_config",
219
+ ]
220
+
221
+ [tool.ruff.lint.flake8-import-conventions]
222
+ banned-from = [
223
+ "base64",
224
+ "csv",
225
+ "datetime",
226
+ "hashlib",
227
+ "hmac",
228
+ "json",
229
+ "logging",
230
+ "math",
231
+ "os",
232
+ "pickle",
233
+ "random",
234
+ "re",
235
+ "shutil",
236
+ "subprocess",
237
+ "sys",
238
+ "tempfile",
239
+ "time",
240
+ "uuid",
241
+ "xml",
242
+ "yaml",
243
+ ]
244
+
245
+ [tool.mypy]
246
+ python_version = "3.12"
247
+ warn_return_any = "True"
248
+ warn_unused_configs = "True"
249
+ ignore_missing_imports = "True"
250
+ plugins = [
251
+ "mypy_django_plugin.main",
252
+ ]
253
+
254
+ [tool.django-stubs]
255
+ django_settings_module = "tests.project.settings"
256
+
257
+ [tool.coverage.run]
258
+ relative_files = true
259
+
260
+ [tool.coverage.report]
261
+ omit = [
262
+ "tests/*",
263
+ "docs/*",
264
+ ".venv/*",
265
+ ".tox/*",
266
+ ]
267
+ exclude_lines = [
268
+ "if TYPE_CHECKING:",
269
+ "except ImportError:",
270
+ "pragma: no cover",
271
+ "pass",
272
+ ]
273
+
274
+ [tool.pytest.ini_options]
275
+ DJANGO_SETTINGS_MODULE = "tests.project.settings"
276
+ addopts = "-vv -s --disable-warnings"
277
+
278
+ [tool.tox]
279
+ legacy_tox_ini = """
280
+ [tox]
281
+ envlist = py{310, 311, 312}-django{42, 50}
282
+ isolated_build = true
283
+
284
+ [gh-actions]
285
+ python =
286
+ 3.10: py310
287
+ 3.11: py311
288
+ 3.12: py312
289
+
290
+ [testenv]
291
+ allowlist_externals =
292
+ poetry
293
+ setenv =
294
+ PYTHONPATH = {toxinidir}
295
+ DJANGO_SETTINGS_MODULE = tests.project.settings
296
+ deps =
297
+ django42: Django >=4.2,<4.3
298
+ django50: Django >=5.0,<5.1
299
+
300
+ python-dotenv
301
+ dj-database-url
302
+ django-cache-url
303
+ typing-extensions
304
+
305
+ pytest
306
+ coverage
307
+ pytest-django
308
+ commands =
309
+ coverage run -m pytest -vv -s --disable-warnings
310
+ """
311
+
312
+ [build-system]
313
+ requires = ["poetry-core>=1.7.0"]
314
+ build-backend = "poetry.core.masonry.api"