Coverage for src/typedal/config.py: 100%
103 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-04 19:01 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-04 19:01 +0100
1"""
2TypeDAL can be configured by a combination of pyproject.toml (static), env (dynamic) and code (programmic).
3"""
4import os
5import typing
6from pathlib import Path
7from typing import Any, Optional
9import black.files
10import tomli
11from configuraptor import TypedConfig, alias
12from dotenv import dotenv_values, find_dotenv
14if typing.TYPE_CHECKING: # pragma: no cover
15 from edwh_migrate import Config as MigrateConfig
16 from pydal2sql.typer_support import Config as P2SConfig
19class TypeDALConfig(TypedConfig):
20 """
21 Unified config for TypeDAL runtime behavior and migration utilities.
22 """
24 # typedal:
25 database: str
26 dialect: str
27 folder: str = "databases"
28 caching: bool = True
29 pool_size: int = 0
30 pyproject: str
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"
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"
51 # typedal (depends on properties above)
52 migrate: bool = True
53 fake_migrate: bool = False
55 # aliases:
56 db_uri: str = alias("database")
57 db_type: str = alias("dialect")
58 db_folder: str = alias("folder")
60 def __repr__(self) -> str:
61 """
62 Dump the config to a (fancy) string.
63 """
64 return f"<TypeDAL {self.__dict__}>"
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
72 if self.pyproject: # pragma: no cover
73 project = Path(self.pyproject).read_text()
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)
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 )
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
98 if self.pyproject: # pragma: no cover
99 project = Path(self.pyproject).read_text()
101 if "[tool.typedal]" not in project and "[tool.migrate]" in project:
102 # no typedal config, but existing p2s config:
103 return get_config()
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 )
122def 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(),))
129def _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.
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))
147 if not toml_path:
148 # nothing to load
149 return "", {}
151 try:
152 with open(toml_path, "rb") as f:
153 data = tomli.load(f)
155 return toml_path or "", typing.cast(dict[str, Any], data["tool"]["typedal"])
156 except Exception:
157 return toml_path or "", {}
160def _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")
170 if not dotenv_path:
171 return "", {}
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
179 typedal_data = {k.lower().removeprefix("typedal_"): v for k, v in data.items()}
181 return dotenv_path, typedal_data
184DB_ALIASES = {
185 "postgresql": "postgres",
186 "psql": "postgres",
187 "sqlite3": "sqlite",
188}
191def 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)
198DEFAULTS: 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}
212def 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
223TRANSFORMS: 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}
230def 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
240def 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)
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 {}
256 combined = connection | dotenv | fallback
257 combined = {k.replace("-", "_"): v for k, v in combined.items()}
259 combined["pyproject"] = toml_path
261 for prop in TypeDALConfig.__annotations__:
262 fill_defaults(combined, prop)
264 for prop in TypeDALConfig.__annotations__:
265 transform(combined, prop)
267 return TypeDALConfig.load(combined, convert_types=True)