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 from pathlib import Path

7 from typing import Any, Optional

8

9 import black.files

10 import tomli

11 from configuraptor import TypedConfig, alias

12 from dotenv import dotenv_values, find_dotenv

13

14 if typing.TYPE_CHECKING: # pragma: no cover

15 from edwh_migrate import Config as MigrateConfig

16 from pydal2sql.typer_support import Config as P2SConfig

17

18

19 class TypeDALConfig(TypedConfig):

20 """

21 Unified config for TypeDAL runtime behavior and migration utilities.

22 """

23

24 # typedal:

25 database: str

26 dialect: str

27 folder: str = "databases"

28 caching: bool = True

29 pool_size: int = 0

30 pyproject: str

31

32 # pydal2sql:

33 input: str = "" # noqa: A003

34 output: str = ""

35 noop: bool = False

36 magic: bool = True

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

38 function: str = "define_tables"

39

40 # edwh-migrate:

41 # migrate uri = database

42 database_to_restore: Optional[str]

43 migrate_cat_command: Optional[str]

44 schema_version: Optional[str]

45 redis_host: Optional[str]

46 migrate_table: str = "typedal_implemented_features"

47 flag_location: str

48 create_flag_location: bool = True

49 schema: str = "public"

50

51 # typedal (depends on properties above)

52 migrate: bool = True

53 fake_migrate: bool = False

54

55 # aliases:

56 db_uri: str = alias("database")

57 db_type: str = alias("dialect")

58 db_folder: str = alias("folder")

59

60 def __repr__(self) -> str:

61 """

62 Dump the config to a (fancy) string.

63 """

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

65

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

67 """

68 Convert the config to the format required by pydal2sql.

69 """

70 from pydal2sql.typer_support import Config, get_pydal2sql_config

71

72 if self.pyproject: # pragma: no cover

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

74

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

76 # no typedal config, but existing p2s config:

77 return get_pydal2sql_config(self.pyproject)

78

79 return Config.load(

80 {

81 "db_type": self.dialect,

82 "format": "edwh-migrate",

83 "tables": self.tables,

84 "magic": self.magic,

85 "function": self.function,

86 "input": self.input,

87 "output": self.output,

88 "pyproject": self.pyproject,

89 }

90 )

91

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

93 """

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

95 """

96 from edwh_migrate import Config, get_config

97

98 if self.pyproject: # pragma: no cover

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

100

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

102 # no typedal config, but existing p2s config:

103 return get_config()

104

105 return Config.load(

106 {

107 "migrate_uri": self.database,

108 "schema_version": self.schema_version,

109 "redis_host": self.redis_host,

110 "migrate_cat_command": self.migrate_cat_command,

111 "database_to_restore": self.database_to_restore,

112 "migrate_table": self.migrate_table,

113 "flag_location": self.flag_location,

114 "create_flag_location": self.create_flag_location,

115 "schema": self.schema,

116 "db_folder": self.folder,

117 "migrations_file": self.output,

118 }

119 )

120

121

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

123 """

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

125 """

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

127

128

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

130 """

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

132

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

134 If it is False, no data is loaded.

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

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

137 """

138 if path is False:

139 toml_path = None

140 elif path in (True, None):

141 toml_path = find_pyproject_toml()

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

143 toml_path = str(path)

144 else:

145 toml_path = find_pyproject_toml(str(path))

146

147 if not toml_path:

148 # nothing to load

149 return "", {}

150

151 try:

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

153 data = tomli.load(f)

154

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

156 except Exception:

157 return toml_path or "", {}

158

159

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

161 if path is False:

162 dotenv_path = None

163 elif path in (True, None):

164 dotenv_path = find_dotenv(usecwd=True)

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

166 dotenv_path = str(path)

167 else:

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

169

170 if not dotenv_path:

171 return "", {}

172

173 # 1. find everything with TYPEDAL_ prefix

174 # 2. remove that prefix

175 # 3. format values if possible

176 data = dotenv_values(dotenv_path)

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

178

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

180

181 return dotenv_path, typedal_data

182

183

184 DB_ALIASES = {

185 "postgresql": "postgres",

186 "psql": "postgres",

187 "sqlite3": "sqlite",

188}

189

190

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

192 """

193 Convert a db dialect alias to the standard name.

194 """

195 return DB_ALIASES.get(db_name, db_name)

196

197

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

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

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

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

202 else data.get("db_type"),

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

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

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

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

207 else "/flags",

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

209}

210

211

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

213 """

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

215 """

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

217 default = DEFAULTS.get(prop)

218 if callable(default):

219 default = default(data)

220 data[prop] = default

221

222

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

224 "database": lambda data: data["database"]

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

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

227}

228

229

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

231 """

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

233 """

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

235 data[prop] = fn(data)

236 return True

237 return False

238

239

240 def load_config(

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

242) -> TypeDALConfig:

243 """

244 Combines multiple sources of config into one config instance.

245 """

246 # load toml data

247 # load .env data

248 # combine and fill with fallback values

249 # load typedal config or fail

250 toml_path, toml = _load_toml(_use_pyproject)

251 dotenv_path, dotenv = _load_dotenv(_use_env)

252

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

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

255

256 combined = connection | dotenv | fallback

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

258

259 combined["pyproject"] = toml_path

260

261 for prop in TypeDALConfig.__annotations__:

262 fill_defaults(combined, prop)

263

264 for prop in TypeDALConfig.__annotations__:

265 transform(combined, prop)

266

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