Coverage for src/typedal/config.py: 100%
107 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 14:43 +0100
« prev ^ index » next coverage.py v7.3.2, created at 2023-12-05 14:43 +0100
1"""
2TypeDAL can be configured by a combination of pyproject.toml (static), env (dynamic) and code (programmic).
3"""
4import os
5import typing
6import warnings
7from pathlib import Path
8from typing import Any, Optional
10import black.files
11import tomli
12from configuraptor import TypedConfig, alias
13from dotenv import dotenv_values, find_dotenv
15if typing.TYPE_CHECKING: # pragma: no cover
16 from edwh_migrate import Config as MigrateConfig
17 from pydal2sql.typer_support import Config as P2SConfig
20class TypeDALConfig(TypedConfig):
21 """
22 Unified config for TypeDAL runtime behavior and migration utilities.
23 """
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"
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"
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"
53 # typedal (depends on properties above)
54 migrate: bool = True
55 fake_migrate: bool = False
57 # aliases:
58 db_uri: str = alias("database")
59 db_type: str = alias("dialect")
60 db_folder: str = alias("folder")
62 def __repr__(self) -> str:
63 """
64 Dump the config to a (fancy) string.
65 """
66 return f"<TypeDAL {self.__dict__}>"
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
74 if self.pyproject: # pragma: no cover
75 project = Path(self.pyproject).read_text()
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)
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 )
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
100 if self.pyproject: # pragma: no cover
101 project = Path(self.pyproject).read_text()
103 if "[tool.typedal]" not in project and "[tool.migrate]" in project:
104 # no typedal config, but existing p2s config:
105 return get_config()
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 )
124def 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(),))
131def _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.
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))
149 if not toml_path:
150 # nothing to load
151 return "", {}
153 try:
154 with open(toml_path, "rb") as f:
155 data = tomli.load(f)
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 "", {}
163def _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")
173 if not dotenv_path:
174 return "", {}
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
182 typedal_data = {k.lower().removeprefix("typedal_"): v for k, v in data.items()}
184 return dotenv_path, typedal_data
187DB_ALIASES = {
188 "postgresql": "postgres",
189 "psql": "postgres",
190 "sqlite3": "sqlite",
191}
194def 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)
201DEFAULTS: 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}
215def 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
226TRANSFORMS: 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}
233def 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
243def 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)
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 {}
259 combined = connection | dotenv | fallback
260 combined = {k.replace("-", "_"): v for k, v in combined.items()}
262 combined["pyproject"] = toml_path
263 combined["connection"] = connection_name
265 for prop in TypeDALConfig.__annotations__:
266 fill_defaults(combined, prop)
268 for prop in TypeDALConfig.__annotations__:
269 transform(combined, prop)
271 return TypeDALConfig.load(combined, convert_types=True)