Coverage for src/pydal2sql_core/state.py: 59%
128 statements
« prev ^ index » next coverage.py v7.11.0, created at 2026-04-22 11:54 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2026-04-22 11:54 +0200
1import inspect
2import operator
3import sys
4import typing
5from dataclasses import dataclass
6from enum import Enum, EnumMeta
7from pathlib import Path
8from typing import Any, Optional
10import configuraptor
11import tomli
12from configuraptor import alias
13from configuraptor.helpers import find_pyproject_toml
15from .types import (
16 DEFAULT_OUTPUT_FORMAT,
17 SUPPORTED_DATABASE_TYPES_WITH_ALIASES,
18 SUPPORTED_OUTPUT_FORMATS,
19)
22class ReprEnumMeta(EnumMeta):
23 """
24 Give an Enum class a fancy repr.
25 """
27 def __repr__(cls) -> str: # sourcery skip
28 """
29 Print all of the enum's members.
30 """
31 options = typing.cast(typing.Iterable[Enum], cls.__members__.values()) # for mypy
32 members_repr = ", ".join(f"{m.value!r}" for m in options)
33 return f"{cls.__name__}({members_repr})"
36class DynamicEnum(Enum, metaclass=ReprEnumMeta):
37 """
38 Combine the enum class with the ReprEnumMeta metaclass.
39 """
42def create_enum_from_literal(name: str, literal_type: typing.Any) -> typing.Type[DynamicEnum]:
43 """
44 Transform a typing.Literal statement into an Enum.
46 literal_type can be a typing.Literal or a Union of Literals
47 """
48 literals: list[str] = []
50 if hasattr(literal_type, "__args__"):
51 for arg in typing.get_args(literal_type):
52 if hasattr(arg, "__args__"):
53 # e.g. literal_type = typing.Union[typing.Literal['one', 'two']]
54 literals.extend(typing.get_args(arg))
55 else:
56 # e.g. literal_type = typing.Literal['one', 'two']
57 literals.append(arg)
58 else:
59 # e.g. literal_type = 'one'
60 literals.append(str(literal_type))
62 literals.sort()
64 enum_dict = {}
66 for literal in literals:
67 enum_name = literal.replace(" ", "_").upper()
68 enum_value = literal
69 enum_dict[enum_name] = enum_value
71 return DynamicEnum(name, enum_dict) # type: ignore
74class Verbosity(Enum):
75 """
76 Verbosity is used with the --verbose argument of the cli commands.
77 """
79 # typer enum can only be string
80 quiet = "1"
81 normal = "2"
82 verbose = "3"
83 debug = "4" # only for internal use
85 @staticmethod
86 def _compare(
87 self: "Verbosity",
88 other: "Verbosity_Comparable",
89 _operator: typing.Callable[["Verbosity_Comparable", "Verbosity_Comparable"], bool],
90 ) -> bool:
91 """
92 Abstraction using 'operator' to have shared functionality between <, <=, ==, >=, >.
94 This enum can be compared with integers, strings and other Verbosity instances.
96 Args:
97 self: the first Verbosity
98 other: the second Verbosity (or other thing to compare)
99 _operator: a callable operator (from 'operators') that takes two of the same types as input.
100 """
101 match other:
102 case Verbosity():
103 return _operator(self.value, other.value)
104 case int():
105 return _operator(int(self.value), other)
106 case str():
107 return _operator(int(self.value), int(other))
109 def __gt__(self, other: "Verbosity_Comparable") -> bool:
110 """
111 Magic method for self > other.
112 """
113 return self._compare(self, other, operator.gt)
115 def __ge__(self, other: "Verbosity_Comparable") -> bool:
116 """
117 Method magic for self >= other.
118 """
119 return self._compare(self, other, operator.ge)
121 def __lt__(self, other: "Verbosity_Comparable") -> bool:
122 """
123 Magic method for self < other.
124 """
125 return self._compare(self, other, operator.lt)
127 def __le__(self, other: "Verbosity_Comparable") -> bool:
128 """
129 Magic method for self <= other.
130 """
131 return self._compare(self, other, operator.le)
133 def __eq__(self, other: typing.Union["Verbosity", str, int, object]) -> bool:
134 """
135 Magic method for self == other.
137 'eq' is a special case because 'other' MUST be object according to mypy
138 """
139 if other is None:
140 other = DEFAULT_VERBOSITY
142 if other is Ellipsis or other is inspect._empty:
143 # both instances of object; can't use Ellipsis or type(ELlipsis) = ellipsis as a type hint in mypy
144 # special cases where Typer instanciates its cli arguments,
145 # return False or it will crash
146 return False
147 if not isinstance(other, (str, int, Verbosity)):
148 raise TypeError(f"Object of type {type(other)} can not be compared with Verbosity")
149 return self._compare(self, other, operator.eq)
151 def __hash__(self) -> int:
152 """
153 Magic method for `hash(self)`, also required for Typer to work.
154 """
155 return hash(self.value)
158Verbosity_Comparable = Verbosity | str | int
160DEFAULT_VERBOSITY = Verbosity.normal
163class AbstractConfig(configuraptor.TypedConfig, configuraptor.Singleton):
164 """
165 Used by state.config and plugin configs.
166 """
168 _strict = True
171DB_Types: typing.Any = create_enum_from_literal("DBType", SUPPORTED_DATABASE_TYPES_WITH_ALIASES)
174# @dataclass
175class Config(AbstractConfig):
176 """
177 Used as typed version of the [tool.pydal2sql] part of pyproject.toml.
179 Also accessible via state.config
180 """
182 # settings go here
183 db_type: typing.Optional[SUPPORTED_DATABASE_TYPES_WITH_ALIASES] = None
184 tables: Optional[list[str]] = None
185 magic: bool = False
186 function: str = "define_tables"
187 format: SUPPORTED_OUTPUT_FORMATS = DEFAULT_OUTPUT_FORMAT
188 dialect: typing.Optional[SUPPORTED_DATABASE_TYPES_WITH_ALIASES] = alias("db_type")
189 input: Optional[str] = None
190 output: Optional[str] = None
191 noop: bool = False
193 pyproject: typing.Optional[str] = None
196MaybeConfig = Optional[Config]
199def _get_pydal2sql_config(overwrites: dict[str, Any], toml_path: Optional[str | Path] = None) -> MaybeConfig:
200 """
201 Parse the users pyproject.toml (found using black's logic) and extract the tool.pydal2sql part.
203 The types as entered in the toml are checked using _ensure_types,
204 to make sure there isn't a string implicitly converted to a list of characters or something.
206 Args:
207 overwrites: cli arguments can overwrite the config toml.
208 toml_path: by default, black will search for a relevant pyproject.toml.
209 If a toml_path is provided, that file will be used instead.
210 """
211 if toml_path is None:
212 toml_path = find_pyproject_toml()
214 if not toml_path:
215 return None
217 with open(toml_path, "rb") as f:
218 full_config = tomli.load(f)
220 tool_config = full_config["tool"]
222 config = configuraptor.load_into(Config, tool_config, key="pydal2sql")
224 config.update(pyproject=str(toml_path))
225 config.update(**overwrites)
227 return config
230def get_pydal2sql_config(toml_path: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY, **overwrites: Any) -> Config:
231 """
232 Load the relevant pyproject.toml config settings.
234 Args:
235 verbosity: if something goes wrong, level 3+ will show a warning and 4+ will raise the exception.
236 toml_path: --config can be used to use a different file than ./pyproject.toml
237 overwrites (dict[str, Any): cli arguments can overwrite the config toml.
238 If a value is None, the key is not overwritten.
239 """
240 # strip out any 'overwrites' with None as value
241 overwrites = configuraptor.convert_config(overwrites)
243 try:
244 if config := _get_pydal2sql_config(overwrites, toml_path=toml_path):
245 return config
246 raise ValueError("Falsey config?")
247 except Exception as e:
248 # something went wrong parsing config, use defaults
249 if verbosity > 3:
250 # verbosity = debug
251 raise e
252 elif verbosity > 2:
253 # verbosity = verbose
254 print("Error parsing pyproject.toml, falling back to defaults.", file=sys.stderr)
255 return Config(**overwrites)
258@dataclass()
259class ApplicationState:
260 """
261 Application State - global user defined variables.
263 State contains generic variables passed BEFORE the subcommand (so --verbosity, --config, ...),
264 whereas Config contains settings from the config toml file, updated with arguments AFTER the subcommand
265 (e.g. pydal2sql subcommand <directory> --flag), directory and flag will be updated in the config and not the state.
267 To summarize: 'state' is applicable to all commands and config only to specific ones.
268 """
270 verbosity: Verbosity = DEFAULT_VERBOSITY
271 config_file: Optional[str] = None # will be filled with black's search logic
272 config: MaybeConfig = None
274 def __post_init__(self) -> None:
275 """
276 Runs after the dataclass init.
277 """
279 def load_config(self, **overwrites: Any) -> Config:
280 """
281 Load the pydal2sql config from pyproject.toml (or other config_file) with optional overwriting settings.
283 Also updates attached plugin configs.
284 """
285 if "verbosity" in overwrites:
286 self.verbosity = overwrites["verbosity"]
287 if "config_file" in overwrites:
288 self.config_file = overwrites.pop("config_file")
290 self.config = get_pydal2sql_config(toml_path=self.config_file, **overwrites)
291 return self.config
293 def get_config(self) -> Config:
294 """
295 Get a filled config instance.
296 """
297 return self.config or self.load_config()
299 def update_config(self, **values: Any) -> Config:
300 """
301 Overwrite default/toml settings with cli values.
303 Example:
304 `config = state.update_config(directory='src')`
305 This will update the state's config and return the same object with the updated settings.
306 """
307 existing_config = self.get_config()
309 values = configuraptor.convert_config(values)
310 existing_config.update(**values)
311 return existing_config
314state = ApplicationState()