env-proxy 1.2.0__tar.gz → 1.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: env-proxy
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Creates a class used to query environmental variables with typehinting a conversion to basic Python types.
5
5
  License: MIT
6
6
  License-File: LICENSE.md
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: typing-extensions (>=4.6.0) ; python_version < "3.11"
17
18
  Project-URL: Homepage, https://github.com/tomasvotava/env-proxy
18
19
  Project-URL: Repository, https://github.com/tomasvotava/env-proxy
19
20
  Description-Content-Type: text/markdown
@@ -174,6 +175,44 @@ debug = config.debug # Looks for MYAPP_DEBUG in the environment
174
175
  database_url = config.database_url # Raises ValueError if not found
175
176
  ```
176
177
 
178
+ #### Overriding Values per Instance
179
+
180
+ `EnvConfig` accepts keyword arguments to override individual fields on a per-instance basis.
181
+ Overrides take precedence over the environment, letting you layer the env-derived config with
182
+ values from any other source — a config file, CLI arguments, programmatic wiring, fixtures —
183
+ without touching `os.environ`.
184
+
185
+ ```python
186
+ class AppConfig(EnvConfig):
187
+ env_proxy = EnvProxy(prefix="APP")
188
+ timeout: int = Field(default=30)
189
+ services: list[str] = Field(default=[])
190
+
191
+ # Layer env with values loaded from a config file:
192
+ file_config = load_yaml("app.yaml") # {"timeout": 5, "services": ["redis", "rabbitmq"]}
193
+ cfg = AppConfig(**file_config)
194
+
195
+ assert cfg.timeout == 5
196
+ assert cfg.services == ["redis", "rabbitmq"]
197
+ ```
198
+
199
+ Semantics:
200
+
201
+ - Keys are **Python field names** (not env-var keys), regardless of any `alias` or `env_prefix`.
202
+ - Values are **used as-is** — no string parsing or type conversion. Pass real `int`, `list`, `dict`, etc.
203
+ - Overrides **shadow the environment** for reads on that instance only; other instances and direct
204
+ `os.environ` access are unaffected.
205
+ - Unknown override keys raise `ValueError`, listing the valid field names — typo-proof.
206
+ - Fields with `allow_set=False` can be initialized via override but cannot be reassigned afterwards;
207
+ the `allow_set` contract is unchanged.
208
+ - For fields with `allow_set=True`, assignment after construction updates both the override entry
209
+ *and* `os.environ` (preserving the existing side-effect contract).
210
+
211
+ Overrides are statically type-checked. `EnvConfig` is decorated with PEP 681's `dataclass_transform`,
212
+ so mypy and Pyright/Pylance synthesize a typed `__init__` from each subclass's annotated fields:
213
+ typos (`AppConfig(timout=5)`) and wrong value types (`AppConfig(timeout="bad")`) are flagged at
214
+ type-check time, and IDEs autocomplete field names with their declared types.
215
+
177
216
  #### Generating a Sample `.env` File
178
217
 
179
218
  You can export a sample `.env` file from your `EnvConfig` class, which documents all fields with their
@@ -154,6 +154,44 @@ debug = config.debug # Looks for MYAPP_DEBUG in the environment
154
154
  database_url = config.database_url # Raises ValueError if not found
155
155
  ```
156
156
 
157
+ #### Overriding Values per Instance
158
+
159
+ `EnvConfig` accepts keyword arguments to override individual fields on a per-instance basis.
160
+ Overrides take precedence over the environment, letting you layer the env-derived config with
161
+ values from any other source — a config file, CLI arguments, programmatic wiring, fixtures —
162
+ without touching `os.environ`.
163
+
164
+ ```python
165
+ class AppConfig(EnvConfig):
166
+ env_proxy = EnvProxy(prefix="APP")
167
+ timeout: int = Field(default=30)
168
+ services: list[str] = Field(default=[])
169
+
170
+ # Layer env with values loaded from a config file:
171
+ file_config = load_yaml("app.yaml") # {"timeout": 5, "services": ["redis", "rabbitmq"]}
172
+ cfg = AppConfig(**file_config)
173
+
174
+ assert cfg.timeout == 5
175
+ assert cfg.services == ["redis", "rabbitmq"]
176
+ ```
177
+
178
+ Semantics:
179
+
180
+ - Keys are **Python field names** (not env-var keys), regardless of any `alias` or `env_prefix`.
181
+ - Values are **used as-is** — no string parsing or type conversion. Pass real `int`, `list`, `dict`, etc.
182
+ - Overrides **shadow the environment** for reads on that instance only; other instances and direct
183
+ `os.environ` access are unaffected.
184
+ - Unknown override keys raise `ValueError`, listing the valid field names — typo-proof.
185
+ - Fields with `allow_set=False` can be initialized via override but cannot be reassigned afterwards;
186
+ the `allow_set` contract is unchanged.
187
+ - For fields with `allow_set=True`, assignment after construction updates both the override entry
188
+ *and* `os.environ` (preserving the existing side-effect contract).
189
+
190
+ Overrides are statically type-checked. `EnvConfig` is decorated with PEP 681's `dataclass_transform`,
191
+ so mypy and Pyright/Pylance synthesize a typed `__init__` from each subclass's annotated fields:
192
+ typos (`AppConfig(timout=5)`) and wrong value types (`AppConfig(timeout="bad")`) are flagged at
193
+ type-check time, and IDEs autocomplete field names with their declared types.
194
+
157
195
  #### Generating a Sample `.env` File
158
196
 
159
197
  You can export a sample `.env` file from your `EnvConfig` class, which documents all fields with their
@@ -2,20 +2,28 @@
2
2
  auto-documenting approach.
3
3
  """
4
4
 
5
+ from __future__ import annotations
6
+
5
7
  import json
6
8
  import logging
7
9
  import os
10
+ import sys
8
11
  from collections.abc import Callable
9
12
  from functools import cached_property, partial
10
13
  from inspect import get_annotations
11
14
  from pathlib import Path
12
15
  from types import NoneType, UnionType
13
- from typing import Any, Literal, TextIO, TypeVar, get_args, get_origin
16
+ from typing import Any, ClassVar, Literal, TextIO, TypeVar, get_args, get_origin
14
17
 
15
18
  from env_proxy.env_proxy import EnvProxy
16
19
 
17
20
  from ._sentinel import UNSET
18
21
 
22
+ if sys.version_info >= (3, 11):
23
+ from typing import dataclass_transform
24
+ else: # pragma: no cover
25
+ from typing_extensions import dataclass_transform
26
+
19
27
  logger = logging.getLogger(__name__)
20
28
 
21
29
  T = TypeVar("T")
@@ -70,96 +78,6 @@ def _get_simplified_annotation(annotation: Any) -> Any:
70
78
  return None
71
79
 
72
80
 
73
- class FieldDocsBuilder:
74
- __env_field_doc_template = "# {key_name} ({field_type}) [{required}]\n{description}{env_key}={default}\n"
75
-
76
- def __init__(self, fields: list["EnvField"]) -> None:
77
- self.fields = list(fields)
78
-
79
- @staticmethod
80
- def _get_field_type(field: "EnvField") -> str:
81
- if field.type_hint is not None:
82
- return field.type_hint
83
- if field.simplified_annotation is not None:
84
- if isinstance(field.simplified_annotation, type):
85
- return field.simplified_annotation.__name__
86
- return str(field.simplified_annotation).lower() # pragma: no cover, unreachable
87
- return "unknown type"
88
-
89
- @staticmethod
90
- def _get_field_default(field: "EnvField") -> str:
91
- if field.default in (UNSET, None):
92
- return ""
93
- if not isinstance(field.default, str) and field.type_hint == "json":
94
- try:
95
- return json.dumps(field.default)
96
- except (ValueError, TypeError) as error:
97
- raise ValueError(
98
- f"Failed to export default for field {field.field_name!r}. "
99
- "Its default value cannot be encoded as a JSON."
100
- ) from error
101
- if isinstance(field.default, list):
102
- return ",".join(field.default)
103
- return str(field.default)
104
-
105
- def generate_env_file_content(self, include_defaults: bool = True, sort_by_name: bool = False) -> str:
106
- lines: list[str] = []
107
- if sort_by_name:
108
- self.fields.sort(key=lambda field: field.key_name)
109
- for field in self.fields:
110
- required = "required" if field.default is UNSET else "optional"
111
- default = self._get_field_default(field) if include_defaults else ""
112
- field_type = self._get_field_type(field)
113
- multiline_description = ""
114
- if field.description:
115
- multiline_description = "\n# ".join(field.description.splitlines())
116
- multiline_description = "# " + multiline_description.rstrip("\n") + "\n"
117
- lines.append(
118
- self.__env_field_doc_template.format(
119
- key_name=field.key_name,
120
- field_type=field_type,
121
- required=required,
122
- description=multiline_description,
123
- env_key=field.env_key,
124
- default=default,
125
- )
126
- )
127
- return "\n".join(lines)
128
-
129
-
130
- class EnvConfig:
131
- """A base class for your configurations based on environment variables.
132
-
133
- Use fields along with Field factory to easily describe your configuration in an self-documenting way.
134
- """
135
-
136
- @classmethod
137
- def __generate_env_file_content(
138
- cls: "type[EnvConfig]", include_defaults: bool = True, sort_by_name: bool = False
139
- ) -> str:
140
- fields: list[EnvField] = []
141
- for field_name, field in vars(cls).items():
142
- if not isinstance(field, EnvField):
143
- logger.debug(f"Skipping class variable {field_name!r}, not a Field.")
144
- continue
145
- fields.append(field)
146
- builder = FieldDocsBuilder(fields)
147
- return builder.generate_env_file_content(include_defaults=include_defaults, sort_by_name=sort_by_name)
148
-
149
- @classmethod
150
- def export_env(
151
- cls: "type[EnvConfig]",
152
- file_or_path: Path | str | TextIO,
153
- include_defaults: bool = True,
154
- sort_by_name: bool = False,
155
- ) -> None:
156
- content = cls.__generate_env_file_content(include_defaults=include_defaults, sort_by_name=sort_by_name)
157
- if isinstance(file_or_path, str | Path):
158
- Path(file_or_path).write_text(content, encoding="utf-8")
159
- return
160
- file_or_path.write(content)
161
-
162
-
163
81
  class EnvField:
164
82
  def __init__(
165
83
  self,
@@ -319,6 +237,9 @@ class EnvField:
319
237
  def __set__(self, instance: EnvConfig, value: Any) -> None:
320
238
  if not self.allow_set:
321
239
  raise TypeError(f"Field {self.field_name!r} of {instance.__class__.__name__!r} is read-only.")
240
+ overrides = instance.__dict__.get("_overrides")
241
+ if overrides is not None and self.field_name in overrides:
242
+ overrides[self.field_name] = value
322
243
  key = self.env_proxy._get_key(self.key_name)
323
244
  logger.debug(f"Setting {key!r} in os.environ.")
324
245
  if value is None:
@@ -327,7 +248,12 @@ class EnvField:
327
248
  else:
328
249
  os.environ[key] = str(value)
329
250
 
330
- def __get__(self, instance: EnvConfig, instance_type: type[EnvConfig]) -> Any:
251
+ def __get__(self, instance: EnvConfig | None, instance_type: type[EnvConfig]) -> Any:
252
+ if instance is None:
253
+ return self
254
+ overrides = instance.__dict__.get("_overrides")
255
+ if overrides is not None and self.field_name in overrides:
256
+ return overrides[self.field_name]
331
257
  return self.value_getter(self.key_name, self.default)
332
258
 
333
259
 
@@ -343,3 +269,134 @@ def Field( # noqa: N802
343
269
  ) -> Any:
344
270
  # A factory function that will help us deal with our descriptor's typehinting issues.
345
271
  return EnvField(alias, description, default, env_proxy, env_prefix, strict, allow_set, type_hint)
272
+
273
+
274
+ class FieldDocsBuilder:
275
+ __env_field_doc_template = "# {key_name} ({field_type}) [{required}]\n{description}{env_key}={default}\n"
276
+
277
+ def __init__(self, fields: list[EnvField]) -> None:
278
+ self.fields = list(fields)
279
+
280
+ @staticmethod
281
+ def _get_field_type(field: EnvField) -> str:
282
+ if field.type_hint is not None:
283
+ return field.type_hint
284
+ if field.simplified_annotation is not None:
285
+ if isinstance(field.simplified_annotation, type):
286
+ return field.simplified_annotation.__name__
287
+ return str(field.simplified_annotation).lower() # pragma: no cover, unreachable
288
+ return "unknown type"
289
+
290
+ @staticmethod
291
+ def _get_field_default(field: EnvField) -> str:
292
+ if field.default in (UNSET, None):
293
+ return ""
294
+ if not isinstance(field.default, str) and field.type_hint == "json":
295
+ try:
296
+ return json.dumps(field.default)
297
+ except (ValueError, TypeError) as error:
298
+ raise ValueError(
299
+ f"Failed to export default for field {field.field_name!r}. "
300
+ "Its default value cannot be encoded as a JSON."
301
+ ) from error
302
+ if isinstance(field.default, list):
303
+ return ",".join(field.default)
304
+ return str(field.default)
305
+
306
+ def generate_env_file_content(self, include_defaults: bool = True, sort_by_name: bool = False) -> str:
307
+ lines: list[str] = []
308
+ if sort_by_name:
309
+ self.fields.sort(key=lambda field: field.key_name)
310
+ for field in self.fields:
311
+ required = "required" if field.default is UNSET else "optional"
312
+ default = self._get_field_default(field) if include_defaults else ""
313
+ field_type = self._get_field_type(field)
314
+ multiline_description = ""
315
+ if field.description:
316
+ multiline_description = "\n# ".join(field.description.splitlines())
317
+ multiline_description = "# " + multiline_description.rstrip("\n") + "\n"
318
+ lines.append(
319
+ self.__env_field_doc_template.format(
320
+ key_name=field.key_name,
321
+ field_type=field_type,
322
+ required=required,
323
+ description=multiline_description,
324
+ env_key=field.env_key,
325
+ default=default,
326
+ )
327
+ )
328
+ return "\n".join(lines)
329
+
330
+
331
+ @dataclass_transform(kw_only_default=True)
332
+ class EnvConfig:
333
+ """A base class for your configurations based on environment variables.
334
+
335
+ Use fields along with Field factory to easily describe your configuration in an self-documenting way.
336
+
337
+ The constructor accepts keyword arguments to override individual fields on a
338
+ per-instance basis. Overrides take precedence over the environment, allowing
339
+ callers to layer env-derived config with values from any other source — a
340
+ config file, CLI arguments, programmatic wiring, fixtures — without mutating
341
+ ``os.environ``. Override values are keyed by Python field name (not env-var
342
+ key), are used as-is (no type conversion), and shadow the environment for
343
+ reads on this instance only::
344
+
345
+ cfg = MyConfig(timeout=5, services=["a", "b"])
346
+
347
+ Unknown override keys raise :class:`ValueError`. Fields with ``allow_set=False``
348
+ can still be initialized via override but cannot be reassigned afterwards.
349
+ """
350
+
351
+ _valid_fields: ClassVar[frozenset[str]] = frozenset()
352
+
353
+ def __init_subclass__(cls, **kwargs: Any) -> None:
354
+ super().__init_subclass__(**kwargs)
355
+ seen: set[str] = set()
356
+ valid: set[str] = set()
357
+ # Leaf-to-root: the first occurrence of each name wins, so a subclass
358
+ # that shadows an inherited EnvField with a non-EnvField correctly
359
+ # excludes that name from the valid override set.
360
+ for klass in cls.__mro__:
361
+ for name, attr in vars(klass).items():
362
+ if name in seen:
363
+ continue
364
+ seen.add(name)
365
+ if isinstance(attr, EnvField):
366
+ valid.add(name)
367
+ cls._valid_fields = frozenset(valid)
368
+
369
+ def __init__(self, **overrides: Any) -> None:
370
+ unknown = overrides.keys() - self._valid_fields
371
+ if unknown:
372
+ raise ValueError(
373
+ f"Unknown override key(s) for {type(self).__name__}: {sorted(unknown)}. "
374
+ f"Valid field names: {sorted(self._valid_fields)}"
375
+ )
376
+ self._overrides: dict[str, Any] = dict(overrides)
377
+
378
+ @classmethod
379
+ def __generate_env_file_content(
380
+ cls: type[EnvConfig], include_defaults: bool = True, sort_by_name: bool = False
381
+ ) -> str:
382
+ fields: list[EnvField] = []
383
+ for field_name, field in vars(cls).items():
384
+ if not isinstance(field, EnvField):
385
+ logger.debug(f"Skipping class variable {field_name!r}, not a Field.")
386
+ continue
387
+ fields.append(field)
388
+ builder = FieldDocsBuilder(fields)
389
+ return builder.generate_env_file_content(include_defaults=include_defaults, sort_by_name=sort_by_name)
390
+
391
+ @classmethod
392
+ def export_env(
393
+ cls: type[EnvConfig],
394
+ file_or_path: Path | str | TextIO,
395
+ include_defaults: bool = True,
396
+ sort_by_name: bool = False,
397
+ ) -> None:
398
+ content = cls.__generate_env_file_content(include_defaults=include_defaults, sort_by_name=sort_by_name)
399
+ if isinstance(file_or_path, str | Path):
400
+ Path(file_or_path).write_text(content, encoding="utf-8")
401
+ return
402
+ file_or_path.write(content)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "env-proxy"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Creates a class used to query environmental variables with typehinting a conversion to basic Python types."
5
5
  authors = ["Tomas Votava <info@tomasvotava.eu>"]
6
6
  license = "MIT"
@@ -13,9 +13,11 @@ pytest = "^9"
13
13
  mypy = "<2"
14
14
  ruff = "<1"
15
15
  pytest-cov = "^6.0.0"
16
+ pytest-benchmark = "^5.2.3"
16
17
 
17
18
  [tool.poetry.dependencies]
18
19
  python = ">=3.10,<3.15"
20
+ typing-extensions = { version = ">=4.6.0", python = "<3.11" }
19
21
 
20
22
  [build-system]
21
23
  requires = ["poetry-core>=1.0.0"]
@@ -60,6 +62,11 @@ select = [
60
62
  "RUF018", # allow asserts with walrus in tests
61
63
  "D", # ignore missing documentation in tests
62
64
  ]
65
+ "benchmarks/**" = [
66
+ "S101", # allow asserts in benchmarks
67
+ "RUF018", # allow asserts with walrus in benchmarks
68
+ "D", # ignore missing documentation in benchmarks
69
+ ]
63
70
 
64
71
  [tool.mypy]
65
72
  strict = true
File without changes
File without changes