diff --git a/projects/fal/pyproject.toml b/projects/fal/pyproject.toml index bdda43c0..143481f3 100644 --- a/projects/fal/pyproject.toml +++ b/projects/fal/pyproject.toml @@ -57,7 +57,8 @@ dependencies = [ "pyjwt[crypto]>=2.8.0,<3", "uvicorn>=0.29.0,<1", "cookiecutter", - "tomli" + "tomli==2.2.1", + "tomli-w==1.2.0", ] [project.optional-dependencies] diff --git a/projects/fal/src/fal/cli/main.py b/projects/fal/src/fal/cli/main.py index 79b663c4..7484cdc8 100644 --- a/projects/fal/src/fal/cli/main.py +++ b/projects/fal/src/fal/cli/main.py @@ -6,7 +6,7 @@ from fal.console import console from fal.console.icons import CROSS_ICON -from . import apps, auth, create, deploy, doctor, keys, run, runners, secrets +from . import apps, auth, create, deploy, doctor, keys, profile, run, runners, secrets from .debug import debugtools, get_debug_parser from .parser import FalParser, FalParserExit @@ -31,7 +31,18 @@ def _get_main_parser() -> argparse.ArgumentParser: required=True, ) - for cmd in [auth, apps, deploy, run, keys, secrets, doctor, create, runners]: + for cmd in [ + auth, + apps, + deploy, + run, + keys, + profile, + secrets, + doctor, + create, + runners, + ]: cmd.add_parser(subparsers, parents) return parser diff --git a/projects/fal/src/fal/cli/profile.py b/projects/fal/src/fal/cli/profile.py new file mode 100644 index 00000000..dfeb8f22 --- /dev/null +++ b/projects/fal/src/fal/cli/profile.py @@ -0,0 +1,129 @@ +from rich.table import Table + +from fal.config import Config + + +def _list(args): + config = Config() + + table = Table() + table.add_column("Default") + table.add_column("Profile") + table.add_column("Settings") + + for profile in config.profiles(): + table.add_row( + "*" if profile == config._profile else "", + profile, + ", ".join(key for key in config._config[profile]), + ) + + args.console.print(table) + + +def _set(args): + config = Config() + config.set_internal("profile", args.PROFILE) + args.console.print(f"Default profile set to [cyan]{args.PROFILE}[/].") + config.profile = args.PROFILE + config.save() + + +def _unset(args): + config = Config() + config.set_internal("profile", None) + args.console.print("Default profile unset.") + config.profile = None + config.save() + + +def _key_set(args): + config = Config() + key_id, key_secret = args.KEY.split(":", 1) + config.set("key", f"{key_id}:{key_secret}") + args.console.print(f"Key set for profile [cyan]{config.profile}[/].") + config.save() + + +def _delete(args): + config = Config() + if config.profile == args.PROFILE: + config.set_internal("profile", None) + + config.delete(args.PROFILE) + args.console.print(f"Profile [cyan]{args.PROFILE}[/] deleted.") + config.save() + + +def add_parser(main_subparsers, parents): + auth_help = "Profile management." + parser = main_subparsers.add_parser( + "profile", + description=auth_help, + help=auth_help, + parents=parents, + ) + + subparsers = parser.add_subparsers( + title="Commands", + metavar="command", + dest="cmd", + required=True, + ) + + list_help = "List all profiles." + list_parser = subparsers.add_parser( + "list", + description=list_help, + help=list_help, + parents=parents, + ) + list_parser.set_defaults(func=_list) + + set_help = "Set default profile." + set_parser = subparsers.add_parser( + "set", + description=set_help, + help=set_help, + parents=parents, + ) + set_parser.add_argument( + "PROFILE", + help="Profile name.", + ) + set_parser.set_defaults(func=_set) + + unset_help = "Unset default profile." + unset_parser = subparsers.add_parser( + "unset", + description=unset_help, + help=unset_help, + parents=parents, + ) + unset_parser.set_defaults(func=_unset) + + key_set_help = "Set key for profile." + key_set_parser = subparsers.add_parser( + "key", + description=key_set_help, + help=key_set_help, + parents=parents, + ) + key_set_parser.add_argument( + "KEY", + help="Key ID and secret separated by a colon.", + ) + key_set_parser.set_defaults(func=_key_set) + + delete_help = "Delete profile." + delete_parser = subparsers.add_parser( + "delete", + description=delete_help, + help=delete_help, + parents=parents, + ) + delete_parser.add_argument( + "PROFILE", + help="Profile name.", + ) + delete_parser.set_defaults(func=_delete) diff --git a/projects/fal/src/fal/config.py b/projects/fal/src/fal/config.py index d1ef2b74..3e317b41 100644 --- a/projects/fal/src/fal/config.py +++ b/projects/fal/src/fal/config.py @@ -1,23 +1,84 @@ import os +from typing import Optional import tomli +import tomli_w + +SETTINGS_SECTION = "__internal__" class Config: + _config: dict[str, dict[str, str]] + _profile: Optional[str] + DEFAULT_CONFIG_PATH = "~/.fal/config.toml" - DEFAULT_PROFILE = "default" def __init__(self): self.config_path = os.path.expanduser( os.getenv("FAL_CONFIG_PATH", self.DEFAULT_CONFIG_PATH) ) - self.profile = os.getenv("FAL_PROFILE", self.DEFAULT_PROFILE) try: with open(self.config_path, "rb") as file: - self.config = tomli.load(file) + self._config = tomli.load(file) except FileNotFoundError: - self.config = {} + self._config = {} + + profile = os.getenv("FAL_PROFILE") + if not profile: + profile = self.get_internal("profile") + + self.profile = profile + + @property + def profile(self) -> Optional[str]: + return self._profile + + @profile.setter + def profile(self, value: Optional[str]): + if value and value not in self._config: + self._config[value] = {} + + self._profile = value + + def profiles(self): + keys = [] + for key in self._config: + if key != SETTINGS_SECTION: + keys.append(key) + + return keys + + def save(self): + with open(self.config_path, "wb") as file: + tomli_w.dump(self._config, file) + + def get(self, key: str) -> Optional[str]: + if not self.profile: + return None + + return self._config.get(self.profile, {}).get(key) + + def set(self, key: str, value: str): + if not self.profile: + raise ValueError("No profile set.") + + self._config[self.profile][key] = value + + def get_internal(self, key): + if SETTINGS_SECTION not in self._config: + self._config[SETTINGS_SECTION] = {} + + return self._config[SETTINGS_SECTION].get(key) + + def set_internal(self, key, value): + if SETTINGS_SECTION not in self._config: + self._config[SETTINGS_SECTION] = {} + + if value is None: + del self._config[SETTINGS_SECTION][key] + else: + self._config[SETTINGS_SECTION][key] = value - def get(self, key): - return self.config.get(self.profile, {}).get(key) + def delete(self, profile): + del self._config[profile]