falyx 0.1.21__py3-none-any.whl → 0.1.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
falyx/__main__.py CHANGED
@@ -6,6 +6,7 @@ Licensed under the MIT License. See LICENSE file for details.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import os
9
10
  import sys
10
11
  from argparse import Namespace
11
12
  from pathlib import Path
@@ -22,6 +23,7 @@ def find_falyx_config() -> Path | None:
22
23
  Path.cwd() / "falyx.toml",
23
24
  Path.cwd() / ".falyx.yaml",
24
25
  Path.cwd() / ".falyx.toml",
26
+ Path(os.environ.get("FALYX_CONFIG", "falyx.yaml")),
25
27
  Path.home() / ".config" / "falyx" / "falyx.yaml",
26
28
  Path.home() / ".config" / "falyx" / "falyx.toml",
27
29
  Path.home() / ".falyx.yaml",
@@ -67,12 +69,7 @@ def run(args: Namespace) -> Any:
67
69
  print("No Falyx config file found. Exiting.")
68
70
  return None
69
71
 
70
- flx = Falyx(
71
- title="🛠️ Config-Driven CLI",
72
- cli_args=args,
73
- columns=4,
74
- )
75
- flx.add_commands(loader(bootstrap_path))
72
+ flx: Falyx = loader(bootstrap_path)
76
73
  return asyncio.run(flx.run())
77
74
 
78
75
 
falyx/action_factory.py CHANGED
@@ -1,3 +1,4 @@
1
+ # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
1
2
  from typing import Any
2
3
 
3
4
  from rich.tree import Tree
falyx/command.py CHANGED
@@ -272,15 +272,21 @@ class Command(BaseModel):
272
272
  if hasattr(self.action, "preview") and callable(self.action.preview):
273
273
  tree = Tree(label)
274
274
  await self.action.preview(parent=tree)
275
+ if self.help_text:
276
+ tree.add(f"[dim]💡 {self.help_text}[/dim]")
275
277
  console.print(tree)
276
278
  elif callable(self.action) and not isinstance(self.action, BaseAction):
277
279
  console.print(f"{label}")
280
+ if self.help_text:
281
+ console.print(f"[dim]💡 {self.help_text}[/dim]")
278
282
  console.print(
279
283
  f"[{OneColors.LIGHT_RED_b}]→ Would call:[/] {self.action.__name__}"
280
284
  f"[dim](args={self.args}, kwargs={self.kwargs})[/dim]"
281
285
  )
282
286
  else:
283
287
  console.print(f"{label}")
288
+ if self.help_text:
289
+ console.print(f"[dim]💡 {self.help_text}[/dim]")
284
290
  console.print(
285
291
  f"[{OneColors.DARK_RED}]⚠️ Action is not callable or lacks a preview method.[/]"
286
292
  )
falyx/config.py CHANGED
@@ -1,17 +1,26 @@
1
1
  # Falyx CLI Framework — (c) 2025 rtj.dev LLC — MIT Licensed
2
2
  """config.py
3
3
  Configuration loader for Falyx CLI commands."""
4
+ from __future__ import annotations
4
5
 
5
6
  import importlib
7
+ import sys
6
8
  from pathlib import Path
7
- from typing import Any
9
+ from typing import Any, Callable
8
10
 
9
11
  import toml
10
12
  import yaml
13
+ from pydantic import BaseModel, Field, field_validator, model_validator
14
+ from rich.console import Console
11
15
 
12
16
  from falyx.action import Action, BaseAction
13
17
  from falyx.command import Command
18
+ from falyx.falyx import Falyx
14
19
  from falyx.retry import RetryPolicy
20
+ from falyx.themes.colors import OneColors
21
+ from falyx.utils import logger
22
+
23
+ console = Console(color_system="auto")
15
24
 
16
25
 
17
26
  def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
@@ -21,8 +30,8 @@ def wrap_if_needed(obj: Any, name=None) -> BaseAction | Command:
21
30
  return Action(name=name or getattr(obj, "__name__", "unnamed"), action=obj)
22
31
  else:
23
32
  raise TypeError(
24
- f"Cannot wrap object of type '{type(obj).__name__}' as a BaseAction or Command. "
25
- "It must be a callable or an instance of BaseAction."
33
+ f"Cannot wrap object of type '{type(obj).__name__}'. "
34
+ "Expected a function or BaseAction."
26
35
  )
27
36
 
28
37
 
@@ -30,12 +39,125 @@ def import_action(dotted_path: str) -> Any:
30
39
  """Dynamically imports a callable from a dotted path like 'my.module.func'."""
31
40
  module_path, _, attr = dotted_path.rpartition(".")
32
41
  if not module_path:
33
- raise ValueError(f"Invalid action path: {dotted_path}")
34
- module = importlib.import_module(module_path)
35
- return getattr(module, attr)
42
+ console.print(f"[{OneColors.DARK_RED}]❌ Invalid action path:[/] {dotted_path}")
43
+ sys.exit(1)
44
+ try:
45
+ module = importlib.import_module(module_path)
46
+ except ModuleNotFoundError as error:
47
+ logger.error("Failed to import module '%s': %s", module_path, error)
48
+ console.print(
49
+ f"[{OneColors.DARK_RED}]❌ Could not import '{dotted_path}': {error}[/]\n"
50
+ f"[{OneColors.COMMENT_GREY}]Ensure the module is installed and discoverable via PYTHONPATH."
51
+ )
52
+ sys.exit(1)
53
+ try:
54
+ action = getattr(module, attr)
55
+ except AttributeError as error:
56
+ logger.error(
57
+ "Module '%s' does not have attribute '%s': %s", module_path, attr, error
58
+ )
59
+ console.print(
60
+ f"[{OneColors.DARK_RED}]❌ Module '{module_path}' has no attribute '{attr}': {error}[/]"
61
+ )
62
+ sys.exit(1)
63
+ return action
64
+
65
+
66
+ class RawCommand(BaseModel):
67
+ key: str
68
+ description: str
69
+ action: str
70
+
71
+ args: tuple[Any, ...] = ()
72
+ kwargs: dict[str, Any] = {}
73
+ aliases: list[str] = []
74
+ tags: list[str] = []
75
+ style: str = "white"
76
+
77
+ confirm: bool = False
78
+ confirm_message: str = "Are you sure?"
79
+ preview_before_confirm: bool = True
80
+
81
+ spinner: bool = False
82
+ spinner_message: str = "Processing..."
83
+ spinner_type: str = "dots"
84
+ spinner_style: str = "cyan"
85
+ spinner_kwargs: dict[str, Any] = {}
86
+
87
+ before_hooks: list[Callable] = []
88
+ success_hooks: list[Callable] = []
89
+ error_hooks: list[Callable] = []
90
+ after_hooks: list[Callable] = []
91
+ teardown_hooks: list[Callable] = []
92
+
93
+ logging_hooks: bool = False
94
+ retry: bool = False
95
+ retry_all: bool = False
96
+ retry_policy: RetryPolicy = Field(default_factory=RetryPolicy)
97
+ requires_input: bool | None = None
98
+ hidden: bool = False
99
+ help_text: str = ""
100
+
101
+ @field_validator("retry_policy")
102
+ @classmethod
103
+ def validate_retry_policy(cls, value: dict | RetryPolicy) -> RetryPolicy:
104
+ if isinstance(value, RetryPolicy):
105
+ return value
106
+ if not isinstance(value, dict):
107
+ raise ValueError("retry_policy must be a dictionary.")
108
+ return RetryPolicy(**value)
109
+
110
+
111
+ def convert_commands(raw_commands: list[dict[str, Any]]) -> list[Command]:
112
+ commands = []
113
+ for entry in raw_commands:
114
+ raw_command = RawCommand(**entry)
115
+ commands.append(
116
+ Command.model_validate(
117
+ {
118
+ **raw_command.model_dump(exclude={"action"}),
119
+ "action": wrap_if_needed(
120
+ import_action(raw_command.action), name=raw_command.description
121
+ ),
122
+ }
123
+ )
124
+ )
125
+ return commands
36
126
 
37
127
 
38
- def loader(file_path: Path | str) -> list[dict[str, Any]]:
128
+ class FalyxConfig(BaseModel):
129
+ title: str = "Falyx CLI"
130
+ prompt: str | list[tuple[str, str]] | list[list[str]] = [
131
+ (OneColors.BLUE_b, "FALYX > ")
132
+ ]
133
+ columns: int = 4
134
+ welcome_message: str = ""
135
+ exit_message: str = ""
136
+ commands: list[Command] | list[dict] = []
137
+
138
+ @model_validator(mode="after")
139
+ def validate_prompt_format(self) -> FalyxConfig:
140
+ if isinstance(self.prompt, list):
141
+ for pair in self.prompt:
142
+ if not isinstance(pair, (list, tuple)) or len(pair) != 2:
143
+ raise ValueError(
144
+ "Prompt list must contain 2-element (style, text) pairs"
145
+ )
146
+ return self
147
+
148
+ def to_falyx(self) -> Falyx:
149
+ flx = Falyx(
150
+ title=self.title,
151
+ prompt=self.prompt,
152
+ columns=self.columns,
153
+ welcome_message=self.welcome_message,
154
+ exit_message=self.exit_message,
155
+ )
156
+ flx.add_commands(self.commands)
157
+ return flx
158
+
159
+
160
+ def loader(file_path: Path | str) -> Falyx:
39
161
  """
40
162
  Load command definitions from a YAML or TOML file.
41
163
 
@@ -48,15 +170,13 @@ def loader(file_path: Path | str) -> list[dict[str, Any]]:
48
170
  file_path (str): Path to the config file (YAML or TOML).
49
171
 
50
172
  Returns:
51
- list[dict[str, Any]]: A list of command configuration dictionaries.
173
+ Falyx: An instance of the Falyx CLI with loaded commands.
52
174
 
53
175
  Raises:
54
176
  ValueError: If the file format is unsupported or file cannot be parsed.
55
177
  """
56
- if isinstance(file_path, str):
178
+ if isinstance(file_path, (str, Path)):
57
179
  path = Path(file_path)
58
- elif isinstance(file_path, Path):
59
- path = file_path
60
180
  else:
61
181
  raise TypeError("file_path must be a string or Path object.")
62
182
 
@@ -72,48 +192,23 @@ def loader(file_path: Path | str) -> list[dict[str, Any]]:
72
192
  else:
73
193
  raise ValueError(f"Unsupported config format: {suffix}")
74
194
 
75
- if not isinstance(raw_config, list):
76
- raise ValueError("Configuration file must contain a list of command definitions.")
77
-
78
- required = ["key", "description", "action"]
79
- commands = []
80
- for entry in raw_config:
81
- for field in required:
82
- if field not in entry:
83
- raise ValueError(f"Missing '{field}' in command entry: {entry}")
84
-
85
- command_dict = {
86
- "key": entry["key"],
87
- "description": entry["description"],
88
- "action": wrap_if_needed(
89
- import_action(entry["action"]), name=entry["description"]
90
- ),
91
- "args": tuple(entry.get("args", ())),
92
- "kwargs": entry.get("kwargs", {}),
93
- "hidden": entry.get("hidden", False),
94
- "aliases": entry.get("aliases", []),
95
- "help_text": entry.get("help_text", ""),
96
- "style": entry.get("style", "white"),
97
- "confirm": entry.get("confirm", False),
98
- "confirm_message": entry.get("confirm_message", "Are you sure?"),
99
- "preview_before_confirm": entry.get("preview_before_confirm", True),
100
- "spinner": entry.get("spinner", False),
101
- "spinner_message": entry.get("spinner_message", "Processing..."),
102
- "spinner_type": entry.get("spinner_type", "dots"),
103
- "spinner_style": entry.get("spinner_style", "cyan"),
104
- "spinner_kwargs": entry.get("spinner_kwargs", {}),
105
- "before_hooks": entry.get("before_hooks", []),
106
- "success_hooks": entry.get("success_hooks", []),
107
- "error_hooks": entry.get("error_hooks", []),
108
- "after_hooks": entry.get("after_hooks", []),
109
- "teardown_hooks": entry.get("teardown_hooks", []),
110
- "retry": entry.get("retry", False),
111
- "retry_all": entry.get("retry_all", False),
112
- "retry_policy": RetryPolicy(**entry.get("retry_policy", {})),
113
- "tags": entry.get("tags", []),
114
- "logging_hooks": entry.get("logging_hooks", False),
115
- "requires_input": entry.get("requires_input", None),
116
- }
117
- commands.append(command_dict)
195
+ if not isinstance(raw_config, dict):
196
+ raise ValueError(
197
+ "Configuration file must contain a dictionary with a list of commands.\n"
198
+ "Example:\n"
199
+ "title: 'My CLI'\n"
200
+ "commands:\n"
201
+ " - key: 'a'\n"
202
+ " description: 'Example command'\n"
203
+ " action: 'my_module.my_function'"
204
+ )
118
205
 
119
- return commands
206
+ commands = convert_commands(raw_config["commands"])
207
+ return FalyxConfig(
208
+ title=raw_config.get("title", f"[{OneColors.BLUE_b}]Falyx CLI"),
209
+ prompt=raw_config.get("prompt", [(OneColors.BLUE_b, "FALYX > ")]),
210
+ columns=raw_config.get("columns", 4),
211
+ welcome_message=raw_config.get("welcome_message", ""),
212
+ exit_message=raw_config.get("exit_message", ""),
213
+ commands=commands,
214
+ ).to_falyx()
falyx/config_schema.py ADDED
@@ -0,0 +1,76 @@
1
+ FALYX_CONFIG_SCHEMA = {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "Falyx CLI Config",
4
+ "type": "object",
5
+ "properties": {
6
+ "title": {"type": "string", "description": "Title shown at top of menu"},
7
+ "prompt": {
8
+ "oneOf": [
9
+ {"type": "string"},
10
+ {
11
+ "type": "array",
12
+ "items": {
13
+ "type": "array",
14
+ "prefixItems": [
15
+ {
16
+ "type": "string",
17
+ "description": "Style string (e.g., 'bold #ff0000 italic')",
18
+ },
19
+ {"type": "string", "description": "Text content"},
20
+ ],
21
+ "minItems": 2,
22
+ "maxItems": 2,
23
+ },
24
+ },
25
+ ]
26
+ },
27
+ "columns": {
28
+ "type": "integer",
29
+ "minimum": 1,
30
+ "description": "Number of menu columns",
31
+ },
32
+ "welcome_message": {"type": "string"},
33
+ "exit_message": {"type": "string"},
34
+ "commands": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "required": ["key", "description", "action"],
39
+ "properties": {
40
+ "key": {"type": "string", "minLength": 1},
41
+ "description": {"type": "string"},
42
+ "action": {
43
+ "type": "string",
44
+ "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*(\\.[a-zA-Z_][a-zA-Z0-9_]*)+$",
45
+ "description": "Dotted import path (e.g., mymodule.task)",
46
+ },
47
+ "args": {"type": "array"},
48
+ "kwargs": {"type": "object"},
49
+ "aliases": {"type": "array", "items": {"type": "string"}},
50
+ "tags": {"type": "array", "items": {"type": "string"}},
51
+ "style": {"type": "string"},
52
+ "confirm": {"type": "boolean"},
53
+ "confirm_message": {"type": "string"},
54
+ "preview_before_confirm": {"type": "boolean"},
55
+ "spinner": {"type": "boolean"},
56
+ "spinner_message": {"type": "string"},
57
+ "spinner_type": {"type": "string"},
58
+ "spinner_style": {"type": "string"},
59
+ "logging_hooks": {"type": "boolean"},
60
+ "retry": {"type": "boolean"},
61
+ "retry_all": {"type": "boolean"},
62
+ "retry_policy": {
63
+ "type": "object",
64
+ "properties": {
65
+ "enabled": {"type": "boolean"},
66
+ "max_retries": {"type": "integer"},
67
+ "delay": {"type": "number"},
68
+ "backoff": {"type": "number"},
69
+ },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ "required": ["commands"],
76
+ }
@@ -9,6 +9,7 @@ from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
11
  from falyx.context import ExecutionContext
12
+ from falyx.themes.colors import OneColors
12
13
  from falyx.utils import logger
13
14
 
14
15
 
@@ -66,10 +67,10 @@ class ExecutionRegistry:
66
67
  duration = f"{ctx.duration:.3f}s" if ctx.duration else "n/a"
67
68
 
68
69
  if ctx.exception:
69
- status = "[bold red]❌ Error"
70
+ status = f"[{OneColors.DARK_RED}]❌ Error"
70
71
  result = repr(ctx.exception)
71
72
  else:
72
- status = "[green]✅ Success"
73
+ status = f"[{OneColors.GREEN}]✅ Success"
73
74
  result = repr(ctx.result)
74
75
  if len(result) > 1000:
75
76
  result = f"{result[:1000]}..."
falyx/falyx.py CHANGED
@@ -283,7 +283,7 @@ class Falyx:
283
283
  self.console.print(table, justify="center")
284
284
  if self.mode == FalyxMode.MENU:
285
285
  self.console.print(
286
- f"📦 Tip: Type '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
286
+ f"📦 Tip: '[{OneColors.LIGHT_YELLOW}]?[KEY][/]' to preview a command before running it.\n",
287
287
  justify="center",
288
288
  )
289
289
 
@@ -291,7 +291,7 @@ class Falyx:
291
291
  """Returns the help command for the menu."""
292
292
  return Command(
293
293
  key="H",
294
- aliases=["HELP"],
294
+ aliases=["HELP", "?"],
295
295
  description="Help",
296
296
  action=self._show_help,
297
297
  style=OneColors.LIGHT_YELLOW,
@@ -343,7 +343,9 @@ class Falyx:
343
343
  error_message = " ".join(message_lines)
344
344
 
345
345
  def validator(text):
346
- _, choice = self.get_command(text, from_validate=True)
346
+ is_preview, choice = self.get_command(text, from_validate=True)
347
+ if is_preview and choice is None:
348
+ return True
347
349
  return True if choice else False
348
350
 
349
351
  return Validator.from_callable(
@@ -558,10 +560,24 @@ class Falyx:
558
560
  self.add_command(key, description, submenu.menu, style=style)
559
561
  submenu.update_exit_command(key="B", description="Back", aliases=["BACK"])
560
562
 
561
- def add_commands(self, commands: list[dict]) -> None:
562
- """Adds multiple commands to the menu."""
563
+ def add_commands(self, commands: list[Command] | list[dict]) -> None:
564
+ """Adds a list of Command instances or config dicts."""
563
565
  for command in commands:
564
- self.add_command(**command)
566
+ if isinstance(command, dict):
567
+ self.add_command(**command)
568
+ elif isinstance(command, Command):
569
+ self.add_command_from_command(command)
570
+ else:
571
+ raise FalyxError(
572
+ "Command must be a dictionary or an instance of Command."
573
+ )
574
+
575
+ def add_command_from_command(self, command: Command) -> None:
576
+ """Adds a command to the menu from an existing Command object."""
577
+ if not isinstance(command, Command):
578
+ raise FalyxError("command must be an instance of Command.")
579
+ self._validate_command_key(command.key)
580
+ self.commands[command.key] = command
565
581
 
566
582
  def add_command(
567
583
  self,
@@ -694,6 +710,16 @@ class Falyx:
694
710
  ) -> tuple[bool, Command | None]:
695
711
  """Returns the selected command based on user input. Supports keys, aliases, and abbreviations."""
696
712
  is_preview, choice = self.parse_preview_command(choice)
713
+ if is_preview and not choice and self.help_command:
714
+ is_preview = False
715
+ choice = "?"
716
+ elif is_preview and not choice:
717
+ if not from_validate:
718
+ self.console.print(
719
+ f"[{OneColors.DARK_RED}]❌ You must enter a command for preview mode.[/]"
720
+ )
721
+ return is_preview, None
722
+
697
723
  choice = choice.upper()
698
724
  name_map = self._name_map
699
725
 
@@ -788,12 +814,17 @@ class Falyx:
788
814
  async def run_key(self, command_key: str, return_context: bool = False) -> Any:
789
815
  """Run a command by key without displaying the menu (non-interactive mode)."""
790
816
  self.debug_hooks()
791
- _, selected_command = self.get_command(command_key)
817
+ is_preview, selected_command = self.get_command(command_key)
792
818
  self.last_run_command = selected_command
793
819
 
794
820
  if not selected_command:
795
821
  return None
796
822
 
823
+ if is_preview:
824
+ logger.info(f"Preview command '{selected_command.key}' selected.")
825
+ await selected_command.preview()
826
+ return None
827
+
797
828
  logger.info(
798
829
  "[run_key] 🚀 Executing: %s — %s",
799
830
  selected_command.key,
@@ -877,28 +908,29 @@ class Falyx:
877
908
  self.debug_hooks()
878
909
  if self.welcome_message:
879
910
  self.print_message(self.welcome_message)
880
- while True:
881
- if callable(self.render_menu):
882
- self.render_menu(self)
883
- else:
884
- self.console.print(self.table, justify="center")
885
- try:
886
- task = asyncio.create_task(self.process_command())
887
- should_continue = await task
888
- if not should_continue:
911
+ try:
912
+ while True:
913
+ if callable(self.render_menu):
914
+ self.render_menu(self)
915
+ else:
916
+ self.console.print(self.table, justify="center")
917
+ try:
918
+ task = asyncio.create_task(self.process_command())
919
+ should_continue = await task
920
+ if not should_continue:
921
+ break
922
+ except (EOFError, KeyboardInterrupt):
923
+ logger.info("EOF or KeyboardInterrupt. Exiting menu.")
889
924
  break
890
- except (EOFError, KeyboardInterrupt):
891
- logger.info("EOF or KeyboardInterrupt. Exiting menu.")
892
- break
893
- except QuitSignal:
894
- logger.info("QuitSignal received. Exiting menu.")
895
- break
896
- except BackSignal:
897
- logger.info("BackSignal received.")
898
- finally:
899
- logger.info(f"Exiting menu: {self.get_title()}")
900
- if self.exit_message:
901
- self.print_message(self.exit_message)
925
+ except QuitSignal:
926
+ logger.info("QuitSignal received. Exiting menu.")
927
+ break
928
+ except BackSignal:
929
+ logger.info("BackSignal received.")
930
+ finally:
931
+ logger.info(f"Exiting menu: {self.get_title()}")
932
+ if self.exit_message:
933
+ self.print_message(self.exit_message)
902
934
 
903
935
  async def run(self) -> None:
904
936
  """Run Falyx CLI with structured subcommands."""
@@ -943,11 +975,14 @@ class Falyx:
943
975
 
944
976
  if self.cli_args.command == "run":
945
977
  self.mode = FalyxMode.RUN
946
- _, command = self.get_command(self.cli_args.name)
978
+ is_preview, command = self.get_command(self.cli_args.name)
979
+ if is_preview:
980
+ if command is None:
981
+ sys.exit(1)
982
+ logger.info(f"Preview command '{command.key}' selected.")
983
+ await command.preview()
984
+ sys.exit(0)
947
985
  if not command:
948
- self.console.print(
949
- f"[{OneColors.DARK_RED}]❌ Command '{self.cli_args.name}' not found.[/]"
950
- )
951
986
  sys.exit(1)
952
987
  self._set_retry_policy(command)
953
988
  try:
@@ -955,6 +990,9 @@ class Falyx:
955
990
  except FalyxError as error:
956
991
  self.console.print(f"[{OneColors.DARK_RED}]❌ Error: {error}[/]")
957
992
  sys.exit(1)
993
+
994
+ if self.cli_args.summary:
995
+ er.summary()
958
996
  sys.exit(0)
959
997
 
960
998
  if self.cli_args.command == "run-all":
@@ -976,6 +1014,10 @@ class Falyx:
976
1014
  for cmd in matching:
977
1015
  self._set_retry_policy(cmd)
978
1016
  await self.run_key(cmd.key)
1017
+
1018
+ if self.cli_args.summary:
1019
+ er.summary()
1020
+
979
1021
  sys.exit(0)
980
1022
 
981
1023
  await self.menu()