invoke-toolkit 0.0.63__tar.gz → 0.0.64__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.
Files changed (129) hide show
  1. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/.gitignore +1 -0
  2. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/PKG-INFO +1 -1
  3. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/program/program.py +90 -18
  4. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/tasks/tasks.py +17 -1
  5. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/script/test_script.py +5 -4
  6. invoke_toolkit-0.0.64/tests/test_alias_self_reference.py +108 -0
  7. invoke_toolkit-0.0.64/tests/test_list_filter_flags.py +208 -0
  8. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_proctitle.py +16 -8
  9. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/LICENSE.txt +0 -0
  10. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/README.md +0 -0
  11. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/pyproject.toml +0 -0
  12. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/__init__.py +0 -0
  13. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/__main__.py +0 -0
  14. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/collections.py +0 -0
  15. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/completion.py +0 -0
  16. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/config/__init__.py +0 -0
  17. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/config/config.py +0 -0
  18. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/config/registry.py +0 -0
  19. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/config/schema.py +0 -0
  20. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/config/status_helper.py +0 -0
  21. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/context/__init__.py +0 -0
  22. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/context/context.py +0 -0
  23. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/context/types.py +0 -0
  24. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/executor.py +0 -0
  25. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/extensions/__init__.py +0 -0
  26. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/extensions/tasks/__init__.py +0 -0
  27. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/extensions/tasks/config.py +0 -0
  28. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/extensions/tasks/create.py +0 -0
  29. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/extensions/tasks/dist.py +0 -0
  30. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/extensions/tasks/shell.py +0 -0
  31. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/loader/entrypoint.py +0 -0
  32. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/log/__init__.py +0 -0
  33. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/log/logger.py +0 -0
  34. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/output/__init__.py +0 -0
  35. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/output/console.py +0 -0
  36. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/output/utils.py +0 -0
  37. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/parser.py +0 -0
  38. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/program/__init__.py +0 -0
  39. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/program/main.py +0 -0
  40. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/py.typed +0 -0
  41. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/runners/__init__.py +0 -0
  42. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/runners/rich.py +0 -0
  43. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/scripts/__init__.py +0 -0
  44. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/scripts/loader.py +0 -0
  45. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/tasks/__init__.py +0 -0
  46. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/tasks/autocomplete.py +0 -0
  47. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/tasks/cache.py +0 -0
  48. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/tasks/types.py +0 -0
  49. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/testing.py +0 -0
  50. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/utils/__init__.py +0 -0
  51. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/utils/fzf.py +0 -0
  52. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/utils/inspection.py +0 -0
  53. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/utils/singleton.py +0 -0
  54. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/src/invoke_toolkit/utils/text.py +0 -0
  55. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/templates/package-template/.gitignore.jinja +0 -0
  56. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/templates/package-template/README.md.jinja +0 -0
  57. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/templates/package-template/copier.yml +0 -0
  58. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/templates/package-template/pyproject.toml.jinja +0 -0
  59. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/templates/package-template/src/{{package_slug}}/__init__.py.jinja +0 -0
  60. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/templates/package-template/src/{{package_slug}}/tasks.py.jinja +0 -0
  61. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/__init__.py +0 -0
  62. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/conftest.py +0 -0
  63. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/examples/cached_completion/tasks.py +0 -0
  64. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/examples/completion_with_config/.gitignore +0 -0
  65. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/examples/completion_with_config/tasks.py +0 -0
  66. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/examples/config_schema/tasks.py +0 -0
  67. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/examples/enum_select_size/tasks.py +0 -0
  68. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/examples/fzf_selector/tasks.py +0 -0
  69. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/examples/literal_set_level/tasks.py +0 -0
  70. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/extensions/conftest.py +0 -0
  71. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/extensions/test_config_tasks.py +0 -0
  72. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/extensions/test_create.py +0 -0
  73. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/extensions/test_package_template_entrypoint.py +0 -0
  74. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/extensions/test_shell_tasks.py +0 -0
  75. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/__init__.py +0 -0
  76. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/all-four/invoke.json +0 -0
  77. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/all-four/invoke.py +0 -0
  78. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/all-four/invoke.yml +0 -0
  79. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/collection.py +0 -0
  80. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/echo.yaml +0 -0
  81. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/json/invoke.json +0 -0
  82. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/json-and-python/invoke.json +0 -0
  83. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/json-and-python/invoke.py +0 -0
  84. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/no-dedupe.yaml +0 -0
  85. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/no-echo.yaml +0 -0
  86. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/package/invoke.yml +0 -0
  87. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/package/tasks/__init__.py +0 -0
  88. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/python/invoke.py +0 -0
  89. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/runtime.py +0 -0
  90. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/three-of-em/invoke.json +0 -0
  91. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/three-of-em/invoke.py +0 -0
  92. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/three-of-em/invoke.yml +0 -0
  93. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/underscores/tasks.py +0 -0
  94. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/yaml/explicit.py +0 -0
  95. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/yaml/tasks.py +0 -0
  96. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/yml/explicit.py +0 -0
  97. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/yml/invoke.yml +0 -0
  98. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/configs/yml/tasks.py +0 -0
  99. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/_support/has_modules.py +0 -0
  100. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/conftest.py +0 -0
  101. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/original_invoke/test_config.py +0 -0
  102. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/program/main.py +0 -0
  103. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/program/tasks/__init__.py +0 -0
  104. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/program/tasks/coll1.py +0 -0
  105. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/tasks/test_cache.py +0 -0
  106. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/tasks/test_extensions_config.py +0 -0
  107. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_annotated_help.py +0 -0
  108. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_cofig_class.py +0 -0
  109. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_collection.py +0 -0
  110. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_collection_configure.py +0 -0
  111. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_completion_with_choices.py +0 -0
  112. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_config_helper.py +0 -0
  113. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_config_registry.py +0 -0
  114. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_config_schema.py +0 -0
  115. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_console.py +0 -0
  116. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_context_class.py +0 -0
  117. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_disable_status_cli.py +0 -0
  118. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_enum_arguments.py +0 -0
  119. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_executor.py +0 -0
  120. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_file_completion.py +0 -0
  121. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_global_context.py +0 -0
  122. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_help_flags.py +0 -0
  123. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_invoke_3_compat.py +0 -0
  124. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_invoke_compatibility.py +0 -0
  125. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_loader.py +0 -0
  126. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_parsing.py +0 -0
  127. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_program_with_collection.py +0 -0
  128. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/test_toplevel.py +0 -0
  129. {invoke_toolkit-0.0.63 → invoke_toolkit-0.0.64}/tests/utils/test_fzf.py +0 -0
@@ -18,3 +18,4 @@ invoke.yaml
18
18
  # Makrdowns
19
19
  *.md
20
20
  !docs/**/*.md
21
+ .worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: invoke-toolkit
3
- Version: 0.0.63
3
+ Version: 0.0.64
4
4
  Summary: Extended APIs for PyInvoke: composable task plugins, typed CLI args, Rich output, shell completion, and PEP 723 script support
5
5
  Project-URL: Documentation, https://github.com/D3f0/invoke-toolkit#readme
6
6
  Project-URL: Issues, https://github.com/D3f0/invoke-toolkit/issues
@@ -15,7 +15,7 @@ from importlib import metadata
15
15
  from importlib.util import module_from_spec
16
16
  from logging import getLogger
17
17
  from pathlib import Path
18
- from typing import Dict, List, Optional, Sequence, Tuple, Union
18
+ from typing import Dict, List, Literal, Optional, Sequence, Tuple, Union
19
19
 
20
20
  from rich.table import Table
21
21
 
@@ -81,6 +81,9 @@ class ToolkitProgram(Program):
81
81
  config_class,
82
82
  binary_names,
83
83
  )
84
+ # Names of sub-collections that were loaded from entry-point plugins.
85
+ # Populated in load_collection(); used by --list-tasks / --list-plugins.
86
+ self._plugin_collection_names: set[str] = set()
84
87
 
85
88
  def get_version(self) -> str:
86
89
  """Compute version
@@ -368,10 +371,92 @@ class ToolkitProgram(Program):
368
371
  default=False,
369
372
  help="Initializes shell for runner via CLI flag",
370
373
  ),
374
+ ToolkitArgument(
375
+ names=("list-tasks", "lt"),
376
+ kind=bool,
377
+ default=False,
378
+ help="List only the main tasks discovered in the current directory (excludes plugins)",
379
+ ),
380
+ ToolkitArgument(
381
+ names=("list-plugins", "lp"),
382
+ kind=bool,
383
+ default=False,
384
+ help="List only the plugin-provided task collections (excludes main tasks)",
385
+ ),
371
386
  ]
372
387
  args.extend(toolkit_program_arguments)
373
388
  return args
374
389
 
390
+ def _handle_list_flags(self) -> None:
391
+ """
392
+ Handle --list, --list-tasks, and --list-plugins flags.
393
+
394
+ Sets ``list_format`` / ``list_depth`` / ``scoped_collection`` as needed
395
+ and raises `Exit` when any listing flag is active.
396
+ """
397
+ self.list_format = self.args["list-format"].value
398
+ self.list_depth = self.args["list-depth"].value
399
+
400
+ # --list [root]: standard task listing (optionally scoped to a sub-collection)
401
+ list_root = self.args.list.value # True or a string namespace
402
+ if list_root:
403
+ if isinstance(list_root, str):
404
+ self.list_root = list_root
405
+ try:
406
+ sub = self.collection.subcollection_from_path(list_root)
407
+ self.scoped_collection = sub
408
+ except KeyError:
409
+ msg = "Sub-collection '{}' not found!"
410
+ raise Exit(msg.format(list_root))
411
+ self.list_tasks()
412
+ raise Exit
413
+
414
+ # --list-tasks: show only main (non-plugin) tasks
415
+ if self.args["list-tasks"].value:
416
+ debug("Saw --list-tasks, listing main tasks only & exiting")
417
+ self.scoped_collection = self._make_filtered_collection("tasks")
418
+ self.list_tasks()
419
+ raise Exit
420
+
421
+ # --list-plugins: show only plugin-provided collections
422
+ if self.args["list-plugins"].value:
423
+ debug("Saw --list-plugins, listing plugin collections only & exiting")
424
+ if not self._plugin_collection_names:
425
+ raise Exit("No plugin collections found.")
426
+ self.scoped_collection = self._make_filtered_collection("plugins")
427
+ self.list_tasks()
428
+ raise Exit
429
+
430
+ def _make_filtered_collection(
431
+ self, mode: "Literal['tasks', 'plugins']"
432
+ ) -> "ToolkitCollection":
433
+ """
434
+ Return a shallow copy of ``self.scoped_collection`` containing only
435
+ the subset of tasks and sub-collections appropriate for *mode*.
436
+
437
+ - ``'tasks'`` – direct tasks + non-plugin sub-collections only
438
+ - ``'plugins'`` – plugin sub-collections only (no direct tasks)
439
+ """
440
+ source = self.scoped_collection
441
+ filtered = ToolkitCollection(source.name or "")
442
+ if mode == "tasks":
443
+ # Include all direct tasks on the scoped collection
444
+ for task_name, task in source.tasks.items():
445
+ filtered.add_task(task, name=task_name)
446
+ # Include sub-collections that are NOT from plugins
447
+ for coll_name, coll in source.collections.items():
448
+ if coll_name not in self._plugin_collection_names:
449
+ filtered.add_collection(coll, name=coll_name)
450
+ else: # mode == "plugins"
451
+ # Include only the plugin sub-collections
452
+ for coll_name, coll in source.collections.items():
453
+ if coll_name in self._plugin_collection_names:
454
+ filtered.add_collection(coll, name=coll_name)
455
+ # Preserve the default task pointer when it still makes sense
456
+ if source.default and source.default in filtered.tasks:
457
+ filtered.default = source.default
458
+ return filtered
459
+
375
460
  def _load_entry_points_collection(self, start: str) -> "ToolkitCollection | None":
376
461
  """
377
462
  Load task collections from entry points.
@@ -453,6 +538,7 @@ class ToolkitProgram(Program):
453
538
  debug("Merging entry point collections with tasks.py")
454
539
  for name, collection in ep_collection.collections.items():
455
540
  self.collection.add_collection(collection, name=name)
541
+ self._plugin_collection_names.add(name)
456
542
  except CollectionNotFound as e:
457
543
  start = self.args["search-root"].value or "."
458
544
  # First try to load entry points (for packages without tasks.py)
@@ -460,6 +546,7 @@ class ToolkitProgram(Program):
460
546
  if ep_collection is not None:
461
547
  debug("Loading collections from entry points")
462
548
  self.collection = ep_collection
549
+ self._plugin_collection_names.update(ep_collection.collections.keys())
463
550
  else:
464
551
  # If entry points weren't found, try local_tasks or fail
465
552
  if not (Path(start) / "local_tasks.py").exists():
@@ -571,23 +658,8 @@ class ToolkitProgram(Program):
571
658
  # should?
572
659
  raise ParseError("No idea what '{}' is!".format(halp))
573
660
 
574
- # Print discovered tasks if necessary
575
- list_root = self.args.list.value # will be True or string
576
-
577
- self.list_format = self.args["list-format"].value
578
- self.list_depth = self.args["list-depth"].value
579
- if list_root:
580
- # Not just --list, but --list some-root - do moar work
581
- if isinstance(list_root, str):
582
- self.list_root = list_root
583
- try:
584
- sub = self.collection.subcollection_from_path(list_root)
585
- self.scoped_collection = sub
586
- except KeyError:
587
- msg = "Sub-collection '{}' not found!"
588
- raise Exit(msg.format(list_root))
589
- self.list_tasks()
590
- raise Exit
661
+ # Handle --list / --list-tasks / --list-plugins (raises Exit if triggered)
662
+ self._handle_list_flags()
591
663
 
592
664
  # Print completion helpers if necessary
593
665
  if self.args.complete.value:
@@ -6,6 +6,7 @@ Type annotated tasks and and overrides over invoke
6
6
 
7
7
  import inspect
8
8
  import os
9
+ import warnings
9
10
  import subprocess
10
11
  import types
11
12
  from enum import Enum
@@ -774,7 +775,22 @@ def task( # pylint: disable=too-many-arguments,too-many-branches
774
775
  if name is not None:
775
776
  task_kwargs["name"] = name
776
777
  if aliases:
777
- task_kwargs["aliases"] = aliases
778
+ # Normalize a name to invoke's canonical form (underscores → hyphens).
779
+ effective_name = (name or getattr(f, "__name__", "")).replace("_", "-")
780
+ filtered_aliases = [
781
+ a for a in aliases if a.replace("_", "-") != effective_name
782
+ ]
783
+ skipped = set(aliases) - set(filtered_aliases)
784
+ if skipped:
785
+ warnings.warn(
786
+ f"Task '{effective_name}': alias(es) {sorted(skipped)!r} "
787
+ "match the task name and will be ignored to prevent a "
788
+ "RecursionError at runtime.",
789
+ UserWarning,
790
+ stacklevel=2,
791
+ )
792
+ if filtered_aliases:
793
+ task_kwargs["aliases"] = filtered_aliases
778
794
  if positional:
779
795
  task_kwargs["positional"] = positional
780
796
  if optional:
@@ -146,10 +146,12 @@ def test_script_loads_project_config(tmp_path: Path, capsys, monkeypatch):
146
146
  # Create a temporary directory with a script and config file
147
147
  monkeypatch.chdir(tmp_path)
148
148
 
149
- # Create invoke.yml config file with echo=true and pty=true
149
+ # Create invoke.yml config file with echo=true
150
+ # Note: pty=true is intentionally omitted — a PTY requires a real terminal
151
+ # and would cause pytest to raise OSError when output is captured.
150
152
  config_file = tmp_path / "invoke.yml"
151
153
  config_file.write_text(
152
- "run:\n echo: true\n pty: true\n",
154
+ "run:\n echo: true\n",
153
155
  encoding="utf-8",
154
156
  )
155
157
 
@@ -158,8 +160,7 @@ def test_script_loads_project_config(tmp_path: Path, capsys, monkeypatch):
158
160
  def check_config(c):
159
161
  # Check that config was loaded
160
162
  assert c.config.run.echo is True, "echo should be True from invoke.yml"
161
- assert c.config.run.pty is True, "pty should be True from invoke.yml"
162
- c.run("echo 'Config loaded successfully'")
163
+ c.run("echo 'Config loaded successfully'", in_stream=False)
163
164
 
164
165
  # Run script and verify config was loaded
165
166
  # Note: need to pass script name as first arg for invoke
@@ -0,0 +1,108 @@
1
+ """Tests for self-referential alias guard in @task."""
2
+
3
+ import warnings
4
+
5
+ import pytest
6
+ from invoke import Collection
7
+
8
+ from invoke_toolkit.tasks.tasks import task
9
+
10
+
11
+ def _make_collection(*tasks):
12
+ c = Collection()
13
+ for t in tasks:
14
+ c.add_task(t)
15
+ return c
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Warning is emitted
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def test_warns_when_alias_matches_function_name_underscore():
24
+ """Alias equal to the function name (underscore form) triggers a UserWarning."""
25
+ with pytest.warns(UserWarning, match="match the task name"):
26
+
27
+ @task(aliases=["my_task"])
28
+ def my_task(ctx):
29
+ pass
30
+
31
+
32
+ def test_warns_when_alias_matches_function_name_hyphen():
33
+ """Alias equal to the hyphenated function name triggers a UserWarning."""
34
+ with pytest.warns(UserWarning, match="match the task name"):
35
+
36
+ @task(aliases=["my-task"])
37
+ def my_task(ctx):
38
+ pass
39
+
40
+
41
+ def test_warns_when_alias_matches_explicit_name():
42
+ """Alias equal to an explicit name= override triggers a UserWarning."""
43
+ with pytest.warns(UserWarning, match="match the task name"):
44
+
45
+ @task(name="deploy", aliases=["deploy"])
46
+ def run_deploy(ctx):
47
+ pass
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Self-referential alias is stripped (no RecursionError)
52
+ # ---------------------------------------------------------------------------
53
+
54
+
55
+ def test_self_alias_stripped_task_is_reachable():
56
+ """After stripping the self-alias the task is still reachable by its name."""
57
+ with warnings.catch_warnings():
58
+ warnings.simplefilter("ignore", UserWarning)
59
+
60
+ @task(aliases=["my_task"])
61
+ def my_task(ctx):
62
+ pass
63
+
64
+ c = _make_collection(my_task)
65
+ # Must not raise RecursionError
66
+ resolved = c["my-task"]
67
+ assert resolved is not None
68
+
69
+
70
+ def test_self_alias_stripped_valid_aliases_kept():
71
+ """Valid aliases are preserved; only the self-referential one is removed."""
72
+ with warnings.catch_warnings():
73
+ warnings.simplefilter("ignore", UserWarning)
74
+
75
+ @task(aliases=["my_task", "mt"])
76
+ def my_task(ctx):
77
+ pass
78
+
79
+ c = _make_collection(my_task)
80
+ assert c["mt"] is not None
81
+ assert c["my-task"] is not None
82
+
83
+
84
+ def test_all_aliases_self_referential_no_aliases_registered():
85
+ """When every alias is self-referential the task has no aliases."""
86
+ with warnings.catch_warnings():
87
+ warnings.simplefilter("ignore", UserWarning)
88
+
89
+ @task(aliases=["my_task3"])
90
+ def my_task3(ctx):
91
+ pass
92
+
93
+ c = _make_collection(my_task3)
94
+ # task_names maps canonical name → list of aliases; list should be empty
95
+ assert c.task_names["my-task3"] == []
96
+
97
+
98
+ def test_no_warning_for_distinct_alias():
99
+ """A genuinely distinct alias produces no warning."""
100
+ with warnings.catch_warnings():
101
+ warnings.simplefilter("error", UserWarning) # would raise if a warning fires
102
+
103
+ @task(aliases=["mt"])
104
+ def my_task(ctx):
105
+ pass
106
+
107
+ c = _make_collection(my_task)
108
+ assert c["mt"] is not None
@@ -0,0 +1,208 @@
1
+ """Tests for --list-tasks and --list-plugins filter flags."""
2
+
3
+ import json
4
+
5
+
6
+ from invoke_toolkit import Context, task
7
+ from invoke_toolkit.collections import ToolkitCollection
8
+ from invoke_toolkit.testing import TestingToolkitProgram
9
+
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Shared task/collection factories
13
+ # ---------------------------------------------------------------------------
14
+
15
+
16
+ @task()
17
+ def main_task_a(ctx: Context):
18
+ """Main task A"""
19
+
20
+
21
+ @task()
22
+ def main_task_b(ctx: Context):
23
+ """Main task B"""
24
+
25
+
26
+ @task()
27
+ def plugin_task_x(ctx: Context):
28
+ """Plugin task X"""
29
+
30
+
31
+ @task()
32
+ def plugin_task_y(ctx: Context):
33
+ """Plugin task Y"""
34
+
35
+
36
+ def _make_collection_with_plugins() -> tuple[ToolkitCollection, set[str]]:
37
+ """
38
+ Build a ToolkitCollection that looks like 'tasks.py + two plugin sub-collections'.
39
+ Returns the collection and the set of plugin sub-collection names.
40
+ """
41
+ coll = ToolkitCollection("root")
42
+ coll.add_task(main_task_a)
43
+ coll.add_task(main_task_b)
44
+
45
+ plugin_coll = ToolkitCollection("myplugin")
46
+ plugin_coll.add_task(plugin_task_x)
47
+ plugin_coll.add_task(plugin_task_y)
48
+ coll.add_collection(plugin_coll, name="myplugin")
49
+
50
+ another_plugin = ToolkitCollection("otherplugin")
51
+ coll.add_collection(another_plugin, name="otherplugin")
52
+
53
+ return coll, {"myplugin", "otherplugin"}
54
+
55
+
56
+ def _program_with_plugins(
57
+ plugin_names: set[str] | None = None,
58
+ ) -> tuple[TestingToolkitProgram, ToolkitCollection]:
59
+ """Return a configured TestingToolkitProgram with a plugin-aware collection."""
60
+ coll, plugin_set = _make_collection_with_plugins()
61
+ p = TestingToolkitProgram(namespace=coll)
62
+ if plugin_names is None:
63
+ p._plugin_collection_names = plugin_set
64
+ else:
65
+ p._plugin_collection_names = plugin_names
66
+ return p, coll
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # --list-tasks
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def test_list_tasks_shows_main_tasks(capsys, suppress_stderr_logging):
75
+ """--list-tasks output contains the direct main tasks."""
76
+ p, _ = _program_with_plugins()
77
+ p.run(["", "--list-tasks", "--list-format", "json"])
78
+ out, _ = capsys.readouterr()
79
+ data = json.loads(out)
80
+ task_names = {t["name"] for t in data["tasks"]}
81
+ assert "main-task-a" in task_names
82
+ assert "main-task-b" in task_names
83
+
84
+
85
+ def test_list_tasks_excludes_plugin_collections(capsys, suppress_stderr_logging):
86
+ """--list-tasks must NOT include plugin sub-collections."""
87
+ p, _ = _program_with_plugins()
88
+ p.run(["", "--list-tasks", "--list-format", "json"])
89
+ out, _ = capsys.readouterr()
90
+ data = json.loads(out)
91
+ coll_names = {c["name"] for c in data.get("collections", [])}
92
+ assert "myplugin" not in coll_names
93
+ assert "otherplugin" not in coll_names
94
+
95
+
96
+ def test_list_tasks_flat_format(capsys, suppress_stderr_logging):
97
+ """--list-tasks works with the default flat format (no JSON)."""
98
+ p, _ = _program_with_plugins()
99
+ p.run(["", "--list-tasks"])
100
+ out, _ = capsys.readouterr()
101
+ assert "main-task-a" in out
102
+ assert "main-task-b" in out
103
+ assert "myplugin" not in out
104
+
105
+
106
+ def test_list_tasks_no_plugins_registered(capsys, suppress_stderr_logging):
107
+ """--list-tasks when there are no plugins still shows the main tasks."""
108
+ coll = ToolkitCollection("root")
109
+ coll.add_task(main_task_a)
110
+ p = TestingToolkitProgram(namespace=coll)
111
+ # _plugin_collection_names starts empty (no plugins)
112
+ p.run(["", "--list-tasks", "--list-format", "json"])
113
+ out, _ = capsys.readouterr()
114
+ data = json.loads(out)
115
+ task_names = {t["name"] for t in data["tasks"]}
116
+ assert "main-task-a" in task_names
117
+
118
+
119
+ def test_list_tasks_preserves_non_plugin_subcollections(
120
+ capsys, suppress_stderr_logging
121
+ ):
122
+ """Non-plugin sub-collections (e.g. from tasks.py namespace) are kept by --list-tasks."""
123
+ coll = ToolkitCollection("root")
124
+ coll.add_task(main_task_a)
125
+
126
+ sub = ToolkitCollection("utils")
127
+ sub.add_task(main_task_b)
128
+ coll.add_collection(sub, name="utils")
129
+
130
+ plugin_coll = ToolkitCollection("myplugin")
131
+ plugin_coll.add_task(plugin_task_x)
132
+ coll.add_collection(plugin_coll, name="myplugin")
133
+
134
+ p = TestingToolkitProgram(namespace=coll)
135
+ p._plugin_collection_names = {"myplugin"}
136
+
137
+ p.run(["", "--list-tasks", "--list-format", "json"])
138
+ out, _ = capsys.readouterr()
139
+ data = json.loads(out)
140
+ coll_names = {c["name"] for c in data.get("collections", [])}
141
+ assert "utils" in coll_names
142
+ assert "myplugin" not in coll_names
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # --list-plugins
147
+ # ---------------------------------------------------------------------------
148
+
149
+
150
+ def test_list_plugins_shows_plugin_collections(capsys, suppress_stderr_logging):
151
+ """--list-plugins output contains the plugin sub-collections."""
152
+ p, _ = _program_with_plugins()
153
+ p.run(["", "--list-plugins", "--list-format", "json"])
154
+ out, _ = capsys.readouterr()
155
+ data = json.loads(out)
156
+ coll_names = {c["name"] for c in data.get("collections", [])}
157
+ assert "myplugin" in coll_names
158
+ assert "otherplugin" in coll_names
159
+
160
+
161
+ def test_list_plugins_excludes_main_tasks(capsys, suppress_stderr_logging):
162
+ """--list-plugins must NOT include the direct main tasks."""
163
+ p, _ = _program_with_plugins()
164
+ p.run(["", "--list-plugins", "--list-format", "json"])
165
+ out, _ = capsys.readouterr()
166
+ data = json.loads(out)
167
+ task_names = {t["name"] for t in data.get("tasks", [])}
168
+ assert "main-task-a" not in task_names
169
+ assert "main-task-b" not in task_names
170
+
171
+
172
+ def test_list_plugins_flat_format(capsys, suppress_stderr_logging):
173
+ """--list-plugins works with the default flat format."""
174
+ p, _ = _program_with_plugins()
175
+ p.run(["", "--list-plugins"])
176
+ out, _ = capsys.readouterr()
177
+ assert "myplugin" in out
178
+ assert "main-task-a" not in out
179
+
180
+
181
+ def test_list_plugins_no_plugins_exits_with_message(capsys, suppress_stderr_logging):
182
+ """--list-plugins when there are no plugins exits with a clear message."""
183
+ coll = ToolkitCollection("root")
184
+ coll.add_task(main_task_a)
185
+ p = TestingToolkitProgram(namespace=coll)
186
+ # _plugin_collection_names is empty — no plugins loaded
187
+ p.run(["", "--list-plugins"])
188
+ # run(..., exit=False) so no sys.exit — just check stderr for the message
189
+ _, err = capsys.readouterr()
190
+ assert "No plugin collections found" in err
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Mutual exclusion sanity (using both flags at once: first one wins)
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def test_list_tasks_takes_precedence_over_list_plugins(capsys, suppress_stderr_logging):
199
+ """When both --list-tasks and --list-plugins are given, --list-tasks runs first."""
200
+ p, _ = _program_with_plugins()
201
+ p.run(["", "--list-tasks", "--list-plugins", "--list-format", "json"])
202
+ out, _ = capsys.readouterr()
203
+ data = json.loads(out)
204
+ # Should behave like --list-tasks
205
+ task_names = {t["name"] for t in data["tasks"]}
206
+ assert "main-task-a" in task_names
207
+ coll_names = {c["name"] for c in data.get("collections", [])}
208
+ assert "myplugin" not in coll_names
@@ -176,22 +176,25 @@ def test_proctitle_visible_in_ps(tmp_path: Path):
176
176
  tasks_file.write_text(tasks_content)
177
177
 
178
178
  # Start the task in a subprocess
179
- with subprocess.Popen(
179
+ proc = subprocess.Popen( # pylint: disable=consider-using-with
180
180
  [sys.executable, "-m", "invoke_toolkit", "-r", str(tmp_path), "test-proctitle"],
181
181
  stdout=subprocess.PIPE,
182
182
  stderr=subprocess.PIPE,
183
- ) as proc:
183
+ )
184
+ try:
184
185
  # Wait for the task to signal it's ready
185
- timeout = 10
186
+ startup_timeout = 10
186
187
  start = time.time()
187
188
  while not signal_file.exists():
188
- if time.time() - start > timeout:
189
- raise TimeoutError("Task did not start in time")
189
+ if time.time() - start > startup_timeout:
190
+ proc.kill()
191
+ proc.wait(timeout=5)
192
+ pytest.fail("Task did not start in time")
190
193
  if proc.poll() is not None:
191
194
  stdout, stderr = proc.communicate()
192
- raise RuntimeError(
195
+ pytest.fail(
193
196
  f"Process exited early: {proc.returncode}\n"
194
- + f"stdout: {stdout.decode()}\nstderr: {stderr.decode()}"
197
+ f"stdout: {stdout.decode()}\nstderr: {stderr.decode()}"
195
198
  )
196
199
  time.sleep(0.05)
197
200
 
@@ -210,7 +213,12 @@ def test_proctitle_visible_in_ps(tmp_path: Path):
210
213
 
211
214
  # Signal the task to finish
212
215
  done_file.write_text("done")
213
- proc.wait(timeout=5)
216
+ proc.wait(timeout=10)
217
+ finally:
218
+ # Ensure the subprocess is always cleaned up
219
+ if proc.poll() is None:
220
+ proc.kill()
221
+ proc.wait(timeout=5)
214
222
 
215
223
 
216
224
  @pytest.fixture