Coverage for nexios\config\base.py: 95%

1 import json

2 import os

3 import multiprocessing

4 from typing import (

5 Any,

6 Callable,

7 Dict,

8 List,

9 Optional,

10 Union,

11 Literal,

12 TypedDict,

13 TypeVar,

14 cast,

15 get_type_hints,

16)

17

18# Type definitions for server configuration

19 InterfaceType = Literal["asgi", "wsgi", "asgi-http"]

20 HttpProtocolType = Literal["h11", "h2", "auto"]

21 LogLevelType = Literal["critical", "error", "warning", "info", "debug", "trace"]

22 ServerType = Literal["auto", "uvicorn", "granian"]

23

24

25 class ServerConfigDict(TypedDict, total=False):

26 """TypedDict for server configuration options."""

27

28 host: str

29 port: int

30 workers: int

31 interface: InterfaceType

32 http_protocol: HttpProtocolType

33 log_level: LogLevelType

34 reload: bool

35 threading: bool

36 access_log: bool

37 server: ServerType

38

39

40# Type for configuration validation functions

41 T = TypeVar("T")

42 ValidationFunc = Callable[[T], bool]

43

44

45 class MakeConfig:

46 """

47 A dynamic configuration class that allows nested dictionary access as attributes,

48 with optional validation and immutability.

49

50 Attributes:

51 _config (dict): Stores configuration data.

52 _immutable (bool): If True, prevents modifications.

53 _validate (dict): Stores validation rules for keys.

54

55 Example Usage:

56 config = MakeConfig({"db": {"host": "localhost"}}, immutable=True)

57 print(config.db.host) # "localhost"

58 """

59

60 def __init__(

61 self,

62 config: Dict[str, Any],

63 defaults: Optional[Dict[str, Any]] = None,

64 validate: Optional[Dict[str, Callable[[Any], bool]]] = None,

65 immutable: bool = False,

66 ):

67 """

68 Initialize the configuration object.

69

70 Args:

71 config (dict): Initial configuration.

72 defaults (dict, optional): Default values for missing keys.

73 validate (dict, optional): Validation rules (e.g., {"port": lambda x: x > 0}).

74 immutable (bool, optional): If True, prevents modifications.

75 """

76 self._config: Dict[str, Any] = {}

77 self._immutable: bool = immutable

78 self._validate: Dict[str, Callable[[Any], bool]] = validate or {}

79

80 # Apply defaults before setting config

81 merged_config = {**(defaults or {}), **config}

82

83 for key, value in merged_config.items():

84 self._set_config(key, value)

85

86 def _set_config(self, key: str, value: Optional[Any]):

87 """Validates and sets a configuration key."""

88 if key in self._validate:

89 if not self._validate[key](value):

90 raise ValueError(f"Invalid value for '{key}': {value}")

91 if isinstance(value, dict):

92 value = MakeConfig(value, immutable=self._immutable) # type: ignore

93 self._config[key] = value

94

95 def __setattr__(self, name: str, value: Any):

96 """Handles attribute assignment while respecting immutability."""

97 if name in {"_config", "_immutable", "_validate"}:

98 super().__setattr__(name, value)

99 elif self._immutable:

100 raise AttributeError(f"Cannot modify immutable config: '{name}'")

101 else:

102 self._set_config(name, value)

103

104 def __getattr__(self, name: str) -> Any:

105 """Handles attribute access, returning None if key is missing."""

106 return self._config.get(name, None)

107

108 def _get_nested(self, path: str) -> Any:

109 """

110 Retrieve a value from nested keys, returning None if any part is missing.

111

112 Args:

113 path (str): Dot-separated path, e.g., "db.host".

114

115 Returns:

116 Any: The value found or None.

117 """

118 keys = path.split(".")

119 current: Any = self

120 for key in keys:

121 if not isinstance(current, MakeConfig):

122 return None

123 current = current._config.get(key, None)

124 return current

125

126 def __getitem__(self, path: str) -> Any:

127 """Allow dictionary-like access via dot-separated keys."""

128 return self._get_nested(path)

129

130 def to_dict(self) -> Dict[str, Any]:

131 """Convert configuration to a standard dictionary."""

132

133 def recurse(config: "MakeConfig") -> Dict[str, Any]:

134 if isinstance(config, MakeConfig): # type: ignore

135 return {k: recurse(v) for k, v in config._config.items()}

136 return config

137

138 return recurse(self)

139

140 def to_json(self) -> str:

141 """Convert configuration to a JSON string."""

142 return json.dumps(self.to_dict(), indent=4)

143

144 def __repr__(self) -> str:

145 return f"MakeConfig({self.to_dict()})"

146

147

148# Server configuration validation

149 SERVER_VALIDATION: Dict[str, ValidationFunc[Any]] = {

150 # Host validation: must be a string

151 "host": lambda x: isinstance(x, str),

152 # Port validation: must be an integer between 1 and 65535

153 "port": lambda x: isinstance(x, int) and 1 <= x <= 65535,

154 # Workers validation: must be a positive integer

155 "workers": lambda x: isinstance(x, int) and x > 0,

156 # Interface validation: must be one of the supported interface types

157 "interface": lambda x: isinstance(x, str) and x in ["asgi", "wsgi", "asgi-http"],

158 # HTTP protocol validation: must be one of the supported protocols

159 "http_protocol": lambda x: isinstance(x, str) and x in ["h11", "h2", "auto"],

160 # Log level validation: must be one of the supported log levels

161 "log_level": lambda x: isinstance(x, str)

162 and x in ["critical", "error", "warning", "info", "debug", "trace"],

163 # Reload validation: must be a boolean

164 "reload": lambda x: isinstance(x, bool),

165 # Threading validation: must be a boolean

166 "threading": lambda x: isinstance(x, bool),

167 # Access log validation: must be a boolean

168 "access_log": lambda x: isinstance(x, bool),

169 # Server validation: must be one of the supported server types

170 "server": lambda x: isinstance(x, str) and x in ["auto", "uvicorn", "granian"],

171}

172

173

174# Function to safely get environment variables with type conversion

175 def _get_env_int(key: str, default: int) -> int:

176 """Get an integer environment variable with a default value."""

177 try:

178 value = os.environ.get(key)

179 return int(value) if value is not None else default

180 except (ValueError, TypeError):

181 return default

182

183

184 def _get_env_bool(key: str, default: bool) -> bool:

185 """Get a boolean environment variable with a default value."""

186 value = os.environ.get(key)

187 if value is None:

188 return default

189 return value.lower() in ("true", "1", "t", "yes", "y")

190

191

192"""

193 Default server configuration for Nexios applications.

194 This configuration is used when running the application with 'nexios run'.

195 Environment variables can override these defaults:

196

197 HOST: The host to bind the server to (default: 127.0.0.1)

198 PORT: The port to bind the server to (default: 4000)

199 WORKERS: Number of worker processes (default: min(CPU count + 1, 8))

200 INTERFACE: Server interface type: asgi, wsgi, or asgi-http (default: asgi)

201 HTTP_PROTOCOL: HTTP protocol: h11, h2, or auto (default: auto)

202 LOG_LEVEL: Logging level (default: info)

203 RELOAD: Enable auto-reload on code changes (default: true)

204 THREADING: Enable threading (default: false)

205 ACCESS_LOG: Enable access logging (default: true)

206 SERVER: Server to use: auto, uvicorn, or granian (default: auto)

207

208 Example usage in code:

209

210```python

211 from nexios.config import get_config

212

213 config = get_config()

214 server_config = config.server

215

216# Access server settings

217 host = server_config.host # "127.0.0.1"

218 port = server_config.port # 4000

219```

220

221 Or override in your application:

222

223```python

224 from nexios.config import MakeConfig, DEFAULT_SERVER_CONFIG

225

226# Create custom server config

227 my_server_config = {

228 **DEFAULT_SERVER_CONFIG,

229 "port": 8000,

230 "workers": 4

231}

232

233# Create application config

234 app_config = MakeConfig({

235 "debug": True,

236 "server": my_server_config

237})

238```

239"""

240 DEFAULT_SERVER_CONFIG: ServerConfigDict = {

241 # The host to bind the server to

242 "host": os.environ.get("HOST", "127.0.0.1"),

243 # The port to bind the server to

244 "port": _get_env_int("PORT", 4000),

245 # Number of worker processes to use

246 "workers": _get_env_int("WORKERS", min(multiprocessing.cpu_count() + 1, 8)),

247 # The interface to use (asgi, wsgi, or asgi-http)

248 "interface": cast(InterfaceType, os.environ.get("INTERFACE", "asgi")),

249 # The HTTP protocol to use (h11, h2, or auto)

250 "http_protocol": cast(HttpProtocolType, os.environ.get("HTTP_PROTOCOL", "auto")),

251 # The log level to use

252 "log_level": cast(LogLevelType, os.environ.get("LOG_LEVEL", "info")),

253 # Whether to enable auto-reloading when code changes

254 "reload": _get_env_bool("RELOAD", True),

255 # Whether to enable threading

256 "threading": _get_env_bool("THREADING", False),

257 # Whether to enable access logging

258 "access_log": _get_env_bool("ACCESS_LOG", True),

259 # The server to use (auto, uvicorn, or granian)

260 "server": cast(ServerType, os.environ.get("SERVER", "auto")),

261}