usecli 0.1.60__tar.gz → 0.1.63__tar.gz
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.
- {usecli-0.1.60 → usecli-0.1.63}/PKG-INFO +1 -1
- {usecli-0.1.60 → usecli-0.1.63}/pyproject.toml +1 -1
- usecli-0.1.63/src/usecli/__init__.py +457 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/config/colors.py +106 -41
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/shared/config/manager.py +253 -81
- usecli-0.1.60/src/usecli/__init__.py +0 -404
- {usecli-0.1.60 → usecli-0.1.63}/LICENSE +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/README.md +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/README.md +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/custom/README.md +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/custom/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/base/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/base/about_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/base/help_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/base/inspire_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/base/internal/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/base/internal/fzf_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/core/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/core/utils.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/make/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/make/make_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/defaults/make/make_theme_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/commands/init_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/config/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/base_command.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/error/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/error/handler.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/error/utils.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/exceptions/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/exceptions/base.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/exceptions/config.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/exceptions/usage.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/exceptions/validation.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/ui/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/ui/list.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/ui/title.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/ui/title.txt +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/validators/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/validators/network.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/validators/numeric.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/validators/path.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/core/validators/string.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/services/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/services/command_service.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/templates/command.py.j2 +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/templates/theme.toml.j2 +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/templates/usecli.config.toml.j2 +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/ayu_dark.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/catppuccin_frappe.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/catppuccin_latte.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/catppuccin_macchiato.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/catppuccin_mocha.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/default.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/dracula.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/gruvbox_dark.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/nord.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/themes/tokyo_night.toml +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/utils/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/utils/interactive/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/cli/utils/interactive/terminal_menu.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/menu.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/params.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/shared/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/shared/config/__init__.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/shared/config/globals.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/ui.py +0 -0
- {usecli-0.1.60 → usecli-0.1.63}/src/usecli/usecli.config.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "usecli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.63"
|
|
4
4
|
description = "A powerful Python CLI framework for building beautiful, developer-friendly command-line tools."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [{ name = "Edward Boswell", email = "thememium@gmail.com" }]
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
"""Usecli CLI main entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
|
|
8
|
+
# Avoid importing typing at module level (costs ~6ms)
|
|
9
|
+
# TYPE_CHECKING is False at runtime, so the if-block never executes
|
|
10
|
+
TYPE_CHECKING = False
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
from usecli.cli.core.base_command import BaseCommand
|
|
19
|
+
from usecli.menu import Menu
|
|
20
|
+
from usecli.params import Argument, Option
|
|
21
|
+
from usecli.ui import Confirm, Prompt
|
|
22
|
+
|
|
23
|
+
console: Console
|
|
24
|
+
|
|
25
|
+
# Lazy module-level placeholders - these are populated on first access
|
|
26
|
+
_app: Any = None
|
|
27
|
+
_service: Any = None
|
|
28
|
+
_help_resolved: bool = False
|
|
29
|
+
|
|
30
|
+
_LAZY_EXPORTS = {
|
|
31
|
+
"Menu": ("usecli.menu", "Menu"),
|
|
32
|
+
"Argument": ("usecli.params", "Argument"),
|
|
33
|
+
"Option": ("usecli.params", "Option"),
|
|
34
|
+
"Prompt": ("usecli.ui", "Prompt"),
|
|
35
|
+
"Confirm": ("usecli.ui", "Confirm"),
|
|
36
|
+
"Console": ("usecli.ui", "Console"),
|
|
37
|
+
"console": ("usecli.ui", "console"),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __getattr__(name: str) -> Any:
|
|
42
|
+
# Handle lazy exports
|
|
43
|
+
export = _LAZY_EXPORTS.get(name)
|
|
44
|
+
if export is not None:
|
|
45
|
+
module_name, attr_name = export
|
|
46
|
+
value = getattr(import_module(module_name), attr_name)
|
|
47
|
+
globals()[name] = value
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
# Handle CLI framework components - lazy initialization
|
|
51
|
+
if name in ("app", "service", "BaseCommand", "colors", "theme"):
|
|
52
|
+
_ensure_cli_initialized()
|
|
53
|
+
return globals()[name]
|
|
54
|
+
|
|
55
|
+
# Handle run_app callback
|
|
56
|
+
if name == "run_app":
|
|
57
|
+
_ensure_cli_initialized()
|
|
58
|
+
_get_run_app_callback()
|
|
59
|
+
return globals()[name]
|
|
60
|
+
|
|
61
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ensure_cli_initialized() -> None:
|
|
65
|
+
"""Lazily initialize the CLI framework components."""
|
|
66
|
+
# Use BaseCommand as the guard - it's set last, so if it's present,
|
|
67
|
+
# initialization completed successfully.
|
|
68
|
+
if "BaseCommand" in globals():
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
global _app, _service
|
|
72
|
+
|
|
73
|
+
import typer
|
|
74
|
+
from click.exceptions import BadParameter, ClickException, Exit, UsageError
|
|
75
|
+
from typer.core import TyperGroup
|
|
76
|
+
|
|
77
|
+
from usecli.cli.config.colors import COLOR
|
|
78
|
+
from usecli.cli.core.base_command import BaseCommand
|
|
79
|
+
from usecli.cli.services.command_service import CommandService
|
|
80
|
+
|
|
81
|
+
# Store exceptions for error handling
|
|
82
|
+
try:
|
|
83
|
+
from typer._click.exceptions import BadParameter as TyperBadParameter # type: ignore[import-untyped]
|
|
84
|
+
from typer._click.exceptions import ClickException as TyperClickException # type: ignore[import-untyped]
|
|
85
|
+
from typer._click.exceptions import UsageError as TyperUsageError # type: ignore[import-untyped]
|
|
86
|
+
except ImportError:
|
|
87
|
+
TyperBadParameter = BadParameter
|
|
88
|
+
TyperClickException = ClickException
|
|
89
|
+
TyperUsageError = UsageError
|
|
90
|
+
|
|
91
|
+
# Store in globals for use by other functions
|
|
92
|
+
globals()["_TyperBadParameter"] = TyperBadParameter
|
|
93
|
+
globals()["_TyperClickException"] = TyperClickException
|
|
94
|
+
globals()["_TyperUsageError"] = TyperUsageError
|
|
95
|
+
globals()["_Exit"] = Exit
|
|
96
|
+
globals()["_BadParameter"] = BadParameter
|
|
97
|
+
globals()["_ClickException"] = ClickException
|
|
98
|
+
globals()["_UsageError"] = UsageError
|
|
99
|
+
globals()["_TyperGroup"] = TyperGroup
|
|
100
|
+
|
|
101
|
+
# Create PrefixMatchingGroup now that TyperGroup is available
|
|
102
|
+
class PrefixMatchingGroup(TyperGroup):
|
|
103
|
+
"""Custom Typer group that supports prefix matching for commands."""
|
|
104
|
+
|
|
105
|
+
def get_command(self, ctx, cmd_name):
|
|
106
|
+
"""Get a command by name, with prefix matching fallback."""
|
|
107
|
+
rv = TyperGroup.get_command(self, ctx, cmd_name)
|
|
108
|
+
if rv is not None:
|
|
109
|
+
return rv
|
|
110
|
+
|
|
111
|
+
app = _get_app()
|
|
112
|
+
group_alias_registry = _get_group_alias_registry(app)
|
|
113
|
+
group_alias_to_primary = _build_alias_to_primary(group_alias_registry)
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
cmd_name in group_alias_to_primary
|
|
117
|
+
and group_alias_to_primary[cmd_name] != cmd_name
|
|
118
|
+
):
|
|
119
|
+
return TyperGroup.get_command(
|
|
120
|
+
self, ctx, group_alias_to_primary[cmd_name]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)]
|
|
124
|
+
group_aliases = [
|
|
125
|
+
alias for aliases in group_alias_registry.values() for alias in aliases
|
|
126
|
+
]
|
|
127
|
+
matches.extend(
|
|
128
|
+
[alias for alias in group_aliases if alias.startswith(cmd_name)]
|
|
129
|
+
)
|
|
130
|
+
matches = list(dict.fromkeys(matches))
|
|
131
|
+
|
|
132
|
+
if not matches:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
if cmd_name in matches:
|
|
136
|
+
if (
|
|
137
|
+
cmd_name in group_alias_to_primary
|
|
138
|
+
and group_alias_to_primary[cmd_name] != cmd_name
|
|
139
|
+
):
|
|
140
|
+
return TyperGroup.get_command(
|
|
141
|
+
self, ctx, group_alias_to_primary[cmd_name]
|
|
142
|
+
)
|
|
143
|
+
return TyperGroup.get_command(self, ctx, cmd_name)
|
|
144
|
+
|
|
145
|
+
return _FilteredListCommand(cmd_name)
|
|
146
|
+
|
|
147
|
+
def main(
|
|
148
|
+
self,
|
|
149
|
+
args=None,
|
|
150
|
+
prog_name=None,
|
|
151
|
+
complete_var=None,
|
|
152
|
+
standalone_mode=True,
|
|
153
|
+
windows_expand_args=True,
|
|
154
|
+
**extra,
|
|
155
|
+
):
|
|
156
|
+
return super().main(
|
|
157
|
+
args=args,
|
|
158
|
+
prog_name=prog_name,
|
|
159
|
+
complete_var=complete_var,
|
|
160
|
+
standalone_mode=False,
|
|
161
|
+
windows_expand_args=windows_expand_args,
|
|
162
|
+
**extra,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def invoke(self, ctx):
|
|
166
|
+
from click.exceptions import BadParameter, ClickException, Exit, UsageError
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return super().invoke(ctx)
|
|
170
|
+
except Exit:
|
|
171
|
+
sys.exit(0)
|
|
172
|
+
except BadParameter as e:
|
|
173
|
+
from usecli.cli.core.exceptions import UsecliBadParameter
|
|
174
|
+
|
|
175
|
+
styled_error = UsecliBadParameter(e.message, ctx=e.ctx, param=e.param)
|
|
176
|
+
styled_error.show()
|
|
177
|
+
sys.exit(styled_error.exit_code)
|
|
178
|
+
except UsageError as e:
|
|
179
|
+
from usecli.cli.core.exceptions import UsecliUsageError
|
|
180
|
+
|
|
181
|
+
styled_error = UsecliUsageError(e.message, ctx=e.ctx)
|
|
182
|
+
styled_error.show()
|
|
183
|
+
sys.exit(styled_error.exit_code)
|
|
184
|
+
except ClickException as e:
|
|
185
|
+
if hasattr(e, "show"):
|
|
186
|
+
e.show()
|
|
187
|
+
sys.exit(e.exit_code if hasattr(e, "exit_code") else 1)
|
|
188
|
+
|
|
189
|
+
globals()["PrefixMatchingGroup"] = PrefixMatchingGroup
|
|
190
|
+
|
|
191
|
+
# Setup module aliasing
|
|
192
|
+
colors = import_module("usecli.cli.config.colors")
|
|
193
|
+
globals()["colors"] = colors
|
|
194
|
+
globals()["theme"] = COLOR
|
|
195
|
+
sys.modules.setdefault(__name__ + ".colors", colors)
|
|
196
|
+
sys.modules.setdefault("colors", colors)
|
|
197
|
+
|
|
198
|
+
# Create app and service
|
|
199
|
+
_app = typer.Typer(
|
|
200
|
+
help="Usecli CLI - An elegant CLI framework for Python",
|
|
201
|
+
invoke_without_command=True,
|
|
202
|
+
no_args_is_help=False,
|
|
203
|
+
cls=PrefixMatchingGroup,
|
|
204
|
+
pretty_exceptions_enable=False,
|
|
205
|
+
)
|
|
206
|
+
globals()["app"] = _app
|
|
207
|
+
|
|
208
|
+
# Set BaseCommand BEFORE load_commands() since commands import it
|
|
209
|
+
globals()["BaseCommand"] = BaseCommand
|
|
210
|
+
|
|
211
|
+
_service = CommandService(_app)
|
|
212
|
+
_service.load_commands()
|
|
213
|
+
globals()["service"] = _service
|
|
214
|
+
|
|
215
|
+
# Register the callback eagerly so --version/--help work
|
|
216
|
+
# even when the app is accessed directly (e.g. smoke tests).
|
|
217
|
+
_get_run_app_callback()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _get_app():
|
|
221
|
+
_ensure_cli_initialized()
|
|
222
|
+
return _app
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _get_service():
|
|
226
|
+
_ensure_cli_initialized()
|
|
227
|
+
return _service
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
__all__ = [
|
|
231
|
+
"BaseCommand",
|
|
232
|
+
"console",
|
|
233
|
+
"Console",
|
|
234
|
+
"main",
|
|
235
|
+
"Menu",
|
|
236
|
+
"Argument",
|
|
237
|
+
"Option",
|
|
238
|
+
"Prompt",
|
|
239
|
+
"Confirm",
|
|
240
|
+
"colors",
|
|
241
|
+
"theme",
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _console():
|
|
246
|
+
return __getattr__("console")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _is_interactive_flag_present() -> bool:
|
|
250
|
+
"""Check if -i/--interactive flag is present in sys.argv.
|
|
251
|
+
|
|
252
|
+
This allows the interactive flag to work regardless of position,
|
|
253
|
+
e.g., both 'usecli -i magic' and 'usecli magic -i' will work.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if -i or --interactive is found in sys.argv, False otherwise.
|
|
257
|
+
"""
|
|
258
|
+
return "-i" in sys.argv or "--interactive" in sys.argv
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _get_cli_help_text() -> str:
|
|
262
|
+
from usecli.shared.config.manager import get_config
|
|
263
|
+
|
|
264
|
+
fallback = "Usecli CLI - An elegant CLI framework for Python"
|
|
265
|
+
fallback_description = "An elegant CLI framework for Python"
|
|
266
|
+
config = get_config()
|
|
267
|
+
|
|
268
|
+
description = config.get("description")
|
|
269
|
+
has_description = (
|
|
270
|
+
config.has_key("description")
|
|
271
|
+
and isinstance(description, str)
|
|
272
|
+
and description.strip()
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
title = config.get("title")
|
|
276
|
+
has_title = config.has_key("title") and isinstance(title, str) and title.strip()
|
|
277
|
+
|
|
278
|
+
command_name = config.get("command_name")
|
|
279
|
+
has_command_name = (
|
|
280
|
+
config.has_key("command_name")
|
|
281
|
+
and isinstance(command_name, str)
|
|
282
|
+
and command_name.strip()
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if not has_description and not has_title and not has_command_name:
|
|
286
|
+
return fallback
|
|
287
|
+
|
|
288
|
+
display_name = (
|
|
289
|
+
title.strip()
|
|
290
|
+
if has_title
|
|
291
|
+
else command_name.strip()
|
|
292
|
+
if has_command_name
|
|
293
|
+
else "Usecli CLI"
|
|
294
|
+
)
|
|
295
|
+
display_description = (
|
|
296
|
+
description.strip() if has_description else fallback_description
|
|
297
|
+
)
|
|
298
|
+
return f"{display_name} - {display_description}"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _get_group_alias_registry(app: Any) -> dict[str, list[str]]:
|
|
302
|
+
registry = getattr(app, "_usecli_group_aliases", {})
|
|
303
|
+
return registry if isinstance(registry, dict) else {}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _build_alias_to_primary(alias_registry: dict[str, list[str]]) -> dict[str, str]:
|
|
307
|
+
alias_to_primary: dict[str, str] = {}
|
|
308
|
+
for primary, aliases in alias_registry.items():
|
|
309
|
+
alias_to_primary[primary] = primary
|
|
310
|
+
for alias in aliases:
|
|
311
|
+
alias_to_primary[alias] = primary
|
|
312
|
+
return alias_to_primary
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# PrefixMatchingGroup is created lazily by _ensure_cli_initialized()
|
|
316
|
+
# to avoid importing typer at module level.
|
|
317
|
+
PrefixMatchingGroup = None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class _FilteredListCommand:
|
|
321
|
+
"""Command that displays a filtered list of commands."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, prefix_filter: str) -> None:
|
|
324
|
+
self.prefix_filter = prefix_filter
|
|
325
|
+
self.name = "filtered-list"
|
|
326
|
+
|
|
327
|
+
def __call__(self, *args, **kwargs):
|
|
328
|
+
from usecli.cli.core.ui.list import list_commands
|
|
329
|
+
|
|
330
|
+
list_commands(_get_app(), prefix_filter=self.prefix_filter)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _get_default_help() -> str:
|
|
334
|
+
return "Usecli CLI - An elegant CLI framework for Python"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _resolve_help():
|
|
338
|
+
global _help_resolved
|
|
339
|
+
if not _help_resolved:
|
|
340
|
+
app = _get_app()
|
|
341
|
+
app.info.help = _get_cli_help_text()
|
|
342
|
+
_help_resolved = True
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _get_run_app_callback():
|
|
346
|
+
"""Get the run_app callback function."""
|
|
347
|
+
if "run_app" in globals():
|
|
348
|
+
return globals()["run_app"]
|
|
349
|
+
|
|
350
|
+
import typer
|
|
351
|
+
|
|
352
|
+
# Inject typer into module globals so annotation evaluation works
|
|
353
|
+
# (required because `from __future__ import annotations` makes them strings)
|
|
354
|
+
globals()["typer"] = typer
|
|
355
|
+
|
|
356
|
+
@_app.callback()
|
|
357
|
+
def run_app(
|
|
358
|
+
ctx: typer.Context,
|
|
359
|
+
version: bool = typer.Option(
|
|
360
|
+
None, "--version", "-v", help="Show the version and exit.", is_eager=True
|
|
361
|
+
),
|
|
362
|
+
help: bool = typer.Option(None, "--help", "-h", is_eager=True),
|
|
363
|
+
interactive: bool = typer.Option(
|
|
364
|
+
False, "--interactive", "-i", help="Run in interactive mode.", is_eager=True
|
|
365
|
+
),
|
|
366
|
+
) -> None:
|
|
367
|
+
_resolve_help()
|
|
368
|
+
|
|
369
|
+
if help:
|
|
370
|
+
from usecli.cli.core.ui.list import list_commands
|
|
371
|
+
|
|
372
|
+
list_commands(_get_app())
|
|
373
|
+
raise typer.Exit()
|
|
374
|
+
|
|
375
|
+
if version:
|
|
376
|
+
import shutil
|
|
377
|
+
|
|
378
|
+
from usecli.shared.config.manager import get_config
|
|
379
|
+
|
|
380
|
+
config = get_config()
|
|
381
|
+
service = _get_service()
|
|
382
|
+
command_path = shutil.which(sys.argv[0]) or sys.argv[0]
|
|
383
|
+
_console().print(
|
|
384
|
+
f"[bold {globals()['theme'].SECONDARY}]{config.get('title')} {service.version}[/bold {globals()['theme'].SECONDARY}] [{globals()['theme'].INFO}]({command_path})[/{globals()['theme'].INFO}]"
|
|
385
|
+
)
|
|
386
|
+
raise typer.Exit()
|
|
387
|
+
|
|
388
|
+
interactive_requested = interactive or _is_interactive_flag_present()
|
|
389
|
+
|
|
390
|
+
if interactive_requested:
|
|
391
|
+
from usecli.cli.commands.defaults.base.internal.fzf_command import (
|
|
392
|
+
run_interactive,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
cmd_parts = [ctx.invoked_subcommand] if ctx.invoked_subcommand else None
|
|
396
|
+
run_interactive(_get_app(), cmd_parts=cmd_parts)
|
|
397
|
+
raise typer.Exit()
|
|
398
|
+
|
|
399
|
+
if ctx.invoked_subcommand is None:
|
|
400
|
+
prefix_filter: str | None = None
|
|
401
|
+
if ctx.obj and isinstance(ctx.obj, dict):
|
|
402
|
+
prefix_filter = ctx.obj.get("prefix_filter")
|
|
403
|
+
from usecli.cli.core.ui.list import list_commands
|
|
404
|
+
|
|
405
|
+
list_commands(_get_app(), prefix_filter=prefix_filter)
|
|
406
|
+
|
|
407
|
+
globals()["run_app"] = run_app
|
|
408
|
+
return run_app
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def main() -> None:
|
|
412
|
+
"""Run the CLI application with custom error handling."""
|
|
413
|
+
from click.exceptions import BadParameter, ClickException, Exit, UsageError
|
|
414
|
+
|
|
415
|
+
_ensure_cli_initialized()
|
|
416
|
+
_resolve_help()
|
|
417
|
+
|
|
418
|
+
from usecli.shared.config.manager import get_config
|
|
419
|
+
|
|
420
|
+
config = get_config()
|
|
421
|
+
command_name = config._get_command_name()
|
|
422
|
+
if command_name == "usecli" and not config.is_usecli_direct_dependency():
|
|
423
|
+
_console().print(
|
|
424
|
+
"[bold red]Error:[/bold red] usecli is not a direct dependency of this project."
|
|
425
|
+
)
|
|
426
|
+
_console().print(
|
|
427
|
+
"Add it to your [cyan]pyproject.toml[/cyan] dependencies or dependency-groups."
|
|
428
|
+
)
|
|
429
|
+
sys.exit(1)
|
|
430
|
+
|
|
431
|
+
# Setup the callback
|
|
432
|
+
_get_run_app_callback()
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
_get_app()()
|
|
436
|
+
except Exit:
|
|
437
|
+
sys.exit(0)
|
|
438
|
+
except BadParameter as e:
|
|
439
|
+
from usecli.cli.core.exceptions import UsecliBadParameter
|
|
440
|
+
|
|
441
|
+
styled_error = UsecliBadParameter(e.message, ctx=e.ctx, param=e.param)
|
|
442
|
+
styled_error.show()
|
|
443
|
+
sys.exit(styled_error.exit_code)
|
|
444
|
+
except UsageError as e:
|
|
445
|
+
from usecli.cli.core.exceptions import UsecliUsageError
|
|
446
|
+
|
|
447
|
+
styled_error = UsecliUsageError(e.message, ctx=e.ctx)
|
|
448
|
+
styled_error.show()
|
|
449
|
+
sys.exit(styled_error.exit_code)
|
|
450
|
+
except ClickException as e:
|
|
451
|
+
if hasattr(e, "show"):
|
|
452
|
+
e.show()
|
|
453
|
+
sys.exit(e.exit_code if hasattr(e, "exit_code") else 1)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
if __name__ == "__main__":
|
|
457
|
+
main()
|
|
@@ -39,6 +39,78 @@ _SKIP_DIRS = {
|
|
|
39
39
|
"pipx",
|
|
40
40
|
"venvs",
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
_MAX_RGLOB_DEPTH = 6
|
|
44
|
+
|
|
45
|
+
_WALK_SKIP_ALWAYS: frozenset[str] = frozenset(
|
|
46
|
+
{
|
|
47
|
+
".git",
|
|
48
|
+
"__pycache__",
|
|
49
|
+
"node_modules",
|
|
50
|
+
".tox",
|
|
51
|
+
".nox",
|
|
52
|
+
".mypy_cache",
|
|
53
|
+
".pytest_cache",
|
|
54
|
+
".ruff_cache",
|
|
55
|
+
".auto",
|
|
56
|
+
".eggs",
|
|
57
|
+
"*.egg-info",
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
_WALK_SKIP_VENV: frozenset[str] = frozenset(
|
|
62
|
+
{
|
|
63
|
+
".venv",
|
|
64
|
+
"venv",
|
|
65
|
+
"site-packages",
|
|
66
|
+
"dist-packages",
|
|
67
|
+
"__pypackages__",
|
|
68
|
+
"pipx",
|
|
69
|
+
"venvs",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _walk_for_filename(
|
|
75
|
+
directory: Path,
|
|
76
|
+
filename: str,
|
|
77
|
+
depth: int,
|
|
78
|
+
max_depth: int,
|
|
79
|
+
skip_dirs: frozenset[str],
|
|
80
|
+
results: list[Path],
|
|
81
|
+
) -> None:
|
|
82
|
+
if depth > max_depth:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
entries = list(directory.iterdir())
|
|
86
|
+
except (PermissionError, OSError):
|
|
87
|
+
return
|
|
88
|
+
for entry in entries:
|
|
89
|
+
try:
|
|
90
|
+
if entry.is_file() and entry.name == filename:
|
|
91
|
+
results.append(entry)
|
|
92
|
+
elif entry.is_dir() and entry.name not in skip_dirs:
|
|
93
|
+
_walk_for_filename(
|
|
94
|
+
entry, filename, depth + 1, max_depth, skip_dirs, results
|
|
95
|
+
)
|
|
96
|
+
except (PermissionError, OSError):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _rglob_limited(
|
|
101
|
+
root_dir: Path,
|
|
102
|
+
filename: str,
|
|
103
|
+
*,
|
|
104
|
+
skip_venv: bool = True,
|
|
105
|
+
max_depth: int = _MAX_RGLOB_DEPTH,
|
|
106
|
+
) -> list[Path]:
|
|
107
|
+
"""Depth-bounded recursive filename search that prunes dirs during the walk."""
|
|
108
|
+
skip_dirs = _WALK_SKIP_ALWAYS | _WALK_SKIP_VENV if skip_venv else _WALK_SKIP_ALWAYS
|
|
109
|
+
results: list[Path] = []
|
|
110
|
+
_walk_for_filename(root_dir, filename, 0, max_depth, skip_dirs, results)
|
|
111
|
+
return results
|
|
112
|
+
|
|
113
|
+
|
|
42
114
|
DEFAULT_THEME_COLORS: dict[str, str] = {
|
|
43
115
|
"primary": "#60D7FF",
|
|
44
116
|
"secondary": "#5EFF87",
|
|
@@ -68,13 +140,7 @@ def _find_usecli_config_path(
|
|
|
68
140
|
if not root_dir.exists() or not root_dir.is_dir():
|
|
69
141
|
return None
|
|
70
142
|
|
|
71
|
-
candidates =
|
|
72
|
-
if skip_venv:
|
|
73
|
-
candidates = [
|
|
74
|
-
path
|
|
75
|
-
for path in candidates
|
|
76
|
-
if not any(part in _SKIP_DIRS for part in path.parts)
|
|
77
|
-
]
|
|
143
|
+
candidates = _rglob_limited(root_dir, USECLI_CONFIG_TOML, skip_venv=skip_venv)
|
|
78
144
|
if not candidates:
|
|
79
145
|
return None
|
|
80
146
|
|
|
@@ -109,17 +175,28 @@ def _get_command_name() -> str | None:
|
|
|
109
175
|
return command if command else None
|
|
110
176
|
|
|
111
177
|
|
|
178
|
+
_distributions_cache: list[Any] | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _get_distributions() -> list[Any]:
|
|
182
|
+
global _distributions_cache
|
|
183
|
+
if _distributions_cache is not None:
|
|
184
|
+
return _distributions_cache
|
|
185
|
+
try:
|
|
186
|
+
import importlib.metadata
|
|
187
|
+
|
|
188
|
+
_distributions_cache = list(importlib.metadata.distributions())
|
|
189
|
+
except Exception:
|
|
190
|
+
_distributions_cache = []
|
|
191
|
+
return _distributions_cache
|
|
192
|
+
|
|
193
|
+
|
|
112
194
|
def _get_console_script_aliases(command_name: str | None) -> set[str]:
|
|
113
195
|
"""Get all aliases for a console script from package metadata."""
|
|
114
196
|
if not command_name:
|
|
115
197
|
return set()
|
|
116
198
|
aliases: set[str] = {command_name}
|
|
117
|
-
|
|
118
|
-
import importlib.metadata
|
|
119
|
-
|
|
120
|
-
distributions = importlib.metadata.distributions()
|
|
121
|
-
except Exception:
|
|
122
|
-
return aliases
|
|
199
|
+
distributions = _get_distributions()
|
|
123
200
|
for dist in distributions:
|
|
124
201
|
try:
|
|
125
202
|
entry_points = dist.entry_points
|
|
@@ -163,13 +240,7 @@ def _find_usecli_config_path_for_command(
|
|
|
163
240
|
if not root_dir.exists() or not root_dir.is_dir():
|
|
164
241
|
return None
|
|
165
242
|
|
|
166
|
-
candidates =
|
|
167
|
-
if skip_venv:
|
|
168
|
-
candidates = [
|
|
169
|
-
path
|
|
170
|
-
for path in candidates
|
|
171
|
-
if not any(part in _SKIP_DIRS for part in path.parts)
|
|
172
|
-
]
|
|
243
|
+
candidates = _rglob_limited(root_dir, USECLI_CONFIG_TOML, skip_venv=skip_venv)
|
|
173
244
|
if not candidates:
|
|
174
245
|
return None
|
|
175
246
|
|
|
@@ -217,9 +288,7 @@ def _find_usecli_config_in_package() -> Path | None:
|
|
|
217
288
|
package_root = Path(location)
|
|
218
289
|
if not package_root.exists() or not package_root.is_dir():
|
|
219
290
|
continue
|
|
220
|
-
candidates =
|
|
221
|
-
path for path in package_root.rglob(USECLI_CONFIG_TOML) if path.exists()
|
|
222
|
-
]
|
|
291
|
+
candidates = _rglob_limited(package_root, USECLI_CONFIG_TOML, skip_venv=False)
|
|
223
292
|
if candidates:
|
|
224
293
|
candidates.sort(key=lambda path: (len(path.parts), str(path)))
|
|
225
294
|
return candidates[0]
|
|
@@ -238,9 +307,7 @@ def _find_usecli_config_in_named_package(package_name: str) -> Path | None:
|
|
|
238
307
|
package_root = Path(location)
|
|
239
308
|
if not package_root.exists() or not package_root.is_dir():
|
|
240
309
|
continue
|
|
241
|
-
candidates =
|
|
242
|
-
path for path in package_root.rglob(USECLI_CONFIG_TOML) if path.exists()
|
|
243
|
-
]
|
|
310
|
+
candidates = _rglob_limited(package_root, USECLI_CONFIG_TOML, skip_venv=False)
|
|
244
311
|
if candidates:
|
|
245
312
|
candidates.sort(key=lambda path: (len(path.parts), str(path)))
|
|
246
313
|
return candidates[0]
|
|
@@ -248,15 +315,10 @@ def _find_usecli_config_in_named_package(package_name: str) -> Path | None:
|
|
|
248
315
|
|
|
249
316
|
|
|
250
317
|
def _find_usecli_config_for_console_script() -> Path | None:
|
|
251
|
-
import importlib.metadata
|
|
252
|
-
|
|
253
318
|
command_name = os.path.basename(sys.argv[0]) if sys.argv else ""
|
|
254
319
|
if not command_name:
|
|
255
320
|
return None
|
|
256
|
-
|
|
257
|
-
distributions = importlib.metadata.distributions()
|
|
258
|
-
except Exception:
|
|
259
|
-
return None
|
|
321
|
+
distributions = _get_distributions()
|
|
260
322
|
for dist in distributions:
|
|
261
323
|
try:
|
|
262
324
|
entry_points = dist.entry_points
|
|
@@ -334,10 +396,8 @@ def _find_project_root(start_dir: Path | None = None) -> Path | None:
|
|
|
334
396
|
current = parent
|
|
335
397
|
|
|
336
398
|
search_root = git_root or start_dir.resolve()
|
|
337
|
-
config_match = _find_usecli_config_path(search_root, start_dir, skip_venv=True)
|
|
338
|
-
if config_match:
|
|
339
|
-
return config_match.parent
|
|
340
399
|
|
|
400
|
+
# Try fast lookups before expensive rglob (perf: global tools).
|
|
341
401
|
console_match = _find_usecli_config_for_console_script()
|
|
342
402
|
if console_match:
|
|
343
403
|
return console_match.parent
|
|
@@ -346,6 +406,10 @@ def _find_project_root(start_dir: Path | None = None) -> Path | None:
|
|
|
346
406
|
if package_match:
|
|
347
407
|
return package_match.parent
|
|
348
408
|
|
|
409
|
+
config_match = _find_usecli_config_path(search_root, start_dir, skip_venv=True)
|
|
410
|
+
if config_match:
|
|
411
|
+
return config_match.parent
|
|
412
|
+
|
|
349
413
|
return git_root
|
|
350
414
|
|
|
351
415
|
|
|
@@ -357,18 +421,19 @@ def _load_usecli_config(
|
|
|
357
421
|
|
|
358
422
|
config_path = project_root / USECLI_CONFIG_TOML
|
|
359
423
|
if not config_path.exists():
|
|
360
|
-
|
|
361
|
-
project_root,
|
|
362
|
-
project_root,
|
|
363
|
-
skip_venv=True,
|
|
364
|
-
)
|
|
365
|
-
if not config_path or not config_path.exists():
|
|
424
|
+
# Try fast lookups before expensive rglob (perf: global tools).
|
|
366
425
|
console_match = _find_usecli_config_for_console_script()
|
|
367
426
|
if console_match:
|
|
368
427
|
return _load_usecli_config_file(console_match), console_match
|
|
369
428
|
package_match = _find_usecli_config_in_package()
|
|
370
429
|
if package_match:
|
|
371
430
|
config_path = package_match
|
|
431
|
+
if not config_path or not config_path.exists():
|
|
432
|
+
config_path = _find_usecli_config_path_for_command(
|
|
433
|
+
project_root,
|
|
434
|
+
project_root,
|
|
435
|
+
skip_venv=True,
|
|
436
|
+
)
|
|
372
437
|
if not config_path or not config_path.exists():
|
|
373
438
|
return {}, None
|
|
374
439
|
|