├── src └── duty │ ├── py.typed │ ├── _internal │ ├── __init__.py │ ├── exceptions.py │ ├── callables │ │ ├── _io.py │ │ ├── ssort.py │ │ ├── __init__.py │ │ ├── build.py │ │ ├── safety.py │ │ ├── blacken_docs.py │ │ ├── autoflake.py │ │ ├── interrogate.py │ │ ├── black.py │ │ └── git_changelog.py │ ├── tools │ │ ├── __init__.py │ │ ├── _ssort.py │ │ ├── _base.py │ │ ├── _build.py │ │ ├── _yore.py │ │ ├── _safety.py │ │ ├── _blacken_docs.py │ │ ├── _autoflake.py │ │ └── _interrogate.py │ ├── debug.py │ ├── decorator.py │ └── context.py │ ├── __main__.py │ ├── cli.py │ ├── context.py │ ├── decorator.py │ ├── collection.py │ ├── exceptions.py │ ├── validation.py │ ├── callables │ ├── mypy.py │ ├── ruff.py │ ├── black.py │ ├── build.py │ ├── isort.py │ ├── ssort.py │ ├── twine.py │ ├── flake8.py │ ├── griffe.py │ ├── mkdocs.py │ ├── pytest.py │ ├── safety.py │ ├── coverage.py │ ├── autoflake.py │ ├── interrogate.py │ ├── blacken_docs.py │ ├── git_changelog.py │ └── __init__.py │ ├── completions.bash │ ├── __init__.py │ └── tools.py ├── scripts ├── make ├── get_version.py ├── gen_credits.py └── make.py ├── .envrc ├── docs ├── demo.svg ├── changelog.md ├── contributing.md ├── code_of_conduct.md ├── css │ ├── material.css │ └── mkdocstrings.css ├── license.md ├── credits.md ├── reference │ └── api.md ├── .overrides │ ├── main.html │ └── partials │ │ ├── path-item.html │ │ └── comments.html ├── js │ └── feedback.js ├── gen_credits.py └── index.md ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── 3-docs.md │ ├── 4-change.md │ ├── 2-feature.md │ └── 1-bug.md └── workflows │ ├── release.yml │ └── ci.yml ├── tests ├── conftest.py ├── fixtures │ ├── basic.py │ ├── code.py │ ├── booleans.py │ ├── precedence.py │ ├── arguments.py │ ├── list.py │ ├── multiple.py │ └── validation.py ├── __init__.py ├── test_decorator.py ├── test_collection.py ├── test_running.py ├── test_context.py ├── test_validation.py ├── test_cli.py └── test_api.py ├── config ├── mypy.ini ├── git-changelog.toml ├── pytest.ini ├── coverage.ini ├── vscode │ ├── settings.json │ ├── launch.json │ └── tasks.json └── ruff.toml ├── .gitignore ├── .copier-answers.yml ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── CONTRIBUTING.md ├── mkdocs.yml ├── CODE_OF_CONDUCT.md └── duties.py /src/duty/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/make: -------------------------------------------------------------------------------- 1 | make.py -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | PATH_add scripts 2 | -------------------------------------------------------------------------------- /docs/demo.svg: -------------------------------------------------------------------------------- 1 | ../demo.svg -------------------------------------------------------------------------------- /src/duty/_internal/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: pawamoy 2 | polar: pawamoy 3 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Configuration for the pytest test suite.""" 2 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | --- 4 | 5 | --8<-- "CHANGELOG.md" 6 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | --8<-- "CONTRIBUTING.md" 6 | -------------------------------------------------------------------------------- /docs/code_of_conduct.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Code of Conduct 3 | --- 4 | 5 | --8<-- "CODE_OF_CONDUCT.md" 6 | -------------------------------------------------------------------------------- /tests/fixtures/basic.py: -------------------------------------------------------------------------------- 1 | from duty import duty 2 | 3 | 4 | @duty 5 | def hello(ctx): 6 | pass 7 | -------------------------------------------------------------------------------- /docs/css/material.css: -------------------------------------------------------------------------------- 1 | /* More space at the bottom of the page. */ 2 | .md-main__inner { 3 | margin-bottom: 1.5rem; 4 | } 5 | -------------------------------------------------------------------------------- /tests/fixtures/code.py: -------------------------------------------------------------------------------- 1 | from duty import duty 2 | 3 | 4 | @duty 5 | def exit_with(ctx, code): 6 | ctx.run(lambda: code) 7 | -------------------------------------------------------------------------------- /docs/license.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: License 3 | hide: 4 | - feedback 5 | --- 6 | 7 | # License 8 | 9 | ``` 10 | --8<-- "LICENSE" 11 | ``` 12 | -------------------------------------------------------------------------------- /config/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = true 3 | exclude = tests/fixtures/ 4 | warn_unused_ignores = true 5 | show_error_codes = true 6 | -------------------------------------------------------------------------------- /docs/credits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Credits 3 | hide: 4 | - toc 5 | --- 6 | 7 | ```python exec="yes" 8 | --8<-- "scripts/gen_credits.py" 9 | ``` 10 | -------------------------------------------------------------------------------- /tests/fixtures/booleans.py: -------------------------------------------------------------------------------- 1 | from duty import duty 2 | 3 | 4 | @duty 5 | def boolean(ctx, zero: bool = True): 6 | ctx.run(lambda: 0 if zero else 1) 7 | -------------------------------------------------------------------------------- /docs/reference/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API reference 3 | hide: 4 | - navigation 5 | --- 6 | 7 | # ::: duty 8 | options: 9 | show_submodules: true 10 | -------------------------------------------------------------------------------- /tests/fixtures/precedence.py: -------------------------------------------------------------------------------- 1 | from duty import duty 2 | 3 | 4 | @duty(nofail=True) 5 | def precedence(ctx): 6 | ctx.run(lambda: 1, title="Precedence", nofail=False) 7 | -------------------------------------------------------------------------------- /tests/fixtures/arguments.py: -------------------------------------------------------------------------------- 1 | from duty import duty 2 | 3 | 4 | @duty 5 | def say_hello(ctx, cat, dog="dog"): 6 | ctx.run(lambda: 0, title=f"Hello cat {cat} and dog {dog}!") 7 | -------------------------------------------------------------------------------- /tests/fixtures/list.py: -------------------------------------------------------------------------------- 1 | from duty import duty 2 | 3 | 4 | @duty 5 | def tong(ctx): 6 | """Tong...""" 7 | 8 | 9 | @duty 10 | def deum(ctx): 11 | """DEUM!""" 12 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests suite for `duty`.""" 2 | 3 | from pathlib import Path 4 | 5 | TESTS_DIR = Path(__file__).parent 6 | TMP_DIR = TESTS_DIR / "tmp" 7 | FIXTURES_DIR = TESTS_DIR / "fixtures" 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: I have a question / I need help 4 | url: https://github.com/pawamoy/duty/discussions/new?category=q-a 5 | about: Ask and answer questions in the Discussions tab. 6 | -------------------------------------------------------------------------------- /tests/fixtures/multiple.py: -------------------------------------------------------------------------------- 1 | from duty import duty 2 | 3 | 4 | @duty 5 | def first_duty(ctx): 6 | ctx.run(lambda: 0, fmt="tap", title="first") 7 | 8 | 9 | @duty 10 | def second_duty(ctx): 11 | ctx.run(lambda: 0, fmt="tap", title="second") 12 | -------------------------------------------------------------------------------- /config/git-changelog.toml: -------------------------------------------------------------------------------- 1 | bump = "auto" 2 | convention = "angular" 3 | in-place = true 4 | output = "CHANGELOG.md" 5 | parse-refs = false 6 | parse-trailers = true 7 | sections = ["build", "deps", "feat", "fix", "refactor"] 8 | template = "keepachangelog" 9 | versioning = "pep440" 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # editors 2 | .idea/ 3 | .vscode/ 4 | 5 | # python 6 | *.egg-info/ 7 | *.py[cod] 8 | .venv/ 9 | .venvs/ 10 | /build/ 11 | /dist/ 12 | 13 | # tools 14 | .coverage* 15 | /.pdm-build/ 16 | /htmlcov/ 17 | /site/ 18 | uv.lock 19 | 20 | # cache 21 | .cache/ 22 | .pytest_cache/ 23 | .mypy_cache/ 24 | .ruff_cache/ 25 | __pycache__/ 26 | -------------------------------------------------------------------------------- /config/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = 3 | test_*.py 4 | addopts = 5 | --cov 6 | --cov-config config/coverage.ini 7 | testpaths = 8 | tests 9 | 10 | # action:message_regex:warning_class:module_regex:line 11 | filterwarnings = 12 | error 13 | # TODO: remove once pytest-xdist 4 is released 14 | ignore:.*rsyncdir:DeprecationWarning:xdist 15 | -------------------------------------------------------------------------------- /docs/.overrides/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block announce %} 4 | 5 | Follow 6 | @pawamoy on 7 | 8 | 9 | {% include ".icons/fontawesome/brands/mastodon.svg" %} 10 | 11 | Fosstodon 12 | 13 | for updates 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /src/duty/__main__.py: -------------------------------------------------------------------------------- 1 | """Entry-point module, in case you use `python -m duty`. 2 | 3 | Why does this file exist, and why `__main__`? For more info, read: 4 | 5 | - https://www.python.org/dev/peps/pep-0338/ 6 | - https://docs.python.org/3/using/cmdline.html#cmdoption-m 7 | """ 8 | 9 | import sys 10 | 11 | from duty._internal.cli import main 12 | 13 | if __name__ == "__main__": 14 | sys.exit(main(sys.argv[1:])) 15 | -------------------------------------------------------------------------------- /src/duty/_internal/exceptions.py: -------------------------------------------------------------------------------- 1 | class DutyFailure(Exception): # noqa: N818 2 | """An exception raised when a duty fails.""" 3 | 4 | def __init__(self, code: int) -> None: 5 | """Initialize the object. 6 | 7 | Parameters: 8 | code: The exit code of a command. 9 | """ 10 | super().__init__(self) 11 | self.code = code 12 | """The exit code of the command that failed.""" 13 | -------------------------------------------------------------------------------- /src/duty/cli.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `duty` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal import cli 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `duty.cli` is deprecated. Import from `duty` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(cli, name) 18 | -------------------------------------------------------------------------------- /src/duty/context.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `duty` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal import context 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `duty.context` is deprecated. Import from `duty` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(context, name) 18 | -------------------------------------------------------------------------------- /src/duty/decorator.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `duty` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal import decorator 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `duty.decorator` is deprecated. Import from `duty` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(decorator, name) 18 | -------------------------------------------------------------------------------- /src/duty/collection.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `duty` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal import collection 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `duty.collection` is deprecated. Import from `duty` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(collection, name) 18 | -------------------------------------------------------------------------------- /src/duty/exceptions.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `duty` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal import exceptions 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `duty.exceptions` is deprecated. Import from `duty` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(exceptions, name) 18 | -------------------------------------------------------------------------------- /src/duty/validation.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Import from `duty` directly.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal import validation 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Importing from `duty.validation` is deprecated. Import from `duty` directly.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(validation, name) 18 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/_io.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from io import StringIO 3 | 4 | # YORE: Bump 2: Remove file. 5 | 6 | 7 | class _LazyStdout(StringIO): 8 | def write(self, value: str) -> int: 9 | return sys.stdout.write(value) 10 | 11 | def __repr__(self) -> str: 12 | return "stdout" 13 | 14 | 15 | class _LazyStderr(StringIO): 16 | def write(self, value: str) -> int: 17 | return sys.stderr.write(value) 18 | 19 | def __repr__(self) -> str: 20 | return "stderr" 21 | -------------------------------------------------------------------------------- /src/duty/callables/mypy.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.mypy`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import mypy as _mypy 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.mypy` instead of `duty.callables.mypy`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_mypy, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/ruff.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.ruff`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import ruff as _ruff 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.ruff` instead of `duty.callables.ruff`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_ruff, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/black.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.black`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import black as _black 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.black` instead of `duty.callables.black`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_black, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/build.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.build`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import build as _build 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.build` instead of `duty.callables.build`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_build, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/isort.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.isort`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import isort as _isort 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.isort` instead of `duty.callables.isort`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_isort, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/ssort.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.ssort`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import ssort as _ssort 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.ssort` instead of `duty.callables.ssort`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_ssort, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/twine.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.twine`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import twine as _twine 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.twine` instead of `duty.callables.twine`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_twine, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/flake8.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.flake8`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import flake8 as _flake8 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.flake8` instead of `duty.callables.flake8`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_flake8, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/griffe.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.griffe`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import griffe as _griffe 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.griffe` instead of `duty.callables.griffe`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_griffe, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/mkdocs.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.mkdocs`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import mkdocs as _mkdocs 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.mkdocs` instead of `duty.callables.mkdocs`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_mkdocs, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/pytest.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.pytest`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import pytest as _pytest 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.pytest` instead of `duty.callables.pytest`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_pytest, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/safety.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.safety`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import safety as _safety 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.safety` instead of `duty.callables.safety`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_safety, name) 18 | -------------------------------------------------------------------------------- /config/coverage.ini: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = true 3 | parallel = true 4 | source = 5 | src/ 6 | tests/ 7 | 8 | [coverage:paths] 9 | equivalent = 10 | src/ 11 | .venv/lib/*/site-packages/ 12 | .venvs/*/lib/*/site-packages/ 13 | 14 | [coverage:report] 15 | precision = 2 16 | omit = 17 | src/*/__init__.py 18 | src/*/__main__.py 19 | src/*/callables/* 20 | src/*/tools/* 21 | tests/__init__.py 22 | exclude_lines = 23 | pragma: no cover 24 | if TYPE_CHECKING 25 | 26 | [coverage:json] 27 | output = htmlcov/coverage.json 28 | -------------------------------------------------------------------------------- /src/duty/callables/coverage.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.coverage`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import coverage as _coverage 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.coverage` instead of `duty.callables.coverage`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_coverage, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/autoflake.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.autoflake`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import autoflake as _autoflake 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.autoflake` instead of `duty.callables.autoflake`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_autoflake, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/interrogate.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.interrogate`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import interrogate as _interrogate 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.interrogate` instead of `duty.callables.interrogate`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_interrogate, name) 18 | -------------------------------------------------------------------------------- /src/duty/callables/blacken_docs.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.blacken_docs`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import blacken_docs as _blacken_docs 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.blacken_docs` instead of `duty.callables.blacken_docs`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_blacken_docs, name) 18 | -------------------------------------------------------------------------------- /docs/js/feedback.js: -------------------------------------------------------------------------------- 1 | const feedback = document.forms.feedback; 2 | feedback.hidden = false; 3 | 4 | feedback.addEventListener("submit", function(ev) { 5 | ev.preventDefault(); 6 | const commentElement = document.getElementById("feedback"); 7 | commentElement.style.display = "block"; 8 | feedback.firstElementChild.disabled = true; 9 | const data = ev.submitter.getAttribute("data-md-value"); 10 | const note = feedback.querySelector(".md-feedback__note [data-md-value='" + data + "']"); 11 | if (note) { 12 | note.hidden = false; 13 | } 14 | }) 15 | -------------------------------------------------------------------------------- /src/duty/callables/git_changelog.py: -------------------------------------------------------------------------------- 1 | """Deprecated. Use [`duty.tools.git_changelog`][] instead.""" 2 | 3 | # YORE: Bump 2: Remove file. 4 | 5 | import warnings 6 | from typing import Any 7 | 8 | from duty._internal.callables import git_changelog as _git_changelog 9 | 10 | 11 | def __getattr__(name: str) -> Any: 12 | warnings.warn( 13 | "Callables are deprecated in favor of tools, use `duty.tools.git_changelog` instead of `duty.callables.git_changelog`.", 14 | DeprecationWarning, 15 | stacklevel=2, 16 | ) 17 | return getattr(_git_changelog, name) 18 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier. 2 | _commit: 1.8.6 3 | _src_path: gh:pawamoy/copier-uv 4 | author_email: dev@pawamoy.fr 5 | author_fullname: Timothée Mazzucotelli 6 | author_username: pawamoy 7 | copyright_date: '2020' 8 | copyright_holder: Timothée Mazzucotelli 9 | copyright_holder_email: dev@pawamoy.fr 10 | copyright_license: ISC 11 | insiders: false 12 | project_description: A simple task runner. 13 | project_name: duty 14 | python_package_command_line_name: duty 15 | python_package_distribution_name: duty 16 | python_package_import_name: duty 17 | repository_name: duty 18 | repository_namespace: pawamoy 19 | repository_provider: github.com 20 | 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation update 3 | about: Point at unclear, missing or outdated documentation. 4 | title: "docs: " 5 | labels: docs 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is something unclear, missing or outdated in our documentation? 10 | 11 | 12 | ### Relevant code snippets 13 | 14 | 15 | ### Link to the relevant documentation section 16 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/4-change.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Change request 3 | about: Suggest any other kind of change for this project. 4 | title: "change: " 5 | assignees: pawamoy 6 | --- 7 | 8 | ### Is your change request related to a problem? Please describe. 9 | 10 | 11 | ### Describe the solution you'd like 12 | 13 | 14 | ### Describe alternatives you've considered 15 | 16 | 17 | ### Additional context 18 | 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project. 4 | title: "feature: " 5 | labels: feature 6 | assignees: pawamoy 7 | --- 8 | 9 | ### Is your feature request related to a problem? Please describe. 10 | 11 | 12 | ### Describe the solution you'd like 13 | 14 | 15 | ### Describe alternatives you've considered 16 | 17 | 18 | ### Additional context 19 | 20 | -------------------------------------------------------------------------------- /docs/.overrides/partials/path-item.html: -------------------------------------------------------------------------------- 1 | {# Fix breadcrumbs for when mkdocs-section-index is used. #} 2 | {# See https://github.com/squidfunk/mkdocs-material/issues/7614. #} 3 | 4 | 5 | {% macro render_content(nav_item) %} 6 | 7 | {{ nav_item.title }} 8 | 9 | {% endmacro %} 10 | 11 | 12 | {% macro render(nav_item, ref=nav_item) %} 13 | {% if nav_item.is_page %} 14 |
  • 15 | 16 | {{ render_content(ref) }} 17 | 18 |
  • 19 | {% elif nav_item.children %} 20 | {{ render(nav_item.children | first, ref) }} 21 | {% endif %} 22 | {% endmacro %} 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: push 4 | permissions: 5 | contents: write 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | if: startsWith(github.ref, 'refs/tags/') 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | fetch-tags: true 17 | - name: Setup Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.12" 21 | - name: Setup uv 22 | uses: astral-sh/setup-uv@v5 23 | - name: Prepare release notes 24 | run: uv tool run git-changelog --release-notes > release-notes.md 25 | - name: Create release 26 | uses: softprops/action-gh-release@v2 27 | with: 28 | body_path: release-notes.md 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2020, Timothée Mazzucotelli 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # If you have `direnv` loaded in your shell, and allow it in the repository, 2 | # the `make` command will point at the `scripts/make` shell script. 3 | # This Makefile is just here to allow auto-completion in the terminal. 4 | 5 | default: help 6 | @echo 7 | @echo 'Enable direnv in your shell to use the `make` command: `direnv allow`' 8 | @echo 'Or use `python scripts/make ARGS` to run the commands/tasks directly.' 9 | 10 | .DEFAULT_GOAL: default 11 | 12 | actions = \ 13 | allrun \ 14 | changelog \ 15 | check \ 16 | check-api \ 17 | check-docs \ 18 | check-quality \ 19 | check-types \ 20 | clean \ 21 | coverage \ 22 | docs \ 23 | docs-deploy \ 24 | format \ 25 | help \ 26 | multirun \ 27 | release \ 28 | run \ 29 | setup \ 30 | test \ 31 | vscode 32 | 33 | .PHONY: $(actions) 34 | $(actions): 35 | @python scripts/make "$@" 36 | -------------------------------------------------------------------------------- /tests/test_decorator.py: -------------------------------------------------------------------------------- 1 | """Tests for the `decorator` module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import inspect 6 | 7 | import pytest 8 | 9 | from duty._internal.context import Context 10 | from duty._internal.decorator import duty as decorate 11 | from duty._internal.exceptions import DutyFailure 12 | 13 | 14 | def test_accept_one_posarg_when_decorating() -> None: 15 | """Accept only one positional argument when decorating.""" 16 | with pytest.raises(ValueError, match="accepts only one positional argument"): 17 | decorate(0, 1) # type: ignore[call-overload] 18 | 19 | 20 | def test_skipping() -> None: 21 | """Wrap function that must be skipped.""" 22 | duty = decorate(lambda ctx: ctx.run("false"), skip_if=True) # type: ignore[call-overload] 23 | # no DutyFailure raised 24 | assert duty.run() is None 25 | with pytest.raises(DutyFailure): 26 | assert inspect.unwrap(duty)(Context({})) 27 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/ssort.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | import sys 6 | 7 | from failprint import lazy 8 | 9 | 10 | @lazy(name="ssort") 11 | def run( 12 | *files: str, 13 | diff: bool | None = None, 14 | check: bool | None = None, 15 | ) -> int: 16 | """Run `ssort`. 17 | 18 | Parameters: 19 | *files: Files to format. 20 | diff: Prints a diff of all changes ssort would make to a file. 21 | check: Check the file for unsorted statements. Returns 0 if nothing needs to be changed. Otherwise returns 1. 22 | """ 23 | from ssort._main import main as ssort # noqa: PLC0415 24 | 25 | cli_args = list(files) 26 | 27 | if diff: 28 | cli_args.append("--diff") 29 | 30 | if check: 31 | cli_args.append("--check") 32 | 33 | old_sys_argv = sys.argv 34 | sys.argv = ["ssort*", *cli_args] 35 | try: 36 | return ssort() 37 | finally: 38 | sys.argv = old_sys_argv 39 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/__init__.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | import warnings 6 | 7 | from duty._internal.callables import ( 8 | autoflake, # noqa: F401 9 | black, # noqa: F401 10 | blacken_docs, # noqa: F401 11 | build, # noqa: F401 12 | coverage, # noqa: F401 13 | flake8, # noqa: F401 14 | git_changelog, # noqa: F401 15 | griffe, # noqa: F401 16 | interrogate, # noqa: F401 17 | isort, # noqa: F401 18 | mkdocs, # noqa: F401 19 | mypy, # noqa: F401 20 | pytest, # noqa: F401 21 | ruff, # noqa: F401 22 | safety, # noqa: F401 23 | ssort, # noqa: F401 24 | twine, # noqa: F401 25 | ) 26 | 27 | warnings.warn( 28 | "Callables are deprecated in favor of our new `duty.tools`. " 29 | "They are easier to use and provide more functionality " 30 | "like automatically computing `command` values in `ctx.run()` calls. " 31 | "Old callables will be removed in a future version.", 32 | DeprecationWarning, 33 | stacklevel=1, 34 | ) 35 | -------------------------------------------------------------------------------- /tests/fixtures/validation.py: -------------------------------------------------------------------------------- 1 | def no_params(ctx): 2 | pass # pragma: no cover 3 | 4 | 5 | def pos_or_kw_param(ctx, a: int): 6 | pass # pragma: no cover 7 | 8 | 9 | def pos_or_kw_params(ctx, a: int, b: int): 10 | pass # pragma: no cover 11 | 12 | 13 | def varpos_param(ctx, *a: int): 14 | pass # pragma: no cover 15 | 16 | 17 | def pos_and_varpos_param(ctx, a: int, *b: int): 18 | pass # pragma: no cover 19 | 20 | 21 | def kwonly_param(ctx, *a: int, b: int): 22 | pass # pragma: no cover 23 | 24 | 25 | def varkw_param(ctx, a: int, **b: int): 26 | pass # pragma: no cover 27 | 28 | 29 | def varkw_no_annotation(ctx, **a): 30 | pass # pragma: no cover 31 | 32 | 33 | def posonly_marker(ctx, a: int, /, b: int): 34 | pass # pragma: no cover 35 | 36 | 37 | def kwonly_marker(ctx, a: int, *, b: int): 38 | pass # pragma: no cover 39 | 40 | 41 | def only_markers(ctx, a: int, /, b: int, *, c: int): 42 | pass # pragma: no cover 43 | 44 | 45 | def full(ctx, a: int, /, b: int, *c: int, d: int, e: int = 0, **f: int): 46 | pass # pragma: no cover 47 | -------------------------------------------------------------------------------- /config/vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/.venv*/**": true, 4 | "**/.venvs*/**": true, 5 | "**/venv*/**": true 6 | }, 7 | "mypy-type-checker.args": [ 8 | "--config-file=config/mypy.ini" 9 | ], 10 | "python.testing.unittestEnabled": false, 11 | "python.testing.pytestEnabled": true, 12 | "python.testing.pytestArgs": [ 13 | "--config-file=config/pytest.ini" 14 | ], 15 | "ruff.enable": true, 16 | "ruff.format.args": [ 17 | "--config=config/ruff.toml" 18 | ], 19 | "ruff.lint.args": [ 20 | "--config=config/ruff.toml" 21 | ], 22 | "yaml.schemas": { 23 | "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" 24 | }, 25 | "yaml.customTags": [ 26 | "!ENV scalar", 27 | "!ENV sequence", 28 | "!relative scalar", 29 | "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", 30 | "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", 31 | "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" 32 | ] 33 | } -------------------------------------------------------------------------------- /scripts/get_version.py: -------------------------------------------------------------------------------- 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 ( # ty: ignore[unresolved-import] 8 | SCMVersion, 9 | Version, 10 | default_version_formatter, 11 | get_version_from_scm, 12 | ) 13 | 14 | _root = Path(__file__).parent.parent 15 | _changelog = _root / "CHANGELOG.md" 16 | _changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 17 | _default_scm_version = SCMVersion(Version("0.0.0"), None, False, None, None) # noqa: FBT003 18 | 19 | 20 | def get_version() -> str: 21 | scm_version = get_version_from_scm(_root) or _default_scm_version 22 | if scm_version.version <= Version("0.1"): # Missing Git tags? 23 | with suppress(OSError, StopIteration): # noqa: SIM117 24 | with _changelog.open("r", encoding="utf8") as file: 25 | match = next(filter(None, map(_changelog_version_re.match, file))) 26 | scm_version = scm_version._replace(version=Version(match.group(1))) 27 | return default_version_formatter(scm_version) 28 | 29 | 30 | if __name__ == "__main__": 31 | print(get_version()) 32 | -------------------------------------------------------------------------------- /src/duty/callables/__init__.py: -------------------------------------------------------------------------------- 1 | """Module containing callables for many tools. 2 | 3 | These callables are **deprecated** in favor of our new [tools][duty.tools]. 4 | """ 5 | 6 | # YORE: Bump 2: Remove file. 7 | 8 | from __future__ import annotations 9 | 10 | import warnings 11 | 12 | from failprint import lazy # noqa: F401 13 | 14 | from duty._internal.callables import ( 15 | autoflake, # noqa: F401 16 | black, # noqa: F401 17 | blacken_docs, # noqa: F401 18 | build, # noqa: F401 19 | coverage, # noqa: F401 20 | flake8, # noqa: F401 21 | git_changelog, # noqa: F401 22 | griffe, # noqa: F401 23 | interrogate, # noqa: F401 24 | isort, # noqa: F401 25 | mkdocs, # noqa: F401 26 | mypy, # noqa: F401 27 | pytest, # noqa: F401 28 | ruff, # noqa: F401 29 | safety, # noqa: F401 30 | ssort, # noqa: F401 31 | twine, # noqa: F401 32 | ) 33 | 34 | warnings.warn( 35 | "Callables are deprecated in favor of our new `duty.tools`. " 36 | "They are easier to use and provide more functionality " 37 | "like automatically computing `command` values in `ctx.run()` calls. " 38 | "Old callables will be removed in a future version.", 39 | DeprecationWarning, 40 | stacklevel=1, 41 | ) 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # duty 2 | 3 | [![ci](https://github.com/pawamoy/duty/workflows/ci/badge.svg)](https://github.com/pawamoy/duty/actions?query=workflow%3Aci) 4 | [![documentation](https://img.shields.io/badge/docs-mkdocs-708FCC.svg?style=flat)](https://pawamoy.github.io/duty/) 5 | [![pypi version](https://img.shields.io/pypi/v/duty.svg)](https://pypi.org/project/duty/) 6 | [![gitter](https://badges.gitter.im/join%20chat.svg)](https://app.gitter.im/#/room/#duty:gitter.im) 7 | 8 | A simple task runner. 9 | 10 | Inspired by [Invoke](https://github.com/pyinvoke/invoke). 11 | 12 | ![demo](demo.svg) 13 | 14 | ## Installation 15 | 16 | ```bash 17 | pip install duty 18 | ``` 19 | 20 | With [`uv`](https://docs.astral.sh/uv/): 21 | 22 | ```bash 23 | uv tool install duty 24 | ``` 25 | 26 | ## Quick start 27 | 28 | Create a `duties.py` file at the root of your repository. 29 | 30 | ```python 31 | from duty import duty 32 | 33 | @duty 34 | def docs(ctx): 35 | ctx.run("mkdocs build", title="Building documentation") 36 | ``` 37 | 38 | You can now use the command line tool to run it: 39 | 40 | ```bash 41 | duty docs 42 | ``` 43 | 44 | See the [Usage](https://pawamoy.github.io/duty/usage/) 45 | section in the documentation for more examples. 46 | 47 | Also see ["Why choosing duty over..."](https://pawamoy.github.io/duty/#why-duty-over). 48 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from duty._internal.tools._autoflake import autoflake 4 | from duty._internal.tools._black import black 5 | from duty._internal.tools._blacken_docs import blacken_docs 6 | from duty._internal.tools._build import build 7 | from duty._internal.tools._coverage import coverage 8 | from duty._internal.tools._flake8 import flake8 9 | from duty._internal.tools._git_changelog import git_changelog 10 | from duty._internal.tools._griffe import griffe 11 | from duty._internal.tools._interrogate import interrogate 12 | from duty._internal.tools._isort import isort 13 | from duty._internal.tools._mkdocs import mkdocs 14 | from duty._internal.tools._mypy import mypy 15 | from duty._internal.tools._pytest import pytest 16 | from duty._internal.tools._ruff import ruff 17 | from duty._internal.tools._safety import safety 18 | from duty._internal.tools._ssort import ssort 19 | from duty._internal.tools._twine import twine 20 | from duty._internal.tools._yore import yore 21 | 22 | __all__ = [ 23 | "autoflake", 24 | "black", 25 | "blacken_docs", 26 | "build", 27 | "coverage", 28 | "flake8", 29 | "git_changelog", 30 | "griffe", 31 | "interrogate", 32 | "isort", 33 | "mkdocs", 34 | "mypy", 35 | "pytest", 36 | "ruff", 37 | "safety", 38 | "ssort", 39 | "twine", 40 | "yore", 41 | ] 42 | -------------------------------------------------------------------------------- /src/duty/completions.bash: -------------------------------------------------------------------------------- 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 31 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/_ssort.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | 5 | from duty._internal.tools._base import Tool 6 | 7 | 8 | class ssort(Tool): # noqa: N801 9 | """Call [ssort](https://github.com/bwhmather/ssort).""" 10 | 11 | cli_name = "ssort" 12 | """The name of the executable on PATH.""" 13 | 14 | def __init__( 15 | self, 16 | *files: str, 17 | diff: bool | None = None, 18 | check: bool | None = None, 19 | ) -> None: 20 | """Run `ssort`. 21 | 22 | Parameters: 23 | *files: Files to format. 24 | diff: Prints a diff of all changes ssort would make to a file. 25 | check: Check the file for unsorted statements. Returns 0 if nothing needs to be changed. Otherwise returns 1. 26 | """ 27 | cli_args = list(files) 28 | 29 | if diff: 30 | cli_args.append("--diff") 31 | 32 | if check: 33 | cli_args.append("--check") 34 | 35 | def __call__(self) -> None: 36 | """Run the command. 37 | 38 | Returns: 39 | The exit code of the command. 40 | """ 41 | from ssort._main import main as run_ssort # noqa: PLC0415 42 | 43 | old_sys_argv = sys.argv 44 | sys.argv = ["ssort", *self.cli_args] 45 | try: 46 | run_ssort() 47 | finally: 48 | sys.argv = old_sys_argv 49 | -------------------------------------------------------------------------------- /src/duty/__init__.py: -------------------------------------------------------------------------------- 1 | """duty package. 2 | 3 | A simple task runner. 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | from failprint import lazy 9 | 10 | from duty._internal.cli import ( 11 | empty, 12 | get_duty_parser, 13 | get_parser, 14 | main, 15 | parse_args, 16 | parse_commands, 17 | parse_options, 18 | print_help, 19 | specified_options, 20 | split_args, 21 | ) 22 | from duty._internal.collection import Collection, Duty, DutyListType, default_duties_file 23 | from duty._internal.context import CmdType, Context 24 | from duty._internal.decorator import create_duty, duty 25 | from duty._internal.exceptions import DutyFailure 26 | from duty._internal.tools._base import LazyStderr, LazyStdout, Tool 27 | from duty._internal.validation import ParamsCaster, cast_arg, to_bool, validate 28 | 29 | __all__: list[str] = [ 30 | "CmdType", 31 | "Collection", 32 | "Context", 33 | "Duty", 34 | "DutyFailure", 35 | "DutyListType", 36 | "LazyStderr", 37 | "LazyStdout", 38 | "ParamsCaster", 39 | "Tool", 40 | "cast_arg", 41 | "create_duty", 42 | "default_duties_file", 43 | "duty", 44 | "empty", 45 | "get_duty_parser", 46 | "get_parser", 47 | "lazy", 48 | "main", 49 | "parse_args", 50 | "parse_commands", 51 | "parse_options", 52 | "print_help", 53 | "specified_options", 54 | "split_args", 55 | "to_bool", 56 | "validate", 57 | ] 58 | -------------------------------------------------------------------------------- /src/duty/tools.py: -------------------------------------------------------------------------------- 1 | """Our collection of tools.""" 2 | 3 | import warnings 4 | from typing import Any 5 | 6 | from duty._internal.tools import ( 7 | autoflake, 8 | black, 9 | blacken_docs, 10 | build, 11 | coverage, 12 | flake8, 13 | git_changelog, 14 | griffe, 15 | interrogate, 16 | isort, 17 | mkdocs, 18 | mypy, 19 | pytest, 20 | ruff, 21 | safety, 22 | ssort, 23 | twine, 24 | yore, 25 | ) 26 | 27 | __all__ = [ 28 | "autoflake", 29 | "black", 30 | "blacken_docs", 31 | "build", 32 | "coverage", 33 | "flake8", 34 | "git_changelog", 35 | "griffe", 36 | "interrogate", 37 | "isort", 38 | "mkdocs", 39 | "mypy", 40 | "pytest", 41 | "ruff", 42 | "safety", 43 | "ssort", 44 | "twine", 45 | "yore", 46 | ] 47 | 48 | 49 | # YORE: Bump 2: Remove block. 50 | def __getattr__(name: str) -> Any: 51 | """Return the tool or lazy object by name.""" 52 | from failprint import lazy # noqa: F401,PLC0415 53 | 54 | from duty._internal.tools._base import LazyStderr, LazyStdout, Tool # noqa: F401,PLC0415 55 | 56 | if name in locals(): 57 | warnings.warn( 58 | f"Importing `{name}` from `duty.tools` is deprecated, import directly from `duty` instead.", 59 | DeprecationWarning, 60 | stacklevel=2, 61 | ) 62 | return locals()[name] 63 | raise AttributeError(f"module '{__name__}' has no attribute '{name}'") 64 | -------------------------------------------------------------------------------- /config/vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "python (current file)", 6 | "type": "debugpy", 7 | "request": "launch", 8 | "program": "${file}", 9 | "console": "integratedTerminal", 10 | "justMyCode": false, 11 | "args": "${command:pickArgs}" 12 | }, 13 | { 14 | "name": "run", 15 | "type": "debugpy", 16 | "request": "launch", 17 | "module": "duty", 18 | "console": "integratedTerminal", 19 | "justMyCode": false, 20 | "args": "${command:pickArgs}" 21 | }, 22 | { 23 | "name": "docs", 24 | "type": "debugpy", 25 | "request": "launch", 26 | "module": "mkdocs", 27 | "justMyCode": false, 28 | "args": [ 29 | "serve", 30 | "-v" 31 | ] 32 | }, 33 | { 34 | "name": "test", 35 | "type": "debugpy", 36 | "request": "launch", 37 | "module": "pytest", 38 | "justMyCode": false, 39 | "args": [ 40 | "-c=config/pytest.ini", 41 | "-vvv", 42 | "--no-cov", 43 | "--dist=no", 44 | "tests", 45 | "-k=${input:tests_selection}" 46 | ] 47 | } 48 | ], 49 | "inputs": [ 50 | { 51 | "id": "tests_selection", 52 | "type": "promptString", 53 | "description": "Tests selection", 54 | "default": "" 55 | } 56 | ] 57 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve. 4 | title: "bug: " 5 | labels: unconfirmed 6 | assignees: [pawamoy] 7 | --- 8 | 9 | ### Description of the bug 10 | 11 | 12 | ### To Reproduce 13 | 28 | 29 | ``` 30 | WRITE MRE / INSTRUCTIONS HERE 31 | ``` 32 | 33 | ### Full traceback 34 | 36 | 37 |
    Full traceback 38 | 39 | ```python 40 | PASTE TRACEBACK HERE 41 | ``` 42 | 43 |
    44 | 45 | ### Expected behavior 46 | 47 | 48 | ### Environment information 49 | 51 | 52 | ```bash 53 | duty --debug-info # | xclip -selection clipboard 54 | ``` 55 | 56 | PASTE MARKDOWN OUTPUT HERE 57 | 58 | ### Additional context 59 | 62 | -------------------------------------------------------------------------------- /docs/.overrides/partials/comments.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/_base.py: -------------------------------------------------------------------------------- 1 | # Utilities for creating tools. 2 | 3 | from __future__ import annotations 4 | 5 | import shlex 6 | import sys 7 | from io import StringIO 8 | from typing import Any 9 | 10 | if sys.version_info >= (3, 11): 11 | from typing import Self 12 | else: 13 | from typing_extensions import Self 14 | 15 | 16 | class LazyStdout(StringIO): 17 | """Lazy stdout buffer. 18 | 19 | Can be used when tools' main entry-points 20 | expect a file-like object for stdout. 21 | """ 22 | 23 | def write(self, value: str) -> int: 24 | """Write a string to the stdout buffer.""" 25 | return sys.stdout.write(value) 26 | 27 | def __repr__(self) -> str: 28 | return "stdout" 29 | 30 | 31 | class LazyStderr(StringIO): 32 | """Lazy stderr buffer. 33 | 34 | Can be used when tools' main entry-points 35 | expect a file-like object for stderr. 36 | """ 37 | 38 | def write(self, value: str) -> int: 39 | """Write a string to the stderr buffer.""" 40 | return sys.stderr.write(value) 41 | 42 | def __repr__(self) -> str: 43 | return "stderr" 44 | 45 | 46 | class Tool: 47 | """Base class for tools.""" 48 | 49 | cli_name: str = "" 50 | """The name of the executable on PATH.""" 51 | 52 | def __init__( 53 | self, 54 | cli_args: list[str] | None = None, 55 | py_args: dict[str, Any] | None = None, 56 | ) -> None: 57 | """Initialize the tool. 58 | 59 | Parameters: 60 | cli_args: Initial command-line arguments. Use `add_args()` to add more. 61 | py_args: Python arguments. Your `__call__` method will be able to access 62 | these arguments as `self.py_args`. 63 | """ 64 | self.cli_args: list[str] = cli_args or [] 65 | """Registered command-line arguments.""" 66 | self.py_args: dict[str, Any] = py_args or {} 67 | """Registered Python arguments.""" 68 | 69 | def add_args(self, *args: str) -> Self: 70 | """Append CLI arguments.""" 71 | self.cli_args.extend(args) 72 | return self 73 | 74 | @property 75 | def cli_command(self) -> str: 76 | """The equivalent CLI command.""" 77 | if not self.cli_name: 78 | raise ValueError("This tool does not provide a CLI.") 79 | return shlex.join([self.cli_name, *self.cli_args]) 80 | -------------------------------------------------------------------------------- /docs/gen_credits.py: -------------------------------------------------------------------------------- 1 | """Generate the credits page.""" 2 | 3 | import functools 4 | import re 5 | import urllib 6 | from itertools import chain 7 | from pathlib import Path 8 | 9 | import mkdocs_gen_files 10 | import toml 11 | from jinja2 import StrictUndefined 12 | from jinja2.sandbox import SandboxedEnvironment 13 | 14 | 15 | def get_credits_data() -> dict: 16 | """Return data used to generate the credits file. 17 | 18 | Returns: 19 | Data required to render the credits template. 20 | """ 21 | project_dir = Path(__file__).parent.parent 22 | metadata = toml.load(project_dir / "pyproject.toml")["project"] 23 | metadata_pdm = toml.load(project_dir / "pyproject.toml")["tool"]["pdm"] 24 | lock_data = toml.load(project_dir / "pdm.lock") 25 | project_name = metadata["name"] 26 | 27 | all_dependencies = chain( 28 | metadata.get("dependencies", []), 29 | chain(*metadata.get("optional-dependencies", {}).values()), 30 | chain(*metadata_pdm.get("dev-dependencies", {}).values()), 31 | ) 32 | direct_dependencies = {re.sub(r"[^\w-].*$", "", dep) for dep in all_dependencies} 33 | direct_dependencies = {dep.lower() for dep in direct_dependencies} 34 | indirect_dependencies = {pkg["name"].lower() for pkg in lock_data["package"]} 35 | indirect_dependencies -= direct_dependencies 36 | 37 | return { 38 | "project_name": project_name, 39 | "direct_dependencies": sorted(direct_dependencies), 40 | "indirect_dependencies": sorted(indirect_dependencies), 41 | "more_credits": "http://pawamoy.github.io/credits/", 42 | } 43 | 44 | 45 | @functools.lru_cache(maxsize=None) 46 | def get_credits(): 47 | """Return credits as Markdown. 48 | 49 | Returns: 50 | The credits page Markdown. 51 | """ 52 | jinja_env = SandboxedEnvironment(undefined=StrictUndefined) 53 | commit = "398879aba2a365049870709116a689618afeb5b7" 54 | template_url = f"https://raw.githubusercontent.com/pawamoy/jinja-templates/{commit}/credits.md" 55 | template_data = get_credits_data() 56 | template_text = urllib.request.urlopen(template_url).read().decode("utf8") # noqa: S310 57 | return jinja_env.from_string(template_text).render(**template_data) 58 | 59 | 60 | with mkdocs_gen_files.open("credits.md", "w") as fd: 61 | fd.write(get_credits()) 62 | mkdocs_gen_files.set_edit_path("credits.md", "gen_credits.py") 63 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/build.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Literal 6 | 7 | from failprint import lazy 8 | 9 | 10 | @lazy(name="build") 11 | def run( 12 | srcdir: str | None = None, 13 | *, 14 | version: bool = False, 15 | verbose: bool = False, 16 | sdist: bool = False, 17 | wheel: bool = False, 18 | outdir: str | None = None, 19 | skip_dependency_check: bool = False, 20 | no_isolation: bool = False, 21 | installer: Literal["pip", "uv"] | None = None, 22 | config_setting: list[str] | None = None, 23 | ) -> None: 24 | """Run `build`. 25 | 26 | Parameters: 27 | srcdir: Source directory (defaults to current directory). 28 | version: Show program's version number and exit. 29 | verbose: Increase verbosity 30 | sdist: Build a source distribution (disables the default behavior). 31 | wheel: Build a wheel (disables the default behavior). 32 | outdir: Output directory (defaults to `{srcdir}/dist`). 33 | skip_dependency_check: Do not check that build dependencies are installed. 34 | no_isolation: Disable building the project in an isolated virtual environment. 35 | Build dependencies must be installed separately when this option is used. 36 | installer: Python package installer to use (defaults to pip). 37 | config_setting: Settings to pass to the backend. Multiple settings can be provided. 38 | """ 39 | from build.__main__ import main as build # noqa: PLC0415 40 | 41 | cli_args = [] 42 | 43 | if srcdir: 44 | cli_args.append(srcdir) 45 | 46 | if version: 47 | cli_args.append("--version") 48 | 49 | if verbose: 50 | cli_args.append("--verbose") 51 | 52 | if sdist: 53 | cli_args.append("--sdist") 54 | 55 | if wheel: 56 | cli_args.append("--wheel") 57 | 58 | if outdir: 59 | cli_args.append("--outdir") 60 | cli_args.append(outdir) 61 | 62 | if skip_dependency_check: 63 | cli_args.append("--skip-dependency-check") 64 | 65 | if no_isolation: 66 | cli_args.append("--no-isolation") 67 | 68 | if installer: 69 | cli_args.append("--installer") 70 | cli_args.append(installer) 71 | 72 | if config_setting: 73 | for setting in config_setting: 74 | cli_args.append(f"--config-setting={setting}") 75 | 76 | build(cli_args) 77 | -------------------------------------------------------------------------------- /config/ruff.toml: -------------------------------------------------------------------------------- 1 | target-version = "py39" 2 | line-length = 120 3 | 4 | [lint] 5 | exclude = [ 6 | "tests/fixtures/*.py", 7 | ] 8 | select = [ 9 | "A", "ANN", "ARG", 10 | "B", "BLE", 11 | "C", "C4", 12 | "COM", 13 | "D", "DTZ", 14 | "E", "ERA", "EXE", 15 | "F", "FBT", 16 | "G", 17 | "I", "ICN", "INP", "ISC", 18 | "N", 19 | "PGH", "PIE", "PL", "PLC", "PLE", "PLR", "PLW", "PT", "PYI", 20 | "Q", 21 | "RUF", "RSE", "RET", 22 | "S", "SIM", "SLF", 23 | "T", "T10", "T20", "TCH", "TID", "TRY", 24 | "UP", 25 | "W", 26 | "YTT", 27 | ] 28 | ignore = [ 29 | "A001", # Variable is shadowing a Python builtin 30 | "ANN101", # Missing type annotation for self 31 | "ANN102", # Missing type annotation for cls 32 | "ANN204", # Missing return type annotation for special method __str__ 33 | "ANN401", # Dynamically typed expressions (typing.Any) are disallowed 34 | "ARG005", # Unused lambda argument 35 | "C901", # Too complex 36 | "D105", # Missing docstring in magic method 37 | "D417", # Missing argument description in the docstring 38 | "E501", # Line too long 39 | "ERA001", # Commented out code 40 | "G004", # Logging statement uses f-string 41 | "PLR0911", # Too many return statements 42 | "PLR0912", # Too many branches 43 | "PLR0913", # Too many arguments to function call 44 | "PLR0915", # Too many statements 45 | "SLF001", # Private member accessed 46 | "TRY003", # Avoid specifying long messages outside the exception class 47 | ] 48 | 49 | [lint.per-file-ignores] 50 | "src/**/cli.py" = [ 51 | "T201", # Print statement 52 | ] 53 | "src/*/debug.py" = [ 54 | "T201", # Print statement 55 | ] 56 | "!src/*/*.py" = [ 57 | "D100", # Missing docstring in public module 58 | ] 59 | "!src/**.py" = [ 60 | "D101", # Missing docstring in public class 61 | "D103", # Missing docstring in public function 62 | ] 63 | "scripts/*.py" = [ 64 | "INP001", # File is part of an implicit namespace package 65 | "T201", # Print statement 66 | ] 67 | "tests/**.py" = [ 68 | "ARG005", # Unused lambda argument 69 | "FBT001", # Boolean positional arg in function definition 70 | "PLR2004", # Magic value used in comparison 71 | "S101", # Use of assert detected 72 | ] 73 | 74 | [lint.flake8-quotes] 75 | docstring-quotes = "double" 76 | 77 | [lint.flake8-tidy-imports] 78 | ban-relative-imports = "all" 79 | 80 | [lint.isort] 81 | known-first-party = ["duty"] 82 | 83 | [lint.pydocstyle] 84 | convention = "google" 85 | 86 | [format] 87 | exclude = [ 88 | "tests/fixtures/*.py", 89 | ] 90 | docstring-code-format = true 91 | docstring-code-line-length = 80 92 | -------------------------------------------------------------------------------- /docs/css/mkdocstrings.css: -------------------------------------------------------------------------------- 1 | /* Indentation. */ 2 | div.doc-contents:not(.first) { 3 | padding-left: 25px; 4 | border-left: .05rem solid var(--md-typeset-table-color); 5 | } 6 | 7 | /* Mark external links as such. */ 8 | a.external::after, 9 | a.autorefs-external::after { 10 | /* https://primer.style/octicons/arrow-up-right-24 */ 11 | mask-image: url('data:image/svg+xml,'); 12 | -webkit-mask-image: url('data:image/svg+xml,'); 13 | content: ' '; 14 | 15 | display: inline-block; 16 | vertical-align: middle; 17 | position: relative; 18 | 19 | height: 1em; 20 | width: 1em; 21 | background-color: currentColor; 22 | } 23 | 24 | a.external:hover::after, 25 | a.autorefs-external:hover::after { 26 | background-color: var(--md-accent-fg-color); 27 | } 28 | 29 | /* Tree-like output for backlinks. */ 30 | .doc-backlink-list { 31 | --tree-clr: var(--md-default-fg-color); 32 | --tree-font-size: 1rem; 33 | --tree-item-height: 1; 34 | --tree-offset: 1rem; 35 | --tree-thickness: 1px; 36 | --tree-style: solid; 37 | display: grid; 38 | list-style: none !important; 39 | } 40 | 41 | .doc-backlink-list li > span:first-child { 42 | text-indent: .3rem; 43 | } 44 | .doc-backlink-list li { 45 | padding-inline-start: var(--tree-offset); 46 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 47 | position: relative; 48 | margin-left: 0 !important; 49 | 50 | &:last-child { 51 | border-color: transparent; 52 | } 53 | &::before{ 54 | content: ''; 55 | position: absolute; 56 | top: calc(var(--tree-item-height) / 2 * -1 * var(--tree-font-size) + var(--tree-thickness)); 57 | left: calc(var(--tree-thickness) * -1); 58 | width: calc(var(--tree-offset) + var(--tree-thickness) * 2); 59 | height: calc(var(--tree-item-height) * var(--tree-font-size)); 60 | border-left: var(--tree-thickness) var(--tree-style) var(--tree-clr); 61 | border-bottom: var(--tree-thickness) var(--tree-style) var(--tree-clr); 62 | } 63 | &::after{ 64 | content: ''; 65 | position: absolute; 66 | border-radius: 50%; 67 | background-color: var(--tree-clr); 68 | top: calc(var(--tree-item-height) / 2 * 1rem); 69 | left: var(--tree-offset) ; 70 | translate: calc(var(--tree-thickness) * -1) calc(var(--tree-thickness) * -1); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | hide: 4 | - feedback 5 | --- 6 | 7 | --8<-- "README.md" 8 | 9 | ## Why duty over... 10 | 11 | ### [Invoke](https://www.pyinvoke.org/)? 12 | 13 | The main difference is duty's ability to run Python callables, 14 | not just (sub)processes. Using Python callables brings three advantages: 15 | 16 | - **performance**: creating subprocesses is costly. Running a callable 17 | in the current Python process is much cheaper. 18 | - **containment**: running an executable in a subprocess can load 19 | various things in the process' environment which you do not 20 | have control over. Running a callable in the current Python 21 | process ensures that the current process' environment is used, 22 | as you configured it. 23 | - **extensibility**: get the full power of Python! You can define 24 | functions dynamically in your tasks and run them through duty. 25 | We actually provide a set of [ready-to-use callables][duty.tools]. 26 | 27 | Notable differences with Invoke: 28 | 29 | - duty captures standard output and error by default. 30 | For **interactive commands**, you have to pass the `capture=False` option. 31 | See [capturing commands output][capturing-commands-output]. 32 | - on the CLI, parameters are passed with `param=value`, not `--param=value`. 33 | For a boolean parameter: `param=true` instead of `--param`. 34 | See [passing parameters][passing-parameters]. 35 | 36 | duty provides additional facilities to: 37 | 38 | - [skip tasks][skipping-duties] 39 | - [create lazy callables][lazy-callables] 40 | - [format the output of commands][formatting-duty-output] 41 | 42 | The rest is pretty much similar to Invoke. duty has: 43 | 44 | - [tasks listing][listing-duties] 45 | - [tasks aliasing][defining-aliases] 46 | - [tasks parameters][passing-parameters] 47 | - [before/after hooks][prepost-duties] 48 | - [working directory management][changing-the-working-directory] 49 | 50 | ### [GNU Make](https://www.gnu.org/software/make/)? 51 | 52 | Make and duty are not really comparable. 53 | However they complement each other well. 54 | For example if you are managing your Python project 55 | with Poetry or PDM, it can be tedious to type 56 | `poetry run duty ...` or `pdm run duty ...` to run tasks. 57 | With a makefile you can shorten this to `make ...`: 58 | 59 | ```makefile 60 | DUTY := $(if $(VIRTUAL_ENV),,pdm run) duty 61 | 62 | clean: 63 | @$(DUTY) clean 64 | ``` 65 | 66 | See [our Makefile](https://github.com/pawamoy/duty/blob/main/Makefile) 67 | for inspiration. 68 | 69 | ### [Task](https://taskfile.dev/)? 70 | 71 | Task is based on a Taskfile written in YAML. 72 | Declarative languages are nice, but quickly get limited 73 | when you have complex things to run. 74 | 75 | Also Task is written in Go so you won't be able to specify 76 | it in your Python dev-dependencies. 77 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/safety.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | import importlib 6 | import sys 7 | from io import StringIO 8 | from typing import TYPE_CHECKING, Literal, cast 9 | 10 | from failprint import lazy 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Sequence 14 | 15 | 16 | @lazy(name="safety.check") 17 | def check( 18 | requirements: str | Sequence[str], 19 | *, 20 | ignore_vulns: dict[str, str] | None = None, 21 | formatter: Literal["json", "bare", "text"] = "text", 22 | full_report: bool = True, 23 | ) -> bool: 24 | """Run the safety check command. 25 | 26 | This function makes sure we load the original, unpatched version of safety. 27 | 28 | Parameters: 29 | requirements: Python "requirements" (list of pinned dependencies). 30 | ignore_vulns: Vulnerabilities to ignore. 31 | formatter: Report format. 32 | full_report: Whether to output a full report. 33 | 34 | Returns: 35 | Success/failure. 36 | """ 37 | # set default parameter values 38 | ignore_vulns = ignore_vulns or {} 39 | 40 | # undo possible patching 41 | # see https://github.com/pyupio/safety/issues/348 42 | for module in sys.modules: 43 | if module.startswith("safety.") or module == "safety": 44 | del sys.modules[module] 45 | 46 | importlib.invalidate_caches() 47 | 48 | # reload original, unpatched safety 49 | from safety.formatter import SafetyFormatter # noqa: PLC0415 50 | from safety.safety import calculate_remediations, check # noqa: PLC0415 51 | from safety.util import read_requirements # noqa: PLC0415 52 | 53 | # check using safety as a library 54 | if isinstance(requirements, (list, tuple, set)): 55 | requirements = "\n".join(requirements) 56 | packages = list(read_requirements(StringIO(cast("str", requirements)))) 57 | 58 | # TODO: Safety 3 support, merge once support for v2 is dropped. 59 | check_kwargs = {"packages": packages, "ignore_vulns": ignore_vulns} 60 | try: 61 | from safety.auth.cli_utils import build_client_session # noqa: PLC0415 62 | 63 | client_session, _ = build_client_session() 64 | check_kwargs["session"] = client_session 65 | except ImportError: 66 | pass 67 | 68 | vulns, db_full = check(**check_kwargs) 69 | remediations = calculate_remediations(vulns, db_full) 70 | output_report = SafetyFormatter(formatter).render_vulnerabilities( 71 | announcements=[], 72 | vulnerabilities=vulns, 73 | remediations=remediations, 74 | full=full_report, 75 | packages=packages, 76 | ) 77 | 78 | # print report, return status 79 | if vulns: 80 | print(output_report) # noqa: T201 81 | return False 82 | return True 83 | -------------------------------------------------------------------------------- /config/vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "changelog", 6 | "type": "process", 7 | "command": "scripts/make", 8 | "args": ["changelog"] 9 | }, 10 | { 11 | "label": "check", 12 | "type": "process", 13 | "command": "scripts/make", 14 | "args": ["check"] 15 | }, 16 | { 17 | "label": "check-quality", 18 | "type": "process", 19 | "command": "scripts/make", 20 | "args": ["check-quality"] 21 | }, 22 | { 23 | "label": "check-types", 24 | "type": "process", 25 | "command": "scripts/make", 26 | "args": ["check-types"] 27 | }, 28 | { 29 | "label": "check-docs", 30 | "type": "process", 31 | "command": "scripts/make", 32 | "args": ["check-docs"] 33 | }, 34 | { 35 | "label": "check-api", 36 | "type": "process", 37 | "command": "scripts/make", 38 | "args": ["check-api"] 39 | }, 40 | { 41 | "label": "clean", 42 | "type": "process", 43 | "command": "scripts/make", 44 | "args": ["clean"] 45 | }, 46 | { 47 | "label": "docs", 48 | "type": "process", 49 | "command": "scripts/make", 50 | "args": ["docs"] 51 | }, 52 | { 53 | "label": "docs-deploy", 54 | "type": "process", 55 | "command": "scripts/make", 56 | "args": ["docs-deploy"] 57 | }, 58 | { 59 | "label": "format", 60 | "type": "process", 61 | "command": "scripts/make", 62 | "args": ["format"] 63 | }, 64 | { 65 | "label": "release", 66 | "type": "process", 67 | "command": "scripts/make", 68 | "args": ["release", "${input:version}"] 69 | }, 70 | { 71 | "label": "setup", 72 | "type": "process", 73 | "command": "scripts/make", 74 | "args": ["setup"] 75 | }, 76 | { 77 | "label": "test", 78 | "type": "process", 79 | "command": "scripts/make", 80 | "args": ["test", "coverage"], 81 | "group": "test" 82 | }, 83 | { 84 | "label": "vscode", 85 | "type": "process", 86 | "command": "scripts/make", 87 | "args": ["vscode"] 88 | } 89 | ], 90 | "inputs": [ 91 | { 92 | "id": "version", 93 | "type": "promptString", 94 | "description": "Version" 95 | } 96 | ] 97 | } -------------------------------------------------------------------------------- /src/duty/_internal/tools/_build.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | 5 | from duty._internal.tools._base import Tool 6 | 7 | 8 | class build(Tool): # noqa: N801 9 | """Call [build](https://github.com/pypa/build).""" 10 | 11 | cli_name = "pyproject-build" 12 | """The name of the executable on PATH.""" 13 | 14 | def __init__( 15 | self, 16 | srcdir: str | None = None, 17 | *, 18 | version: bool = False, 19 | verbose: bool = False, 20 | sdist: bool = False, 21 | wheel: bool = False, 22 | outdir: str | None = None, 23 | skip_dependency_check: bool = False, 24 | no_isolation: bool = False, 25 | installer: Literal["pip", "uv"] | None = None, 26 | config_setting: list[str] | None = None, 27 | ) -> None: 28 | """Run `build`. 29 | 30 | Parameters: 31 | srcdir: Source directory (defaults to current directory). 32 | version: Show program's version number and exit. 33 | verbose: Increase verbosity 34 | sdist: Build a source distribution (disables the default behavior). 35 | wheel: Build a wheel (disables the default behavior). 36 | outdir: Output directory (defaults to `{srcdir}/dist`). 37 | skip_dependency_check: Do not check that build dependencies are installed. 38 | no_isolation: Disable building the project in an isolated virtual environment. 39 | Build dependencies must be installed separately when this option is used. 40 | installer: Python package installer to use (defaults to pip). 41 | config_setting: Settings to pass to the backend. Multiple settings can be provided. 42 | """ 43 | cli_args = [] 44 | 45 | if srcdir: 46 | cli_args.append(srcdir) 47 | 48 | if version: 49 | cli_args.append("--version") 50 | 51 | if verbose: 52 | cli_args.append("--verbose") 53 | 54 | if sdist: 55 | cli_args.append("--sdist") 56 | 57 | if wheel: 58 | cli_args.append("--wheel") 59 | 60 | if outdir: 61 | cli_args.append("--outdir") 62 | cli_args.append(outdir) 63 | 64 | if skip_dependency_check: 65 | cli_args.append("--skip-dependency-check") 66 | 67 | if no_isolation: 68 | cli_args.append("--no-isolation") 69 | 70 | if installer: 71 | cli_args.append("--installer") 72 | cli_args.append(installer) 73 | 74 | if config_setting: 75 | for setting in config_setting: 76 | cli_args.append(f"--config-setting={setting}") 77 | 78 | super().__init__(cli_args) 79 | 80 | def __call__(self) -> None: 81 | """Run the command.""" 82 | from build.__main__ import main as run_build # noqa: PLC0415 83 | 84 | run_build(self.cli_args) 85 | -------------------------------------------------------------------------------- /tests/test_collection.py: -------------------------------------------------------------------------------- 1 | """Tests for the `collection` module.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from duty._internal.collection import Collection, Duty 8 | from duty._internal.decorator import duty as decorate 9 | 10 | 11 | def none(*args, **kwargs) -> None: # noqa: ANN002, ANN003 12 | ... # pragma: no cover 13 | 14 | 15 | def test_instantiate_duty() -> None: 16 | """Instantiate a duty.""" 17 | assert Duty("name", "description", none) 18 | assert Duty("name", "description", none, pre=["0", "1"], post=["2"]) 19 | 20 | 21 | def test_dont_get_duty() -> None: 22 | """Don't find a duty.""" 23 | collection = Collection() 24 | with pytest.raises(KeyError): 25 | collection.get("hello") 26 | 27 | 28 | def test_register_aliases() -> None: 29 | """Register a duty and its aliases.""" 30 | duty = decorate(none, name="hello", aliases=["HELLO", "_hello_", ".hello."]) # type: ignore[call-overload] 31 | collection = Collection() 32 | collection.add(duty) 33 | assert collection.get("hello") 34 | assert collection.get("HELLO") 35 | assert collection.get("_hello_") 36 | assert collection.get(".hello.") 37 | 38 | 39 | def test_replace_name_and_set_alias() -> None: 40 | """Replace underscores by dashes in duties names.""" 41 | collection = Collection() 42 | collection.add(decorate(none, name="snake_case")) # type: ignore[call-overload] 43 | assert collection.get("snake_case") is collection.get("snake-case") 44 | 45 | 46 | def test_clear_collection() -> None: 47 | """Check that duties and their aliases are correctly cleared from a collection.""" 48 | collection = Collection() 49 | collection.add(decorate(none, name="duty_1")) # type: ignore[call-overload] 50 | collection.clear() 51 | with pytest.raises(KeyError): 52 | collection.get("duty-1") 53 | 54 | 55 | def test_add_duty_to_multiple_collections() -> None: 56 | """Check what happens when adding the same duty to multiple collections.""" 57 | collection1 = Collection() 58 | collection2 = Collection() 59 | 60 | duty = decorate(none, name="duty") # type: ignore[call-overload] 61 | 62 | collection1.add(duty) 63 | collection2.add(duty) 64 | 65 | duty1 = collection1.get("duty") 66 | duty2 = collection2.get("duty") 67 | 68 | assert duty1 is not duty2 69 | assert duty1.collection is collection1 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 | ] 87 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | env: 14 | LANG: en_US.utf-8 15 | LC_ALL: en_US.utf-8 16 | PYTHONIOENCODING: UTF-8 17 | PYTHON_VERSIONS: "" 18 | 19 | jobs: 20 | 21 | quality: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | fetch-tags: true 31 | 32 | - name: Setup Python 33 | uses: actions/setup-python@v5 34 | with: 35 | python-version: "3.12" 36 | 37 | - name: Setup uv 38 | uses: astral-sh/setup-uv@v5 39 | with: 40 | enable-cache: true 41 | cache-dependency-glob: pyproject.toml 42 | 43 | - name: Install dependencies 44 | run: make setup 45 | 46 | - name: Check if the documentation builds correctly 47 | run: make check-docs 48 | 49 | - name: Check the code quality 50 | run: make check-quality 51 | 52 | - name: Check if the code is correctly typed 53 | run: make check-types 54 | 55 | - name: Check for breaking changes in the API 56 | run: make check-api 57 | 58 | - name: Store objects inventory for tests 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: objects.inv 62 | path: site/objects.inv 63 | 64 | tests: 65 | 66 | needs: 67 | - quality 68 | strategy: 69 | matrix: 70 | os: 71 | - ubuntu-latest 72 | - macos-latest 73 | - windows-latest 74 | python-version: 75 | - "3.9" 76 | - "3.10" 77 | - "3.11" 78 | - "3.12" 79 | - "3.13" 80 | - "3.14" 81 | resolution: 82 | - highest 83 | - lowest-direct 84 | exclude: 85 | - os: macos-latest 86 | resolution: lowest-direct 87 | - os: windows-latest 88 | resolution: lowest-direct 89 | runs-on: ${{ matrix.os }} 90 | continue-on-error: ${{ matrix.python-version == '3.14' }} 91 | 92 | steps: 93 | - name: Checkout 94 | uses: actions/checkout@v4 95 | with: 96 | fetch-depth: 0 97 | fetch-tags: true 98 | 99 | - name: Setup Python 100 | uses: actions/setup-python@v5 101 | with: 102 | python-version: ${{ matrix.python-version }} 103 | allow-prereleases: true 104 | 105 | - name: Setup uv 106 | uses: astral-sh/setup-uv@v5 107 | with: 108 | enable-cache: true 109 | cache-dependency-glob: pyproject.toml 110 | cache-suffix: ${{ matrix.resolution }} 111 | 112 | - name: Install dependencies 113 | env: 114 | UV_RESOLUTION: ${{ matrix.resolution }} 115 | run: make setup 116 | 117 | - name: Download objects inventory 118 | uses: actions/download-artifact@v4 119 | with: 120 | name: objects.inv 121 | path: site/ 122 | 123 | - name: Run the test suite 124 | run: make test 125 | -------------------------------------------------------------------------------- /src/duty/_internal/debug.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import platform 5 | import sys 6 | from dataclasses import dataclass 7 | from importlib import metadata 8 | 9 | 10 | @dataclass 11 | class _Variable: 12 | """Dataclass describing an environment variable.""" 13 | 14 | name: str 15 | """Variable name.""" 16 | value: str 17 | """Variable value.""" 18 | 19 | 20 | @dataclass 21 | class _Package: 22 | """Dataclass describing a Python package.""" 23 | 24 | name: str 25 | """Package name.""" 26 | version: str 27 | """Package version.""" 28 | 29 | 30 | @dataclass 31 | class _Environment: 32 | """Dataclass to store environment information.""" 33 | 34 | interpreter_name: str 35 | """Python interpreter name.""" 36 | interpreter_version: str 37 | """Python interpreter version.""" 38 | interpreter_path: str 39 | """Path to Python executable.""" 40 | platform: str 41 | """Operating System.""" 42 | packages: list[_Package] 43 | """Installed packages.""" 44 | variables: list[_Variable] 45 | """Environment variables.""" 46 | 47 | 48 | def _interpreter_name_version() -> tuple[str, str]: 49 | if hasattr(sys, "implementation"): 50 | impl = sys.implementation.version 51 | version = f"{impl.major}.{impl.minor}.{impl.micro}" 52 | kind = impl.releaselevel 53 | if kind != "final": 54 | version += kind[0] + str(impl.serial) 55 | return sys.implementation.name, version 56 | return "", "0.0.0" 57 | 58 | 59 | def _get_version(dist: str = "duty") -> str: 60 | """Get version of the given distribution. 61 | 62 | Parameters: 63 | dist: A distribution name. 64 | 65 | Returns: 66 | A version number. 67 | """ 68 | try: 69 | return metadata.version(dist) 70 | except metadata.PackageNotFoundError: 71 | return "0.0.0" 72 | 73 | 74 | def _get_debug_info() -> _Environment: 75 | """Get debug/environment information. 76 | 77 | Returns: 78 | Environment information. 79 | """ 80 | py_name, py_version = _interpreter_name_version() 81 | packages = ["duty"] 82 | variables = ["PYTHONPATH", *[var for var in os.environ if var.startswith("DUTY")]] 83 | return _Environment( 84 | interpreter_name=py_name, 85 | interpreter_version=py_version, 86 | interpreter_path=sys.executable, 87 | platform=platform.platform(), 88 | variables=[_Variable(var, val) for var in variables if (val := os.getenv(var))], # ty: ignore[invalid-argument-type] 89 | packages=[_Package(pkg, _get_version(pkg)) for pkg in packages], 90 | ) 91 | 92 | 93 | def _print_debug_info() -> None: 94 | """Print debug/environment information.""" 95 | info = _get_debug_info() 96 | print(f"- __System__: {info.platform}") 97 | print(f"- __Python__: {info.interpreter_name} {info.interpreter_version} ({info.interpreter_path})") 98 | print("- __Environment variables__:") 99 | for var in info.variables: 100 | print(f" - `{var.name}`: `{var.value}`") 101 | print("- __Installed packages__:") 102 | for pkg in info.packages: 103 | print(f" - `{pkg.name}` v{pkg.version}") 104 | 105 | 106 | if __name__ == "__main__": 107 | _print_debug_info() 108 | -------------------------------------------------------------------------------- /src/duty/_internal/decorator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from functools import wraps 5 | from typing import TYPE_CHECKING, Any, Callable, overload 6 | 7 | from duty._internal.collection import Duty, DutyListType 8 | 9 | if TYPE_CHECKING: 10 | from collections.abc import Iterable 11 | 12 | from duty._internal.context import Context 13 | 14 | 15 | def _skip(func: Callable, reason: str) -> Callable: 16 | @wraps(func) 17 | def wrapper(ctx: Context, *args, **kwargs) -> None: # noqa: ARG001,ANN002,ANN003 18 | ctx.run(lambda: True, title=reason) 19 | 20 | return wrapper 21 | 22 | 23 | def create_duty( 24 | func: Callable, 25 | *, 26 | name: str | None = None, 27 | aliases: Iterable[str] | None = None, 28 | pre: DutyListType | None = None, 29 | post: DutyListType | None = None, 30 | skip_if: bool = False, 31 | skip_reason: str | None = None, 32 | **opts: Any, 33 | ) -> Duty: 34 | """Register a duty in the collection. 35 | 36 | Parameters: 37 | func: The callable to register as a duty. 38 | name: The duty name. 39 | aliases: A set of aliases for this duty. 40 | pre: Pre-duties. 41 | post: Post-duties. 42 | skip_if: Skip running the duty if the given condition is met. 43 | skip_reason: Custom message when skipping. 44 | opts: Options passed to the context. 45 | 46 | Returns: 47 | The registered duty. 48 | """ 49 | aliases = set(aliases) if aliases else set() 50 | name = name or func.__name__ 51 | dash_name = name.replace("_", "-") 52 | if name != dash_name: 53 | aliases.add(name) 54 | name = dash_name 55 | description = inspect.getdoc(func) or "" 56 | if skip_if: 57 | func = _skip(func, skip_reason or f"{dash_name}: skipped") 58 | duty = Duty(name, description, func, aliases=aliases, pre=pre, post=post, opts=opts) 59 | duty.__name__ = name # type: ignore[attr-defined] 60 | duty.__doc__ = description 61 | duty.__wrapped__ = func # type: ignore[attr-defined] 62 | return duty 63 | 64 | 65 | @overload 66 | def duty(**kwargs: Any) -> Callable[[Callable], Duty]: ... 67 | 68 | 69 | @overload 70 | def duty(func: Callable) -> Duty: ... 71 | 72 | 73 | def duty(*args: Any, **kwargs: Any) -> Callable | Duty: 74 | """Decorate a callable to transform it and register it as a duty. 75 | 76 | Parameters: 77 | args: One callable. 78 | kwargs: Context options. 79 | 80 | Raises: 81 | ValueError: When the decorator is misused. 82 | 83 | Examples: 84 | Decorate a function: 85 | 86 | ```python 87 | @duty 88 | def clean(ctx): 89 | ctx.run("rm -rf build", silent=True) 90 | ``` 91 | 92 | Pass options to the context: 93 | 94 | ```python 95 | @duty(silent=True) 96 | def clean(ctx): 97 | ctx.run("rm -rf build") # silent=True is implied 98 | ``` 99 | 100 | Returns: 101 | A duty when used without parentheses, a decorator otherwise. 102 | """ 103 | if args: 104 | if len(args) > 1: 105 | raise ValueError("The duty decorator accepts only one positional argument") 106 | return create_duty(args[0], **kwargs) 107 | 108 | def decorator(func: Callable) -> Duty: 109 | return create_duty(func, **kwargs) 110 | 111 | return decorator 112 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "duty" 7 | description = "A simple task runner." 8 | authors = [{name = "Timothée Mazzucotelli", email = "dev@pawamoy.fr"}] 9 | license = "ISC" 10 | license-files = ["LICENSE"] 11 | readme = "README.md" 12 | requires-python = ">=3.9" 13 | keywords = ["task-runner", "task", "runner", "cross-platform"] 14 | dynamic = ["version"] 15 | classifiers = [ 16 | "Development Status :: 4 - Beta", 17 | "Intended Audience :: Developers", 18 | "Programming Language :: Python", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python :: 3 :: Only", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | "Topic :: Documentation", 28 | "Topic :: Software Development", 29 | "Topic :: Utilities", 30 | "Typing :: Typed", 31 | ] 32 | dependencies = [ 33 | # YORE: EOL 3.9: Remove line. 34 | "eval-type-backport; python_version < '3.10'", 35 | "failprint>=1.0.5", 36 | "typing-extensions>=4.0; python_version < '3.11'", 37 | ] 38 | 39 | [project.urls] 40 | Homepage = "https://pawamoy.github.io/duty" 41 | Documentation = "https://pawamoy.github.io/duty" 42 | Changelog = "https://pawamoy.github.io/duty/changelog" 43 | Repository = "https://github.com/pawamoy/duty" 44 | Issues = "https://github.com/pawamoy/duty/issues" 45 | Discussions = "https://github.com/pawamoy/duty/discussions" 46 | Gitter = "https://gitter.im/duty/community" 47 | Funding = "https://github.com/sponsors/pawamoy" 48 | 49 | [project.scripts] 50 | duty = "duty:main" 51 | 52 | [tool.pdm.version] 53 | source = "call" 54 | getter = "scripts.get_version:get_version" 55 | 56 | [tool.pdm.build] 57 | # Include as much as possible in the source distribution, to help redistributors. 58 | excludes = ["**/.pytest_cache", "**/.mypy_cache"] 59 | source-includes = [ 60 | "config", 61 | "docs", 62 | "scripts", 63 | "share", 64 | "tests", 65 | "duties.py", 66 | "mkdocs.yml", 67 | "*.md", 68 | "LICENSE", 69 | ] 70 | 71 | [tool.pdm.build.wheel-data] 72 | # Manual pages can be included in the wheel. 73 | # Depending on the installation tool, they will be accessible to users. 74 | # pipx supports it, uv does not yet, see https://github.com/astral-sh/uv/issues/4731. 75 | data = [ 76 | {path = "share/**/*", relative-to = "."}, 77 | ] 78 | 79 | [dependency-groups] 80 | maintain = [ 81 | "build>=1.2", 82 | "git-changelog>=2.5", 83 | "twine>=5.1", 84 | "yore>=0.3.3", 85 | ] 86 | ci = [ 87 | "duty>=1.6", 88 | "ruff>=0.4", 89 | "pytest>=8.2", 90 | "pytest-cov>=5.0", 91 | "pytest-randomly>=3.15", 92 | "pytest-xdist>=3.6", 93 | "mypy>=1.10", 94 | "types-markdown>=3.6", 95 | "types-pyyaml>=6.0", 96 | ] 97 | docs = [ 98 | "markdown-callouts>=0.4", 99 | "markdown-exec>=1.8", 100 | "mkdocs>=1.6", 101 | "mkdocs-coverage>=1.0", 102 | "mkdocs-git-revision-date-localized-plugin>=1.2", 103 | "mkdocs-llmstxt>=0.2", 104 | "mkdocs-material>=9.5", 105 | "mkdocs-minify-plugin>=0.8", 106 | "mkdocs-section-index>=0.3", 107 | "mkdocstrings[python]>=0.29", 108 | # YORE: EOL 3.10: Remove line. 109 | "tomli>=2.0; python_version < '3.11'", 110 | ] 111 | 112 | [tool.uv] 113 | default-groups = ["maintain", "ci", "docs"] 114 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/blacken_docs.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | import re 6 | from pathlib import Path 7 | from re import Pattern 8 | from typing import TYPE_CHECKING 9 | 10 | from failprint import lazy 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Sequence 14 | 15 | 16 | @lazy(name="blacken_docs") 17 | def run( 18 | *paths: str | Path, 19 | exts: Sequence[str] | None = None, 20 | exclude: Sequence[str | Pattern] | None = None, 21 | skip_errors: bool = False, 22 | rst_literal_blocks: bool = False, 23 | line_length: int | None = None, 24 | string_normalization: bool = True, 25 | is_pyi: bool = False, 26 | is_ipynb: bool = False, 27 | skip_source_first_line: bool = False, 28 | magic_trailing_comma: bool = True, 29 | python_cell_magics: set[str] | None = None, 30 | preview: bool = False, 31 | check_only: bool = False, 32 | ) -> int: 33 | """Run `blacken-docs`. 34 | 35 | Parameters: 36 | *paths: Directories and files to format. 37 | exts: List of extensions to select files with. 38 | exclude: List of regular expressions to exclude files. 39 | skip_errors: Don't exit non-zero for errors from Black (normally syntax errors). 40 | rst_literal_blocks: Also format literal blocks in reStructuredText files (more below). 41 | line_length: How many characters per line to allow. 42 | string_normalization: Normalize string quotes or prefixes. 43 | is_pyi: Format all input files like typing stubs regardless of file extension. 44 | is_ipynb: Format all input files like Jupyter Notebooks regardless of file extension. 45 | skip_source_first_line: Skip the first line of the source code. 46 | magic_trailing_comma: Use trailing commas as a reason to split lines. 47 | python_cell_magics: When processing Jupyter Notebooks, add the given magic to the list 48 | of known python-magics (capture, prun, pypy, python, python3, time, timeit). 49 | Useful for formatting cells with custom python magics. 50 | preview: Enable potentially disruptive style changes that may be added 51 | to Black's main functionality in the next major release. 52 | check_only: Don't modify files but indicate when changes are necessary 53 | with a message and non-zero return code. 54 | 55 | Returns: 56 | Success/failure. 57 | """ 58 | import black # noqa: PLC0415 59 | from blacken_docs import format_file # noqa: PLC0415 60 | 61 | exts = ("md", "py") if exts is None else tuple(ext.lstrip(".") for ext in exts) 62 | if exclude: 63 | exclude = tuple(re.compile(regex, re.I) if isinstance(regex, str) else regex for regex in exclude) 64 | filepaths = set() 65 | for path in paths: 66 | path = Path(path) # noqa: PLW2901 67 | if path.is_file(): 68 | filepaths.add(path.as_posix()) 69 | else: 70 | for ext in exts: 71 | filepaths |= {filepath.as_posix() for filepath in path.rglob(f"*.{ext}")} 72 | 73 | black_mode = black.Mode( 74 | line_length=line_length or black.DEFAULT_LINE_LENGTH, 75 | string_normalization=string_normalization, 76 | is_pyi=is_pyi, 77 | is_ipynb=is_ipynb, 78 | skip_source_first_line=skip_source_first_line, 79 | magic_trailing_comma=magic_trailing_comma, 80 | python_cell_magics=python_cell_magics or set(), 81 | preview=preview, 82 | ) 83 | retv = 0 84 | for filepath in sorted(filepaths): 85 | retv |= format_file( 86 | filepath, 87 | black_mode, 88 | skip_errors=skip_errors, 89 | rst_literal_blocks=rst_literal_blocks, 90 | check_only=check_only, 91 | ) 92 | return retv 93 | -------------------------------------------------------------------------------- /src/duty/_internal/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from contextlib import contextmanager, suppress 5 | from typing import TYPE_CHECKING, Any, Callable, Union 6 | 7 | from failprint import run as failprint_run 8 | 9 | from duty._internal.exceptions import DutyFailure 10 | from duty._internal.tools._base import Tool 11 | 12 | if TYPE_CHECKING: 13 | from collections.abc import Iterator 14 | 15 | CmdType = Union[str, list[str], Callable] 16 | """Type of a command that can be run in a subprocess or as a Python callable.""" 17 | 18 | 19 | class Context: 20 | """A simple context class. 21 | 22 | Context instances are passed to functions decorated with `duty`. 23 | """ 24 | 25 | def __init__(self, options: dict[str, Any], options_override: dict[str, Any] | None = None) -> None: 26 | """Initialize the context. 27 | 28 | Parameters: 29 | options: Base options specified in `@duty(**options)`. 30 | options_override: Options that override `run` and `@duty` options. 31 | This argument is used to allow users to override options from the CLI or environment. 32 | """ 33 | self._options = options 34 | self._option_stack: list[dict[str, Any]] = [] 35 | self._options_override = options_override or {} 36 | 37 | @contextmanager 38 | def cd(self, directory: str) -> Iterator: 39 | """Change working directory as a context manager. 40 | 41 | Parameters: 42 | directory: The directory to go into. 43 | 44 | Yields: 45 | Nothing. 46 | """ 47 | if not directory: 48 | yield 49 | return 50 | old_wd = os.getcwd() 51 | os.chdir(directory) 52 | try: 53 | yield 54 | finally: 55 | os.chdir(old_wd) 56 | 57 | def run(self, cmd: CmdType, **options: Any) -> str: 58 | """Run a command in a subprocess or a Python callable. 59 | 60 | Parameters: 61 | cmd: A command or a Python callable. 62 | options: Options passed to `failprint` functions. 63 | 64 | Raises: 65 | DutyFailure: When the exit code / function result is greather than 0. 66 | 67 | Returns: 68 | The output of the command. 69 | """ 70 | final_options = dict(self._options) 71 | final_options.update(options) 72 | 73 | if "command" not in final_options and isinstance(cmd, Tool): 74 | with suppress(ValueError): 75 | final_options["command"] = cmd.cli_command 76 | 77 | allow_overrides = final_options.pop("allow_overrides", True) 78 | workdir = final_options.pop("workdir", None) 79 | 80 | if allow_overrides: 81 | final_options.update(self._options_override) 82 | 83 | with self.cd(workdir): 84 | try: 85 | result = failprint_run(cmd, **final_options) 86 | except KeyboardInterrupt as ki: 87 | raise DutyFailure(130) from ki 88 | 89 | if result.code: 90 | raise DutyFailure(result.code) 91 | 92 | return result.output 93 | 94 | @contextmanager 95 | def options(self, **opts: Any) -> Iterator: 96 | """Change options as a context manager. 97 | 98 | Can be nested as will, previous options will pop once out of the with clause. 99 | 100 | Parameters: 101 | **opts: Options used in `run`. 102 | 103 | Yields: 104 | Nothing. 105 | """ 106 | self._option_stack.append(self._options) 107 | self._options = {**self._options, **opts} 108 | try: 109 | yield 110 | finally: 111 | self._options = self._option_stack.pop() 112 | -------------------------------------------------------------------------------- /tests/test_running.py: -------------------------------------------------------------------------------- 1 | """Tests about running duties.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import NoReturn 6 | from unittest.mock import NonCallableMock 7 | 8 | import pytest 9 | 10 | from duty._internal.collection import Collection, Duty 11 | from duty._internal.decorator import duty as decorate 12 | from duty._internal.exceptions import DutyFailure 13 | 14 | INTERRUPT_CODE = 130 15 | 16 | 17 | def test_run_duty() -> None: 18 | """Run a duty.""" 19 | duty = Duty("name", "description", lambda ctx: 1) 20 | assert duty.run() is None # type: ignore[func-returns-value] 21 | assert duty(duty.context) is None # type: ignore[func-returns-value] 22 | 23 | 24 | def test_run_pre_post_duties_lambdas() -> None: 25 | """Run pre- and post- duties as lambdas.""" 26 | pre_calls = [] 27 | post_calls = [] 28 | 29 | duty = Duty( 30 | "name", 31 | "description", 32 | lambda ctx: None, 33 | pre=[lambda ctx: pre_calls.append(True)], 34 | post=[lambda ctx: post_calls.append(True)], 35 | ) 36 | 37 | duty.run() 38 | 39 | assert pre_calls[0] is True 40 | assert post_calls[0] is True 41 | 42 | 43 | def test_run_pre_post_duties_instances() -> None: 44 | """Run pre- and post- duties as duties.""" 45 | pre_calls = [] 46 | post_calls = [] 47 | 48 | pre_duty = Duty("pre", "", lambda ctx: pre_calls.append(True)) 49 | post_duty = Duty("post", "", lambda ctx: post_calls.append(True)) 50 | 51 | duty = Duty( 52 | name="name", 53 | description="description", 54 | function=lambda ctx: None, 55 | pre=[pre_duty], 56 | post=[post_duty], 57 | ) 58 | 59 | duty.run() 60 | 61 | assert pre_calls[0] is True 62 | assert post_calls[0] is True 63 | 64 | 65 | def test_run_pre_post_duties_refs() -> None: 66 | """Run pre- and post- duties as duties references.""" 67 | pre_calls = [] 68 | post_calls = [] 69 | 70 | collection = Collection() 71 | collection.add(decorate(lambda ctx: pre_calls.append(True), name="pre")) # type: ignore[call-overload] 72 | collection.add(decorate(lambda ctx: post_calls.append(True), name="post")) # type: ignore[call-overload] 73 | 74 | duty = Duty("name", "description", lambda ctx: None, collection=collection, pre=["pre"], post=["post"]) 75 | duty.run() 76 | 77 | assert pre_calls[0] is True 78 | assert post_calls[0] is True 79 | 80 | 81 | def test_dont_run_other_pre_post_duties() -> None: 82 | """Don't run other types of pre- and post- duties.""" 83 | pre_duty = NonCallableMock() 84 | post_duty = NonCallableMock() 85 | 86 | duty = Duty("name", "description", lambda ctx: 0, pre=[pre_duty], post=[post_duty]) 87 | duty.run() 88 | 89 | assert not pre_duty.called 90 | assert not post_duty.called 91 | 92 | 93 | def test_code_when_keyboard_interrupt() -> None: 94 | """Return a code 130 on keyboard interruption.""" 95 | 96 | def interrupt() -> NoReturn: 97 | raise KeyboardInterrupt 98 | 99 | with pytest.raises(DutyFailure) as excinfo: 100 | Duty("name", "description", lambda ctx: ctx.run(interrupt)).run() 101 | assert excinfo.value.code == INTERRUPT_CODE 102 | 103 | 104 | def test_dont_raise_duty_failure() -> None: 105 | """Don't raise a duty failure on success.""" 106 | duty = Duty("n", "d", lambda ctx: ctx.run(lambda: 0)) 107 | assert not duty.run() # type: ignore[func-returns-value] 108 | 109 | 110 | def test_cant_find_duty_without_collection() -> None: 111 | """Check that we can't find a duty with its name without a collection.""" 112 | duty = decorate(lambda ctx: None, name="duty1", post=["duty2"]) # type: ignore[call-overload] 113 | with pytest.raises(RuntimeError): 114 | duty.run() 115 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/_yore.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from duty._internal.tools._base import Tool 4 | 5 | 6 | class yore(Tool): # noqa: N801 7 | """Call [Yore](https://github.com/pawamoy/yore).""" 8 | 9 | cli_name = "yore" 10 | """The name of the executable on PATH.""" 11 | 12 | @classmethod 13 | def check( 14 | cls, 15 | *paths: str, 16 | bump: str | None = None, 17 | eol_within: str | None = None, 18 | bol_within: str | None = None, 19 | ) -> yore: 20 | """Check Yore comments against Python EOL dates or the provided next version of your project. 21 | 22 | Parameters: 23 | paths: Path to files or directories to check. 24 | bump: The next version of your project. 25 | eol_within: The time delta to start checking before the End of Life of a Python version. 26 | It is provided in a human-readable format, like `2 weeks` or `1 month`. 27 | Spaces are optional, and the unit can be shortened to a single letter: 28 | `d` for days, `w` for weeks, `m` for months, and `y` for years. 29 | bol_within: The time delta to start checking before the Beginning of Life of a Python version. 30 | It is provided in a human-readable format, like `2 weeks` or `1 month`. 31 | Spaces are optional, and the unit can be shortened to a single letter: 32 | `d` for days, `w` for weeks, `m` for months, and `y` for years. 33 | """ 34 | cli_args = ["check", *paths] 35 | 36 | if bump: 37 | cli_args.append("--bump") 38 | cli_args.append(bump) 39 | 40 | if eol_within: 41 | cli_args.append("--eol-within") 42 | cli_args.append(eol_within) 43 | 44 | if bol_within: 45 | cli_args.append("--bol-within") 46 | cli_args.append(bol_within) 47 | 48 | return cls(cli_args) 49 | 50 | @classmethod 51 | def fix( 52 | cls, 53 | *paths: str, 54 | bump: str | None = None, 55 | eol_within: str | None = None, 56 | bol_within: str | None = None, 57 | ) -> yore: 58 | """Fix your code by transforming it according to the Yore comments. 59 | 60 | Parameters: 61 | paths: Path to files or directories to fix. 62 | bump: The next version of your project. 63 | eol_within: The time delta to start fixing before the End of Life of a Python version. 64 | It is provided in a human-readable format, like `2 weeks` or `1 month`. 65 | Spaces are optional, and the unit can be shortened to a single letter: 66 | `d` for days, `w` for weeks, `m` for months, and `y` for years. 67 | bol_within: The time delta to start fixing before the Beginning of Life of a Python version. 68 | It is provided in a human-readable format, like `2 weeks` or `1 month`. 69 | Spaces are optional, and the unit can be shortened to a single letter: 70 | `d` for days, `w` for weeks, `m` for months, and `y` for years. 71 | """ 72 | cli_args = ["fix", *paths] 73 | 74 | if bump: 75 | cli_args.append("--bump") 76 | cli_args.append(bump) 77 | 78 | if eol_within: 79 | cli_args.append("--eol-within") 80 | cli_args.append(eol_within) 81 | 82 | if bol_within: 83 | cli_args.append("--bol-within") 84 | cli_args.append(bol_within) 85 | 86 | return cls(cli_args) 87 | 88 | def __call__(self) -> int: 89 | """Run the command. 90 | 91 | Returns: 92 | The exit code of the command. 93 | """ 94 | from yore import main as run_yore # noqa: PLC0415 95 | 96 | return run_yore(self.cli_args) 97 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/_safety.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import importlib 4 | import sys 5 | from io import StringIO 6 | from typing import TYPE_CHECKING, Literal, cast 7 | 8 | from duty._internal.tools._base import Tool 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | 13 | 14 | class safety(Tool): # noqa: N801 15 | """Call [Safety](https://github.com/pyupio/safety).""" 16 | 17 | cli_name = "safety" 18 | """The name of the executable on PATH.""" 19 | 20 | @classmethod 21 | def check( 22 | cls, 23 | requirements: str | Sequence[str], 24 | *, 25 | ignore_vulns: dict[str, str] | None = None, 26 | formatter: Literal["json", "bare", "text"] = "text", 27 | full_report: bool = True, 28 | ) -> safety: 29 | """Run the safety check command. 30 | 31 | This function makes sure we load the original, unpatched version of safety. 32 | 33 | Parameters: 34 | requirements: Python "requirements" (list of pinned dependencies). 35 | ignore_vulns: Vulnerabilities to ignore. 36 | formatter: Report format. 37 | full_report: Whether to output a full report. 38 | 39 | Returns: 40 | Success/failure. 41 | """ 42 | return cls(py_args=dict(locals())) 43 | 44 | @property 45 | def cli_command(self) -> str: 46 | """The equivalent CLI command.""" 47 | raise ValueError("This command cannot be translated to a CLI command.") 48 | 49 | def __call__(self) -> bool: 50 | """Run the command. 51 | 52 | Returns: 53 | False when vulnerabilities are found. 54 | """ 55 | requirements = self.py_args["requirements"] 56 | ignore_vulns = self.py_args["ignore_vulns"] 57 | formatter = self.py_args["formatter"] 58 | full_report = self.py_args["full_report"] 59 | 60 | # set default parameter values 61 | ignore_vulns = ignore_vulns or {} 62 | 63 | # undo possible patching 64 | # see https://github.com/pyupio/safety/issues/348 65 | for module in sys.modules: 66 | if module.startswith("safety.") or module == "safety": 67 | del sys.modules[module] 68 | 69 | importlib.invalidate_caches() 70 | 71 | # reload original, unpatched safety 72 | from safety.formatter import SafetyFormatter # noqa: PLC0415 73 | from safety.safety import calculate_remediations, check # noqa: PLC0415 74 | from safety.util import read_requirements # noqa: PLC0415 75 | 76 | # check using safety as a library 77 | if isinstance(requirements, (list, tuple, set)): 78 | requirements = "\n".join(requirements) 79 | packages = list(read_requirements(StringIO(cast("str", requirements)))) 80 | 81 | # TODO: Safety 3 support, merge once support for v2 is dropped. 82 | check_kwargs = {"packages": packages, "ignore_vulns": ignore_vulns} 83 | try: 84 | from safety.auth.cli_utils import build_client_session # noqa: PLC0415 85 | 86 | client_session, _ = build_client_session() 87 | check_kwargs["session"] = client_session 88 | except ImportError: 89 | pass 90 | 91 | vulns, db_full = check(**check_kwargs) 92 | remediations = calculate_remediations(vulns, db_full) 93 | output_report = SafetyFormatter(formatter).render_vulnerabilities( 94 | announcements=[], 95 | vulnerabilities=vulns, 96 | remediations=remediations, 97 | full=full_report, 98 | packages=packages, 99 | ) 100 | 101 | # print report, return status 102 | if vulns: 103 | print(output_report) # noqa: T201 104 | return False 105 | return True 106 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | """Tests for the `context` module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import namedtuple 6 | from pathlib import Path 7 | 8 | import pytest 9 | 10 | from duty._internal import context 11 | from duty._internal.exceptions import DutyFailure 12 | 13 | RunResult = namedtuple("RunResult", "code output") # noqa: PYI024 14 | 15 | 16 | def test_allow_overrides(monkeypatch: pytest.MonkeyPatch) -> None: 17 | """Test the `allow_overrides` option. 18 | 19 | Parameters: 20 | monkeypatch: A Pytest fixture to monkeypatch objects. 21 | """ 22 | ctx = context.Context({"a": 1}, {"a": 2}) 23 | records = [] 24 | monkeypatch.setattr(context, "failprint_run", lambda _, **opts: RunResult(records.append(opts), "")) # type: ignore[func-returns-value] 25 | ctx.run("") 26 | ctx.run("", allow_overrides=False) 27 | ctx.run("", allow_overrides=True) 28 | ctx.run("", allow_overrides=False, a=3) 29 | assert records[0]["a"] == 2 30 | assert records[1]["a"] == 1 31 | assert records[2]["a"] == 2 32 | assert records[3]["a"] == 3 33 | 34 | 35 | def test_options_context_manager(monkeypatch: pytest.MonkeyPatch) -> None: 36 | """Test changing options using the context manager. 37 | 38 | Parameters: 39 | monkeypatch: A Pytest fixture to monkeypatch objects. 40 | """ 41 | ctx = context.Context({"a": 1}, {"a": 2}) 42 | records = [] 43 | monkeypatch.setattr(context, "failprint_run", lambda _, **opts: RunResult(records.append(opts), "")) # type: ignore[func-returns-value] 44 | 45 | with ctx.options(a=3): 46 | ctx.run("") # should be overridden by 2 47 | with ctx.options(a=4, allow_overrides=False): 48 | ctx.run("") # should be 4 49 | ctx.run("", allow_overrides=True) # should be 2 50 | ctx.run("", allow_overrides=False) # should be 3 51 | 52 | assert records[0]["a"] == 2 53 | assert records[1]["a"] == 4 54 | assert records[2]["a"] == 2 55 | assert records[3]["a"] == 3 56 | 57 | 58 | def test_workdir(monkeypatch: pytest.MonkeyPatch) -> None: 59 | """Test the `workdir` option. 60 | 61 | Parameters: 62 | monkeypatch: A Pytest fixture to monkeypatch objects. 63 | """ 64 | ctx = context.Context({}) 65 | monkeypatch.setattr(context, "failprint_run", lambda _: RunResult(len(Path.cwd().parts), "")) 66 | records = [] 67 | with pytest.raises(DutyFailure) as failure: 68 | ctx.run("") 69 | records.append(failure.value.code) 70 | with pytest.raises(DutyFailure) as failure: 71 | ctx.run("", workdir="..") 72 | records.append(failure.value.code) 73 | assert records[0] == records[1] + 1 74 | 75 | 76 | def test_workdir_as_context_manager(monkeypatch: pytest.MonkeyPatch) -> None: 77 | """Test the `workdir` option as a context manager, and the `cd` context manager. 78 | 79 | Parameters: 80 | monkeypatch: A Pytest fixture to monkeypatch objects. 81 | """ 82 | ctx = context.Context({}) 83 | monkeypatch.setattr(context, "failprint_run", lambda _: RunResult(len(Path.cwd().parts), "")) 84 | records = [] 85 | with pytest.raises(DutyFailure) as failure, ctx.options(workdir=".."): 86 | ctx.run("") 87 | records.append(failure.value.code) 88 | with pytest.raises(DutyFailure) as failure, ctx.cd("../.."): 89 | ctx.run("") 90 | records.append(failure.value.code) 91 | with pytest.raises(DutyFailure) as failure, ctx.cd(".."), ctx.options(workdir="../.."): 92 | ctx.run("") 93 | records.append(failure.value.code) 94 | with pytest.raises(DutyFailure) as failure, ctx.cd("../../.."): 95 | ctx.run("", workdir="..") 96 | records.append(failure.value.code) 97 | 98 | base = records[0] 99 | 100 | # If the repository is checked out near the root of the filesystem, the working directory will 101 | # eventually be the root, so cap the lowest depth at 1. 102 | expected_depths = [max(1, base - offset) for offset in range(len(records))] 103 | assert records == expected_depths 104 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. 4 | 5 | ## Environment setup 6 | 7 | Nothing easier! 8 | 9 | Fork and clone the repository, then: 10 | 11 | ```bash 12 | cd duty 13 | make setup 14 | ``` 15 | 16 | > NOTE: If it fails for some reason, you'll need to install [uv](https://github.com/astral-sh/uv) manually. 17 | > 18 | > You can install it with: 19 | > 20 | > ```bash 21 | > curl -LsSf https://astral.sh/uv/install.sh | sh 22 | > ``` 23 | > 24 | > Now you can try running `make setup` again, or simply `uv sync`. 25 | 26 | You now have the dependencies installed. 27 | 28 | You can run the application with `make run duty [ARGS...]`. 29 | 30 | Run `make help` to see all the available actions! 31 | 32 | ## Tasks 33 | 34 | The entry-point to run commands and tasks is the `make` Python script, located in the `scripts` directory. Try running `make` to show the available commands and tasks. The *commands* do not need the Python dependencies to be installed, 35 | while the *tasks* do. The cross-platform tasks are written in Python, thanks to [duty](https://github.com/pawamoy/duty). 36 | 37 | If you work in VSCode, we provide [an action to configure VSCode](https://pawamoy.github.io/copier-uv/work/#vscode-setup) for the project. 38 | 39 | ## Development 40 | 41 | As usual: 42 | 43 | 1. create a new branch: `git switch -c feature-or-bugfix-name` 44 | 1. edit the code and/or the documentation 45 | 46 | **Before committing:** 47 | 48 | 1. run `make format` to auto-format the code 49 | 1. run `make check` to check everything (fix any warning) 50 | 1. run `make test` to run the tests (fix any issue) 51 | 1. if you updated the documentation or the project dependencies: 52 | 1. run `make docs` 53 | 1. go to http://localhost:8000 and check that everything looks good 54 | 1. follow our [commit message convention](#commit-message-convention) 55 | 56 | If you are unsure about how to fix or ignore a warning, just let the continuous integration fail, and we will help you during review. 57 | 58 | Don't bother updating the changelog, we will take care of this. 59 | 60 | ## Commit message convention 61 | 62 | Commit messages must follow our convention based on the [Angular style](https://gist.github.com/stephenparish/9941e89d80e2bc58a153#format-of-the-commit-message) or the [Karma convention](https://karma-runner.github.io/4.0/dev/git-commit-msg.html): 63 | 64 | ``` 65 | [(scope)]: Subject 66 | 67 | [Body] 68 | ``` 69 | 70 | **Subject and body must be valid Markdown.** Subject must have proper casing (uppercase for first letter if it makes sense), but no dot at the end, and no punctuation in general. 71 | 72 | Scope and body are optional. Type can be: 73 | 74 | - `build`: About packaging, building wheels, etc. 75 | - `chore`: About packaging or repo/files management. 76 | - `ci`: About Continuous Integration. 77 | - `deps`: Dependencies update. 78 | - `docs`: About documentation. 79 | - `feat`: New feature. 80 | - `fix`: Bug fix. 81 | - `perf`: About performance. 82 | - `refactor`: Changes that are not features or bug fixes. 83 | - `style`: A change in code style/format. 84 | - `tests`: About tests. 85 | 86 | If you write a body, please add trailers at the end (for example issues and PR references, or co-authors), without relying on GitHub's flavored Markdown: 87 | 88 | ``` 89 | Body. 90 | 91 | Issue #10: https://github.com/namespace/project/issues/10 92 | Related to PR namespace/other-project#15: https://github.com/namespace/other-project/pull/15 93 | ``` 94 | 95 | These "trailers" must appear at the end of the body, without any blank lines between them. The trailer title can contain any character except colons `:`. We expect a full URI for each trailer, not just GitHub autolinks (for example, full GitHub URLs for commits and issues, not the hash or the #issue-number). 96 | 97 | We do not enforce a line length on commit messages summary and body, but please avoid very long summaries, and very long lines in the body, unless they are part of code blocks that must not be wrapped. 98 | 99 | ## Pull requests guidelines 100 | 101 | Link to any related issue in the Pull Request message. 102 | 103 | During the review, we recommend using fixups: 104 | 105 | ```bash 106 | # SHA is the SHA of the commit you want to fix 107 | git commit --fixup=SHA 108 | ``` 109 | 110 | Once all the changes are approved, you can squash your commits: 111 | 112 | ```bash 113 | git rebase -i --autosquash main 114 | ``` 115 | 116 | And force-push: 117 | 118 | ```bash 119 | git push -f 120 | ``` 121 | 122 | If this seems all too complicated, you can push or force-push each new commit, and we will squash them ourselves if needed, before merging. 123 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: "duty" 2 | site_description: "A simple task runner." 3 | site_url: "https://pawamoy.github.io/duty" 4 | repo_url: "https://github.com/pawamoy/duty" 5 | repo_name: "pawamoy/duty" 6 | site_dir: "site" 7 | watch: [mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/duty] 8 | copyright: Copyright © 2020 Timothée Mazzucotelli 9 | edit_uri: edit/main/docs/ 10 | 11 | validation: 12 | omitted_files: warn 13 | absolute_links: warn 14 | unrecognized_links: warn 15 | 16 | nav: 17 | - Home: 18 | - Overview: index.md 19 | - Changelog: changelog.md 20 | - Credits: credits.md 21 | - License: license.md 22 | - Usage: usage.md 23 | - API reference: reference/api.md 24 | - Development: 25 | - Contributing: contributing.md 26 | - Code of Conduct: code_of_conduct.md 27 | - Coverage report: coverage.md 28 | - Author's website: https://pawamoy.github.io/ 29 | 30 | theme: 31 | name: material 32 | custom_dir: docs/.overrides 33 | icon: 34 | logo: material/currency-sign 35 | features: 36 | - announce.dismiss 37 | - content.action.edit 38 | - content.action.view 39 | - content.code.annotate 40 | - content.code.copy 41 | - content.tooltips 42 | - navigation.footer 43 | - navigation.instant.preview 44 | - navigation.path 45 | - navigation.sections 46 | - navigation.tabs 47 | - navigation.tabs.sticky 48 | - navigation.top 49 | - search.highlight 50 | - search.suggest 51 | - toc.follow 52 | palette: 53 | - media: "(prefers-color-scheme)" 54 | toggle: 55 | icon: material/brightness-auto 56 | name: Switch to light mode 57 | - media: "(prefers-color-scheme: light)" 58 | scheme: default 59 | primary: teal 60 | accent: purple 61 | toggle: 62 | icon: material/weather-sunny 63 | name: Switch to dark mode 64 | - media: "(prefers-color-scheme: dark)" 65 | scheme: slate 66 | primary: black 67 | accent: lime 68 | toggle: 69 | icon: material/weather-night 70 | name: Switch to system preference 71 | 72 | extra_css: 73 | - css/material.css 74 | - css/mkdocstrings.css 75 | 76 | extra_javascript: 77 | - js/feedback.js 78 | 79 | markdown_extensions: 80 | - attr_list 81 | - admonition 82 | - callouts 83 | - footnotes 84 | - pymdownx.emoji: 85 | emoji_index: !!python/name:material.extensions.emoji.twemoji 86 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 87 | - pymdownx.magiclink 88 | - pymdownx.snippets: 89 | base_path: [!relative $config_dir] 90 | check_paths: true 91 | - pymdownx.superfences 92 | - pymdownx.tabbed: 93 | alternate_style: true 94 | slugify: !!python/object/apply:pymdownx.slugs.slugify 95 | kwds: 96 | case: lower 97 | - pymdownx.tasklist: 98 | custom_checkbox: true 99 | - toc: 100 | permalink: "¤" 101 | 102 | plugins: 103 | - search 104 | - autorefs 105 | - markdown-exec 106 | - section-index 107 | - coverage 108 | - mkdocstrings: 109 | handlers: 110 | python: 111 | inventories: 112 | - https://docs.python.org/3/objects.inv 113 | - https://pawamoy.github.io/failprint/objects.inv 114 | paths: [src] 115 | options: 116 | backlinks: tree 117 | docstring_options: 118 | ignore_init_summary: true 119 | docstring_section_style: list 120 | filters: public 121 | heading_level: 1 122 | inherited_members: true 123 | merge_init_into_class: true 124 | preload_modules: [failprint] 125 | separate_signature: true 126 | show_root_heading: true 127 | show_root_full_path: false 128 | show_signature_annotations: true 129 | show_source: true 130 | show_symbol_type_heading: true 131 | show_symbol_type_toc: true 132 | signature_crossrefs: true 133 | summary: true 134 | - llmstxt: 135 | full_output: llms-full.txt 136 | sections: 137 | Usage: 138 | - index.md 139 | API: 140 | - reference/api.md 141 | - git-revision-date-localized: 142 | enabled: !ENV [DEPLOY, false] 143 | enable_creation_date: true 144 | type: timeago 145 | - minify: 146 | minify_html: !ENV [DEPLOY, false] 147 | - group: 148 | enabled: !ENV [MATERIAL_INSIDERS, false] 149 | plugins: 150 | - typeset 151 | 152 | extra: 153 | social: 154 | - icon: fontawesome/brands/github 155 | link: https://github.com/pawamoy 156 | - icon: fontawesome/brands/mastodon 157 | link: https://fosstodon.org/@pawamoy 158 | - icon: fontawesome/brands/twitter 159 | link: https://twitter.com/pawamoy 160 | - icon: fontawesome/brands/gitter 161 | link: https://gitter.im/duty/community 162 | - icon: fontawesome/brands/python 163 | link: https://pypi.org/project/duty/ 164 | analytics: 165 | feedback: 166 | title: Was this page helpful? 167 | ratings: 168 | - icon: material/emoticon-happy-outline 169 | name: This page was helpful 170 | data: 1 171 | note: Thanks for your feedback! 172 | - icon: material/emoticon-sad-outline 173 | name: This page could be improved 174 | data: 0 175 | note: Let us know how we can improve this page. 176 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/autoflake.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | from failprint import lazy 6 | 7 | from duty._internal.callables import _io 8 | 9 | 10 | @lazy(name="autoflake") 11 | def run( 12 | *files: str, 13 | config: str | None = None, 14 | check: bool | None = None, 15 | check_diff: bool | None = None, 16 | imports: list[str] | None = None, 17 | remove_all_unused_imports: bool | None = None, 18 | recursive: bool | None = None, 19 | jobs: int | None = None, 20 | exclude: list[str] | None = None, 21 | expand_star_imports: bool | None = None, 22 | ignore_init_module_imports: bool | None = None, 23 | remove_duplicate_keys: bool | None = None, 24 | remove_unused_variables: bool | None = None, 25 | remove_rhs_for_unused_variables: bool | None = None, 26 | ignore_pass_statements: bool | None = None, 27 | ignore_pass_after_docstring: bool | None = None, 28 | quiet: bool | None = None, 29 | verbose: bool | None = None, 30 | stdin_display_name: str | None = None, 31 | in_place: bool | None = None, 32 | stdout: bool | None = None, 33 | ) -> int: 34 | r"""Run `autoflake`. 35 | 36 | Parameters: 37 | *files: Files to format. 38 | config: Explicitly set the config file instead of auto determining based on file location. 39 | check: Return error code if changes are needed. 40 | check_diff: Return error code if changes are needed, also display file diffs. 41 | imports: By default, only unused standard library imports are removed; specify a comma-separated list of additional modules/packages. 42 | remove_all_unused_imports: Remove all unused imports (not just those from the standard library). 43 | recursive: Drill down directories recursively. 44 | jobs: Number of parallel jobs; match CPU count if value is 0 (default: 0). 45 | exclude: Exclude file/directory names that match these comma-separated globs. 46 | expand_star_imports: Expand wildcard star imports with undefined names; this only triggers if there is only one star import in the file; this is skipped if there are any uses of `__all__` or `del` in the file. 47 | ignore_init_module_imports: Exclude `__init__.py` when removing unused imports. 48 | remove_duplicate_keys: Remove all duplicate keys in objects. 49 | remove_unused_variables: Remove unused variables. 50 | remove_rhs_for_unused_variables: Remove RHS of statements when removing unused variables (unsafe). 51 | ignore_pass_statements: Ignore all pass statements. 52 | ignore_pass_after_docstring: Ignore pass statements after a newline ending on `\"\"\"`. 53 | quiet: Suppress output if there are no issues. 54 | verbose: Print more verbose logs (you can repeat `-v` to make it more verbose). 55 | stdin_display_name: The name used when processing input from stdin. 56 | in_place: Make changes to files instead of printing diffs. 57 | stdout: Print changed text to stdout. defaults to true when formatting stdin, or to false otherwise. 58 | """ 59 | from autoflake import _main as autoflake # noqa: PLC0415 60 | 61 | cli_args = list(files) 62 | 63 | if check: 64 | cli_args.append("--check") 65 | 66 | if check_diff: 67 | cli_args.append("--check-diff") 68 | 69 | if imports: 70 | cli_args.append("--imports") 71 | cli_args.append(",".join(imports)) 72 | 73 | if remove_all_unused_imports: 74 | cli_args.append("--remove-all-unused-imports") 75 | 76 | if recursive: 77 | cli_args.append("--recursive") 78 | 79 | if jobs: 80 | cli_args.append("--jobs") 81 | cli_args.append(str(jobs)) 82 | 83 | if exclude: 84 | cli_args.append("--exclude") 85 | cli_args.append(",".join(exclude)) 86 | 87 | if expand_star_imports: 88 | cli_args.append("--expand-star-imports") 89 | 90 | if ignore_init_module_imports: 91 | cli_args.append("--ignore-init-module-imports") 92 | 93 | if remove_duplicate_keys: 94 | cli_args.append("--remove-duplicate-keys") 95 | 96 | if remove_unused_variables: 97 | cli_args.append("--remove-unused-variables") 98 | 99 | if remove_rhs_for_unused_variables: 100 | cli_args.append("remove-rhs-for-unused-variables") 101 | 102 | if ignore_pass_statements: 103 | cli_args.append("--ignore-pass-statements") 104 | 105 | if ignore_pass_after_docstring: 106 | cli_args.append("--ignore-pass-after-docstring") 107 | 108 | if quiet: 109 | cli_args.append("--quiet") 110 | 111 | if verbose: 112 | cli_args.append("--verbose") 113 | 114 | if stdin_display_name: 115 | cli_args.append("--stdin-display-name") 116 | cli_args.append(stdin_display_name) 117 | 118 | if config: 119 | cli_args.append("--config") 120 | cli_args.append(config) 121 | 122 | if in_place: 123 | cli_args.append("--in-place") 124 | 125 | if stdout: 126 | cli_args.append("--stdout") 127 | 128 | return autoflake( 129 | cli_args, 130 | standard_out=_io._LazyStdout(), 131 | standard_error=_io._LazyStderr(), 132 | ) 133 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or advances of any kind 22 | * Trolling, insulting or derogatory comments, and personal or political attacks 23 | * Public or private harassment 24 | * Publishing others' private information, such as a physical or email address, without their explicit permission 25 | * Other conduct which could reasonably be considered inappropriate in a professional setting 26 | 27 | ## Enforcement Responsibilities 28 | 29 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 32 | 33 | ## Scope 34 | 35 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at dev@pawamoy.fr. All complaints will be reviewed and investigated promptly and fairly. 40 | 41 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 42 | 43 | ## Enforcement Guidelines 44 | 45 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 46 | 47 | ### 1. Correction 48 | 49 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 50 | 51 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 52 | 53 | ### 2. Warning 54 | 55 | **Community Impact**: A violation through a single incident or series of actions. 56 | 57 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 58 | 59 | ### 3. Temporary Ban 60 | 61 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 62 | 63 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 64 | 65 | ### 4. Permanent Ban 66 | 67 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 68 | 69 | **Consequence**: A permanent ban from any sort of public interaction within the community. 70 | 71 | ## Attribution 72 | 73 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 74 | 75 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 76 | 77 | For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. 78 | 79 | [homepage]: https://www.contributor-covenant.org 80 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 81 | [Mozilla CoC]: https://github.com/mozilla/diversity 82 | [FAQ]: https://www.contributor-covenant.org/faq 83 | [translations]: https://www.contributor-covenant.org/translations 84 | 85 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/_blacken_docs.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from pathlib import Path 5 | from re import Pattern 6 | from typing import TYPE_CHECKING 7 | 8 | from duty._internal.tools._base import Tool 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | 13 | 14 | class blacken_docs(Tool): # noqa: N801 15 | """Call [blacken-docs](https://github.com/adamchainz/blacken-docs).""" 16 | 17 | cli_name = "blacken-docs" 18 | """The name of the executable on PATH.""" 19 | 20 | def __init__( 21 | self, 22 | *paths: str | Path, 23 | exts: Sequence[str] | None = None, 24 | exclude: Sequence[str | Pattern] | None = None, 25 | skip_errors: bool = False, 26 | rst_literal_blocks: bool = False, 27 | line_length: int | None = None, 28 | string_normalization: bool = True, 29 | is_pyi: bool = False, 30 | is_ipynb: bool = False, 31 | skip_source_first_line: bool = False, 32 | magic_trailing_comma: bool = True, 33 | python_cell_magics: set[str] | None = None, 34 | preview: bool = False, 35 | check_only: bool = False, 36 | ) -> None: 37 | """Run `blacken-docs`. 38 | 39 | Parameters: 40 | *paths: Directories and files to format. 41 | exts: List of extensions to select files with. 42 | exclude: List of regular expressions to exclude files. 43 | skip_errors: Don't exit non-zero for errors from Black (normally syntax errors). 44 | rst_literal_blocks: Also format literal blocks in reStructuredText files (more below). 45 | line_length: How many characters per line to allow. 46 | string_normalization: Normalize string quotes or prefixes. 47 | is_pyi: Format all input files like typing stubs regardless of file extension. 48 | is_ipynb: Format all input files like Jupyter Notebooks regardless of file extension. 49 | skip_source_first_line: Skip the first line of the source code. 50 | magic_trailing_comma: Use trailing commas as a reason to split lines. 51 | python_cell_magics: When processing Jupyter Notebooks, add the given magic to the list 52 | of known python-magics (capture, prun, pypy, python, python3, time, timeit). 53 | Useful for formatting cells with custom python magics. 54 | preview: Enable potentially disruptive style changes that may be added 55 | to Black's main functionality in the next major release. 56 | check_only: Don't modify files but indicate when changes are necessary 57 | with a message and non-zero return code. 58 | 59 | Returns: 60 | Success/failure. 61 | """ 62 | super().__init__(py_args=dict(locals())) 63 | 64 | @property 65 | def cli_command(self) -> str: 66 | """The equivalent CLI command.""" 67 | raise ValueError("This command cannot be translated to a CLI command.") 68 | 69 | def __call__(self) -> int: 70 | """Run the command. 71 | 72 | Returns: 73 | The exit code of the command. 74 | """ 75 | import black # noqa: PLC0415 76 | from blacken_docs import format_file # noqa: PLC0415 77 | 78 | # Restore locals. 79 | exts = self.py_args["exts"] 80 | exclude = self.py_args["exclude"] 81 | paths = self.py_args["paths"] 82 | line_length = self.py_args["line_length"] 83 | string_normalization = self.py_args["string_normalization"] 84 | is_pyi = self.py_args["is_pyi"] 85 | is_ipynb = self.py_args["is_ipynb"] 86 | skip_source_first_line = self.py_args["skip_source_first_line"] 87 | magic_trailing_comma = self.py_args["magic_trailing_comma"] 88 | python_cell_magics = self.py_args["python_cell_magics"] 89 | preview = self.py_args["preview"] 90 | skip_errors = self.py_args["skip_errors"] 91 | rst_literal_blocks = self.py_args["rst_literal_blocks"] 92 | check_only = self.py_args["check_only"] 93 | 94 | # Build filepaths. 95 | exts = ("md", "py") if exts is None else tuple(ext.lstrip(".") for ext in exts) 96 | if exclude: 97 | exclude = tuple(re.compile(regex, re.I) if isinstance(regex, str) else regex for regex in exclude) 98 | filepaths = set() 99 | for path in paths: 100 | path = Path(path) # noqa: PLW2901 101 | if path.is_file(): 102 | filepaths.add(path.as_posix()) 103 | else: 104 | for ext in exts: 105 | filepaths |= {filepath.as_posix() for filepath in path.rglob(f"*.{ext}")} 106 | 107 | # Initiate black. 108 | black_mode = black.Mode( 109 | line_length=line_length or black.DEFAULT_LINE_LENGTH, 110 | string_normalization=string_normalization, 111 | is_pyi=is_pyi, 112 | is_ipynb=is_ipynb, 113 | skip_source_first_line=skip_source_first_line, 114 | magic_trailing_comma=magic_trailing_comma, 115 | python_cell_magics=python_cell_magics or set(), 116 | preview=preview, 117 | ) 118 | 119 | # Run blacken-docs. 120 | retv = 0 121 | for filepath in sorted(filepaths): 122 | retv |= format_file( 123 | filepath, 124 | black_mode, 125 | skip_errors=skip_errors, 126 | rst_literal_blocks=rst_literal_blocks, 127 | check_only=check_only, 128 | ) 129 | return retv 130 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/interrogate.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Literal 6 | 7 | from failprint import lazy 8 | 9 | _BADGE_STYLE = Literal["flat", "flat-square", "flat-square-modified", "for-the-badge", "plastic", "social"] 10 | 11 | 12 | @lazy(name="interrogate") 13 | def run( 14 | *src: str, 15 | verbose: int | None = None, 16 | quiet: bool | None = None, 17 | fail_under: float | None = None, 18 | exclude: str | None = None, 19 | ignore_init_method: bool | None = None, 20 | ignore_init_module: bool | None = None, 21 | ignore_magic: bool | None = None, 22 | ignore_module: bool | None = None, 23 | ignore_nested_functions: bool | None = None, 24 | ignore_nested_classes: bool | None = None, 25 | ignore_private: bool | None = None, 26 | ignore_property_decorators: bool | None = None, 27 | ignore_setters: bool | None = None, 28 | ignore_semiprivate: bool | None = None, 29 | ignore_regex: str | None = None, 30 | whitelist_regex: str | None = None, 31 | output: str | None = None, 32 | config: str | None = None, 33 | color: bool | None = None, 34 | omit_covered_files: bool | None = None, 35 | generate_badge: str | None = None, 36 | badge_format: Literal["png", "svg"] | None = None, 37 | badge_style: _BADGE_STYLE | None = None, 38 | ) -> None: 39 | """Run `interrogate`. 40 | 41 | Args: 42 | src: Format the directories and file paths. 43 | verbose: Level of verbosity. 44 | quiet: Do not print output. 45 | fail_under: Fail when coverage % is less than a given amount. 46 | exclude: Exclude PATHs of files and/or directories. 47 | ignore_init_method: Ignore `__init__` method of classes. 48 | ignore_init_module: Ignore `__init__.py` modules. 49 | ignore_magic: Ignore all magic methods of classes. 50 | ignore_module: Ignore module-level docstrings. 51 | ignore_nested_functions: Ignore nested functions and methods. 52 | ignore_nested_classes: Ignore nested classes. 53 | ignore_private: Ignore private classes, methods, and functions starting with two underscores. 54 | ignore_property_decorators: Ignore methods with property setter/getter decorators. 55 | ignore_setters: Ignore methods with property setter decorators. 56 | ignore_semiprivate: Ignore semiprivate classes, methods, and functions starting with a single underscore. 57 | ignore_regex: Regex identifying class, method, and function names to ignore. 58 | whitelist_regex: Regex identifying class, method, and function names to include. 59 | output: Write output to a given FILE. 60 | config: Read configuration from pyproject.toml or setup.cfg. 61 | color: Toggle color output on/off when printing to stdout. 62 | omit_covered_files: Omit reporting files that have 100% documentation coverage. 63 | generate_badge: Generate a shields.io status badge (an SVG image) in at a given file or directory. 64 | badge_format: File format for the generated badge. 65 | badge_style: Desired style of shields.io badge. 66 | """ 67 | from interrogate.cli import main as interrogate # noqa: PLC0415 68 | 69 | cli_args: list[str] = list(src) 70 | 71 | if verbose: 72 | cli_args.append("--verbose") 73 | cli_args.append(str(verbose)) 74 | 75 | if quiet: 76 | cli_args.append("--quiet") 77 | 78 | if fail_under: 79 | cli_args.append("--fail-under") 80 | cli_args.append(str(fail_under)) 81 | 82 | if exclude: 83 | cli_args.append("--exclude") 84 | cli_args.append(exclude) 85 | 86 | if ignore_init_method: 87 | cli_args.append("--ignore-init-method") 88 | 89 | if ignore_init_module: 90 | cli_args.append("--ignore-init-module") 91 | 92 | if ignore_magic: 93 | cli_args.append("--ignore-magic") 94 | 95 | if ignore_module: 96 | cli_args.append("--ignore-module") 97 | 98 | if ignore_nested_functions: 99 | cli_args.append("--ignore-nested-functions") 100 | 101 | if ignore_nested_classes: 102 | cli_args.append("--ignore-nested-classes") 103 | 104 | if ignore_private: 105 | cli_args.append("--ignore-private") 106 | 107 | if ignore_property_decorators: 108 | cli_args.append("--ignore-property-decorators") 109 | 110 | if ignore_setters: 111 | cli_args.append("--ignore-setters") 112 | 113 | if ignore_semiprivate: 114 | cli_args.append("--ignore-semiprivate") 115 | 116 | if ignore_regex: 117 | cli_args.append("--ignore-regex") 118 | cli_args.append(ignore_regex) 119 | 120 | if whitelist_regex: 121 | cli_args.append("--whitelist-regex") 122 | cli_args.append(whitelist_regex) 123 | 124 | if output: 125 | cli_args.append("--output") 126 | cli_args.append(output) 127 | 128 | if omit_covered_files: 129 | cli_args.append("--omit-covered-files") 130 | 131 | if generate_badge: 132 | cli_args.append("--generate-badge") 133 | cli_args.append(generate_badge) 134 | 135 | if badge_format: 136 | cli_args.append("--badge-format") 137 | cli_args.append(badge_format) 138 | 139 | if badge_style: 140 | cli_args.append("--badge-style") 141 | cli_args.append(badge_style) 142 | 143 | if config: 144 | cli_args.append("--config") 145 | cli_args.append(config) 146 | 147 | if color is True: 148 | cli_args.append("--color") 149 | elif color is False: 150 | cli_args.append("--no-color") 151 | 152 | interrogate(cli_args) 153 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/_autoflake.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from duty._internal.tools._base import LazyStderr, LazyStdout, Tool 4 | 5 | 6 | class autoflake(Tool): # noqa: N801 7 | """Call [autoflake](https://github.com/PyCQA/autoflake).""" 8 | 9 | cli_name = "autoflake" 10 | """The name of the executable on PATH.""" 11 | 12 | def __init__( 13 | self, 14 | *files: str, 15 | config: str | None = None, 16 | check: bool | None = None, 17 | check_diff: bool | None = None, 18 | imports: list[str] | None = None, 19 | remove_all_unused_imports: bool | None = None, 20 | recursive: bool | None = None, 21 | jobs: int | None = None, 22 | exclude: list[str] | None = None, 23 | expand_star_imports: bool | None = None, 24 | ignore_init_module_imports: bool | None = None, 25 | remove_duplicate_keys: bool | None = None, 26 | remove_unused_variables: bool | None = None, 27 | remove_rhs_for_unused_variables: bool | None = None, 28 | ignore_pass_statements: bool | None = None, 29 | ignore_pass_after_docstring: bool | None = None, 30 | quiet: bool | None = None, 31 | verbose: bool | None = None, 32 | stdin_display_name: str | None = None, 33 | in_place: bool | None = None, 34 | stdout: bool | None = None, 35 | ) -> None: 36 | r"""Run `autoflake`. 37 | 38 | Parameters: 39 | *files: Files to format. 40 | config: Explicitly set the config file instead of auto determining based on file location. 41 | check: Return error code if changes are needed. 42 | check_diff: Return error code if changes are needed, also display file diffs. 43 | imports: By default, only unused standard library imports are removed; specify a comma-separated list of additional modules/packages. 44 | remove_all_unused_imports: Remove all unused imports (not just those from the standard library). 45 | recursive: Drill down directories recursively. 46 | jobs: Number of parallel jobs; match CPU count if value is 0 (default: 0). 47 | exclude: Exclude file/directory names that match these comma-separated globs. 48 | expand_star_imports: Expand wildcard star imports with undefined names; this only triggers if there is only one star import in the file; this is skipped if there are any uses of `__all__` or `del` in the file. 49 | ignore_init_module_imports: Exclude `__init__.py` when removing unused imports. 50 | remove_duplicate_keys: Remove all duplicate keys in objects. 51 | remove_unused_variables: Remove unused variables. 52 | remove_rhs_for_unused_variables: Remove RHS of statements when removing unused variables (unsafe). 53 | ignore_pass_statements: Ignore all pass statements. 54 | ignore_pass_after_docstring: Ignore pass statements after a newline ending on `\"\"\"`. 55 | quiet: Suppress output if there are no issues. 56 | verbose: Print more verbose logs (you can repeat `-v` to make it more verbose). 57 | stdin_display_name: The name used when processing input from stdin. 58 | in_place: Make changes to files instead of printing diffs. 59 | stdout: Print changed text to stdout. defaults to true when formatting stdin, or to false otherwise. 60 | """ 61 | cli_args = list(files) 62 | 63 | if check: 64 | cli_args.append("--check") 65 | 66 | if check_diff: 67 | cli_args.append("--check-diff") 68 | 69 | if imports: 70 | cli_args.append("--imports") 71 | cli_args.append(",".join(imports)) 72 | 73 | if remove_all_unused_imports: 74 | cli_args.append("--remove-all-unused-imports") 75 | 76 | if recursive: 77 | cli_args.append("--recursive") 78 | 79 | if jobs: 80 | cli_args.append("--jobs") 81 | cli_args.append(str(jobs)) 82 | 83 | if exclude: 84 | cli_args.append("--exclude") 85 | cli_args.append(",".join(exclude)) 86 | 87 | if expand_star_imports: 88 | cli_args.append("--expand-star-imports") 89 | 90 | if ignore_init_module_imports: 91 | cli_args.append("--ignore-init-module-imports") 92 | 93 | if remove_duplicate_keys: 94 | cli_args.append("--remove-duplicate-keys") 95 | 96 | if remove_unused_variables: 97 | cli_args.append("--remove-unused-variables") 98 | 99 | if remove_rhs_for_unused_variables: 100 | cli_args.append("remove-rhs-for-unused-variables") 101 | 102 | if ignore_pass_statements: 103 | cli_args.append("--ignore-pass-statements") 104 | 105 | if ignore_pass_after_docstring: 106 | cli_args.append("--ignore-pass-after-docstring") 107 | 108 | if quiet: 109 | cli_args.append("--quiet") 110 | 111 | if verbose: 112 | cli_args.append("--verbose") 113 | 114 | if stdin_display_name: 115 | cli_args.append("--stdin-display-name") 116 | cli_args.append(stdin_display_name) 117 | 118 | if config: 119 | cli_args.append("--config") 120 | cli_args.append(config) 121 | 122 | if in_place: 123 | cli_args.append("--in-place") 124 | 125 | if stdout: 126 | cli_args.append("--stdout") 127 | 128 | super().__init__(cli_args) 129 | 130 | def __call__(self) -> int: 131 | """Run the command. 132 | 133 | Returns: 134 | The exit code of the command. 135 | """ 136 | from autoflake import _main as run_autoflake # noqa: PLC0415 137 | 138 | return run_autoflake( 139 | self.cli_args, 140 | standard_out=LazyStdout(), 141 | standard_error=LazyStderr(), 142 | ) 143 | -------------------------------------------------------------------------------- /tests/test_validation.py: -------------------------------------------------------------------------------- 1 | """Tests for the `validation` module.""" 2 | 3 | from __future__ import annotations 4 | 5 | from inspect import Parameter 6 | from typing import Any, Callable 7 | 8 | import pytest 9 | 10 | from duty._internal.validation import _get_params_caster, cast_arg, to_bool 11 | from tests.fixtures import validation as valfix 12 | 13 | 14 | @pytest.mark.parametrize( 15 | ("value", "expected"), 16 | [ 17 | ("y", True), 18 | ("Y", True), 19 | ("yes", True), 20 | ("YES", True), 21 | ("on", True), 22 | ("ON", True), 23 | ("true", True), 24 | ("TRUE", True), 25 | ("anything else", True), 26 | ("-1", True), 27 | ("1", True), 28 | ("", False), 29 | ("n", False), 30 | ("N", False), 31 | ("no", False), 32 | ("NO", False), 33 | ("false", False), 34 | ("FALSE", False), 35 | ("off", False), 36 | ("OFF", False), 37 | ], 38 | ) 39 | def test_bool_casting(value: str, expected: bool) -> None: 40 | """Check that we correctly cast string values to booleans. 41 | 42 | Parameters: 43 | value: The value to cast. 44 | expected: The expected result. 45 | """ 46 | assert to_bool(value) == expected 47 | 48 | 49 | class CustomType1: # noqa: PLW1641 50 | """Dummy type to test type-casting.""" 51 | 52 | def __init__(self, value: str): # noqa: D107 53 | self.value = value 54 | 55 | def __eq__(self, other: object): 56 | return self.value == other.value # type: ignore[attr-defined] 57 | 58 | 59 | class CustomType2: 60 | """Dummy type to test type-casting.""" 61 | 62 | def __init__(self, value, extra): # noqa: ANN001,D107 63 | ... # pragma: no cover 64 | 65 | 66 | @pytest.mark.parametrize( 67 | ("arg", "annotation", "expected"), 68 | [ 69 | ("hello", Parameter.empty, "hello"), 70 | ("off", bool, False), 71 | ("on", bool, True), 72 | ("1", int, 1), 73 | ("1", float, 1.0), 74 | ("fie", str, "fie"), 75 | ("fih", CustomType1, CustomType1("fih")), 76 | ("foh", CustomType2, "foh"), 77 | ], 78 | ) 79 | def test_cast_arg(arg: str, annotation: Any, expected: Any) -> None: 80 | """Check that arguments are properly casted given an annotation. 81 | 82 | Parameters: 83 | arg: The argument value to cast. 84 | annotation: The annotation to use. 85 | expected: The expected result. 86 | """ 87 | assert cast_arg(arg, annotation) == expected 88 | 89 | 90 | _parametrization = [ 91 | (valfix.no_params, (), {}, (), {}), 92 | (valfix.pos_or_kw_param, ("1",), {}, (1,), {}), 93 | (valfix.pos_or_kw_param, (), {"a": "1"}, (), {"a": 1}), 94 | (valfix.pos_or_kw_params, ("1", "2"), {}, (1, 2), {}), 95 | (valfix.pos_or_kw_params, ("1",), {"b": "2"}, (1,), {"b": 2}), 96 | (valfix.pos_or_kw_params, (), {"a": "1", "b": "2"}, (), {"a": 1, "b": 2}), 97 | (valfix.varpos_param, (), {}, (), {}), 98 | (valfix.varpos_param, ("1", "2"), {}, (1, 2), {}), 99 | (valfix.pos_and_varpos_param, ("1",), {}, (1,), {}), 100 | (valfix.pos_and_varpos_param, ("1", "2"), {}, (1, 2), {}), 101 | (valfix.pos_and_varpos_param, ("1", "2", "3"), {}, (1, 2, 3), {}), 102 | (valfix.kwonly_param, (), {"b": "1"}, (), {"b": 1}), 103 | (valfix.kwonly_param, ("2",), {"b": "1"}, (2,), {"b": 1}), 104 | (valfix.kwonly_param, ("2", "3"), {"b": "1"}, (2, 3), {"b": 1}), 105 | (valfix.varkw_param, ("1",), {}, (1,), {}), 106 | (valfix.varkw_param, ("1",), {"b": "2"}, (1,), {"b": 2}), 107 | (valfix.varkw_param, ("1",), {"b": "2", "c": "3"}, (1,), {"b": 2, "c": 3}), 108 | (valfix.varkw_no_annotation, (), {"a": "1"}, (), {"a": "1"}), 109 | (valfix.posonly_marker, ("1", "2"), {}, (1, 2), {}), 110 | (valfix.posonly_marker, ("1",), {"b": "2"}, (1,), {"b": 2}), 111 | (valfix.kwonly_marker, ("1",), {"b": "2"}, (1,), {"b": 2}), 112 | (valfix.kwonly_marker, (), {"a": "1", "b": "2"}, (), {"a": 1, "b": 2}), 113 | (valfix.only_markers, ("1",), {"b": "2", "c": "3"}, (1,), {"b": 2, "c": 3}), 114 | (valfix.only_markers, ("1", "2"), {"c": "3"}, (1, 2), {"c": 3}), 115 | (valfix.full, ("1", "2", "3", "4"), {"d": "5", "e": "6", "f": "7"}, (1, 2, 3, 4), {"d": 5, "e": 6, "f": 7}), 116 | ] 117 | 118 | 119 | @pytest.mark.parametrize( 120 | ("func", "args", "kwargs", "expected_args", "expected_kwargs"), 121 | _parametrization, 122 | ) 123 | def test_params_caster(func: Callable, args: tuple, kwargs: dict, expected_args: tuple, expected_kwargs: dict) -> None: 124 | """Test the whole parameters casting helper class. 125 | 126 | Parameters: 127 | func: The function to work with. 128 | args: The positional arguments to cast. 129 | kwargs: The keyword arguments to cast. 130 | expected_args: The expected positional arguments result. 131 | expected_kwargs: The expected keyword arguments result. 132 | """ 133 | caster = _get_params_caster(func, *args, **kwargs) 134 | new_args, new_kwargs = caster.cast(*args, **kwargs) 135 | assert new_args == expected_args 136 | assert new_kwargs == expected_kwargs 137 | 138 | 139 | def test_casting_based_on_default_value_type() -> None: 140 | """Test that we cast according to the default value type when there is no annotation.""" 141 | 142 | def func(ctx, a=0): # noqa: ANN202, ANN001 143 | ... 144 | 145 | caster = _get_params_caster(func, a="1") 146 | _, kwargs = caster.cast(a="1") 147 | assert kwargs == {"a": 1} 148 | 149 | 150 | def test_validating_modern_annotations() -> None: 151 | """Test modern type annotations in function signatures.""" 152 | 153 | def func(ctx, a: int | None = None): # noqa: ANN202, ANN001 154 | ... 155 | 156 | caster = _get_params_caster(func, a=1) 157 | _, kwargs = caster.cast(a="1") 158 | assert kwargs == {"a": 1} 159 | caster = _get_params_caster(func, a=None) 160 | _, kwargs = caster.cast(a=None) 161 | assert kwargs == {"a": None} 162 | caster = _get_params_caster(func) 163 | _, kwargs = caster.cast() 164 | assert kwargs == {} 165 | -------------------------------------------------------------------------------- /src/duty/_internal/tools/_interrogate.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Literal 4 | 5 | from duty._internal.tools._base import Tool 6 | 7 | _BADGE_STYLE = Literal["flat", "flat-square", "flat-square-modified", "for-the-badge", "plastic", "social"] 8 | 9 | 10 | class interrogate(Tool): # noqa: N801 11 | """Call [Interrogate](https://github.com/econchick/interrogate).""" 12 | 13 | cli_name = "interrogate" 14 | """The name of the executable on PATH.""" 15 | 16 | def __init__( 17 | self, 18 | *src: str, 19 | verbose: int | None = None, 20 | quiet: bool | None = None, 21 | fail_under: float | None = None, 22 | exclude: str | None = None, 23 | ignore_init_method: bool | None = None, 24 | ignore_init_module: bool | None = None, 25 | ignore_magic: bool | None = None, 26 | ignore_module: bool | None = None, 27 | ignore_nested_functions: bool | None = None, 28 | ignore_nested_classes: bool | None = None, 29 | ignore_private: bool | None = None, 30 | ignore_property_decorators: bool | None = None, 31 | ignore_setters: bool | None = None, 32 | ignore_semiprivate: bool | None = None, 33 | ignore_regex: str | None = None, 34 | whitelist_regex: str | None = None, 35 | output: str | None = None, 36 | config: str | None = None, 37 | color: bool | None = None, 38 | omit_covered_files: bool | None = None, 39 | generate_badge: str | None = None, 40 | badge_format: Literal["png", "svg"] | None = None, 41 | badge_style: _BADGE_STYLE | None = None, 42 | ) -> None: 43 | """Run `interrogate`. 44 | 45 | Args: 46 | src: Format the directories and file paths. 47 | verbose: Level of verbosity. 48 | quiet: Do not print output. 49 | fail_under: Fail when coverage % is less than a given amount. 50 | exclude: Exclude PATHs of files and/or directories. 51 | ignore_init_method: Ignore `__init__` method of classes. 52 | ignore_init_module: Ignore `__init__.py` modules. 53 | ignore_magic: Ignore all magic methods of classes. 54 | ignore_module: Ignore module-level docstrings. 55 | ignore_nested_functions: Ignore nested functions and methods. 56 | ignore_nested_classes: Ignore nested classes. 57 | ignore_private: Ignore private classes, methods, and functions starting with two underscores. 58 | ignore_property_decorators: Ignore methods with property setter/getter decorators. 59 | ignore_setters: Ignore methods with property setter decorators. 60 | ignore_semiprivate: Ignore semiprivate classes, methods, and functions starting with a single underscore. 61 | ignore_regex: Regex identifying class, method, and function names to ignore. 62 | whitelist_regex: Regex identifying class, method, and function names to include. 63 | output: Write output to a given FILE. 64 | config: Read configuration from pyproject.toml or setup.cfg. 65 | color: Toggle color output on/off when printing to stdout. 66 | omit_covered_files: Omit reporting files that have 100% documentation coverage. 67 | generate_badge: Generate a shields.io status badge (an SVG image) in at a given file or directory. 68 | badge_format: File format for the generated badge. 69 | badge_style: Desired style of shields.io badge. 70 | """ 71 | cli_args = list(src) 72 | 73 | if verbose: 74 | cli_args.append("--verbose") 75 | cli_args.append(str(verbose)) 76 | 77 | if quiet: 78 | cli_args.append("--quiet") 79 | 80 | if fail_under: 81 | cli_args.append("--fail-under") 82 | cli_args.append(str(fail_under)) 83 | 84 | if exclude: 85 | cli_args.append("--exclude") 86 | cli_args.append(exclude) 87 | 88 | if ignore_init_method: 89 | cli_args.append("--ignore-init-method") 90 | 91 | if ignore_init_module: 92 | cli_args.append("--ignore-init-module") 93 | 94 | if ignore_magic: 95 | cli_args.append("--ignore-magic") 96 | 97 | if ignore_module: 98 | cli_args.append("--ignore-module") 99 | 100 | if ignore_nested_functions: 101 | cli_args.append("--ignore-nested-functions") 102 | 103 | if ignore_nested_classes: 104 | cli_args.append("--ignore-nested-classes") 105 | 106 | if ignore_private: 107 | cli_args.append("--ignore-private") 108 | 109 | if ignore_property_decorators: 110 | cli_args.append("--ignore-property-decorators") 111 | 112 | if ignore_setters: 113 | cli_args.append("--ignore-setters") 114 | 115 | if ignore_semiprivate: 116 | cli_args.append("--ignore-semiprivate") 117 | 118 | if ignore_regex: 119 | cli_args.append("--ignore-regex") 120 | cli_args.append(ignore_regex) 121 | 122 | if whitelist_regex: 123 | cli_args.append("--whitelist-regex") 124 | cli_args.append(whitelist_regex) 125 | 126 | if output: 127 | cli_args.append("--output") 128 | cli_args.append(output) 129 | 130 | if omit_covered_files: 131 | cli_args.append("--omit-covered-files") 132 | 133 | if generate_badge: 134 | cli_args.append("--generate-badge") 135 | cli_args.append(generate_badge) 136 | 137 | if badge_format: 138 | cli_args.append("--badge-format") 139 | cli_args.append(badge_format) 140 | 141 | if badge_style: 142 | cli_args.append("--badge-style") 143 | cli_args.append(badge_style) 144 | 145 | if config: 146 | cli_args.append("--config") 147 | cli_args.append(config) 148 | 149 | if color is True: 150 | cli_args.append("--color") 151 | elif color is False: 152 | cli_args.append("--no-color") 153 | 154 | super().__init__(cli_args) 155 | 156 | def __call__(self) -> None: 157 | """Run the command.""" 158 | from interrogate.cli import main as run_interrogate # noqa: PLC0415 159 | 160 | return run_interrogate(self.cli_args) 161 | -------------------------------------------------------------------------------- /scripts/gen_credits.py: -------------------------------------------------------------------------------- 1 | # Script to generate the project's credits. 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import sys 7 | from collections import defaultdict 8 | from collections.abc import Iterable 9 | from importlib.metadata import distributions 10 | from itertools import chain 11 | from pathlib import Path 12 | from textwrap import dedent 13 | from typing import Union 14 | 15 | from jinja2 import StrictUndefined 16 | from jinja2.sandbox import SandboxedEnvironment 17 | from packaging.requirements import Requirement 18 | 19 | # YORE: EOL 3.10: Replace block with line 2. 20 | if sys.version_info >= (3, 11): 21 | import tomllib 22 | else: 23 | import tomli as tomllib 24 | 25 | project_dir = Path(os.getenv("MKDOCS_CONFIG_DIR", ".")) 26 | with project_dir.joinpath("pyproject.toml").open("rb") as pyproject_file: 27 | pyproject = tomllib.load(pyproject_file) 28 | project = pyproject["project"] 29 | project_name = project["name"] 30 | devdeps = [dep for group in pyproject["dependency-groups"].values() for dep in group if not dep.startswith("-e")] 31 | 32 | PackageMetadata = dict[str, Union[str, Iterable[str]]] 33 | Metadata = dict[str, PackageMetadata] 34 | 35 | 36 | def _merge_fields(metadata: dict) -> PackageMetadata: 37 | fields = defaultdict(list) 38 | for header, value in metadata.items(): 39 | fields[header.lower()].append(value.strip()) 40 | return { 41 | field: value if len(value) > 1 or field in ("classifier", "requires-dist") else value[0] 42 | for field, value in fields.items() 43 | } 44 | 45 | 46 | def _norm_name(name: str) -> str: 47 | return name.replace("_", "-").replace(".", "-").lower() 48 | 49 | 50 | def _requirements(deps: list[str]) -> dict[str, Requirement]: 51 | return {_norm_name((req := Requirement(dep)).name): req for dep in deps} 52 | 53 | 54 | def _extra_marker(req: Requirement) -> str | None: 55 | if not req.marker: 56 | return None 57 | try: 58 | return next(marker[2].value for marker in req.marker._markers if getattr(marker[0], "value", None) == "extra") 59 | except StopIteration: 60 | return None 61 | 62 | 63 | def _get_metadata() -> Metadata: 64 | metadata = {} 65 | for pkg in distributions(): 66 | name = _norm_name(pkg.name) # type: ignore[attr-defined,unused-ignore] 67 | metadata[name] = _merge_fields(pkg.metadata) # type: ignore[arg-type] 68 | metadata[name]["spec"] = set() 69 | metadata[name]["extras"] = set() 70 | metadata[name].setdefault("summary", "") 71 | _set_license(metadata[name]) 72 | return metadata 73 | 74 | 75 | def _set_license(metadata: PackageMetadata) -> None: 76 | license_field = metadata.get("license-expression", metadata.get("license", "")) 77 | license_name = license_field if isinstance(license_field, str) else " + ".join(license_field) 78 | check_classifiers = license_name in ("UNKNOWN", "Dual License", "") or license_name.count("\n") 79 | if check_classifiers: 80 | license_names = [] 81 | for classifier in metadata["classifier"]: 82 | if classifier.startswith("License ::"): 83 | license_names.append(classifier.rsplit("::", 1)[1].strip()) 84 | license_name = " + ".join(license_names) 85 | metadata["license"] = license_name or "?" 86 | 87 | 88 | def _get_deps(base_deps: dict[str, Requirement], metadata: Metadata) -> Metadata: 89 | deps = {} 90 | for dep_name, dep_req in base_deps.items(): 91 | if dep_name not in metadata or dep_name == "duty": 92 | continue 93 | metadata[dep_name]["spec"] |= {str(spec) for spec in dep_req.specifier} # type: ignore[operator] 94 | metadata[dep_name]["extras"] |= dep_req.extras # type: ignore[operator] 95 | deps[dep_name] = metadata[dep_name] 96 | 97 | again = True 98 | while again: 99 | again = False 100 | for pkg_name in metadata: 101 | if pkg_name in deps: 102 | for pkg_dependency in metadata[pkg_name].get("requires-dist", []): 103 | requirement = Requirement(pkg_dependency) 104 | dep_name = _norm_name(requirement.name) 105 | extra_marker = _extra_marker(requirement) 106 | if ( 107 | dep_name in metadata 108 | and dep_name not in deps 109 | and dep_name != project["name"] 110 | and (not extra_marker or extra_marker in deps[pkg_name]["extras"]) 111 | ): 112 | metadata[dep_name]["spec"] |= {str(spec) for spec in requirement.specifier} # type: ignore[operator] 113 | deps[dep_name] = metadata[dep_name] 114 | again = True 115 | 116 | return deps 117 | 118 | 119 | def _render_credits() -> str: 120 | metadata = _get_metadata() 121 | dev_dependencies = _get_deps(_requirements(devdeps), metadata) 122 | prod_dependencies = _get_deps( 123 | _requirements( 124 | chain( # type: ignore[arg-type] 125 | project.get("dependencies", []), 126 | chain(*project.get("optional-dependencies", {}).values()), 127 | ), 128 | ), 129 | metadata, 130 | ) 131 | 132 | template_data = { 133 | "project_name": project_name, 134 | "prod_dependencies": sorted(prod_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 135 | "dev_dependencies": sorted(dev_dependencies.values(), key=lambda dep: str(dep["name"]).lower()), 136 | "more_credits": "http://pawamoy.github.io/credits/", 137 | } 138 | template_text = dedent( 139 | """ 140 | # Credits 141 | 142 | These projects were used to build *{{ project_name }}*. **Thank you!** 143 | 144 | [Python](https://www.python.org/) | 145 | [uv](https://github.com/astral-sh/uv) | 146 | [copier-uv](https://github.com/pawamoy/copier-uv) 147 | 148 | {% macro dep_line(dep) -%} 149 | [{{ dep.name }}](https://pypi.org/project/{{ dep.name }}/) | {{ dep.summary }} | {{ ("`" ~ dep.spec|sort(reverse=True)|join(", ") ~ "`") if dep.spec else "" }} | `{{ dep.version }}` | {{ dep.license }} 150 | {%- endmacro %} 151 | 152 | {% if prod_dependencies -%} 153 | ### Runtime dependencies 154 | 155 | Project | Summary | Version (accepted) | Version (last resolved) | License 156 | ------- | ------- | ------------------ | ----------------------- | ------- 157 | {% for dep in prod_dependencies -%} 158 | {{ dep_line(dep) }} 159 | {% endfor %} 160 | 161 | {% endif -%} 162 | {% if dev_dependencies -%} 163 | ### Development dependencies 164 | 165 | Project | Summary | Version (accepted) | Version (last resolved) | License 166 | ------- | ------- | ------------------ | ----------------------- | ------- 167 | {% for dep in dev_dependencies -%} 168 | {{ dep_line(dep) }} 169 | {% endfor %} 170 | 171 | {% endif -%} 172 | {% if more_credits %}**[More credits from the author]({{ more_credits }})**{% endif %} 173 | """, 174 | ) 175 | jinja_env = SandboxedEnvironment(undefined=StrictUndefined) 176 | return jinja_env.from_string(template_text).render(**template_data) 177 | 178 | 179 | print(_render_credits()) 180 | -------------------------------------------------------------------------------- /duties.py: -------------------------------------------------------------------------------- 1 | """Development tasks.""" 2 | 3 | from __future__ import annotations 4 | 5 | import os 6 | import re 7 | import sys 8 | from contextlib import contextmanager 9 | from importlib.metadata import version as pkgversion 10 | from pathlib import Path 11 | from typing import TYPE_CHECKING 12 | 13 | from duty import duty 14 | from duty._internal import tools 15 | 16 | if TYPE_CHECKING: 17 | from collections.abc import Iterator 18 | 19 | from duty._internal.context import Context 20 | 21 | 22 | PY_SRC_PATHS = (Path(_) for _ in ("src", "tests", "duties.py", "scripts")) 23 | PY_SRC_LIST = tuple(str(_) for _ in PY_SRC_PATHS) 24 | PY_SRC = " ".join(PY_SRC_LIST) 25 | CI = os.environ.get("CI", "0") in {"1", "true", "yes", ""} 26 | WINDOWS = os.name == "nt" 27 | PTY = not WINDOWS and not CI 28 | MULTIRUN = os.environ.get("MULTIRUN", "0") == "1" 29 | 30 | 31 | def pyprefix(title: str) -> str: 32 | if MULTIRUN: 33 | prefix = f"(python{sys.version_info.major}.{sys.version_info.minor})" 34 | return f"{prefix:14}{title}" 35 | return title 36 | 37 | 38 | @contextmanager 39 | def material_insiders() -> Iterator[bool]: 40 | if "+insiders" in pkgversion("mkdocs-material"): 41 | os.environ["MATERIAL_INSIDERS"] = "true" 42 | try: 43 | yield True 44 | finally: 45 | os.environ.pop("MATERIAL_INSIDERS") 46 | else: 47 | yield False 48 | 49 | 50 | def _get_changelog_version() -> str: 51 | changelog_version_re = re.compile(r"^## \[(\d+\.\d+\.\d+)\].*$") 52 | with Path(__file__).parent.joinpath("CHANGELOG.md").open("r", encoding="utf8") as file: 53 | return next(filter(bool, map(changelog_version_re.match, file))).group(1) # type: ignore[union-attr] 54 | 55 | 56 | @duty 57 | def changelog(ctx: Context, bump: str = "") -> None: 58 | """Update the changelog in-place with latest commits. 59 | 60 | Parameters: 61 | bump: Bump option passed to git-changelog. 62 | """ 63 | ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog") 64 | ctx.run(tools.yore.check(bump=bump or _get_changelog_version()), title="Checking legacy code") 65 | 66 | 67 | @duty(pre=["check-quality", "check-types", "check-docs", "check-api"]) 68 | def check(ctx: Context) -> None: 69 | """Check it all!""" 70 | 71 | 72 | @duty 73 | def check_quality(ctx: Context) -> None: 74 | """Check the code quality.""" 75 | ctx.run( 76 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"), 77 | title=pyprefix("Checking code quality"), 78 | ) 79 | 80 | 81 | @duty 82 | def check_docs(ctx: Context) -> None: 83 | """Check if the documentation builds correctly.""" 84 | Path("htmlcov").mkdir(parents=True, exist_ok=True) 85 | Path("htmlcov/index.html").touch(exist_ok=True) 86 | with material_insiders(): 87 | ctx.run( 88 | tools.mkdocs.build(strict=True, verbose=True), 89 | title=pyprefix("Building documentation"), 90 | ) 91 | 92 | 93 | @duty 94 | def check_types(ctx: Context) -> None: 95 | """Check that the code is correctly typed.""" 96 | os.environ["FORCE_COLOR"] = "1" 97 | ctx.run( 98 | tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"), 99 | title=pyprefix("Type-checking"), 100 | ) 101 | 102 | 103 | @duty 104 | def check_api(ctx: Context, *cli_args: str) -> None: 105 | """Check for API breaking changes.""" 106 | ctx.run( 107 | tools.griffe.check("duty", search=["src"], color=True).add_args(*cli_args), 108 | title="Checking for API breaking changes", 109 | nofail=True, 110 | ) 111 | 112 | 113 | @duty 114 | def docs(ctx: Context, *cli_args: str, host: str = "127.0.0.1", port: int = 8000) -> None: 115 | """Serve the documentation (localhost:8000). 116 | 117 | Parameters: 118 | host: The host to serve the docs from. 119 | port: The port to serve the docs on. 120 | """ 121 | with material_insiders(): 122 | ctx.run( 123 | tools.mkdocs.serve(dev_addr=f"{host}:{port}").add_args(*cli_args), 124 | title="Serving documentation", 125 | capture=False, 126 | ) 127 | 128 | 129 | @duty 130 | def docs_deploy(ctx: Context) -> None: 131 | """Deploy the documentation to GitHub pages.""" 132 | os.environ["DEPLOY"] = "true" 133 | with material_insiders() as insiders: 134 | if not insiders: 135 | ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!") 136 | ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation") 137 | 138 | 139 | @duty 140 | def format(ctx: Context) -> None: 141 | """Run formatting tools on the code.""" 142 | ctx.run( 143 | tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True), 144 | title="Auto-fixing code", 145 | ) 146 | ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code") 147 | 148 | 149 | @duty 150 | def build(ctx: Context) -> None: 151 | """Build source and wheel distributions.""" 152 | ctx.run( 153 | tools.build(), 154 | title="Building source and wheel distributions", 155 | pty=PTY, 156 | ) 157 | 158 | 159 | @duty 160 | def publish(ctx: Context) -> None: 161 | """Publish source and wheel distributions to PyPI.""" 162 | if not Path("dist").exists(): 163 | ctx.run("false", title="No distribution files found") 164 | dists = [str(dist) for dist in Path("dist").iterdir()] 165 | ctx.run( 166 | tools.twine.upload(*dists, skip_existing=True), 167 | title="Publishing source and wheel distributions to PyPI", 168 | pty=PTY, 169 | ) 170 | 171 | 172 | @duty(post=["build", "publish", "docs-deploy"]) 173 | def release(ctx: Context, version: str = "") -> None: 174 | """Release a new Python package. 175 | 176 | Parameters: 177 | version: The new version number to use. 178 | """ 179 | if not (version := (version or input("> Version to release: ")).strip()): 180 | ctx.run("false", title="A version must be provided") 181 | ctx.run("git add pyproject.toml CHANGELOG.md", title="Staging files", pty=PTY) 182 | ctx.run(["git", "commit", "-m", f"chore: Prepare release {version}"], title="Committing changes", pty=PTY) 183 | ctx.run(f"git tag {version}", title="Tagging commit", pty=PTY) 184 | ctx.run("git push", title="Pushing commits", pty=False) 185 | ctx.run("git push --tags", title="Pushing tags", pty=False) 186 | 187 | 188 | @duty(silent=True, aliases=["cov"]) 189 | def coverage(ctx: Context) -> None: 190 | """Report coverage as text and HTML.""" 191 | ctx.run(tools.coverage.combine(), nofail=True) 192 | ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False) 193 | ctx.run(tools.coverage.html(rcfile="config/coverage.ini")) 194 | 195 | 196 | @duty 197 | def test(ctx: Context, *cli_args: str, match: str = "") -> None: # noqa: PT028 198 | """Run the test suite. 199 | 200 | Parameters: 201 | match: A pytest expression to filter selected tests. 202 | """ 203 | py_version = f"{sys.version_info.major}{sys.version_info.minor}" 204 | os.environ["COVERAGE_FILE"] = f".coverage.{py_version}" 205 | ctx.run( 206 | tools.pytest( 207 | "tests", 208 | config_file="config/pytest.ini", 209 | select=match, 210 | color="yes", 211 | ).add_args("-n", "auto", *cli_args), 212 | title=pyprefix("Running tests"), 213 | ) 214 | -------------------------------------------------------------------------------- /scripts/make.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from __future__ import annotations 3 | 4 | import os 5 | import shutil 6 | import subprocess 7 | import sys 8 | from contextlib import contextmanager 9 | from pathlib import Path 10 | from textwrap import dedent 11 | from typing import TYPE_CHECKING, Any 12 | 13 | if TYPE_CHECKING: 14 | from collections.abc import Iterator 15 | 16 | 17 | PYTHON_VERSIONS = os.getenv("PYTHON_VERSIONS", "3.9 3.10 3.11 3.12 3.13 3.14").split() 18 | 19 | 20 | def shell(cmd: str, *, capture_output: bool = False, **kwargs: Any) -> str | None: 21 | """Run a shell command.""" 22 | if capture_output: 23 | return subprocess.check_output(cmd, shell=True, text=True, **kwargs) # noqa: S602 24 | subprocess.run(cmd, shell=True, check=True, stderr=subprocess.STDOUT, **kwargs) # noqa: S602 25 | return None 26 | 27 | 28 | @contextmanager 29 | def environ(**kwargs: str) -> Iterator[None]: 30 | """Temporarily set environment variables.""" 31 | original = dict(os.environ) 32 | os.environ.update(kwargs) 33 | try: 34 | yield 35 | finally: 36 | os.environ.clear() 37 | os.environ.update(original) 38 | 39 | 40 | def uv_install(venv: Path) -> None: 41 | """Install dependencies using uv.""" 42 | with environ(UV_PROJECT_ENVIRONMENT=str(venv), PYO3_USE_ABI3_FORWARD_COMPATIBILITY="1"): 43 | if "CI" in os.environ: 44 | shell("uv sync --no-editable") 45 | else: 46 | shell("uv sync") 47 | 48 | 49 | def setup() -> None: 50 | """Setup the project.""" 51 | if not shutil.which("uv"): 52 | raise ValueError("make: setup: uv must be installed, see https://github.com/astral-sh/uv") 53 | 54 | print("Installing dependencies (default environment)") 55 | default_venv = Path(".venv") 56 | if not default_venv.exists(): 57 | shell("uv venv") 58 | uv_install(default_venv) 59 | 60 | if PYTHON_VERSIONS: 61 | for version in PYTHON_VERSIONS: 62 | print(f"\nInstalling dependencies (python{version})") 63 | venv_path = Path(f".venvs/{version}") 64 | if not venv_path.exists(): 65 | shell(f"uv venv --python {version} {venv_path}") 66 | with environ(UV_PROJECT_ENVIRONMENT=str(venv_path.resolve())): 67 | uv_install(venv_path) 68 | 69 | 70 | def run(version: str, cmd: str, *args: str, **kwargs: Any) -> None: 71 | """Run a command in a virtual environment.""" 72 | kwargs = {"check": True, **kwargs} 73 | uv_run = ["uv", "run", "--no-sync"] 74 | if version == "default": 75 | with environ(UV_PROJECT_ENVIRONMENT=".venv"): 76 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 77 | else: 78 | with environ(UV_PROJECT_ENVIRONMENT=f".venvs/{version}", MULTIRUN="1"): 79 | subprocess.run([*uv_run, cmd, *args], **kwargs) # noqa: S603, PLW1510 80 | 81 | 82 | def multirun(cmd: str, *args: str, **kwargs: Any) -> None: 83 | """Run a command for all configured Python versions.""" 84 | if PYTHON_VERSIONS: 85 | for version in PYTHON_VERSIONS: 86 | run(version, cmd, *args, **kwargs) 87 | else: 88 | run("default", cmd, *args, **kwargs) 89 | 90 | 91 | def allrun(cmd: str, *args: str, **kwargs: Any) -> None: 92 | """Run a command in all virtual environments.""" 93 | run("default", cmd, *args, **kwargs) 94 | if PYTHON_VERSIONS: 95 | multirun(cmd, *args, **kwargs) 96 | 97 | 98 | def clean() -> None: 99 | """Delete build artifacts and cache files.""" 100 | paths_to_clean = ["build", "dist", "htmlcov", "site", ".coverage*", ".pdm-build"] 101 | for path in paths_to_clean: 102 | shutil.rmtree(path, ignore_errors=True) 103 | 104 | cache_dirs = {".cache", ".pytest_cache", ".mypy_cache", ".ruff_cache", "__pycache__"} 105 | for dirpath in Path(".").rglob("*/"): 106 | if dirpath.parts[0] not in (".venv", ".venvs") and dirpath.name in cache_dirs: 107 | shutil.rmtree(dirpath, ignore_errors=True) 108 | 109 | 110 | def vscode() -> None: 111 | """Configure VSCode to work on this project.""" 112 | shutil.copytree("config/vscode", ".vscode", dirs_exist_ok=True) 113 | 114 | 115 | def main() -> int: 116 | """Main entry point.""" 117 | args = list(sys.argv[1:]) 118 | if not args or args[0] == "help": 119 | if len(args) > 1: 120 | run("default", "duty", "--help", args[1]) 121 | else: 122 | print( 123 | dedent( 124 | """ 125 | Available commands 126 | help Print this help. Add task name to print help. 127 | setup Setup all virtual environments (install dependencies). 128 | run Run a command in the default virtual environment. 129 | multirun Run a command for all configured Python versions. 130 | allrun Run a command in all virtual environments. 131 | 3.x Run a command in the virtual environment for Python 3.x. 132 | clean Delete build artifacts and cache files. 133 | vscode Configure VSCode to work on this project. 134 | """, 135 | ), 136 | flush=True, 137 | ) 138 | if os.path.exists(".venv"): 139 | print("\nAvailable tasks", flush=True) 140 | run("default", "duty", "--list") 141 | return 0 142 | 143 | while args: 144 | cmd = args.pop(0) 145 | 146 | if cmd == "run": 147 | if not args: 148 | print("make: run: missing command", file=sys.stderr) 149 | return 1 150 | run("default", *args) # ty: ignore[missing-argument] 151 | return 0 152 | 153 | if cmd == "multirun": 154 | if not args: 155 | print("make: run: missing command", file=sys.stderr) 156 | return 1 157 | multirun(*args) # ty: ignore[missing-argument] 158 | return 0 159 | 160 | if cmd == "allrun": 161 | if not args: 162 | print("make: run: missing command", file=sys.stderr) 163 | return 1 164 | allrun(*args) # ty: ignore[missing-argument] 165 | return 0 166 | 167 | if cmd.startswith("3."): 168 | if not args: 169 | print("make: run: missing command", file=sys.stderr) 170 | return 1 171 | run(cmd, *args) # ty: ignore[missing-argument] 172 | return 0 173 | 174 | opts = [] 175 | while args and (args[0].startswith("-") or "=" in args[0]): 176 | opts.append(args.pop(0)) 177 | 178 | if cmd == "clean": 179 | clean() 180 | elif cmd == "setup": 181 | setup() 182 | elif cmd == "vscode": 183 | vscode() 184 | elif cmd == "check": 185 | multirun("duty", "check-quality", "check-types", "check-docs") 186 | run("default", "duty", "check-api") 187 | elif cmd in {"check-quality", "check-docs", "check-types", "test"}: 188 | multirun("duty", cmd, *opts) 189 | else: 190 | run("default", "duty", cmd, *opts) 191 | 192 | return 0 193 | 194 | 195 | if __name__ == "__main__": 196 | try: 197 | sys.exit(main()) 198 | except subprocess.CalledProcessError as process: 199 | if process.output: 200 | print(process.output, file=sys.stderr) 201 | sys.exit(process.returncode) 202 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/black.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | from failprint import lazy 6 | 7 | 8 | @lazy(name="black") 9 | def run( 10 | *src: str, 11 | config: str | None = None, 12 | code: str | None = None, 13 | line_length: int | None = None, 14 | target_version: str | None = None, 15 | check: bool | None = None, 16 | diff: bool | None = None, 17 | color: bool | None = None, 18 | fast: bool | None = None, 19 | pyi: bool | None = None, 20 | ipynb: bool | None = None, 21 | python_cell_magics: str | None = None, 22 | skip_source_first_line: bool | None = None, 23 | skip_string_normalization: bool | None = None, 24 | skip_magic_trailing_comma: bool | None = None, 25 | experimental_string_processing: bool | None = None, 26 | preview: bool | None = None, 27 | quiet: bool | None = None, 28 | verbose: bool | None = None, 29 | required_version: str | None = None, 30 | include: str | None = None, 31 | exclude: str | None = None, 32 | extend_exclude: str | None = None, 33 | force_exclude: str | None = None, 34 | stdin_filename: str | None = None, 35 | workers: int | None = None, 36 | ) -> None: 37 | r"""Run `black`. 38 | 39 | Parameters: 40 | src: Format the directories and file paths. 41 | config: Read configuration from this file path. 42 | code: Format the code passed in as a string. 43 | line_length: How many characters per line to allow [default: 120]. 44 | target_version: Python versions that should be supported by Black's output. 45 | By default, Black will try to infer this from the project metadata in pyproject.toml. 46 | If this does not yield conclusive results, Black will use per-file auto-detection. 47 | check: Don't write the files back, just return the status. Return code 0 means nothing would change. 48 | Return code 1 means some files would be reformatted. Return code 123 means there was an internal error. 49 | diff: Don't write the files back, just output a diff for each file on stdout. 50 | color: Show colored diff. Only applies when `--diff` is given. 51 | fast: If --fast given, skip temporary sanity checks. [default: --safe] 52 | pyi: Format all input files like typing stubs regardless of file extension 53 | (useful when piping source on standard input). 54 | ipynb: Format all input files like Jupyter Notebooks regardless of file extension 55 | (useful when piping source on standard input). 56 | python_cell_magics: When processing Jupyter Notebooks, add the given magic to the list of known python-magics 57 | (capture, prun, pypy, python, python3, time, timeit). Useful for formatting cells with custom python magics. 58 | skip_source_first_line: Skip the first line of the source code. 59 | skip_string_normalization: Don't normalize string quotes or prefixes. 60 | skip_magic_trailing_comma: Don't use trailing commas as a reason to split lines. 61 | preview: Enable potentially disruptive style changes that may be added 62 | to Black's main functionality in the next major release. 63 | quiet: Don't emit non-error messages to stderr. Errors are still emitted; silence those with 2>/dev/null. 64 | verbose: Also emit messages to stderr about files that were not changed or were ignored due to exclusion patterns. 65 | required_version: Require a specific version of Black to be running (useful for unifying results 66 | across many environments e.g. with a pyproject.toml file). 67 | It can be either a major version number or an exact version. 68 | include: A regular expression that matches files and directories that should be included on recursive searches. 69 | An empty value means all files are included regardless of the name. Use forward slashes for directories 70 | on all platforms (Windows, too). Exclusions are calculated first, inclusions later [default: (\.pyi?|\.ipynb)$]. 71 | exclude: A regular expression that matches files and directories that should be excluded on recursive searches. 72 | An empty value means no paths are excluded. Use forward slashes for directories on all platforms (Windows, too). 73 | Exclusions are calculated first, inclusions later [default: /(\.direnv|\.eggs|\.git|\.hg|\.mypy_cache|\.nox| 74 | \.tox|\.venv|venv|\.svn|\.ipynb_checkpoints|_build|buck-out|build|dist|__pypackages__)/]. 75 | extend_exclude: Like --exclude, but adds additional files and directories on top of the excluded ones 76 | (useful if you simply want to add to the default). 77 | force_exclude: Like --exclude, but files and directories matching this regex will be excluded 78 | even when they are passed explicitly as arguments. 79 | stdin_filename: The name of the file when passing it through stdin. Useful to make sure Black will respect 80 | --force-exclude option on some editors that rely on using stdin. 81 | workers: Number of parallel workers [default: number CPUs in the system]. 82 | """ 83 | from black import main as black # noqa: PLC0415 84 | 85 | cli_args = list(src) 86 | 87 | if config: 88 | cli_args.append("--config") 89 | cli_args.append(config) 90 | 91 | if code: 92 | cli_args.append("--code") 93 | cli_args.append(code) 94 | 95 | if line_length: 96 | cli_args.append("--line-length") 97 | cli_args.append(str(line_length)) 98 | 99 | if target_version: 100 | cli_args.append("--target-version") 101 | cli_args.append(target_version) 102 | 103 | if check: 104 | cli_args.append("--check") 105 | 106 | if diff: 107 | cli_args.append("--diff") 108 | 109 | if color is True: 110 | cli_args.append("--color") 111 | elif color is False: 112 | cli_args.append("--no-color") 113 | 114 | if fast: 115 | cli_args.append("--fast") 116 | 117 | if pyi: 118 | cli_args.append("--pyi") 119 | 120 | if ipynb: 121 | cli_args.append("--ipynb") 122 | 123 | if python_cell_magics: 124 | cli_args.append("--python-cell-magics") 125 | cli_args.append(python_cell_magics) 126 | 127 | if skip_source_first_line: 128 | cli_args.append("--skip_source_first_line") 129 | 130 | if skip_string_normalization: 131 | cli_args.append("--skip_string_normalization") 132 | 133 | if skip_magic_trailing_comma: 134 | cli_args.append("--skip_magic_trailing_comma") 135 | 136 | if experimental_string_processing: 137 | cli_args.append("--experimental_string_processing") 138 | 139 | if preview: 140 | cli_args.append("--preview") 141 | 142 | if quiet: 143 | cli_args.append("--quiet") 144 | 145 | if verbose: 146 | cli_args.append("--verbose") 147 | 148 | if required_version: 149 | cli_args.append("--required-version") 150 | cli_args.append(required_version) 151 | 152 | if include: 153 | cli_args.append("--include") 154 | cli_args.append(include) 155 | 156 | if exclude: 157 | cli_args.append("--exclude") 158 | cli_args.append(exclude) 159 | 160 | if extend_exclude: 161 | cli_args.append("--extend-exclude") 162 | cli_args.append(extend_exclude) 163 | 164 | if force_exclude: 165 | cli_args.append("--force-exclude") 166 | cli_args.append(force_exclude) 167 | 168 | if stdin_filename: 169 | cli_args.append("--stdin-filename") 170 | cli_args.append(stdin_filename) 171 | 172 | if workers: 173 | cli_args.append("--workers") 174 | cli_args.append(str(workers)) 175 | 176 | return black(cli_args, prog_name="black") 177 | -------------------------------------------------------------------------------- /src/duty/_internal/callables/git_changelog.py: -------------------------------------------------------------------------------- 1 | # YORE: Bump 2: Remove file. 2 | 3 | from __future__ import annotations 4 | 5 | from typing import Literal 6 | 7 | from failprint import lazy 8 | 9 | 10 | @lazy(name="git_changelog") 11 | def run( 12 | repository: str | None = None, 13 | *, 14 | config_file: str | None = None, 15 | bump: str | None = None, 16 | versioning: Literal["semver", "pep440"] | None = None, 17 | in_place: bool = False, 18 | version_regex: str | None = None, 19 | marker_line: str | None = None, 20 | output: str | None = None, 21 | provider: Literal["github", "gitlab", "bitbucket"] | None = None, 22 | parse_refs: bool = False, 23 | release_notes: bool = False, 24 | input: str | None = None, # noqa: A002 25 | convention: Literal["basic", "angular", "conventional"] | None = None, 26 | sections: list[str] | None = None, 27 | template: str | None = None, 28 | git_trailers: bool = False, 29 | omit_empty_versions: bool = False, 30 | no_zerover: bool = False, 31 | filter_commits: str | None = None, 32 | jinja_context: list[str] | None = None, 33 | version: bool = False, 34 | debug_info: bool = False, 35 | ) -> None: 36 | r"""Run `git-changelog`. 37 | 38 | Parameters: 39 | repository: The repository path, relative or absolute. Default: current working directory. 40 | config_file: Configuration file(s). 41 | bump: Specify the bump from latest version for the set of unreleased commits. 42 | Can be one of `auto`, `major`, `minor`, `patch` or a valid SemVer version (eg. 1.2.3). 43 | For both SemVer and PEP 440 versioning schemes (see -n), `auto` will bump the major number 44 | if a commit contains breaking changes (or the minor number for 0.x versions, see -Z), 45 | else the minor number if there are new features, else the patch number. Default: unset (false). 46 | versioning: Versioning scheme to use when bumping and comparing versions. 47 | The selected scheme will impact the values accepted by the `--bump` option. 48 | Supported: `pep440`, `semver`. 49 | 50 | PEP 440 provides the following bump strategies: `auto`, `epoch`, `release`, `major`, `minor`, `micro`, `patch`, 51 | `pre`, `alpha`, `beta`, `candidate`, `post`, `dev`. 52 | Values `auto`, `major`, `minor`, `micro` can be suffixed with one of `+alpha`, `+beta`, `+candidate`, and/or `+dev`. 53 | Values `alpha`, `beta` and `candidate` can be suffixed with `+dev`. 54 | Examples: `auto+alpha`, `major+beta+dev`, `micro+dev`, `candidate+dev`, etc.. 55 | 56 | SemVer provides the following bump strategies: `auto`, `major`, `minor`, `patch`, `release`. 57 | See the docs for more information. Default: unset (`semver`). 58 | in_place: Insert new entries (versions missing from changelog) in-place. 59 | An output file must be specified. With custom templates, you can pass two additional 60 | arguments: `--version-regex` and `--marker-line`. 61 | When writing in-place, an `in_place` variable will be injected in the Jinja context, 62 | allowing to adapt the generated contents (for example to skip changelog headers or footers). 63 | Default: unset (false). 64 | version_regex: A regular expression to match versions in the existing changelog 65 | (used to find the latest release) when writing in-place. 66 | The regular expression must be a Python regex with a `version` named group. 67 | Default: `^## \[(?Pv?[^\]]+)`. 68 | marker_line: A marker line at which to insert new entries (versions missing from changelog). 69 | If two marker lines are present in the changelog, the contents between those two lines 70 | will be overwritten (useful to update an 'Unreleased' entry for example). Default: ``. 71 | output: Output to given file. Default: standard output. 72 | provider: Explicitly specify the repository provider. Default: unset. 73 | parse_refs: Parse provider-specific references in commit messages (GitHub/GitLab/Bitbucket issues, PRs, etc.). 74 | Default: unset (false). 75 | release_notes: Output release notes to stdout based on the last entry in the changelog. Default: unset (false). 76 | input: Read from given file when creating release notes. Default: `CHANGELOG.md`. 77 | convention: The commit convention to match against. Default: `basic`. 78 | sections: A comma-separated list of sections to render. 79 | See the available sections for each supported convention in the description. Default: unset (None). 80 | template: The Jinja2 template to use. 81 | Prefix it with `path:` to specify the path to a Jinja templated file. Default: `keepachangelog`. 82 | git_trailers: Parse Git trailers in the commit message. 83 | See https://git-scm.com/docs/git-interpret-trailers. Default: unset (false). 84 | omit_empty_versions: Omit empty versions from the output. Default: unset (false). 85 | no_zerover: By default, breaking changes on a 0.x don't bump the major version, maintaining it at 0. 86 | With this option, a breaking change will bump a 0.x version to 1.0. 87 | filter_commits: The Git revision-range filter to use (e.g. `v1.2.0..`). Default: no filter. 88 | jinja_context: Pass additional key/value pairs to the template. 89 | Option can be used multiple times. 90 | The key/value pairs are accessible as 'jinja_context' in the template. 91 | version: Show the current version of the program and exit. 92 | debug_info: Print debug information. 93 | """ 94 | from git_changelog.cli import main as git_changelog # noqa: PLC0415 95 | 96 | cli_args = [] 97 | 98 | if repository: 99 | cli_args.append(repository) 100 | 101 | if config_file: 102 | cli_args.append("--config-file") 103 | cli_args.append(config_file) 104 | 105 | if bump: 106 | cli_args.append("--bump") 107 | cli_args.append(bump) 108 | 109 | if versioning: 110 | cli_args.append("--versioning") 111 | cli_args.append(versioning) 112 | 113 | if in_place: 114 | cli_args.append("--in-place") 115 | 116 | if version_regex: 117 | cli_args.append("--version-regex") 118 | cli_args.append(version_regex) 119 | 120 | if marker_line: 121 | cli_args.append("--marker-line") 122 | cli_args.append(marker_line) 123 | 124 | if output: 125 | cli_args.append("--output") 126 | cli_args.append(output) 127 | 128 | if provider: 129 | cli_args.append("--provider") 130 | cli_args.append(provider) 131 | 132 | if parse_refs: 133 | cli_args.append("--parse-refs") 134 | 135 | if release_notes: 136 | cli_args.append("--release-notes") 137 | 138 | if input: 139 | cli_args.append("--input") 140 | cli_args.append(input) 141 | 142 | if convention: 143 | cli_args.append("--convention") 144 | cli_args.append(convention) 145 | 146 | if sections: 147 | cli_args.append("--sections") 148 | cli_args.append(",".join(sections)) 149 | 150 | if template: 151 | cli_args.append("--template") 152 | cli_args.append(template) 153 | 154 | if git_trailers: 155 | cli_args.append("--git-trailers") 156 | 157 | if omit_empty_versions: 158 | cli_args.append("--omit-empty-versions") 159 | 160 | if no_zerover: 161 | cli_args.append("--no-zerover") 162 | 163 | if filter_commits: 164 | cli_args.append("--filter-commits") 165 | cli_args.append(filter_commits) 166 | 167 | if jinja_context: 168 | for key_value in jinja_context: 169 | cli_args.append("--jinja-context") 170 | cli_args.append(key_value) 171 | 172 | if version: 173 | cli_args.append("--version") 174 | 175 | if debug_info: 176 | cli_args.append("--debug-info") 177 | 178 | git_changelog(cli_args) 179 | -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | """Tests for the CLI.""" 2 | 3 | from __future__ import annotations 4 | 5 | import pytest 6 | 7 | from duty import main 8 | from duty._internal import debug 9 | 10 | 11 | def test_no_duty(capsys: pytest.CaptureFixture) -> None: 12 | """Run no duties. 13 | 14 | Parameters: 15 | capsys: Pytest fixture to capture output. 16 | """ 17 | assert main([]) == 1 18 | captured = capsys.readouterr() 19 | assert "Available duties" in captured.out 20 | 21 | 22 | def test_show_help(capsys: pytest.CaptureFixture) -> None: 23 | """Show help. 24 | 25 | Parameters: 26 | capsys: Pytest fixture to capture output. 27 | """ 28 | assert main(["-h"]) == 0 29 | captured = capsys.readouterr() 30 | assert "duty" in captured.out 31 | 32 | 33 | def test_show_help_for_given_duties(capsys: pytest.CaptureFixture) -> None: 34 | """Show help for given duties. 35 | 36 | Parameters: 37 | capsys: Pytest fixture to capture output. 38 | """ 39 | assert main(["-d", "tests/fixtures/basic.py", "-h", "hello"]) == 0 40 | captured = capsys.readouterr() 41 | assert "hello" in captured.out 42 | 43 | 44 | def test_show_help_unknown_duty(capsys: pytest.CaptureFixture) -> None: 45 | """Show help for an unknown duty. 46 | 47 | Parameters: 48 | capsys: Pytest fixture to capture output. 49 | """ 50 | assert main(["-d", "tests/fixtures/basic.py", "-h", "not-here"]) == 0 51 | captured = capsys.readouterr() 52 | assert "Unknown duty" in captured.out 53 | 54 | 55 | def test_select_duties() -> None: 56 | """Run a duty.""" 57 | assert main(["-d", "tests/fixtures/basic.py", "hello"]) == 0 58 | 59 | 60 | def test_unknown_duty() -> None: 61 | """Don't run an unknown duty.""" 62 | assert main(["-d", "tests/fixtures/basic.py", "byebye"]) == 1 63 | 64 | 65 | def test_incorrect_arguments() -> None: 66 | """Use incorrect arguments.""" 67 | assert main(["-d", "tests/fixtures/basic.py", "hello=1"]) == 1 68 | 69 | 70 | # we use 300 because it's slightly above the valid maximum 255 71 | @pytest.mark.parametrize("code", range(-100, 300, 7)) 72 | def test_duty_failure(code: int) -> None: 73 | """Check exit code. 74 | 75 | Parameters: 76 | code: Code to match. 77 | """ 78 | assert main(["-d", "tests/fixtures/code.py", "exit_with", f"code={code}"]) == code 79 | 80 | 81 | def test_multiple_duties(capfd: pytest.CaptureFixture) -> None: 82 | """Run multiple duties. 83 | 84 | Parameters: 85 | capfd: Pytest fixture to capture output. 86 | """ 87 | assert main(["-d", "tests/fixtures/multiple.py", "first_duty", "second_duty"]) == 0 88 | captured = capfd.readouterr() 89 | assert "first" in captured.out 90 | assert "second" in captured.out 91 | 92 | 93 | def test_duty_arguments(capfd: pytest.CaptureFixture) -> None: 94 | """Run duty with arguments. 95 | 96 | Parameters: 97 | capfd: Pytest fixture to capture output. 98 | """ 99 | assert main(["-d", "tests/fixtures/arguments.py", "say_hello", "cat=fabric"]) == 0 100 | captured = capfd.readouterr() 101 | assert "cat fabric" in captured.out 102 | assert "dog dog" in captured.out 103 | 104 | assert main(["-d", "tests/fixtures/arguments.py", "say_hello", "dog=paramiko", "cat=invoke"]) == 0 105 | captured = capfd.readouterr() 106 | assert "cat invoke" in captured.out 107 | assert "dog paramiko" in captured.out 108 | 109 | 110 | def test_list_duties(capsys: pytest.CaptureFixture) -> None: 111 | """List duties. 112 | 113 | Parameters: 114 | capsys: Pytest fixture to capture output. 115 | """ 116 | assert main(["-d", "tests/fixtures/list.py", "-l"]) == 0 117 | captured = capsys.readouterr() 118 | assert "Tong..." in captured.out 119 | assert "DEUM!" in captured.out 120 | 121 | 122 | def test_global_options() -> None: 123 | """Test global options.""" 124 | assert main(["-d", "tests/fixtures/code.py", "-z", "exit_with", "1"]) == 0 125 | 126 | 127 | def test_global_and_local_options() -> None: 128 | """Test global and local options.""" 129 | assert main(["-d", "tests/fixtures/code.py", "-z", "exit_with", "-Z", "1"]) == 1 130 | 131 | 132 | def test_options_precedence() -> None: 133 | """Test options precedence.""" 134 | # @duty(nofail=True) is overridden by ctx.run(nofail=False) 135 | assert main(["-d", "tests/fixtures/precedence.py", "precedence"]) == 1 136 | 137 | # ctx.run(nofail=False) is overridden by local option -z 138 | assert main(["-d", "tests/fixtures/precedence.py", "precedence", "-z"]) == 0 139 | 140 | # ctx.run(nofail=False) is overridden by global option -z 141 | assert main(["-d", "tests/fixtures/precedence.py", "-z", "precedence"]) == 0 142 | 143 | # global option -z is overridden by local option -z 144 | assert main(["-d", "tests/fixtures/precedence.py", "-z", "precedence", "-Z"]) == 1 145 | 146 | 147 | # test options precedence (CLI option, env var, ctx.run, @duty 148 | # test positional arguments 149 | # test extra keyword arguments 150 | # test complete (global options + local options + multi duties + positional args + keyword args + extra keyword args) 151 | 152 | 153 | @pytest.mark.parametrize( 154 | ("param", "expected"), 155 | [ 156 | ("", 1), 157 | ("n", 1), 158 | ("N", 1), 159 | ("no", 1), 160 | ("NO", 1), 161 | ("false", 1), 162 | ("FALSE", 1), 163 | ("off", 1), 164 | ("OFF", 1), 165 | ("zero=", 1), 166 | ("zero=0", 1), 167 | ("zero=n", 1), 168 | ("zero=N", 1), 169 | ("zero=no", 1), 170 | ("zero=NO", 1), 171 | ("zero=false", 1), 172 | ("zero=FALSE", 1), 173 | ("zero=off", 1), 174 | ("zero=OFF", 1), 175 | ("y", 0), 176 | ("Y", 0), 177 | ("yes", 0), 178 | ("YES", 0), 179 | ("on", 0), 180 | ("ON", 0), 181 | ("true", 0), 182 | ("TRUE", 0), 183 | ("anything else", 0), 184 | ("-1", 0), 185 | ("1", 0), 186 | ("zero=y", 0), 187 | ("zero=Y", 0), 188 | ("zero=yes", 0), 189 | ("zero=YES", 0), 190 | ("zero=on", 0), 191 | ("zero=ON", 0), 192 | ("zero=true", 0), 193 | ("zero=TRUE", 0), 194 | ("zero=anything else", 0), 195 | ("zero=-1", 0), 196 | ("zero=1", 0), 197 | ], 198 | ) 199 | def test_cast_bool_parameter(param: str, expected: int) -> None: 200 | """Test parameters casting as boolean. 201 | 202 | Parameters: 203 | param: Pytest parametrization fixture. 204 | expected: Pytest parametrization fixture. 205 | """ 206 | assert main(["-d", "tests/fixtures/booleans.py", "boolean", param]) == expected 207 | 208 | 209 | def test_invalid_params(capsys: pytest.CaptureFixture) -> None: 210 | """Check that invalid parameters are early and correctly detected. 211 | 212 | Parameters: 213 | capsys: Pytest fixture to capture output. 214 | """ 215 | assert main(["-d", "tests/fixtures/booleans.py", "boolean", "zore=off"]) == 1 216 | captured = capsys.readouterr() 217 | assert "unexpected keyword argument 'zore'" in captured.err 218 | 219 | assert main(["-d", "tests/fixtures/code.py", "exit_with"]) == 1 220 | captured = capsys.readouterr() 221 | assert "missing 1 required positional argument: 'code'" in captured.err 222 | 223 | 224 | def test_show_version(capsys: pytest.CaptureFixture) -> None: 225 | """Show version. 226 | 227 | Parameters: 228 | capsys: Pytest fixture to capture output. 229 | """ 230 | with pytest.raises(SystemExit): 231 | main(["-V"]) 232 | captured = capsys.readouterr() 233 | assert debug._get_version() in captured.out 234 | 235 | 236 | def test_show_debug_info(capsys: pytest.CaptureFixture) -> None: 237 | """Show debug information. 238 | 239 | Parameters: 240 | capsys: Pytest fixture to capture output. 241 | """ 242 | with pytest.raises(SystemExit): 243 | main(["--debug-info"]) 244 | captured = capsys.readouterr().out.lower() 245 | assert "python" in captured 246 | assert "system" in captured 247 | assert "environment" in captured 248 | assert "packages" in captured 249 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | """Tests for our own API exposition.""" 2 | 3 | from __future__ import annotations 4 | 5 | from collections import defaultdict 6 | from pathlib import Path 7 | from typing import TYPE_CHECKING 8 | 9 | import griffe 10 | import pytest 11 | from mkdocstrings import Inventory 12 | 13 | import duty 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterator 17 | 18 | 19 | @pytest.fixture(name="loader", scope="module") 20 | def _fixture_loader() -> griffe.GriffeLoader: 21 | loader = griffe.GriffeLoader() 22 | loader.load("duty") 23 | loader.load("failprint") 24 | loader.resolve_aliases() 25 | return loader 26 | 27 | 28 | @pytest.fixture(name="internal_api", scope="module") 29 | def _fixture_internal_api(loader: griffe.GriffeLoader) -> griffe.Module: 30 | return loader.modules_collection["duty._internal"] 31 | 32 | 33 | @pytest.fixture(name="public_api", scope="module") 34 | def _fixture_public_api(loader: griffe.GriffeLoader) -> griffe.Module: 35 | return loader.modules_collection["duty"] 36 | 37 | 38 | def _yield_public_objects( 39 | obj: griffe.Module | griffe.Class, 40 | *, 41 | modules: bool = False, 42 | modulelevel: bool = True, 43 | inherited: bool = False, 44 | special: bool = False, 45 | ) -> Iterator[griffe.Object | griffe.Alias]: 46 | for member in obj.all_members.values() if inherited else obj.members.values(): 47 | try: 48 | if member.is_module: 49 | if member.is_alias or not member.is_public: 50 | continue 51 | if modules: 52 | yield member 53 | yield from _yield_public_objects( 54 | member, # type: ignore[arg-type] 55 | modules=modules, 56 | modulelevel=modulelevel, 57 | inherited=inherited, 58 | special=special, 59 | ) 60 | elif member.is_public and (special or not member.is_special): 61 | yield member 62 | else: 63 | continue 64 | if member.is_class and not modulelevel: 65 | yield from _yield_public_objects( 66 | member, # type: ignore[arg-type] 67 | modules=modules, 68 | modulelevel=False, 69 | inherited=inherited, 70 | special=special, 71 | ) 72 | except (griffe.AliasResolutionError, griffe.CyclicAliasError): 73 | continue 74 | 75 | 76 | @pytest.fixture(name="modulelevel_internal_objects", scope="module") 77 | def _fixture_modulelevel_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 78 | return list(_yield_public_objects(internal_api, modulelevel=True)) 79 | 80 | 81 | @pytest.fixture(name="internal_objects", scope="module") 82 | def _fixture_internal_objects(internal_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 83 | return list(_yield_public_objects(internal_api, modulelevel=False, special=True)) 84 | 85 | 86 | @pytest.fixture(name="public_objects", scope="module") 87 | def _fixture_public_objects(public_api: griffe.Module) -> list[griffe.Object | griffe.Alias]: 88 | return list(_yield_public_objects(public_api, modulelevel=False, inherited=True, special=True)) 89 | 90 | 91 | @pytest.fixture(name="inventory", scope="module") 92 | def _fixture_inventory() -> Inventory: 93 | inventory_file = Path(__file__).parent.parent / "site" / "objects.inv" 94 | if not inventory_file.exists(): 95 | pytest.skip("The objects inventory is not available.") # ty: ignore[call-non-callable] 96 | with inventory_file.open("rb") as file: 97 | return Inventory.parse_sphinx(file) 98 | 99 | 100 | def test_exposed_objects(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 101 | """All public objects in the internal API are exposed under `duty`.""" 102 | not_exposed = [ 103 | obj.path 104 | for obj in modulelevel_internal_objects 105 | # YORE: Bump 2: Remove line. 106 | if (".tools." not in obj.path and ".callables." not in obj.path) 107 | and (obj.name not in duty.__all__ or not hasattr(duty, obj.name)) 108 | ] 109 | assert not not_exposed, "Objects not exposed:\n" + "\n".join(sorted(not_exposed)) 110 | 111 | 112 | def test_unique_names(modulelevel_internal_objects: list[griffe.Object | griffe.Alias]) -> None: 113 | """All internal objects have unique names.""" 114 | names_to_paths = defaultdict(list) 115 | for obj in modulelevel_internal_objects: 116 | # YORE: Bump 2: Remove line. 117 | if ".tools." not in obj.path and ".callables." not in obj.path: 118 | names_to_paths[obj.name].append(obj.path) 119 | non_unique = [paths for paths in names_to_paths.values() if len(paths) > 1] 120 | assert not non_unique, "Non-unique names:\n" + "\n".join(str(paths) for paths in non_unique) 121 | 122 | 123 | def test_single_locations(public_api: griffe.Module) -> None: 124 | """All objects have a single public location.""" 125 | 126 | def _public_path(obj: griffe.Object | griffe.Alias) -> bool: 127 | return obj.is_public and (obj.parent is None or _public_path(obj.parent)) 128 | 129 | multiple_locations = {} 130 | for obj_name in duty.__all__: 131 | obj = public_api[obj_name] 132 | if obj.aliases and ( 133 | public_aliases := [path for path, alias in obj.aliases.items() if path != obj.path and _public_path(alias)] 134 | ): 135 | multiple_locations[obj.path] = public_aliases 136 | assert not multiple_locations, "Multiple public locations:\n" + "\n".join( 137 | f"{path}: {aliases}" for path, aliases in multiple_locations.items() 138 | ) 139 | 140 | 141 | def test_api_matches_inventory(inventory: Inventory, public_objects: list[griffe.Object | griffe.Alias]) -> None: 142 | """All public objects are added to the inventory.""" 143 | ignore_names = {"__getattr__", "__init__", "__repr__", "__str__", "__post_init__"} 144 | not_in_inventory = [ 145 | obj.path for obj in public_objects if obj.name not in ignore_names and obj.path not in inventory 146 | ] 147 | msg = "Objects not in the inventory (try running `make run mkdocs build`):\n{paths}" 148 | assert not not_in_inventory, msg.format(paths="\n".join(sorted(not_in_inventory))) 149 | 150 | 151 | def test_inventory_matches_api( 152 | inventory: Inventory, 153 | public_objects: list[griffe.Object | griffe.Alias], 154 | loader: griffe.GriffeLoader, 155 | ) -> None: 156 | """The inventory doesn't contain any additional Python object.""" 157 | not_in_api = [] 158 | public_api_paths = {obj.path for obj in public_objects} 159 | public_api_paths.add("duty") 160 | for item in inventory.values(): 161 | if item.domain == "py" and "(" not in item.name and (item.name == "duty" or item.name.startswith("duty.")): 162 | obj = loader.modules_collection[item.name] 163 | if obj.path not in public_api_paths and not any(path in public_api_paths for path in obj.aliases): # noqa: SIM102 164 | # YORE: Bump 2: Remove line. 165 | if ".callables." not in obj.path and item.role != "module": 166 | not_in_api.append(item.name) 167 | msg = "Inventory objects not in public API (try running `make run mkdocs build`):\n{paths}" 168 | assert not not_in_api, msg.format(paths="\n".join(sorted(not_in_api))) 169 | 170 | 171 | def test_no_module_docstrings_in_internal_api(internal_api: griffe.Module) -> None: 172 | """No module docstrings should be written in our internal API. 173 | 174 | The reasoning is that docstrings are addressed to users of the public API, 175 | but internal modules are not exposed to users, so they should not have docstrings. 176 | """ 177 | 178 | def _modules(obj: griffe.Module) -> Iterator[griffe.Module]: 179 | for member in obj.modules.values(): 180 | yield member 181 | yield from _modules(member) 182 | 183 | for obj in _modules(internal_api): 184 | # YORE: Bump 2: Remove line. 185 | if not obj.path.endswith(".lazy"): 186 | assert not obj.docstring, f"{obj.path} shouldn't have a docstring" 187 | --------------------------------------------------------------------------------