duty 1.4.3__tar.gz → 1.6.0__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 (102) hide show
  1. {duty-1.4.3 → duty-1.6.0}/CHANGELOG.md +16 -0
  2. {duty-1.4.3 → duty-1.6.0}/PKG-INFO +1 -1
  3. {duty-1.4.3 → duty-1.6.0}/docs/index.md +1 -1
  4. {duty-1.4.3 → duty-1.6.0}/docs/usage.md +25 -13
  5. {duty-1.4.3 → duty-1.6.0}/duties.py +1 -0
  6. {duty-1.4.3 → duty-1.6.0}/pyproject.toml +5 -7
  7. {duty-1.4.3 → duty-1.6.0}/scripts/gen_credits.py +1 -1
  8. duty-1.6.0/scripts/get_version.py +27 -0
  9. duty-1.6.0/scripts/make +1 -0
  10. duty-1.4.3/scripts/make → duty-1.6.0/scripts/make.py +18 -17
  11. {duty-1.4.3 → duty-1.6.0}/src/duty/cli.py +29 -1
  12. {duty-1.4.3 → duty-1.6.0}/src/duty/collection.py +29 -0
  13. duty-1.6.0/src/duty/completions.bash +30 -0
  14. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/__init__.py +5 -3
  15. duty-1.6.0/src/duty/tools/_yore.py +54 -0
  16. {duty-1.4.3 → duty-1.6.0}/tests/test_collection.py +16 -0
  17. {duty-1.4.3 → duty-1.6.0}/CODE_OF_CONDUCT.md +0 -0
  18. {duty-1.4.3 → duty-1.6.0}/CONTRIBUTING.md +0 -0
  19. {duty-1.4.3 → duty-1.6.0}/LICENSE +0 -0
  20. {duty-1.4.3 → duty-1.6.0}/README.md +0 -0
  21. {duty-1.4.3 → duty-1.6.0}/config/coverage.ini +0 -0
  22. {duty-1.4.3 → duty-1.6.0}/config/git-changelog.toml +0 -0
  23. {duty-1.4.3 → duty-1.6.0}/config/mypy.ini +0 -0
  24. {duty-1.4.3 → duty-1.6.0}/config/pytest.ini +0 -0
  25. {duty-1.4.3 → duty-1.6.0}/config/ruff.toml +0 -0
  26. {duty-1.4.3 → duty-1.6.0}/config/vscode/launch.json +0 -0
  27. {duty-1.4.3 → duty-1.6.0}/config/vscode/settings.json +0 -0
  28. {duty-1.4.3 → duty-1.6.0}/config/vscode/tasks.json +0 -0
  29. {duty-1.4.3 → duty-1.6.0}/docs/.overrides/main.html +0 -0
  30. {duty-1.4.3 → duty-1.6.0}/docs/.overrides/partials/comments.html +0 -0
  31. {duty-1.4.3 → duty-1.6.0}/docs/changelog.md +0 -0
  32. {duty-1.4.3 → duty-1.6.0}/docs/code_of_conduct.md +0 -0
  33. {duty-1.4.3 → duty-1.6.0}/docs/contributing.md +0 -0
  34. {duty-1.4.3 → duty-1.6.0}/docs/credits.md +0 -0
  35. {duty-1.4.3 → duty-1.6.0}/docs/css/material.css +0 -0
  36. {duty-1.4.3 → duty-1.6.0}/docs/css/mkdocstrings.css +0 -0
  37. {duty-1.4.3 → duty-1.6.0}/docs/demo.svg +0 -0
  38. {duty-1.4.3 → duty-1.6.0}/docs/gen_credits.py +0 -0
  39. {duty-1.4.3 → duty-1.6.0}/docs/js/feedback.js +0 -0
  40. {duty-1.4.3 → duty-1.6.0}/docs/license.md +0 -0
  41. {duty-1.4.3 → duty-1.6.0}/mkdocs.yml +0 -0
  42. {duty-1.4.3 → duty-1.6.0}/scripts/gen_ref_nav.py +0 -0
  43. {duty-1.4.3 → duty-1.6.0}/src/duty/__init__.py +0 -0
  44. {duty-1.4.3 → duty-1.6.0}/src/duty/__main__.py +0 -0
  45. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/__init__.py +0 -0
  46. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/_io.py +0 -0
  47. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/autoflake.py +0 -0
  48. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/black.py +0 -0
  49. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/blacken_docs.py +0 -0
  50. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/build.py +0 -0
  51. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/coverage.py +0 -0
  52. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/flake8.py +0 -0
  53. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/git_changelog.py +0 -0
  54. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/griffe.py +0 -0
  55. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/interrogate.py +0 -0
  56. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/isort.py +0 -0
  57. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/mkdocs.py +0 -0
  58. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/mypy.py +0 -0
  59. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/pytest.py +0 -0
  60. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/ruff.py +0 -0
  61. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/safety.py +0 -0
  62. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/ssort.py +0 -0
  63. {duty-1.4.3 → duty-1.6.0}/src/duty/callables/twine.py +0 -0
  64. {duty-1.4.3 → duty-1.6.0}/src/duty/context.py +0 -0
  65. {duty-1.4.3 → duty-1.6.0}/src/duty/debug.py +0 -0
  66. {duty-1.4.3 → duty-1.6.0}/src/duty/decorator.py +0 -0
  67. {duty-1.4.3 → duty-1.6.0}/src/duty/exceptions.py +0 -0
  68. {duty-1.4.3 → duty-1.6.0}/src/duty/py.typed +0 -0
  69. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_autoflake.py +0 -0
  70. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_base.py +0 -0
  71. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_black.py +0 -0
  72. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_blacken_docs.py +0 -0
  73. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_build.py +0 -0
  74. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_coverage.py +0 -0
  75. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_flake8.py +0 -0
  76. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_git_changelog.py +0 -0
  77. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_griffe.py +0 -0
  78. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_interrogate.py +0 -0
  79. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_isort.py +0 -0
  80. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_mkdocs.py +0 -0
  81. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_mypy.py +0 -0
  82. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_pytest.py +0 -0
  83. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_ruff.py +0 -0
  84. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_safety.py +0 -0
  85. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_ssort.py +0 -0
  86. {duty-1.4.3 → duty-1.6.0}/src/duty/tools/_twine.py +0 -0
  87. {duty-1.4.3 → duty-1.6.0}/src/duty/validation.py +0 -0
  88. {duty-1.4.3 → duty-1.6.0}/tests/__init__.py +0 -0
  89. {duty-1.4.3 → duty-1.6.0}/tests/conftest.py +0 -0
  90. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/arguments.py +0 -0
  91. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/basic.py +0 -0
  92. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/booleans.py +0 -0
  93. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/code.py +0 -0
  94. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/list.py +0 -0
  95. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/multiple.py +0 -0
  96. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/precedence.py +0 -0
  97. {duty-1.4.3 → duty-1.6.0}/tests/fixtures/validation.py +0 -0
  98. {duty-1.4.3 → duty-1.6.0}/tests/test_cli.py +0 -0
  99. {duty-1.4.3 → duty-1.6.0}/tests/test_context.py +0 -0
  100. {duty-1.4.3 → duty-1.6.0}/tests/test_decorator.py +0 -0
  101. {duty-1.4.3 → duty-1.6.0}/tests/test_running.py +0 -0
  102. {duty-1.4.3 → duty-1.6.0}/tests/test_validation.py +0 -0
@@ -5,6 +5,22 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  <!-- insertion marker -->
8
+ ## [1.6.0](https://github.com/pawamoy/duty/releases/tag/1.6.0) - 2025-03-01
9
+
10
+ <small>[Compare with 1.5.0](https://github.com/pawamoy/duty/compare/1.5.0...1.6.0)</small>
11
+
12
+ ### Features
13
+
14
+ - Add Yore tool ([4cbb478](https://github.com/pawamoy/duty/commit/4cbb478ac41258316188fcd3402e5165b0c233ac) by Timothée Mazzucotelli).
15
+
16
+ ## [1.5.0](https://github.com/pawamoy/duty/releases/tag/1.5.0) - 2025-02-02
17
+
18
+ <small>[Compare with 1.4.3](https://github.com/pawamoy/duty/compare/1.4.3...1.5.0)</small>
19
+
20
+ ### Features
21
+
22
+ - Enable Bash completions ([9ed4400](https://github.com/pawamoy/duty/commit/9ed44002ff8e122ea6e5aaaf4a968e08d0dc83fd) by Bartosz Sławecki). [Issue-27](https://github.com/pawamoy/duty/issues/27), [PR-33](https://github.com/pawamoy/duty/pull/33), Co-authored-by: Timothée Mazzucotelli <dev@pawamoy.fr>
23
+
8
24
  ## [1.4.3](https://github.com/pawamoy/duty/releases/tag/1.4.3) - 2024-10-17
9
25
 
10
26
  <small>[Compare with 1.4.2](https://github.com/pawamoy/duty/compare/1.4.2...1.4.3)</small>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: duty
3
- Version: 1.4.3
3
+ Version: 1.6.0
4
4
  Summary: A simple task runner.
5
5
  Keywords: task-runner,task,runner,cross-platform
6
6
  Author-Email: =?utf-8?q?Timoth=C3=A9e_Mazzucotelli?= <dev@pawamoy.fr>
@@ -63,7 +63,7 @@ clean:
63
63
  ```
64
64
 
65
65
  See [our Makefile](https://github.com/pawamoy/duty/blob/main/Makefile)
66
- for inspiration.
66
+ for inspiration.
67
67
 
68
68
  ### [Task](https://taskfile.dev/)?
69
69
 
@@ -54,18 +54,17 @@ def docs(ctx):
54
54
  # avoid the overhead of an extra Python process
55
55
  ```
56
56
 
57
- For convenience, `duty` provides callables for many popular Python tools,
57
+ For convenience, `duty` provides callables (called "tools") for many popular Python tools,
58
58
  so that you don't have to read their source and learn how to call them.
59
59
  For example, the `mkdocs build` command can be called like this:
60
60
 
61
61
  ```python
62
- from duty import duty
63
- from duty.callables import mkdocs
62
+ from duty import duty, tools
64
63
 
65
64
 
66
65
  @duty
67
66
  def docs(ctx):
68
- ctx.run(mkdocs.build, kwargs={"strict": True}, title="Building documentation")
67
+ ctx.run(tool.mkdocs.build, kwargs={"strict": True}, title="Building documentation")
69
68
  ```
70
69
 
71
70
  ### Lazy callables
@@ -78,19 +77,18 @@ without passing arguments and keyword arguments
78
77
  with the `args` and `kwargs` parameters of `ctx.run()`:
79
78
 
80
79
  ```python
81
- from duty import duty
82
- from duty.callables import mkdocs
80
+ from duty import duty, tools
83
81
 
84
82
 
85
83
  @duty
86
84
  def docs(ctx):
87
- ctx.run(mkdocs.build(strict=True), title="Building documentation")
85
+ ctx.run(tools.mkdocs.build(strict=True), title="Building documentation")
88
86
  ```
89
87
 
90
88
  The main benefit is that it enables IDE features like help tooltips and auto-completion,
91
89
  as well as improving readability and writability.
92
90
 
93
- **[See all our callables in the Code reference][duty.tools].**
91
+ **[See all our tools in the Code reference][duty.tools].**
94
92
 
95
93
  You can also create your own lazy callables with [`duty.tools.Tool`][]
96
94
  and [`duty.tools.lazy`][failprint.lazy.lazy].
@@ -647,7 +645,7 @@ You can also pass parameters as positional arguments:
647
645
  duty shoot 5,15
648
646
  ```
649
647
 
650
- WARNING: **Limitation with positional arguments.**
648
+ WARNING: **Limitation with positional arguments.**
651
649
  When passing positional arguments,
652
650
  make sure there is no overlap between other duties' names
653
651
  and the argument value, otherwise `duty` will not be able
@@ -713,7 +711,7 @@ def play(ctx, file):
713
711
  ```bash
714
712
  duty --capture=none --strict play this-file.mp4
715
713
  # or with the short options
716
- duty -Zc none play this-file.mp4
714
+ duty -Zc none play this-file.mp4
717
715
  ```
718
716
 
719
717
  #### Local options
@@ -724,7 +722,7 @@ you can pass them to a specific duty on the command line.
724
722
  If we use the previous example again:
725
723
 
726
724
  ```bash
727
- duty play -Zc none this-file.mp4
725
+ duty play -Zc none this-file.mp4
728
726
  ```
729
727
 
730
728
  It allows to use different options for different duties
@@ -778,6 +776,8 @@ It is not possible to capture only stdout, or only stderr,
778
776
  and let the other one be printed to the console.
779
777
  Capturing one is capturing both, but discarding the other.
780
778
 
779
+ WARNING: **Windows quirks.** On Windows you might need to set the following environment variables to allow proper output capture: `PYTHONLEGACYWINDOWSSTDIO=1`, `PYTHONUTF8=1`, `PYTHONIOENCODING=UTF8`. If the `✓` and `✗` characters are mangled, try changing them by [customizing the output format](#formatting-duty-output).
780
+
781
781
  ### Formatting duty output
782
782
 
783
783
  Thanks to its underlying [`failprint`](https://github.com/pawamoy/failprint) dependency,
@@ -789,7 +789,7 @@ For example, the two builtin `failprint` formats are:
789
789
  ```jinja
790
790
  {% if success %}<green>✓</green>
791
791
  {% elif nofail %}<yellow>✗</yellow>
792
- {% else %}<red>✗</red>{% endif %}
792
+ {% else %}<red>✗</red>{% endif %}
793
793
  <bold>{{ title or command }}</bold>
794
794
  {% if failure %} ({{ code }}){% endif %}
795
795
  {% if failure and output and not quiet %}\n
@@ -871,4 +871,16 @@ export FAILPRINT_FORMAT="custom={{output}}"
871
871
  # always print the captured output, nothing else
872
872
 
873
873
  duty task1 task2
874
- ```
874
+ ```
875
+
876
+ ### Shell completions
877
+
878
+ You can enable auto-completion in Bash with these commands:
879
+
880
+ ```bash
881
+ completions_dir="${BASH_COMPLETION_USER_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/bash-completion}/completions"
882
+ mkdir -p "${completions_dir}"
883
+ duty --completion > "${completions_dir}/duty"
884
+ ```
885
+
886
+ Only Bash is supported for now.
@@ -84,6 +84,7 @@ def check_docs(ctx: Context) -> None:
84
84
  @duty
85
85
  def check_types(ctx: Context) -> None:
86
86
  """Check that the code is correctly typed."""
87
+ os.environ["FORCE_COLOR"] = "1"
87
88
  ctx.run(
88
89
  tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"),
89
90
  title=pyprefix("Type-checking"),
@@ -41,7 +41,7 @@ dependencies = [
41
41
  "failprint>=0.11,!=1.0.0",
42
42
  "typing-extensions>=4.0; python_version < '3.11'",
43
43
  ]
44
- version = "1.4.3"
44
+ version = "1.6.0"
45
45
 
46
46
  [project.license]
47
47
  text = "ISC"
@@ -60,11 +60,10 @@ Funding = "https://github.com/sponsors/pawamoy"
60
60
  duty = "duty.cli:main"
61
61
 
62
62
  [tool.pdm.version]
63
- source = "scm"
63
+ source = "call"
64
+ getter = "scripts.get_version:get_version"
64
65
 
65
66
  [tool.pdm.build]
66
- package-dir = "src"
67
- editable-backend = "editables"
68
67
  excludes = [
69
68
  "**/.pytest_cache",
70
69
  ]
@@ -85,9 +84,8 @@ data = [
85
84
  { path = "share/**/*", relative-to = "." },
86
85
  ]
87
86
 
88
- [tool.uv]
89
- dev-dependencies = [
90
- "editables>=0.5",
87
+ [dependency-groups]
88
+ dev = [
91
89
  "build>=1.2",
92
90
  "git-changelog>=2.5",
93
91
  "twine>=5.1",
@@ -27,7 +27,7 @@ with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file:
27
27
  pyproject = tomllib.load(pyproject_file)
28
28
  project = pyproject["project"]
29
29
  project_name = project["name"]
30
- devdeps = [dep for dep in pyproject["tool"]["uv"]["dev-dependencies"] if not dep.startswith("-e")]
30
+ devdeps = [dep for dep in pyproject["dependency-groups"]["dev"] if not dep.startswith("-e")]
31
31
 
32
32
  PackageMetadata = dict[str, Union[str, Iterable[str]]]
33
33
  Metadata = dict[str, PackageMetadata]
@@ -0,0 +1,27 @@
1
+ """Get current project version from Git tags or changelog."""
2
+
3
+ import re
4
+ from contextlib import suppress
5
+ from pathlib import Path
6
+
7
+ from pdm.backend.hooks.version import SCMVersion, Version, default_version_formatter, get_version_from_scm
8
+
9
+ _root = Path(__file__).parent.parent
10
+ _changelog = _root / "CHANGELOG.md"
11
+ _changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$")
12
+ _default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003
13
+
14
+
15
+ def get_version() -> str:
16
+ """Get current project version from Git tags or changelog."""
17
+ scm_version = get_version_from_scm(_root) or _default_scm_version
18
+ if scm_version.version <= Version("0.1"): # Missing Git tags?
19
+ with suppress(OSError, StopIteration): # noqa: SIM117
20
+ with _changelog.open("r", encoding="utf8") as file:
21
+ match = next(filter(None, map(_changelog_version_re.match, file)))
22
+ scm_version = scm_version._replace(version=Version(match.group(1)))
23
+ return default_version_formatter(scm_version)
24
+
25
+
26
+ if __name__ == "__main__":
27
+ print(get_version())
@@ -0,0 +1 @@
1
+ make.py
@@ -10,12 +10,16 @@ import sys
10
10
  from contextlib import contextmanager
11
11
  from pathlib import Path
12
12
  from textwrap import dedent
13
- from typing import Any, Iterator
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Iterator
17
+
14
18
 
15
19
  PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split()
16
20
 
17
21
 
18
- def shell(cmd: str, capture_output: bool = False, **kwargs: Any) -> str | None:
22
+ def shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None:
19
23
  """Run a shell command."""
20
24
  if capture_output:
21
25
  return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602
@@ -49,15 +53,15 @@ def setup() -> None:
49
53
  if not shutil.which("uv"):
50
54
  raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv")
51
55
 
52
- print("Installing dependencies (default environment)") # noqa: T201
56
+ print("Installing dependencies (default environment)")
53
57
  default_venv = Path(".venv")
54
58
  if not default_venv.exists():
55
- shell("uv venv --python python")
59
+ shell("uv venv")
56
60
  uv_install(default_venv)
57
61
 
58
62
  if PYTHON_VERSIONS:
59
63
  for version in PYTHON_VERSIONS:
60
- print(f"\nInstalling dependencies (python{version})") # noqa: T201
64
+ print(f"\nInstalling dependencies (python{version})")
61
65
  venv_path = Path(f".venvs/{version}")
62
66
  if not venv_path.exists():
63
67
  shell(f"uv venv --python {version} {venv_path}")
@@ -65,12 +69,10 @@ def setup() -> None:
65
69
  uv_install(venv_path)
66
70
 
67
71
 
68
- def run(version: str, cmd: str, *args: str, no_sync: bool = False, **kwargs: Any) -> None:
72
+ def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None:
69
73
  """Run a command in a virtual environment."""
70
74
  kwargs = {"check": True, **kwargs}
71
- uv_run = ["uv", "run"]
72
- if no_sync:
73
- uv_run.append("--no-sync")
75
+ uv_run = ["uv", "run", "--no-sync"]
74
76
  if version == "default":
75
77
  with environ(UV_PROJECT_ENVIRONMENT=".venv"):
76
78
  subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510
@@ -99,7 +101,7 @@ def clean() -> None:
99
101
  """Delete build artifacts and cache files."""
100
102
  paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"]
101
103
  for path in paths_to_clean:
102
- shell(f"rm -rf {path}")
104
+ shutil.rmtree(path, ignore_errors=True)
103
105
 
104
106
  cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"}
105
107
  for dirpath in Path(".").rglob("*/"):
@@ -109,8 +111,7 @@ def clean() -> None:
109
111
 
110
112
  def vscode() -> None:
111
113
  """Configure VSCode to work on this project."""
112
- Path(".vscode").mkdir(parents=True, exist_ok=True)
113
- shell("cp -v config/vscode/* .vscode")
114
+ shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True)
114
115
 
115
116
 
116
117
  def main() -> int:
@@ -132,13 +133,13 @@ def main() -> int:
132
133
  3.x Run a command in the virtual environment for Python 3.x.
133
134
  clean Delete build artifacts and cache files.
134
135
  vscode Configure VSCode to work on this project.
135
- """
136
+ """,
136
137
  ),
137
138
  flush=True,
138
- ) # noqa: T201
139
+ )
139
140
  if os.path.exists(".venv"):
140
- print("\nAvailable tasks", flush=True) # noqa: T201
141
- run("default", "duty", "--list", no_sync=True)
141
+ print("\nAvailable tasks", flush=True)
142
+ run("default", "duty", "--list")
142
143
  return 0
143
144
 
144
145
  while args:
@@ -186,5 +187,5 @@ if __name__ == "__main__":
186
187
  sys.exit(main())
187
188
  except subprocess.CalledProcessError as process:
188
189
  if process.output:
189
- print(process.output, file=sys.stderr) # noqa: T201
190
+ print(process.output, file=sys.stderr)
190
191
  sys.exit(process.returncode)
@@ -17,6 +17,7 @@ import argparse
17
17
  import inspect
18
18
  import sys
19
19
  import textwrap
20
+ from pathlib import Path
20
21
  from typing import Any
21
22
 
22
23
  from failprint.cli import ArgParser, add_flags
@@ -69,6 +70,18 @@ def get_parser() -> ArgParser:
69
70
  metavar="DUTY",
70
71
  help="Show this help message and exit. Pass duties names to print their help.",
71
72
  )
73
+ parser.add_argument(
74
+ "--completion",
75
+ dest="completion",
76
+ action="store_true",
77
+ help=argparse.SUPPRESS,
78
+ )
79
+ parser.add_argument(
80
+ "--complete",
81
+ dest="complete",
82
+ action="store_true",
83
+ help=argparse.SUPPRESS,
84
+ )
72
85
  parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {debug.get_version()}")
73
86
  parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")
74
87
 
@@ -261,6 +274,18 @@ def main(args: list[str] | None = None) -> int:
261
274
  collection = Collection(opts.duties_file)
262
275
  collection.load()
263
276
 
277
+ if opts.completion:
278
+ print(Path(__file__).parent.joinpath("completions.bash").read_text())
279
+ return 0
280
+
281
+ if opts.complete:
282
+ words = collection.completion_candidates(remainder)
283
+ words += sorted(
284
+ opt for opt, action in parser._option_string_actions.items() if action.help != argparse.SUPPRESS
285
+ )
286
+ print(*words, sep="\n")
287
+ return 0
288
+
264
289
  if opts.help is not None:
265
290
  print_help(parser, opts, collection)
266
291
  return 0
@@ -279,7 +304,10 @@ def main(args: list[str] | None = None) -> int:
279
304
  print_help(parser, opts, collection)
280
305
  return 1
281
306
 
282
- global_opts = specified_options(opts, exclude={"duties_file", "list", "help", "remainder"})
307
+ global_opts = specified_options(
308
+ opts,
309
+ exclude={"duties_file", "list", "help", "remainder", "complete", "completion"},
310
+ )
283
311
  try:
284
312
  commands = parse_commands(arg_lists, global_opts, collection)
285
313
  except TypeError as error:
@@ -143,6 +143,35 @@ class Collection:
143
143
  """
144
144
  return list(self.duties.keys()) + list(self.aliases.keys())
145
145
 
146
+ def completion_candidates(self, args: tuple[str, ...]) -> list[str]:
147
+ """Find shell completion candidates within this collection.
148
+
149
+ Returns:
150
+ The list of shell completion candidates, sorted alphabetically.
151
+ """
152
+ # Find last duty name in args.
153
+ name = None
154
+ names = set(self.names())
155
+ for arg in reversed(args):
156
+ if arg in names:
157
+ name = arg
158
+ break
159
+
160
+ completion_names = sorted(names)
161
+
162
+ # If no duty found, return names.
163
+ if name is None:
164
+ return completion_names
165
+
166
+ params = [
167
+ f"{param.name}="
168
+ for param in inspect.signature(self.get(name).function).parameters.values()
169
+ if param.kind is not param.VAR_POSITIONAL
170
+ ][1:]
171
+
172
+ # If duty found, return names *and* duty parameters.
173
+ return completion_names + sorted(params)
174
+
146
175
  def get(self, name_or_alias: str) -> Duty:
147
176
  """Get a duty by its name or alias.
148
177
 
@@ -0,0 +1,30 @@
1
+ # Taken and adapted from pyinvoke:
2
+ # Copyright (c) 2020 Jeff Forcier.
3
+ # All rights reserved.
4
+
5
+ _complete_duty() {
6
+ local candidates
7
+
8
+ # COMP_WORDS contains the entire command string up til now (including # program name).
9
+ # We hand it to Invoke so it can figure out the current context:
10
+ # spit back core options, task names, the current task's options, or some combo.
11
+ candidates=$(duty --complete -- "${COMP_WORDS[@]}")
12
+
13
+ # `compgen -W` takes list of valid options & a partial word & spits back possible matches.
14
+ # Necessary for any partial word completions
15
+ # (vs. completions performed when no partial words are present).
16
+ #
17
+ # $2 is the current word or token being tabbed on, either empty string or a
18
+ # partial word, and thus wants to be compgen'd to arrive at some subset of
19
+ # our candidate list which actually matches.
20
+ #
21
+ # COMPREPLY is the list of valid completions handed back to `complete`.
22
+ COMPREPLY=( $(compgen -W "${candidates}" -- $2) )
23
+ }
24
+
25
+
26
+ # Tell shell builtin to use the above for completing our invocations.
27
+ # * -F: use given function name to generate completions.
28
+ # * -o default: when function generates no results, use filenames.
29
+ # * positional args: program names to complete for.
30
+ complete -F _complete_duty -o default duty
@@ -22,12 +22,12 @@ from duty.tools._ruff import ruff
22
22
  from duty.tools._safety import safety
23
23
  from duty.tools._ssort import ssort
24
24
  from duty.tools._twine import twine
25
+ from duty.tools._yore import yore
25
26
 
26
27
  __all__ = [
27
- "Tool",
28
- "LazyStdout",
29
28
  "LazyStderr",
30
- "lazy",
29
+ "LazyStdout",
30
+ "Tool",
31
31
  "autoflake",
32
32
  "black",
33
33
  "blacken_docs",
@@ -38,6 +38,7 @@ __all__ = [
38
38
  "griffe",
39
39
  "interrogate",
40
40
  "isort",
41
+ "lazy",
41
42
  "mkdocs",
42
43
  "mypy",
43
44
  "pytest",
@@ -45,4 +46,5 @@ __all__ = [
45
46
  "safety",
46
47
  "ssort",
47
48
  "twine",
49
+ "yore",
48
50
  ]
@@ -0,0 +1,54 @@
1
+ """Callable for [Yore](https://github.com/pawamoy/yore)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from duty.tools._base import Tool
6
+
7
+
8
+ class yore(Tool): # noqa: N801
9
+ """Call [Yore](https://github.com/pawamoy/yore)."""
10
+
11
+ cli_name = "yore"
12
+
13
+ @classmethod
14
+ def check(
15
+ cls,
16
+ *paths: str,
17
+ bump: str | None = None,
18
+ eol_within: str | None = None,
19
+ bol_within: str | None = None,
20
+ ) -> yore:
21
+ """Checks Yore comments against Python EOL dates or the provided next version of your project.
22
+
23
+ Parameters:
24
+ paths: Path to files or directories to check.
25
+ bump: The next version of your project.
26
+ eol_within: The time delta to start checking before the End of Life of a Python version.
27
+ It is provided in a human-readable format, like `2 weeks` or `1 month`.
28
+ Spaces are optional, and the unit can be shortened to a single letter:
29
+ `d` for days, `w` for weeks, `m` for months, and `y` for years.
30
+ bol_within: The time delta to start checking before the Beginning of Life of a Python version.
31
+ It is provided in a human-readable format, like `2 weeks` or `1 month`.
32
+ Spaces are optional, and the unit can be shortened to a single letter:
33
+ `d` for days, `w` for weeks, `m` for months, and `y` for years.
34
+ """
35
+ cli_args = ["check", *paths]
36
+
37
+ if bump:
38
+ cli_args.append("--bump")
39
+ cli_args.append(bump)
40
+
41
+ if eol_within:
42
+ cli_args.append("--eol-within")
43
+ cli_args.append(eol_within)
44
+
45
+ if bol_within:
46
+ cli_args.append("--bol-within")
47
+ cli_args.append(bol_within)
48
+
49
+ return cls(cli_args)
50
+
51
+ def __call__(self) -> int:
52
+ from yore import main as run_yore
53
+
54
+ return run_yore(self.cli_args)
@@ -68,3 +68,19 @@ def test_add_duty_to_multiple_collections() -> None:
68
68
  assert duty1 is not duty2
69
69
  assert duty1.collection is collection1
70
70
  assert duty2.collection is collection2
71
+
72
+
73
+ def test_completion_candidates() -> None:
74
+ """Check whether proper completion candidates are returned from collections."""
75
+ collection = Collection()
76
+
77
+ collection.add(decorate(none, name="duty_1")) # type: ignore[call-overload]
78
+ collection.add(decorate(none, name="duty_2", aliases=["alias_2"])) # type: ignore[call-overload]
79
+
80
+ assert collection.completion_candidates(("duty",)) == [
81
+ "alias_2",
82
+ "duty-1",
83
+ "duty-2",
84
+ "duty_1",
85
+ "duty_2",
86
+ ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes