typer-extensions 0.2.1__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.
@@ -0,0 +1,9 @@
1
+ """Typer extension for command aliases with grouped help display"""
2
+
3
+ from typer_extensions._version import __version__
4
+ from typer_extensions.core import ExtendedTyper
5
+
6
+ __all__ = [
7
+ "ExtendedTyper",
8
+ "__version__",
9
+ ]
@@ -0,0 +1,3 @@
1
+ """Version information for typer-extensions"""
2
+
3
+ __version__ = "0.2.1"
@@ -0,0 +1,538 @@
1
+ """Core ExtendedTyper class extending typer.Typer with alias support"""
2
+
3
+ import re
4
+ from typing import Any, Callable, Optional, Protocol, Union, cast
5
+
6
+ import typer
7
+ import typer.main
8
+ from click import Command, Context, Group
9
+ from typer.core import TyperGroup
10
+
11
+
12
+ class HasName(Protocol):
13
+ """Protocol for objects that have a name attribute"""
14
+
15
+ __name__: str
16
+
17
+
18
+ class AliasedGroup(TyperGroup):
19
+ """Custom Click Group that handles command aliases"""
20
+
21
+ def __init__(
22
+ self,
23
+ *args: Any,
24
+ aliased_typer: Optional["ExtendedTyper"] = None,
25
+ **kwargs: Any,
26
+ ) -> None:
27
+ """Initialise the AliasedGroup
28
+
29
+ Args:
30
+ aliased_typer: Reference to the ExtendedTyper instance for alias resolution
31
+ *args, **kwargs: Arguments passed to Click Group
32
+ """
33
+ super().__init__(*args, **kwargs)
34
+ self.aliased_typer = aliased_typer
35
+
36
+ def get_command(self, ctx: Context, cmd_name: str) -> Optional[Command]:
37
+ """Override Click's get_command to support aliases
38
+
39
+ Args:
40
+ ctx: The Click context
41
+ cmd_name: The name of the command
42
+
43
+ Returns:
44
+ The command if found, None otherwise
45
+ """
46
+ if self.aliased_typer is None:
47
+ return super().get_command(ctx, cmd_name)
48
+
49
+ # Try to resolve as an active alias first
50
+ primary_cmd = self.aliased_typer._resolve_alias(cmd_name)
51
+ if primary_cmd is not None:
52
+ return super().get_command(ctx, primary_cmd)
53
+
54
+ cmd = super().get_command(ctx, cmd_name)
55
+ if cmd is not None:
56
+ return cmd
57
+
58
+ return None
59
+
60
+ def format_help(self, ctx: Context, formatter: Any) -> None:
61
+ """Override TyperGroup's format_help to inject aliases into Rich output
62
+
63
+ Args:
64
+ ctx: The Click context
65
+ formatter: The Click HelpFormatter instance
66
+ """
67
+ # Check if Rich formatting is enabled
68
+ if not hasattr(self, "rich_markup_mode") or self.rich_markup_mode is None:
69
+ return super().format_help(ctx, formatter)
70
+
71
+ from typer import rich_utils
72
+
73
+ # Store original _print_commands_panel
74
+ original_print = rich_utils._print_commands_panel
75
+
76
+ def custom_print_commands_panel(
77
+ *,
78
+ name: str,
79
+ commands: list[Command],
80
+ markup_mode: Any,
81
+ console: Any,
82
+ cmd_len: int,
83
+ ) -> None:
84
+ """Wrapper that modifies command names to include aliases
85
+
86
+ Args:
87
+ name: The name of the command group
88
+ commands: The list of commands in the group
89
+ markup_mode: The markup mode for the console output
90
+ console: The console instance for output
91
+ cmd_len: The length of the longest command name
92
+ """
93
+ modified_commands = []
94
+ max_len = cmd_len
95
+
96
+ for command in commands:
97
+ if (
98
+ self.aliased_typer
99
+ and self.aliased_typer._show_aliases_in_help
100
+ and command.name in self.aliased_typer._command_aliases
101
+ ):
102
+ from .format import format_command_with_aliases
103
+
104
+ formatted_name = format_command_with_aliases(
105
+ command.name,
106
+ self.aliased_typer._command_aliases[command.name],
107
+ display_format=self.aliased_typer._alias_display_format,
108
+ max_num=self.aliased_typer._max_num_aliases,
109
+ separator=self.aliased_typer._alias_separator,
110
+ )
111
+
112
+ # Longest formatted name for correct column width
113
+ max_len = max(max_len, len(formatted_name))
114
+
115
+ # Temporary command object with the formatted name
116
+ cmd_copy = command
117
+ cmd_copy.name = formatted_name
118
+
119
+ modified_commands.append(cmd_copy)
120
+ else:
121
+ modified_commands.append(command)
122
+
123
+ # Call the original with modified commands & cmd_len
124
+ original_print(
125
+ name=name,
126
+ commands=modified_commands,
127
+ markup_mode=markup_mode,
128
+ console=console,
129
+ cmd_len=max_len,
130
+ )
131
+
132
+ # Temporarily replace the function
133
+ rich_utils._print_commands_panel = custom_print_commands_panel # type: ignore[assignment]
134
+ try:
135
+ # Call parent's format_help with custom_print_commands_panel
136
+ super().format_help(ctx, formatter)
137
+ finally:
138
+ # Restore original function
139
+ rich_utils._print_commands_panel = original_print # type: ignore[assignment]
140
+
141
+
142
+ # Store original function
143
+ _original_get_group_from_info = typer.main.get_group_from_info
144
+
145
+
146
+ def _aliased_get_group_from_info(
147
+ typer_info: "typer.main.TyperInfo",
148
+ **kwargs: Any,
149
+ ) -> Union[AliasedGroup, TyperGroup]:
150
+ """Custom version of get_group_from_info that returns AliasedGroup for ExtendedTyper instances
151
+
152
+ Args:
153
+ typer_info: The TyperInfo instance containing information about the Typer instance
154
+ **kwargs: Additional keyword arguments
155
+
156
+ Returns:
157
+ An AliasedGroup if the Typer instance is an ExtendedTyper, otherwise a standard TyperGroup
158
+ """
159
+ # Call original function to get standard TyperGroup
160
+ group = _original_get_group_from_info(typer_info, **kwargs)
161
+
162
+ # If Typer instance is ExtendedTyper, wrap it in an AliasedGroup
163
+ if isinstance(typer_info.typer_instance, ExtendedTyper):
164
+ aliased_typer = typer_info.typer_instance
165
+ aliased_group = AliasedGroup(
166
+ name=group.name,
167
+ callback=group.callback,
168
+ params=group.params,
169
+ help=group.help,
170
+ epilog=group.epilog,
171
+ short_help=group.short_help,
172
+ options_metavar=group.options_metavar,
173
+ subcommand_metavar=group.subcommand_metavar,
174
+ chain=group.chain,
175
+ result_callback=group.result_callback,
176
+ context_settings=group.context_settings,
177
+ aliased_typer=aliased_typer,
178
+ rich_markup_mode=group.rich_markup_mode,
179
+ rich_help_panel=group.rich_help_panel,
180
+ )
181
+
182
+ for name, cmd in group.commands.items():
183
+ aliased_group.add_command(cmd, name=name)
184
+
185
+ return aliased_group
186
+
187
+ # Standard TyperGroup
188
+ return group
189
+
190
+
191
+ # Apply monkey-patch to Typer's group creation function
192
+ typer.main.get_group_from_info = _aliased_get_group_from_info # type: ignore[assignment]
193
+
194
+
195
+ class ExtendedTyper(typer.Typer):
196
+ """Typer application with alias support"""
197
+
198
+ # Expose Typer's Argument and Option
199
+ Argument = staticmethod(typer.Argument)
200
+ Option = staticmethod(typer.Option)
201
+
202
+ def __init__(
203
+ self,
204
+ *args: Any,
205
+ alias_case_sensitive: Optional[bool] = None,
206
+ show_aliases_in_help: bool = True,
207
+ alias_display_format: str = "({aliases})",
208
+ alias_separator: str = ", ",
209
+ max_num_aliases: int = 3,
210
+ **kwargs: Any,
211
+ ) -> None:
212
+ """Initialise ExtendedTyper
213
+
214
+ Args:
215
+ *args: Positional arguments for Typer
216
+ alias_case_sensitive: Whether aliases are case sensitive. If None (default), will match
217
+ Typer's case_sensitive setting from context_settings (defaults to True)
218
+ show_aliases_in_help: Whether to show aliases in help
219
+ alias_display_format: Format string for displaying aliases in help
220
+ Must include the placeholder '{aliases}'
221
+ alias_separator: Separator for displaying aliases in help (default: ', ')
222
+ max_num_aliases: Maximum number of aliases to display before truncating with '+ N more'
223
+ **kwargs: Keyword arguments for Typer
224
+ """
225
+ kwargs.setdefault("rich_markup_mode", "rich")
226
+ kwargs.setdefault("rich_help_panel", True)
227
+ super().__init__(*args, **kwargs)
228
+
229
+ # Sync with Typer's case_sensitive setting if not explicitly set
230
+ if alias_case_sensitive is None:
231
+ context_settings = kwargs.get("context_settings") or {}
232
+ typer_case_sensitive = context_settings.get("case_sensitive", True)
233
+ self._alias_case_sensitive = typer_case_sensitive
234
+ else:
235
+ self._alias_case_sensitive = alias_case_sensitive
236
+
237
+ self._show_aliases_in_help = show_aliases_in_help
238
+ self._alias_display_format = alias_display_format
239
+ self._alias_separator = alias_separator
240
+ self._max_num_aliases = max_num_aliases
241
+
242
+ # Mapping of command names to aliases (O(1) lookup)
243
+ self._command_aliases: dict[str, list[str]] = {}
244
+ self._alias_to_command: dict[str, str] = {}
245
+
246
+ def _normalise_name(self, name: str) -> str:
247
+ """Normalise command/alias name based on case sensitivity
248
+
249
+ Args:
250
+ name: The command/alias name to normalise
251
+
252
+ Returns:
253
+ The normalised command/alias name (lowercase if case insensitive)
254
+ """
255
+ return name.lower() if not self._alias_case_sensitive else name
256
+
257
+ def _register_alias(self, command_name: str, alias: str) -> None:
258
+ """Register an alias for a command
259
+
260
+ Args:
261
+ command_name: The name of the command
262
+ alias: The alias to register
263
+
264
+ Raises:
265
+ ValueError: If the alias conflicts with an existing command/alias
266
+ """
267
+ if not alias or not isinstance(alias, str):
268
+ raise ValueError("Alias must be a non-empty string")
269
+
270
+ if any(c.isspace() for c in alias):
271
+ raise ValueError("Alias cannot contain whitespace")
272
+
273
+ if not re.match(r"^[\w\-]+$", alias, re.UNICODE):
274
+ raise ValueError(
275
+ "Alias must only contain alphanumeric characters, dashes, and underscores (Unicode allowed)"
276
+ )
277
+
278
+ normalised_alias = self._normalise_name(alias)
279
+ normalised_cmd = self._normalise_name(command_name)
280
+
281
+ # Check if alias is the same as command name
282
+ if normalised_alias == normalised_cmd:
283
+ raise ValueError(
284
+ f"Alias '{alias}' cannot be the same as command name '{command_name}'"
285
+ )
286
+
287
+ # Check if alias is already registered
288
+ if normalised_alias in self._alias_to_command:
289
+ existing_cmd = self._alias_to_command[normalised_alias]
290
+ raise ValueError(
291
+ f"Alias '{alias}' is already registered for command '{existing_cmd}'"
292
+ )
293
+
294
+ # Register the alias
295
+ self._alias_to_command[normalised_alias] = command_name
296
+
297
+ # Add alias to command mapping
298
+ if command_name not in self._command_aliases:
299
+ self._command_aliases[command_name] = []
300
+ self._command_aliases[command_name].append(alias)
301
+
302
+ def _register_command_with_aliases(
303
+ self,
304
+ func: Callable[..., Any],
305
+ name: str,
306
+ aliases: Optional[list[str]] = None,
307
+ **kwargs: Any,
308
+ ) -> Command:
309
+ """Register a command with aliases
310
+
311
+ Args:
312
+ func: The command function
313
+ name: The command name
314
+ aliases: List of aliases for the command
315
+ **kwargs: Additional keyword arguments for command registration
316
+
317
+ Returns:
318
+ The registered Click Command object
319
+
320
+ Raises:
321
+ ValueError: If any aliases conflict with existing commands/aliases
322
+ """
323
+ aliases = aliases or []
324
+
325
+ self.command(name, **kwargs)(func)
326
+
327
+ for alias in aliases:
328
+ self._register_alias(name, alias)
329
+
330
+ click_obj = typer.main.get_command(self)
331
+ if isinstance(click_obj, Group):
332
+ command = click_obj.commands[name]
333
+ else:
334
+ command = click_obj
335
+
336
+ return command
337
+
338
+ def _resolve_alias(self, name: str) -> Optional[str]:
339
+ """Resolve a command/alias name to its primary command name
340
+
341
+ Args:
342
+ name: The command/alias name to resolve
343
+
344
+ Returns:
345
+ The primary command name if found, or None if not found or removed
346
+ """
347
+ normalised_name = self._normalise_name(name)
348
+
349
+ return self._alias_to_command.get(normalised_name)
350
+
351
+ def get_command(self, ctx: Context, cmd_name: str) -> Optional[Command]:
352
+ """Programmatically retrieve a command by its name/alias
353
+
354
+ Args:
355
+ ctx: The context in which the command is being invoked
356
+ cmd_name: The name or alias of the command to retrieve
357
+
358
+ Returns:
359
+ The command if found, else None
360
+ """
361
+ if not hasattr(self, "_group") or self._group is None:
362
+ if not hasattr(self, "_command") or self._command is None:
363
+ # Trigger CLI build
364
+ click_obj = typer.main.get_command(self)
365
+
366
+ if hasattr(click_obj, "commands"):
367
+ self._group = click_obj
368
+ else:
369
+ self._command = click_obj
370
+
371
+ primary_cmd = self._resolve_alias(cmd_name)
372
+ effective_name = primary_cmd if primary_cmd is not None else cmd_name
373
+
374
+ # Single command apps
375
+ if hasattr(self, "_command"):
376
+ command = self._command
377
+ if command.name == effective_name:
378
+ return command
379
+ return None
380
+
381
+ # Multi-command apps
382
+ group = cast(Group, self._group)
383
+ if effective_name in group.commands:
384
+ return group.commands[effective_name]
385
+ return group.get_command(ctx, effective_name)
386
+
387
+ def command_with_aliases(
388
+ self,
389
+ name: Optional[Union[str, Callable[..., Any]]] = None,
390
+ *,
391
+ aliases: Optional[list[str]] = None,
392
+ **kwargs: Any,
393
+ ) -> Callable[[Callable[..., Any]], Command]:
394
+ """Decorator to register a command with aliases, similar to Typer's @app.command decorator
395
+
396
+ Args:
397
+ name: The name of the command - if not provided, inferred from the function name
398
+ aliases: A list of aliases for the command
399
+ **kwargs: Additional keyword arguments for command registration
400
+ (e.g. help, hidden, deprecated, context_settings, etc.)
401
+
402
+ Returns:
403
+ A decorator that registers the command with the specified name and aliases
404
+ """
405
+ # Decorator used without parentheses (name inferred)
406
+ if callable(name):
407
+ func = name
408
+
409
+ return self._register_command_with_aliases(
410
+ func, name=cast(HasName, func).__name__, aliases=None, **kwargs
411
+ )
412
+
413
+ # Standard decorator with parentheses
414
+ def decorator(func: Callable[..., Any]) -> Command:
415
+ """Decorator to register a command with aliases
416
+
417
+ Args:
418
+ func: The command function
419
+
420
+ Returns:
421
+ The registered Click Command object
422
+ """
423
+ if isinstance(name, str) and name:
424
+ command_name = name
425
+ else:
426
+ command_name = cast(HasName, func).__name__
427
+
428
+ return self._register_command_with_aliases(
429
+ func, name=command_name, aliases=aliases, **kwargs
430
+ )
431
+
432
+ return decorator
433
+
434
+ def add_aliased_command(
435
+ self,
436
+ func: Callable[..., Any],
437
+ name: Optional[str] = None,
438
+ aliases: Optional[list[str]] = None,
439
+ **kwargs: Any,
440
+ ) -> Command:
441
+ """Programmatically register a command with aliases
442
+
443
+ Args:
444
+ func: The command function
445
+ name: The name of the command - if not provided, inferred from the function name
446
+ aliases: A list of aliases for the command
447
+ **kwargs: Additional keyword arguments for command registration
448
+
449
+ Returns:
450
+ The registered Click Command object
451
+
452
+ Raises:
453
+ ValueError: If any alias conflicts with existing commands/aliases
454
+ """
455
+ if isinstance(name, str) and name:
456
+ command_name = name
457
+ else:
458
+ command_name = cast(HasName, func).__name__
459
+
460
+ return self._register_command_with_aliases(
461
+ func, name=command_name, aliases=aliases, **kwargs
462
+ )
463
+
464
+ def add_alias(self, command_name: str, alias: str) -> None:
465
+ """Programmatically add an alias to an existing command
466
+
467
+ This does not allow adding aliases to single-command applications, in line with Typer's design principle of treating single-commands apps as the default command, making aliases redundant
468
+
469
+ Args:
470
+ command_name: The name of the existing command
471
+ alias: The alias to add
472
+
473
+ Raises:
474
+ ValueError: If the command doesn't exist, is a single-command app, or the alias conflicts with existing commands/aliases
475
+ """
476
+ # Get the underlying Click group
477
+ click_obj = typer.main.get_command(self)
478
+
479
+ if not isinstance(click_obj, Group):
480
+ raise ValueError("Cannot add aliases to single-command applications")
481
+
482
+ existing_command = click_obj.get_command(Context(click_obj), command_name)
483
+ if existing_command is None:
484
+ raise ValueError(f"Command '{command_name}' does not exist")
485
+
486
+ self._register_alias(command_name, alias)
487
+
488
+ def remove_alias(self, alias: str) -> bool:
489
+ """Programmatically remove an alias from an existing command
490
+
491
+ Args:
492
+ alias: The alias to remove
493
+
494
+ Returns:
495
+ True if the alias was removed, False if it doesn't exist
496
+ """
497
+ normalised_alias = self._normalise_name(alias)
498
+
499
+ if normalised_alias not in self._alias_to_command:
500
+ return False
501
+
502
+ primary_name = self._alias_to_command[normalised_alias]
503
+ del self._alias_to_command[normalised_alias]
504
+
505
+ if primary_name in self._command_aliases:
506
+ try:
507
+ self._command_aliases[primary_name].remove(alias)
508
+
509
+ if not self._command_aliases[primary_name]:
510
+ del self._command_aliases[primary_name]
511
+
512
+ except ValueError:
513
+ pass
514
+
515
+ return True
516
+
517
+ def get_aliases(self, command_name: str) -> list[str]:
518
+ """Retrieve the list of aliases for a given command
519
+
520
+ Args:
521
+ command_name: The name of the command
522
+
523
+ Returns:
524
+ A list of aliases for the command, or an empty list if no aliases or command doesn't exist
525
+ """
526
+ if command_name in self._command_aliases:
527
+ return self._command_aliases[command_name].copy()
528
+ return []
529
+
530
+ def list_commands_with_aliases(self) -> dict[str, list[str]]:
531
+ """List all commands and their aliases
532
+
533
+ Returns a dictionary mapping command names to their aliases - only includes commands with aliases and returns a copy, so modifications won't affect the original
534
+
535
+ Returns:
536
+ A dictionary mapping command names to their aliases, or an empty dictionary if no commands have aliases
537
+ """
538
+ return {cmd: aliases.copy() for cmd, aliases in self._command_aliases.items()}
@@ -0,0 +1,104 @@
1
+ """Help text formatting utilities"""
2
+
3
+ from typing import Optional
4
+
5
+
6
+ def truncate_aliases(
7
+ aliases: list[str],
8
+ max_num: int,
9
+ separator: str = ", ",
10
+ ) -> str:
11
+ """Truncate the list of aliases to a maximum number, join with a separator, and truncate with '+ N more' if needed
12
+
13
+ Args:
14
+ aliases: The list of aliases to truncate
15
+ max_num: The maximum number of aliases to display
16
+ separator: The separator to use when joining aliases, defaults to ', '
17
+
18
+ Returns:
19
+ The formatted string of aliases, truncated if necessary
20
+ """
21
+ if not aliases:
22
+ return ""
23
+
24
+ # Handle negative max_num edge case
25
+ if max_num < 0:
26
+ max_num = 0
27
+
28
+ if len(aliases) <= max_num:
29
+ return separator.join(aliases)
30
+
31
+ visible = aliases[:max_num]
32
+ hidden = len(aliases) - max_num
33
+
34
+ return f"{separator.join(visible)}{separator if visible else ''}+{hidden} more"
35
+
36
+
37
+ def format_command_with_aliases(
38
+ command_name: str,
39
+ aliases: list[str],
40
+ *,
41
+ display_format: str = "({aliases})",
42
+ max_num: int = 3,
43
+ separator: str = ", ",
44
+ ) -> str:
45
+ """Format a command with its aliases with specified format
46
+
47
+ Args:
48
+ command_name: The name of the command
49
+ aliases: The list of aliases for the command
50
+ display_format: The format string for displaying aliases
51
+ max_num: The maximum number of aliases to display
52
+ separator: The separator to use between aliases
53
+
54
+ Returns:
55
+ The formatted command string with aliases
56
+ """
57
+ if not aliases:
58
+ return command_name
59
+
60
+ aliases_str = truncate_aliases(aliases, max_num, separator)
61
+
62
+ aliases_display = display_format.format(aliases=aliases_str)
63
+
64
+ return f"{command_name} {aliases_display}"
65
+
66
+
67
+ def format_commands_section(
68
+ commands: list[tuple[str, Optional[str]]],
69
+ command_aliases: dict[str, list[str]],
70
+ *,
71
+ display_format: str = "({aliases})",
72
+ max_num: int = 3,
73
+ separator: str = ", ",
74
+ ) -> list[tuple[str, Optional[str]]]:
75
+ """Format a list of commands with their aliases
76
+
77
+ Args:
78
+ commands: The list of commands to format
79
+ command_aliases: A dictionary mapping command names to their aliases
80
+ display_format: The format string for displaying aliases
81
+ max_num: The maximum number of aliases to display
82
+ separator: The separator to use between aliases
83
+
84
+ Returns:
85
+ A list of (formatted_command, help text) tuples
86
+ """
87
+ formatted_commands = []
88
+
89
+ for cmd_name, help_text in commands:
90
+ if cmd_name in command_aliases:
91
+ aliases = command_aliases[cmd_name]
92
+ formatted_cmd = format_command_with_aliases(
93
+ cmd_name,
94
+ aliases,
95
+ display_format=display_format,
96
+ max_num=max_num,
97
+ separator=separator,
98
+ )
99
+ else:
100
+ formatted_cmd = cmd_name
101
+
102
+ formatted_commands.append((formatted_cmd, help_text))
103
+
104
+ return formatted_commands
File without changes
@@ -0,0 +1,506 @@
1
+ Metadata-Version: 2.4
2
+ Name: typer-extensions
3
+ Version: 0.2.1
4
+ Summary: Command aliases for Typer CLI applications
5
+ Project-URL: Homepage, https://github.com/rdawebb/typer-extensions
6
+ Project-URL: Documentation, https://github.com/rdawebb/typer-extensions#readme
7
+ Project-URL: Repository, https://github.com/rdawebb/typer-extensions
8
+ Project-URL: Changelog, https://github.com/rdawebb/typer-extensions/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/rdawebb/typer-extensions/issues
10
+ Author: Rob Webb
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: aliases,cli,click,command-line,typer
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: System :: Shells
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.9
28
+ Requires-Dist: click<9.0.0,>=8.0.0
29
+ Requires-Dist: typer>=0.9.0
30
+ Provides-Extra: dev
31
+ Requires-Dist: prek<1.0.0,>=0.2.27; extra == 'dev'
32
+ Requires-Dist: pytest-cov<8.0.0,>=7.0.0; extra == 'dev'
33
+ Requires-Dist: pytest<10.0.0,>=8.0.0; extra == 'dev'
34
+ Requires-Dist: ruff<15.0.0,>=0.14.0; extra == 'dev'
35
+ Requires-Dist: rust-just<2.0.0,>=1.46.0; extra == 'dev'
36
+ Requires-Dist: ty<0.1.0,>=0.0.10; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # typer-extensions
40
+
41
+ **Command aliases for Typer CLI applications with grouped help text display**
42
+
43
+ [![CI](https://github.com/rdawebb/typer-extensions/workflows/test/badge.svg)](https://github.com/rdawebb/typer-extensions/actions)
44
+ [![PyPI](https://img.shields.io/pypi/v/typer-extensions.svg)](https://pypi.org/project/typer-extensions/)
45
+ [![Python Versions](https://img.shields.io/pypi/pyversions/typer-extensions.svg)](https://pypi.org/project/typer-extensions/)
46
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
47
+
48
+ ---
49
+
50
+ ## Overview
51
+
52
+ `typer-extensions` extends [Typer](https://typer.tiangolo.com/) to provide simple drop-in support for command aliases. Instead of duplicating commands, hiding commands, or maintaining wrapper functions, define aliases directly and have them displayed cleanly in help text.
53
+
54
+ 100% backwards compatible with Typer & existing Typer apps!
55
+
56
+ ### The Problem
57
+
58
+ Standard Typer requires duplicating or hiding commands to create aliases:
59
+
60
+ ```python
61
+ from typer import Typer
62
+
63
+ app = Typer()
64
+
65
+ @app.command()
66
+ def list_items():
67
+ """List all items."""
68
+ print("Listing...")
69
+
70
+ # Registered as separate command, hidden in help
71
+ @app.command("ls", hidden=True)
72
+ def ls_items():
73
+ list_items()
74
+
75
+ @app.command("l")("list") # Duplicate!
76
+ ```
77
+
78
+ **Help output:**
79
+ ```
80
+ Commands:
81
+ list List all items.
82
+ l List all items.
83
+ ```
84
+
85
+ **Issues:**
86
+ - Code duplication
87
+ - Help text shows commands & aliases separately
88
+ - Hidden 'aliases' not shown in help text
89
+ - Maintenance burden
90
+
91
+ ### The Solution
92
+
93
+ With `typer-extensions`:
94
+
95
+ ```python
96
+ from typer_extensions import ExtendedTyper
97
+
98
+ app = ExtendedTyper()
99
+
100
+ @app.command_with_aliases("list", aliases=["ls", "l"])
101
+ def list_items():
102
+ """List all items."""
103
+ print("Listing...")
104
+ ```
105
+
106
+ **Help output:**
107
+ ```
108
+ Commands:
109
+ list (ls, l) List all items.
110
+ ```
111
+
112
+ **All work identically:**
113
+ - `app list`
114
+ - `app ls`
115
+ - `app l`
116
+
117
+ ---
118
+
119
+ ## Features
120
+
121
+ ✨ **Decorator-based alias registration** - Clean, intuitive syntax
122
+
123
+ 🔧 **Programmatic API** - Dynamic alias management at runtime
124
+
125
+ 📋 **Grouped help display** - Aliases shown with their primary command
126
+
127
+ ⚙️ **Highly configurable** - Customise format, separators, truncation
128
+
129
+ 🎯 **Type-safe** - Full type hints and editor support
130
+
131
+ 🔄 **Fully backwards compatible** - Works with all Typer/Click features and existing Typer apps
132
+
133
+ ✅ **Shell completion ready** - Alias support doesn't interfere with existing shell completion
134
+
135
+ 🧪 **Well-tested** - Tested on Python 3.9-3.14 with 100% test coverage
136
+
137
+ ---
138
+
139
+ ## Installation
140
+
141
+ ```bash
142
+ pip install typer-extensions
143
+ ```
144
+
145
+ **Requirements:**
146
+ - Python 3.9+
147
+ - typer >= 0.9.0 (recommend installing the latest version)
148
+ - click >= 8.0.0
149
+
150
+ ---
151
+
152
+ ## Quick Start
153
+
154
+ ### Basic Usage
155
+
156
+ ```python
157
+ from typer_extensions import ExtendedTyper
158
+
159
+ app = ExtendedTyper()
160
+
161
+ @app.command_with_aliases("list", aliases=["ls", "l"])
162
+ def list_items():
163
+ """List all items."""
164
+ print("Listing items...")
165
+
166
+ @app.command_with_aliases("delete", aliases=["rm", "remove"])
167
+ def delete_item(name: str):
168
+ """Delete an item."""
169
+ print(f"Deleting {name}")
170
+
171
+ if __name__ == "__main__":
172
+ app()
173
+ ```
174
+
175
+ **Run it:**
176
+ ```bash
177
+ $ python app.py --help
178
+ Commands:
179
+ list (ls, l) List all items.
180
+ delete (rm, remove) Delete an item.
181
+
182
+ $ python app.py ls
183
+ Listing items...
184
+
185
+ $ python app.py rm test.txt
186
+ Deleting test.txt
187
+ ```
188
+
189
+ > 💡 **Want to learn more?** Check the [API Reference](docs/API_REFERENCE.md) for detailed configuration options, or see the [User Guide](docs/USER_GUIDE.md) for patterns and best practices.
190
+
191
+ ### Drop-in Compatibility
192
+
193
+ Have an existing Typer project? Add alias support without changing your code:
194
+
195
+ ```python
196
+ # Before (regular Typer)
197
+ from typer import Typer
198
+ app = Typer()
199
+
200
+ # After (with typer-extensions)
201
+ from typer_extensions import ExtendedTyper
202
+ app = ExtendedTyper()
203
+
204
+ # That's it! Everything else stays the same.
205
+ # Existing commands, shell completion and help text configuration still work exactly as before.
206
+ ```
207
+
208
+ Once migrated, you can add aliases to new commands using `@app.command_with_aliases()`, or to existing commands using the programmatic API.
209
+
210
+ See [Migration Guide](docs/MIGRATION.md) for detailed migration strategies and patterns.
211
+
212
+ ### Advanced Features
213
+
214
+ **Programmatic alias management:**
215
+ ```python
216
+ # Add aliases dynamically
217
+ app.add_alias("list", "dir")
218
+
219
+ # Remove aliases
220
+ app.remove_alias("dir")
221
+
222
+ # Query aliases
223
+ aliases = app.get_aliases("list") # ["ls", "l"]
224
+ ```
225
+
226
+ **Custom help formatting:**
227
+ ```python
228
+ app = ExtendedTyper(
229
+ alias_display_format="[{aliases}]", # Use brackets
230
+ alias_separator=" | ", # Pipe separator
231
+ max_aliases_inline=2, # Show max 2, then "+N more"
232
+ )
233
+ ```
234
+
235
+ **Configuration-based aliases:**
236
+ ```python
237
+ # Load aliases from config
238
+ config = {"list": ["ls", "l", "dir"]}
239
+ for cmd, aliases in config.items():
240
+ for alias in aliases:
241
+ app.add_alias(cmd, alias)
242
+ ```
243
+
244
+ ---
245
+
246
+ ## Documentation
247
+
248
+ 📚 **[User Guide](docs/USER_GUIDE.md)** - Tutorials and common patterns
249
+
250
+ 📖 **[API Reference](docs/API_REFERENCE.md)** - Complete API documentation
251
+
252
+ 🔄 **[Migration Guide](docs/MIGRATION.md)** - Migrating from standard Typer
253
+
254
+ ---
255
+
256
+ ## Examples
257
+
258
+ All examples are in the [`examples/`](examples/) directory:
259
+
260
+ - **[basic_usage.py](examples/basic_usage.py)** - Simple CLI with aliases
261
+ - **[advanced_usage.py](examples/advanced_usage.py)** - Git-like CLI with options
262
+ - **[programmatic_usage.py](examples/programmatic_usage.py)** - Dynamic alias management
263
+ - **[help_formatting.py](examples/help_formatting.py)** - Customising help display
264
+ - **[argument_option_usage.py](examples/argument_option_usage.py)** - Using Typer's Argument & Option
265
+
266
+ **Run any example:**
267
+ ```bash
268
+ python examples/basic_usage.py --help
269
+ python examples/basic_usage.py ls
270
+ ```
271
+
272
+ ---
273
+
274
+ ## Real-World Use Cases
275
+
276
+ ### Git-like CLI
277
+ ```python
278
+ @app.command_with_aliases("checkout", aliases=["co"])
279
+ def checkout(branch: str):
280
+ """Switch branches."""
281
+ ...
282
+
283
+ @app.command_with_aliases("status", aliases=["st"])
284
+ def status():
285
+ """Show status."""
286
+ ...
287
+ ```
288
+
289
+ ### Package Manager
290
+ ```python
291
+ @app.command_with_aliases("install", aliases=["i", "add"])
292
+ def install(package: str):
293
+ """Install a package."""
294
+ ...
295
+
296
+ @app.command_with_aliases("remove", aliases=["rm", "uninstall"])
297
+ def remove(package: str):
298
+ """Remove a package."""
299
+ ...
300
+ ```
301
+
302
+ ### Cross-Platform Commands
303
+ ```python
304
+ @app.command("list")
305
+ def list_files():
306
+ """List files."""
307
+ ...
308
+
309
+ # Add platform-specific aliases
310
+ if platform.system() == "Windows":
311
+ app.add_alias("list", "dir")
312
+ else:
313
+ app.add_alias("list", "ls")
314
+ ```
315
+
316
+ ---
317
+
318
+ ## API Overview
319
+
320
+ ### Decorator Registration
321
+ ```python
322
+ @app.command_with_aliases(name, aliases=[...])
323
+ ```
324
+
325
+ ### Programmatic Registration
326
+ ```python
327
+ app.add_aliased_command(func, name, aliases=[...])
328
+ ```
329
+
330
+ ### Alias Management
331
+ ```python
332
+ app.add_alias(command, alias) # Add alias
333
+ app.remove_alias(alias) → bool # Remove alias
334
+ app.get_aliases(command) → list # Query aliases
335
+ app.list_commands_with_aliases() → dict # All mappings
336
+ ```
337
+
338
+ ### Configuration
339
+ ```python
340
+ ExtendedTyper(
341
+ alias_case_sensitive=None, # Case-sensitive matching (default True per Typer)
342
+ show_aliases_in_help=True, # Display aliases in help
343
+ alias_display_format="({aliases})", # Display format
344
+ alias_separator=", ", # Between aliases
345
+ max_num_aliases=3, # Before truncation
346
+ )
347
+ ```
348
+
349
+ ---
350
+
351
+ ## Development
352
+
353
+ ### Setup
354
+
355
+ **Standard setup with pip:**
356
+
357
+ ```bash
358
+ git clone https://github.com/rdawebb/typer-extensions.git
359
+ cd typer-extensions
360
+ python -m venv venv
361
+ source venv/bin/activate # or venv\Scripts\activate on Windows
362
+ pip install -e ".[dev]"
363
+ ```
364
+
365
+ **Or with uv (recommended):**
366
+
367
+ ```bash
368
+ git clone https://github.com/rdawebb/typer-extensions.git
369
+ cd typer-extensions
370
+ uv sync --all-extras
371
+ source .venv/bin/activate
372
+ ```
373
+
374
+ **Quick commands with justfile:**
375
+
376
+ If you have [just](https://github.com/casey/just) installed (included in `[dev]`), common tasks are available:
377
+
378
+ ```bash
379
+ just test # Run tests
380
+ just lint # Run linter
381
+ just format # Format code
382
+ just type # Check type safety
383
+ just test-cov # Full test suite with coverage
384
+ just help # List all available commands
385
+ ```
386
+
387
+ If you prefer to use `just` without the dev setup, you can install it globally via `pip install rust-just` or your system package manager.
388
+
389
+ See `Justfile` for all available commands.
390
+
391
+ ### Testing
392
+
393
+ **With justfile (recommended):**
394
+
395
+ ```bash
396
+ just check # Run linting, formatting, and type checks
397
+ just test # Run tests
398
+ just test-cov # Run tests with coverage report
399
+ just pre # Full pre-commit check (all checks + tests)
400
+ ```
401
+
402
+ **Or with direct commands:**
403
+
404
+ ```bash
405
+ pytest # Run all tests
406
+ pytest --cov # With coverage
407
+ pytest -v # Verbose output
408
+ ruff check . # Lint
409
+ ruff format . # Format
410
+ ty check src/ # Type check
411
+ ```
412
+
413
+ ### Contributing
414
+
415
+ Contributions are welcome! Please open an issue, ask a question, or submit a pull request.
416
+
417
+ ---
418
+
419
+ ## Project Status
420
+
421
+ **Current Version:** 0.2.1 (Beta)
422
+
423
+ ✅ **Core Features Complete:**
424
+ - Alias registration (decorator + programmatic)
425
+ - Help text formatting
426
+ - Dynamic alias management
427
+ - Full test coverage
428
+
429
+ 🚧 **In Development:**
430
+ - Dynamic config file loading with import/export
431
+ - Shell completion enhancement and typo suggestions
432
+ - Documentation site
433
+ - Performance optimisations
434
+
435
+ 📋 **Planned Features:**
436
+ - Shared & chained subcommand aliases
437
+ - Per-alias help text
438
+ - Dataclass, Pydantic & Attrs support
439
+ - Custom themes and help text formatting
440
+ - Argument & Option customisation
441
+
442
+ See [CHANGELOG.md](CHANGELOG.md) for version history.
443
+
444
+ ---
445
+
446
+ ## Why typer-extensions?
447
+
448
+ **For Users:**
449
+ - ⚡ Faster workflows with short aliases
450
+ - 🎯 Familiar commands (git-like shortcuts)
451
+ - 📖 Clear help text showing all options
452
+
453
+ **For Developers:**
454
+ - 🧹 DRY - no code duplication
455
+ - 🔧 Flexible - static or dynamic aliases
456
+ - 🎨 Customisable - match your style
457
+ - ✅ Tested - reliable, stable, and type-safe
458
+
459
+ **Compared to Alternatives:**
460
+ - **Plain Typer:** Requires command duplication or hiding
461
+ - **click-aliases:** Click-specific, no Typer integration
462
+ - **Custom solutions:** Reinventing the wheel
463
+
464
+ ---
465
+
466
+ ## Related Projects
467
+
468
+ - **[Typer](https://typer.tiangolo.com/)** - The CLI framework this extends
469
+ - **[Click](https://click.palletsprojects.com/)** - The underlying library
470
+ - **[Rich](https://rich.readthedocs.io/)** - Beautiful terminal formatting (used by Typer)
471
+
472
+ ---
473
+
474
+ ## License
475
+
476
+ MIT License - see [LICENSE](LICENSE) file for details.
477
+
478
+ ---
479
+
480
+ ## Acknowledgments
481
+
482
+ Built on the excellent [Typer](https://typer.tiangolo.com/) framework by Sebastián Ramírez.
483
+
484
+ Inspired by Git's command aliasing and various CLI tools that make shortcuts feel natural.
485
+
486
+ ---
487
+
488
+ ## Support
489
+
490
+ - 🐛 **Issues:** [GitHub Issues](https://github.com/rdawebb/typer-extensions/issues)
491
+ - 💬 **Discussions:** [GitHub Discussions](https://github.com/rdawebb/typer-extensions/discussions)
492
+ - 📧 **Contact:** Create an issue for questions
493
+
494
+ ---
495
+
496
+ ## Links
497
+
498
+ - **GitHub:** https://github.com/rdawebb/typer-extensions
499
+ - **PyPI:** https://pypi.org/project/typer-extensions/
500
+ - **Changelog:** [CHANGELOG.md](CHANGELOG.md)
501
+
502
+ ---
503
+
504
+ <p align="center">
505
+ <i>Making CLI aliases natural and maintainable</i>
506
+ </p>
@@ -0,0 +1,9 @@
1
+ typer_extensions/__init__.py,sha256=kmkNxOvj1__xZMSjRsIzKcy2HXuSnmf0ygK8NRUsMJA,222
2
+ typer_extensions/_version.py,sha256=Q2J7zRwFHCkJjeBWiJBf8XlhWEMS3Yaf38zyC6lSXJY,70
3
+ typer_extensions/core.py,sha256=nQ4bLyDWAg7u1EDq1EWOT1DQZ-5zF0QT96BozXw8q9Y,19188
4
+ typer_extensions/format.py,sha256=TjpwinoCj1rMNuledPvHkSBntupjQQD0mMoJiwYycws,3000
5
+ typer_extensions/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ typer_extensions-0.2.1.dist-info/METADATA,sha256=Dtxw__rUspP50nK_FFcwcKyDuC7boDNY5WvYMlj97bQ,13068
7
+ typer_extensions-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ typer_extensions-0.2.1.dist-info/licenses/LICENSE,sha256=DLjGYQ3mgvK3gtIfFbGR-kW87JH5uVfYUHS-GecmeJM,1052
9
+ typer_extensions-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2026 Rob Webb
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.