dotenvmodel 0.1.1__py3-none-any.whl

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.
dotenvmodel/fields.py ADDED
@@ -0,0 +1,298 @@
1
+ """Field descriptor and Required sentinel for dotenvmodel."""
2
+
3
+ import re
4
+ from collections.abc import Callable
5
+ from decimal import Decimal
6
+ from typing import Any, TypeVar
7
+
8
+ # Type variable for generic field types
9
+ T = TypeVar("T")
10
+
11
+
12
+ class _MissingSentinel:
13
+ """Sentinel to indicate a required field with no default."""
14
+
15
+ def __repr__(self) -> str:
16
+ return "..."
17
+
18
+
19
+ _MISSING = _MissingSentinel()
20
+
21
+
22
+ class _RequiredSentinel:
23
+ """Sentinel to indicate a required field (explicit alternative to no default)."""
24
+
25
+ def __repr__(self) -> str:
26
+ return "Required"
27
+
28
+
29
+ # Public sentinel value for required fields
30
+ # Type checkers will see this as Any to avoid type errors
31
+ Required: Any = _RequiredSentinel()
32
+
33
+
34
+ class FieldInfo:
35
+ """
36
+ Information about a configuration field.
37
+
38
+ This class holds all metadata about a field including its default value,
39
+ validation constraints, and documentation.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ default: Any = _MISSING,
45
+ *,
46
+ default_factory: Callable[[], Any] | None = None,
47
+ alias: str | None = None,
48
+ description: str | None = None,
49
+ # Numeric validation
50
+ ge: int | float | None = None,
51
+ le: int | float | None = None,
52
+ gt: int | float | None = None,
53
+ lt: int | float | None = None,
54
+ # String validation
55
+ min_length: int | None = None,
56
+ max_length: int | None = None,
57
+ regex: str | None = None,
58
+ # General validation
59
+ choices: list[Any] | None = None,
60
+ # Collection validation
61
+ min_items: int | None = None,
62
+ max_items: int | None = None,
63
+ # UUID validation
64
+ uuid_version: int | None = None,
65
+ # Collection parsing
66
+ separator: str = ",",
67
+ # SecretStr options
68
+ url_unquote: bool = True,
69
+ ) -> None:
70
+ # Validate that only one default mechanism is used
71
+ if default is not _MISSING and default is not ... and default_factory is not None:
72
+ raise ValueError("Cannot specify both 'default' and 'default_factory'")
73
+
74
+ # Treat ellipsis as _MISSING (Pydantic-style required indicator)
75
+ if default is ...:
76
+ default = _MISSING
77
+
78
+ # Validate numeric constraint types
79
+ for param_name, param_value in [("ge", ge), ("le", le), ("gt", gt), ("lt", lt)]:
80
+ if param_value is not None and not isinstance(param_value, (int, float, Decimal)):
81
+ raise TypeError(
82
+ f"{param_name} must be int, float, or Decimal, got {type(param_value).__name__}"
83
+ )
84
+
85
+ # Validate length/size constraint types
86
+ for param_name, param_value in [
87
+ ("min_length", min_length),
88
+ ("max_length", max_length),
89
+ ("min_items", min_items),
90
+ ("max_items", max_items),
91
+ ]:
92
+ if param_value is not None and (not isinstance(param_value, int) or param_value < 0):
93
+ raise ValueError(
94
+ f"{param_name} must be a non-negative integer, got {param_value!r}"
95
+ )
96
+
97
+ # Validate UUID version
98
+ if uuid_version is not None and uuid_version not in (1, 3, 4, 5):
99
+ raise ValueError(f"uuid_version must be 1, 3, 4, or 5, got {uuid_version}")
100
+
101
+ # Validate contradictory constraints
102
+ if ge is not None and le is not None and ge > le:
103
+ raise ValueError(f"ge ({ge}) cannot be greater than le ({le})")
104
+ if gt is not None and lt is not None and gt >= lt:
105
+ raise ValueError(f"gt ({gt}) must be less than lt ({lt})")
106
+ if min_length is not None and max_length is not None and min_length > max_length:
107
+ raise ValueError(
108
+ f"min_length ({min_length}) cannot be greater than max_length ({max_length})"
109
+ )
110
+ if min_items is not None and max_items is not None and min_items > max_items:
111
+ raise ValueError(
112
+ f"min_items ({min_items}) cannot be greater than max_items ({max_items})"
113
+ )
114
+
115
+ self.default = default
116
+ self.default_factory = default_factory
117
+ self.alias = alias
118
+ self.description = description
119
+
120
+ # Numeric constraints
121
+ self.ge = ge
122
+ self.le = le
123
+ self.gt = gt
124
+ self.lt = lt
125
+
126
+ # String constraints
127
+ self.min_length = min_length
128
+ self.max_length = max_length
129
+ self.regex = regex
130
+ # Compile regex pattern with error handling
131
+ if regex:
132
+ try:
133
+ self._compiled_regex = re.compile(regex)
134
+ except re.error as e:
135
+ raise ValueError(f"Invalid regex pattern: {regex!r} - {e}") from e
136
+ else:
137
+ self._compiled_regex = None
138
+
139
+ # General constraints
140
+ self.choices = choices
141
+
142
+ # Collection constraints
143
+ self.min_items = min_items
144
+ self.max_items = max_items
145
+
146
+ # UUID constraints
147
+ self.uuid_version = uuid_version
148
+
149
+ # Collection parsing
150
+ self.separator = separator
151
+
152
+ # SecretStr options
153
+ self.url_unquote = url_unquote
154
+
155
+ # Mark if field is required
156
+ self.required = default is _MISSING and default_factory is None
157
+
158
+ def get_default(self) -> Any:
159
+ """Get the default value for this field."""
160
+ if self.default_factory is not None:
161
+ return self.default_factory()
162
+ if self.default is _MISSING:
163
+ return _MISSING
164
+ return self.default
165
+
166
+ @property
167
+ def has_default(self) -> bool:
168
+ """Check if this field has a default value."""
169
+ return not self.required
170
+
171
+ def __repr__(self) -> str:
172
+ parts = []
173
+ if self.default is not _MISSING:
174
+ parts.append(f"default={self.default!r}")
175
+ if self.default_factory is not None:
176
+ parts.append(f"default_factory={self.default_factory!r}")
177
+ if self.alias:
178
+ parts.append(f"alias={self.alias!r}")
179
+ if self.description:
180
+ parts.append(f"description={self.description!r}")
181
+
182
+ # Add constraints
183
+ if self.ge is not None:
184
+ parts.append(f"ge={self.ge}")
185
+ if self.le is not None:
186
+ parts.append(f"le={self.le}")
187
+ if self.gt is not None:
188
+ parts.append(f"gt={self.gt}")
189
+ if self.lt is not None:
190
+ parts.append(f"lt={self.lt}")
191
+ if self.min_length is not None:
192
+ parts.append(f"min_length={self.min_length}")
193
+ if self.max_length is not None:
194
+ parts.append(f"max_length={self.max_length}")
195
+ if self.regex is not None:
196
+ parts.append(f"regex={self.regex!r}")
197
+ if self.choices is not None:
198
+ parts.append(f"choices={self.choices!r}")
199
+ if self.min_items is not None:
200
+ parts.append(f"min_items={self.min_items}")
201
+ if self.max_items is not None:
202
+ parts.append(f"max_items={self.max_items}")
203
+ if self.uuid_version is not None:
204
+ parts.append(f"uuid_version={self.uuid_version}")
205
+ if self.separator != ",": # Only show if non-default
206
+ parts.append(f"separator={self.separator!r}")
207
+
208
+ return f"FieldInfo({', '.join(parts)})"
209
+
210
+
211
+ def Field(
212
+ default: Any = _MISSING,
213
+ *,
214
+ default_factory: Callable[[], Any] | None = None,
215
+ alias: str | None = None,
216
+ description: str | None = None,
217
+ ge: int | float | None = None,
218
+ le: int | float | None = None,
219
+ gt: int | float | None = None,
220
+ lt: int | float | None = None,
221
+ min_length: int | None = None,
222
+ max_length: int | None = None,
223
+ regex: str | None = None,
224
+ choices: list[Any] | None = None,
225
+ min_items: int | None = None,
226
+ max_items: int | None = None,
227
+ uuid_version: int | None = None,
228
+ separator: str = ",",
229
+ url_unquote: bool = True,
230
+ ) -> Any:
231
+ """
232
+ Define a configuration field with validation and default values.
233
+
234
+ Args:
235
+ default: Default value if environment variable not set
236
+ default_factory: Callable that returns default value (for mutable defaults)
237
+ alias: Alternative environment variable name to read from
238
+ description: Human-readable description for documentation
239
+ ge: Greater than or equal to (>=)
240
+ le: Less than or equal to (<=)
241
+ gt: Greater than (>)
242
+ lt: Less than (<)
243
+ min_length: Minimum string length
244
+ max_length: Maximum string length
245
+ regex: Regular expression pattern to match
246
+ choices: List of allowed values
247
+ min_items: Minimum number of items in collection (list, set, tuple, dict)
248
+ max_items: Maximum number of items in collection (list, set, tuple, dict)
249
+ uuid_version: Required UUID version (1, 3, 4, or 5)
250
+ separator: Separator for list/set/tuple parsing (default: ",")
251
+
252
+ Returns:
253
+ FieldInfo instance containing field metadata
254
+
255
+ Example:
256
+ ```python
257
+ class Config(DotEnvConfig):
258
+ # Required field (no default)
259
+ database_url: str = Field()
260
+
261
+ # Optional with default
262
+ debug: bool = Field(default=False)
263
+
264
+ # With validation
265
+ port: int = Field(default=8000, ge=1, le=65535)
266
+
267
+ # With alias
268
+ postgres_dsn: str = Field(alias="DATABASE_URL")
269
+
270
+ # List with custom separator
271
+ tags: list[str] = Field(default_factory=list, separator=";")
272
+
273
+ # Collection size constraints
274
+ allowed_ips: list[str] = Field(min_items=1, max_items=10)
275
+
276
+ # UUID version constraint
277
+ tenant_id: UUID = Field(uuid_version=4)
278
+ ```
279
+ """
280
+ return FieldInfo(
281
+ default=default,
282
+ default_factory=default_factory,
283
+ alias=alias,
284
+ description=description,
285
+ ge=ge,
286
+ le=le,
287
+ gt=gt,
288
+ lt=lt,
289
+ min_length=min_length,
290
+ max_length=max_length,
291
+ regex=regex,
292
+ choices=choices,
293
+ min_items=min_items,
294
+ max_items=max_items,
295
+ uuid_version=uuid_version,
296
+ separator=separator,
297
+ url_unquote=url_unquote,
298
+ )
dotenvmodel/loading.py ADDED
@@ -0,0 +1,145 @@
1
+ """Environment variable and .env file loading logic."""
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from dotenv import load_dotenv
8
+
9
+ # Module-level logger
10
+ logger = logging.getLogger("dotenvmodel")
11
+
12
+
13
+ def load_env_files(
14
+ env: str | None = None,
15
+ *,
16
+ override: bool = True,
17
+ env_dir: Path | None = None,
18
+ ) -> dict[str, str]:
19
+ """
20
+ Load environment variables from cascading .env files.
21
+
22
+ This function implements Node.js-style .env file cascading, loading files
23
+ in the following order (later files override earlier):
24
+ 1. .env (base configuration)
25
+ 2. .env.local (local base overrides)
26
+ 3. .env.{env} (environment-specific)
27
+ 4. .env.{env}.local (local environment overrides)
28
+
29
+ Args:
30
+ env: Environment name (e.g., "dev", "prod", "test"). If None, reads from
31
+ ENV environment variable, defaults to "dev"
32
+ override: If True, .env file values override existing environment variables.
33
+ If False, existing env vars take precedence
34
+ env_dir: Optional custom base directory for .env files. If None, uses
35
+ DOTENV_DIR environment variable or current working directory
36
+
37
+ Returns:
38
+ Dictionary of all environment variables after loading
39
+
40
+ Example:
41
+ >>> load_env_files(env="dev", override=True)
42
+ {'DATABASE_URL': 'postgresql://localhost/myapp_dev', ...}
43
+ """
44
+ # Determine environment
45
+ if env is None:
46
+ env = os.getenv("ENV", "dev")
47
+
48
+ # Validate env parameter to prevent path traversal attacks
49
+ # Only allow alphanumeric characters, hyphens, and underscores
50
+ if not env or not all(c.isalnum() or c in ("-", "_") for c in env):
51
+ raise ValueError(
52
+ f"Invalid environment name: {env!r}. "
53
+ "Environment names must only contain alphanumeric characters, hyphens, and underscores."
54
+ )
55
+
56
+ logger.info(f"Loading configuration for environment: {env}")
57
+
58
+ # Determine base directory
59
+ if env_dir is None:
60
+ env_dir_str = os.getenv("DOTENV_DIR")
61
+ base_dir = Path(env_dir_str) if env_dir_str else Path.cwd()
62
+ else:
63
+ base_dir = env_dir
64
+
65
+ logger.debug(f"Base directory for .env files: {base_dir}")
66
+
67
+ # Validate base directory exists
68
+ if not base_dir.exists():
69
+ logger.error(f"Environment file directory does not exist: {base_dir}")
70
+ raise FileNotFoundError(f"Environment file directory does not exist: {base_dir}")
71
+
72
+ # Define file loading order
73
+ env_files = [
74
+ base_dir / ".env", # Base shared configuration
75
+ base_dir / ".env.local", # Local base overrides
76
+ base_dir / f".env.{env}", # Environment-specific config
77
+ base_dir / f".env.{env}.local", # Local environment overrides
78
+ ]
79
+
80
+ logger.debug(f"Searching for .env files in order: {[str(f) for f in env_files]}")
81
+
82
+ # Load each file in order
83
+ loaded_files = []
84
+ missing_files = []
85
+
86
+ for file_path in env_files:
87
+ if file_path.exists():
88
+ logger.info(f"Loading environment variables from {file_path}")
89
+ load_dotenv(file_path, override=override)
90
+ loaded_files.append(str(file_path))
91
+ else:
92
+ logger.debug(f"{file_path} not found (skipping)")
93
+ missing_files.append(str(file_path))
94
+
95
+ if loaded_files:
96
+ logger.info(f"Successfully loaded {len(loaded_files)} file(s): {', '.join(loaded_files)}")
97
+ else:
98
+ logger.warning(f"No .env files found in {base_dir}")
99
+
100
+ # Return all current environment variables
101
+ return dict(os.environ)
102
+
103
+
104
+ def get_env_var(field_name: str, alias: str | None = None, prefix: str | None = None) -> str | None:
105
+ """
106
+ Get environment variable value by field name or alias.
107
+
108
+ Args:
109
+ field_name: Name of the field
110
+ alias: Optional alias for the environment variable
111
+ prefix: Optional prefix to prepend to the environment variable name
112
+
113
+ Returns:
114
+ Environment variable value or None if not set
115
+ """
116
+ # Use alias if provided, otherwise convert field_name to UPPER_CASE
117
+ env_var_name = alias if alias else field_name.upper()
118
+
119
+ # Prepend prefix if provided (and alias is not used, since alias is absolute)
120
+ if prefix and not alias:
121
+ env_var_name = f"{prefix}{env_var_name}"
122
+
123
+ return os.getenv(env_var_name)
124
+
125
+
126
+ def get_env_var_name(field_name: str, alias: str | None = None, prefix: str | None = None) -> str:
127
+ """
128
+ Get the environment variable name for a field.
129
+
130
+ Args:
131
+ field_name: Name of the field
132
+ alias: Optional alias for the environment variable
133
+ prefix: Optional prefix to prepend to the environment variable name
134
+
135
+ Returns:
136
+ Environment variable name
137
+ """
138
+ # Use alias if provided, otherwise convert field_name to UPPER_CASE
139
+ env_var_name = alias if alias else field_name.upper()
140
+
141
+ # Prepend prefix if provided (and alias is not used, since alias is absolute)
142
+ if prefix and not alias:
143
+ env_var_name = f"{prefix}{env_var_name}"
144
+
145
+ return env_var_name
@@ -0,0 +1,92 @@
1
+ """Logging configuration utilities for dotenvmodel."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+
7
+
8
+ def configure_logging(
9
+ level: str | int | None = None,
10
+ *,
11
+ format_string: str | None = None,
12
+ handler: logging.Handler | None = None,
13
+ ) -> None:
14
+ """
15
+ Configure logging for dotenvmodel.
16
+
17
+ This is a convenience function to quickly enable dotenvmodel logging.
18
+ For more control, configure the 'dotenvmodel' logger directly using
19
+ the standard logging module.
20
+
21
+ Args:
22
+ level: Logging level. Can be a string ("DEBUG", "INFO", "WARNING", "ERROR")
23
+ or an int (logging.DEBUG, etc.). If None, reads from DOTENVMODEL_LOG_LEVEL
24
+ environment variable, defaults to WARNING.
25
+ format_string: Custom format string for log messages. If None, uses a
26
+ default format with timestamp, level, and message.
27
+ handler: Custom logging handler. If None, uses StreamHandler (stdout).
28
+
29
+ Example:
30
+ ```python
31
+ from dotenvmodel.logging_config import configure_logging
32
+
33
+ # Enable INFO level logging
34
+ configure_logging("INFO")
35
+
36
+ # Or use DEBUG for more verbose output
37
+ configure_logging("DEBUG")
38
+
39
+ # Custom format
40
+ configure_logging(
41
+ "DEBUG",
42
+ format_string="[%(levelname)s] %(message)s"
43
+ )
44
+ ```
45
+ """
46
+ # Determine log level
47
+ if level is None:
48
+ env_level = os.getenv("DOTENVMODEL_LOG_LEVEL", "WARNING").upper()
49
+ level = getattr(logging, env_level, logging.WARNING)
50
+ elif isinstance(level, str):
51
+ level = getattr(logging, level.upper(), logging.WARNING)
52
+
53
+ # Get the dotenvmodel logger
54
+ logger = logging.getLogger("dotenvmodel")
55
+ logger.setLevel(level)
56
+
57
+ # Remove existing handlers to avoid duplicates
58
+ logger.handlers.clear()
59
+
60
+ # Create handler
61
+ if handler is None:
62
+ handler = logging.StreamHandler(sys.stdout)
63
+
64
+ # Set format
65
+ if format_string is None:
66
+ format_string = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
67
+
68
+ formatter = logging.Formatter(format_string)
69
+ handler.setFormatter(formatter)
70
+
71
+ # Add handler to logger
72
+ logger.addHandler(handler)
73
+
74
+ # Prevent propagation to root logger to avoid duplicate logs
75
+ logger.propagate = False
76
+
77
+
78
+ def disable_logging() -> None:
79
+ """
80
+ Disable all dotenvmodel logging.
81
+
82
+ Example:
83
+ ```python
84
+ from dotenvmodel.logging_config import disable_logging
85
+
86
+ disable_logging()
87
+ ```
88
+ """
89
+ logger = logging.getLogger("dotenvmodel")
90
+ logger.setLevel(logging.CRITICAL + 1) # Above CRITICAL
91
+ logger.handlers.clear()
92
+ logger.propagate = False
dotenvmodel/py.typed ADDED
File without changes