Coverage for src/typedal/config.py: 100%

1"""

2 TypeDAL can be configured by a combination of pyproject.toml (static), env (dynamic) and code (programmic).

3"""

4 import os

5 import typing

6 import warnings

7 from pathlib import Path

8 from typing import Any, Optional

9

10 import black.files

11 import tomli

12 from configuraptor import TypedConfig, alias

13 from dotenv import dotenv_values, find_dotenv

14

15 if typing.TYPE_CHECKING: # pragma: no cover

16 from edwh_migrate import Config as MigrateConfig

17 from pydal2sql.typer_support import Config as P2SConfig

18

19

20 class TypeDALConfig(TypedConfig):

21 """

22 Unified config for TypeDAL runtime behavior and migration utilities.

23 """

24

25 # typedal:

26 database: str

27 dialect: str

28 folder: str = "databases"

29 caching: bool = True

30 pool_size: int = 0

31 pyproject: str

32 connection: str = "default"

33

34 # pydal2sql:

35 input: str = "" # noqa: A003

36 output: str = ""

37 noop: bool = False

38 magic: bool = True

39 tables: Optional[list[str]] = None

40 function: str = "define_tables"

41

42 # edwh-migrate:

43 # migrate uri = database

44 database_to_restore: Optional[str]

45 migrate_cat_command: Optional[str]

46 schema_version: Optional[str]

47 redis_host: Optional[str]

48 migrate_table: str = "typedal_implemented_features"

49 flag_location: str

50 create_flag_location: bool = True

51 schema: str = "public"

52

53 # typedal (depends on properties above)

54 migrate: bool = True

55 fake_migrate: bool = False

56

57 # aliases:

58 db_uri: str = alias("database")

59 db_type: str = alias("dialect")

60 db_folder: str = alias("folder")

61

62 def __repr__(self) -> str:

63 """

64 Dump the config to a (fancy) string.

65 """

66 return f"<TypeDAL {self.__dict__}>"

67

68 def to_pydal2sql(self) -> "P2SConfig":

69 """

70 Convert the config to the format required by pydal2sql.

71 """

72 from pydal2sql.typer_support import Config, get_pydal2sql_config

73

74 if self.pyproject: # pragma: no cover

75 project = Path(self.pyproject).read_text()

76

77 if "[tool.typedal]" not in project and "[tool.pydal2sql]" in project:

78 # no typedal config, but existing p2s config:

79 return get_pydal2sql_config(self.pyproject)

80

81 return Config.load(

82 {

83 "db_type": self.dialect,

84 "format": "edwh-migrate",

85 "tables": self.tables,

86 "magic": self.magic,

87 "function": self.function,

88 "input": self.input,

89 "output": self.output,

90 "pyproject": self.pyproject,

91 }

92 )

93

94 def to_migrate(self) -> "MigrateConfig":

95 """

96 Convert the config to the format required by edwh-migrate.

97 """

98 from edwh_migrate import Config, get_config

99

100 if self.pyproject: # pragma: no cover

101 project = Path(self.pyproject).read_text()

102

103 if "[tool.typedal]" not in project and "[tool.migrate]" in project:

104 # no typedal config, but existing p2s config:

105 return get_config()

106

107 return Config.load(

108 {

109 "migrate_uri": self.database,

110 "schema_version": self.schema_version,

111 "redis_host": self.redis_host,

112 "migrate_cat_command": self.migrate_cat_command,

113 "database_to_restore": self.database_to_restore,

114 "migrate_table": self.migrate_table,

115 "flag_location": self.flag_location,

116 "create_flag_location": self.create_flag_location,

117 "schema": self.schema,

118 "db_folder": self.folder,

119 "migrations_file": self.output,

120 }

121 )

122

123

124 def find_pyproject_toml(directory: str | None = None) -> typing.Optional[str]:

125 """

126 Find the project's config toml, looks up until it finds the project root (black's logic).

127 """

128 return black.files.find_pyproject_toml((directory or os.getcwd(),))

129

130

131 def _load_toml(path: str | bool | None = True) -> tuple[str, dict[str, Any]]:

132 """

133 Path can be a file, a directory, a bool or None.

134

135 If it is True or None, the default logic is used.

136 If it is False, no data is loaded.

137 if it is a directory, the pyproject.toml will be searched there.

138 If it is a path, that file will be used.

139 """

140 if path is False:

141 toml_path = None

142 elif path in (True, None):

143 toml_path = find_pyproject_toml()

144 elif Path(str(path)).is_file():

145 toml_path = str(path)

146 else:

147 toml_path = find_pyproject_toml(str(path))

148

149 if not toml_path:

150 # nothing to load

151 return "", {}

152

153 try:

154 with open(toml_path, "rb") as f:

155 data = tomli.load(f)

156

157 return toml_path or "", typing.cast(dict[str, Any], data["tool"]["typedal"])

158 except Exception as e:

159 warnings.warn(f"Could not load typedal config toml: {e}", source=e)

160 return toml_path or "", {}

161

162

163 def _load_dotenv(path: str | bool | None = True) -> tuple[str, dict[str, Any]]:

164 if path is False:

165 dotenv_path = None

166 elif path in (True, None):

167 dotenv_path = find_dotenv(usecwd=True)

168 elif Path(str(path)).is_file():

169 dotenv_path = str(path)

170 else:

171 dotenv_path = str(Path(str(path)) / ".env")

172

173 if not dotenv_path:

174 return "", {}

175

176 # 1. find everything with TYPEDAL_ prefix

177 # 2. remove that prefix

178 # 3. format values if possible

179 data = dotenv_values(dotenv_path)

180 data |= os.environ # higher prio than .env

181

182 typedal_data = {k.lower().removeprefix("typedal_"): v for k, v in data.items()}

183

184 return dotenv_path, typedal_data

185

186

187 DB_ALIASES = {

188 "postgresql": "postgres",

189 "psql": "postgres",

190 "sqlite3": "sqlite",

191}

192

193

194 def get_db_for_alias(db_name: str) -> str:

195 """

196 Convert a db dialect alias to the standard name.

197 """

198 return DB_ALIASES.get(db_name, db_name)

199

200

201 DEFAULTS: dict[str, Any | typing.Callable[[dict[str, Any]], Any]] = {

202 "database": lambda data: data.get("db_uri") or "sqlite:memory",

203 "dialect": lambda data: get_db_for_alias(data["database"].split(":")[0])

204 if ":" in data["database"]

205 else data.get("db_type"),

206 "migrate": lambda data: not (data.get("input") or data.get("output")),

207 "folder": lambda data: data.get("db_folder"),

208 "flag_location": lambda data: f"{db_folder}/flags"

209 if (db_folder := (data.get("folder") or data.get("db_folder")))

210 else "/flags",

211 "pool_size": lambda data: 1 if data.get("dialect", "sqlite") == "sqlite" else 3,

212}

213

214

215 def fill_defaults(data: dict[str, Any], prop: str) -> None:

216 """

217 Fill missing property defaults with (calculated) sane defaults.

218 """

219 if data.get(prop, None) is None:

220 default = DEFAULTS.get(prop)

221 if callable(default):

222 default = default(data)

223 data[prop] = default

224

225

226 TRANSFORMS: dict[str, typing.Callable[[dict[str, Any]], Any]] = {

227 "database": lambda data: data["database"]

228 if (":" in data["database"] or not data.get("dialect"))

229 else (data["dialect"] + "://" + data["database"])

230}

231

232

233 def transform(data: dict[str, Any], prop: str) -> bool:

234 """

235 After the user has chosen a value, possibly transform it.

236 """

237 if fn := TRANSFORMS.get(prop):

238 data[prop] = fn(data)

239 return True

240 return False

241

242

243 def load_config(

244 _use_pyproject: bool | str | None = True, _use_env: bool | str | None = True, **fallback: Any

245) -> TypeDALConfig:

246 """

247 Combines multiple sources of config into one config instance.

248 """

249 # load toml data

250 # load .env data

251 # combine and fill with fallback values

252 # load typedal config or fail

253 toml_path, toml = _load_toml(_use_pyproject)

254 dotenv_path, dotenv = _load_dotenv(_use_env)

255

256 connection_name = dotenv.get("connection", "") or toml.get("default", "")

257 connection: dict[str, Any] = (toml.get(connection_name) if connection_name else toml) or {}

258

259 combined = connection | dotenv | fallback

260 combined = {k.replace("-", "_"): v for k, v in combined.items()}

261

262 combined["pyproject"] = toml_path

263 combined["connection"] = connection_name

264

265 for prop in TypeDALConfig.__annotations__:

266 fill_defaults(combined, prop)

267

268 for prop in TypeDALConfig.__annotations__:

269 transform(combined, prop)

270

271 return TypeDALConfig.load(combined, convert_types=True)