Coverage for src/pydal2sql/typer_support.py: 100%
153 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-11-13 13:33 +0100
« prev ^ index » next coverage.py v7.2.7, created at 2023-11-13 13:33 +0100
1"""
2Cli-specific support
3"""
4import contextlib
5import functools
6import inspect
7import operator
8import os
9import sys
10import typing
11from dataclasses import dataclass
12from enum import Enum, EnumMeta
13from pathlib import Path
14from typing import Any, Optional
16import configuraptor
17import dotenv
18import rich
19import tomli
20import typer
21from black.files import find_project_root
22from configuraptor.helpers import find_pyproject_toml
23from pydal2sql_core.types import SUPPORTED_DATABASE_TYPES_WITH_ALIASES
24from su6.core import (
25 EXIT_CODE_ERROR,
26 EXIT_CODE_SUCCESS,
27 T_Command,
28 T_Inner_Wrapper,
29 T_Outer_Wrapper,
30)
31from typing_extensions import Never
33T_Literal = typing._SpecialForm
35LiteralType = typing.TypeVar("LiteralType", str, typing.Union[str, str] | T_Literal)
38class ReprEnumMeta(EnumMeta):
39 def __repr__(cls) -> str: # sourcery skip
40 options = typing.cast(typing.Iterable[Enum], cls.__members__.values()) # for mypy
41 members_repr = ", ".join(f"{m.value!r}" for m in options)
42 return f"{cls.__name__}({members_repr})"
45class DynamicEnum(Enum, metaclass=ReprEnumMeta):
46 ...
49def create_enum_from_literal(name: str, literal_type: LiteralType) -> typing.Type[DynamicEnum]:
50 literals: list[str] = []
52 if hasattr(literal_type, "__args__"):
53 for arg in typing.get_args(literal_type):
54 if hasattr(arg, "__args__"):
55 # e.g. literal_type = typing.Union[typing.Literal['one', 'two']]
56 literals.extend(typing.get_args(arg))
57 else:
58 # e.g. literal_type = typing.Literal['one', 'two']
59 literals.append(arg)
60 else:
61 # e.g. literal_type = 'one'
62 literals.append(str(literal_type))
64 literals.sort()
66 enum_dict = {}
68 for literal in literals:
69 enum_name = literal.replace(" ", "_").upper()
70 enum_value = literal
71 enum_dict[enum_name] = enum_value
73 return DynamicEnum(name, enum_dict) # type: ignore
76class Verbosity(Enum):
77 """
78 Verbosity is used with the --verbose argument of the cli commands.
79 """
81 # typer enum can only be string
82 quiet = "1"
83 normal = "2"
84 verbose = "3"
85 debug = "4" # only for internal use
87 @staticmethod
88 def _compare(
89 self: "Verbosity",
90 other: "Verbosity_Comparable",
91 _operator: typing.Callable[["Verbosity_Comparable", "Verbosity_Comparable"], bool],
92 ) -> bool:
93 """
94 Abstraction using 'operator' to have shared functionality between <, <=, ==, >=, >.
96 This enum can be compared with integers, strings and other Verbosity instances.
98 Args:
99 self: the first Verbosity
100 other: the second Verbosity (or other thing to compare)
101 _operator: a callable operator (from 'operators') that takes two of the same types as input.
102 """
103 match other:
104 case Verbosity():
105 return _operator(self.value, other.value)
106 case int():
107 return _operator(int(self.value), other)
108 case str():
109 return _operator(int(self.value), int(other))
111 def __gt__(self, other: "Verbosity_Comparable") -> bool:
112 """
113 Magic method for self > other.
114 """
115 return self._compare(self, other, operator.gt)
117 def __ge__(self, other: "Verbosity_Comparable") -> bool:
118 """
119 Method magic for self >= other.
120 """
121 return self._compare(self, other, operator.ge)
123 def __lt__(self, other: "Verbosity_Comparable") -> bool:
124 """
125 Magic method for self < other.
126 """
127 return self._compare(self, other, operator.lt)
129 def __le__(self, other: "Verbosity_Comparable") -> bool:
130 """
131 Magic method for self <= other.
132 """
133 return self._compare(self, other, operator.le)
135 def __eq__(self, other: typing.Union["Verbosity", str, int, object]) -> bool:
136 """
137 Magic method for self == other.
139 'eq' is a special case because 'other' MUST be object according to mypy
140 """
141 if other is Ellipsis or other is inspect._empty:
142 # both instances of object; can't use Ellipsis or type(ELlipsis) = ellipsis as a type hint in mypy
143 # special cases where Typer instanciates its cli arguments,
144 # return False or it will crash
145 return False
146 if not isinstance(other, (str, int, Verbosity)):
147 raise TypeError(f"Object of type {type(other)} can not be compared with Verbosity")
148 return self._compare(self, other, operator.eq)
150 def __hash__(self) -> int:
151 """
152 Magic method for `hash(self)`, also required for Typer to work.
153 """
154 return hash(self.value)
157Verbosity_Comparable = Verbosity | str | int
159DEFAULT_VERBOSITY = Verbosity.normal
162class AbstractConfig(configuraptor.TypedConfig, configuraptor.Singleton):
163 """
164 Used by state.config and plugin configs.
165 """
167 _strict = True
170DB_Types: typing.Any = create_enum_from_literal("DBType", SUPPORTED_DATABASE_TYPES_WITH_ALIASES)
173@dataclass
174class Config(AbstractConfig):
175 """
176 Used as typed version of the [tool.pydal2sql] part of pyproject.toml.
178 Also accessible via state.config
179 """
181 # settings go here
182 db_type: typing.Optional[SUPPORTED_DATABASE_TYPES_WITH_ALIASES] = None
183 magic: bool = False
184 noop: bool = False
185 tables: Optional[list[str]] = None
186 pyproject: typing.Optional[str] = None
187 function: str = "define_tables"
190MaybeConfig = Optional[Config]
193def _get_pydal2sql_config(overwrites: dict[str, Any], toml_path: str = None) -> MaybeConfig:
194 """
195 Parse the users pyproject.toml (found using black's logic) and extract the tool.pydal2sql part.
197 The types as entered in the toml are checked using _ensure_types,
198 to make sure there isn't a string implicitly converted to a list of characters or something.
200 Args:
201 overwrites: cli arguments can overwrite the config toml.
202 toml_path: by default, black will search for a relevant pyproject.toml.
203 If a toml_path is provided, that file will be used instead.
204 """
205 if toml_path is None:
206 toml_path = find_pyproject_toml()
208 if not toml_path:
209 return None
211 with open(toml_path, "rb") as f:
212 full_config = tomli.load(f)
214 tool_config = full_config["tool"]
216 config = configuraptor.load_into(Config, tool_config, key="pydal2sql")
218 config.update(pyproject=toml_path)
219 config.update(**overwrites)
221 return config
224def get_pydal2sql_config(toml_path: str = None, verbosity: Verbosity = DEFAULT_VERBOSITY, **overwrites: Any) -> Config:
225 """
226 Load the relevant pyproject.toml config settings.
228 Args:
229 verbosity: if something goes wrong, level 3+ will show a warning and 4+ will raise the exception.
230 toml_path: --config can be used to use a different file than ./pyproject.toml
231 overwrites (dict[str, Any): cli arguments can overwrite the config toml.
232 If a value is None, the key is not overwritten.
233 """
234 # strip out any 'overwrites' with None as value
235 overwrites = configuraptor.convert_config(overwrites)
237 try:
238 if config := _get_pydal2sql_config(overwrites, toml_path=toml_path):
239 return config
240 raise ValueError("Falsey config?")
241 except Exception as e:
242 # something went wrong parsing config, use defaults
243 if verbosity > 3:
244 # verbosity = debug
245 raise e
246 elif verbosity > 2:
247 # verbosity = verbose
248 print("Error parsing pyproject.toml, falling back to defaults.", file=sys.stderr)
249 return Config(**overwrites)
252@dataclass()
253class ApplicationState:
254 """
255 Application State - global user defined variables.
257 State contains generic variables passed BEFORE the subcommand (so --verbosity, --config, ...),
258 whereas Config contains settings from the config toml file, updated with arguments AFTER the subcommand
259 (e.g. pydal2sql subcommand <directory> --flag), directory and flag will be updated in the config and not the state.
261 To summarize: 'state' is applicable to all commands and config only to specific ones.
262 """
264 verbosity: Verbosity = DEFAULT_VERBOSITY
265 config_file: Optional[str] = None # will be filled with black's search logic
266 config: MaybeConfig = None
268 def __post_init__(self) -> None:
269 ...
271 def load_config(self, **overwrites: Any) -> Config:
272 """
273 Load the pydal2sql config from pyproject.toml (or other config_file) with optional overwriting settings.
275 Also updates attached plugin configs.
276 """
277 if "verbosity" in overwrites:
278 self.verbosity = overwrites["verbosity"]
279 if "config_file" in overwrites:
280 self.config_file = overwrites.pop("config_file")
282 self.config = get_pydal2sql_config(toml_path=self.config_file, **overwrites)
283 return self.config
285 def get_config(self) -> Config:
286 """
287 Get a filled config instance.
288 """
289 return self.config or self.load_config()
291 def update_config(self, **values: Any) -> Config:
292 """
293 Overwrite default/toml settings with cli values.
295 Example:
296 `config = state.update_config(directory='src')`
297 This will update the state's config and return the same object with the updated settings.
298 """
299 existing_config = self.get_config()
301 values = configuraptor.convert_config(values)
302 existing_config.update(**values)
303 return existing_config
306def with_exit_code(hide_tb: bool = True) -> T_Outer_Wrapper:
307 """
308 Convert the return value of an app.command (bool or int) to an typer Exit with return code, \
309 Unless the return value is Falsey, in which case the default exit happens (with exit code 0 indicating success).
311 Usage:
312 > @app.command()
313 > @with_exit_code()
314 def some_command(): ...
316 When calling a command from a different command, _suppress=True can be added to not raise an Exit exception.
318 See also:
319 github.com:trialandsuccess/su6-checker
320 """
322 def outer_wrapper(func: T_Command) -> T_Inner_Wrapper:
323 @functools.wraps(func)
324 def inner_wrapper(*args: Any, **kwargs: Any) -> Never:
325 try:
326 result = func(*args, **kwargs)
327 except Exception as e:
328 result = EXIT_CODE_ERROR
329 if hide_tb:
330 rich.print(f"[red]{e}[/red]", file=sys.stderr)
331 else: # pragma: no cover
332 raise e
334 if isinstance(result, bool):
335 if result in (None, True):
336 # assume no issue then
337 result = EXIT_CODE_SUCCESS
338 elif result is False:
339 result = EXIT_CODE_ERROR
341 raise typer.Exit(code=int(result or 0))
343 return inner_wrapper
345 return outer_wrapper
348def _is_debug() -> bool: # pragma: no cover
349 folder, _ = find_project_root((os.getcwd(),))
350 if not folder:
351 folder = Path(os.getcwd())
352 dotenv.load_dotenv(folder / ".env")
354 return os.getenv("IS_DEBUG") == "1"
357def is_debug() -> bool: # pragma: no cover
358 with contextlib.suppress(Exception):
359 return _is_debug()
360 return False
363IS_DEBUG = is_debug()