Skip to content

Commit

Permalink
feat: Rewrite callables as "tools", to allow building the command v…
Browse files Browse the repository at this point in the history
…alue automatically

With this change, we deprecate the previous "callables" in favor of these new tools.

Issue-21: #21
  • Loading branch information
pawamoy committed May 18, 2024
1 parent 65a22bf commit 55c9b9f
Show file tree
Hide file tree
Showing 25 changed files with 4,906 additions and 57 deletions.
1 change: 1 addition & 0 deletions config/coverage.ini
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ omit =
src/*/__init__.py
src/*/__main__.py
src/*/callables/*
src/*/tools/*
tests/__init__.py
exclude_lines =
pragma: no cover
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ not just (sub)processes. Using Python callables brings three advantages:
as you configured it.
- **extensibility**: get the full power of Python! You can define
functions dynamically in your tasks and run them through duty.
We actually provide a set of [ready-to-use callables][duty.callables].
We actually provide a set of [ready-to-use callables][duty.tools].

Notable differences with Invoke:

Expand Down
16 changes: 9 additions & 7 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,22 +90,24 @@ def docs(ctx):
The main benefit is that it enables IDE features like help tooltips and auto-completion,
as well as improving readability and writability.

**[See all our callables in the Code reference][duty.callables].**
**[See all our callables in the Code reference][duty.tools].**

You can also create your own lazy callables with [`duty.callables.lazy`][failprint.lazy.lazy].
The `lazy` function (which can also be used as a decorator)
takes any other callable and makes it lazy:
You can also create your own lazy callables with [`duty.tools.Tool`][]
and [`duty.tools.lazy`][failprint.lazy.lazy].
Check out our tools to see how to create your own.

The `lazy` function/decorator is a quicker way
to create a lazy callable:

```python
from duty import duty
from duty.callables import lazy
from duty import duty, tools

from griffe.cli import check


@duty
def check_api(ctx):
griffe_check = lazy(check, name="griffe.check")
griffe_check = tools.lazy(check, name="griffe.check")
ctx.run(griffe_check("pkg"))
```

Expand Down
41 changes: 17 additions & 24 deletions duties.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Iterator

from duty import callables, duty
from duty import duty, tools

if TYPE_CHECKING:
from duty.context import Context
Expand Down Expand Up @@ -50,7 +50,7 @@ def changelog(ctx: Context, bump: str = "") -> None:
Parameters:
bump: Bump option passed to git-changelog.
"""
ctx.run(callables.git_changelog.run(bump=bump or None), title="Updating changelog", command="git-changelog")
ctx.run(tools.git_changelog(bump=bump or None), title="Updating changelog", command="git-changelog")


@duty(pre=["check_quality", "check_types", "check_docs", "check_dependencies", "check-api"])
Expand All @@ -62,9 +62,8 @@ def check(ctx: Context) -> None: # noqa: ARG001
def check_quality(ctx: Context) -> None:
"""Check the code quality."""
ctx.run(
callables.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"),
tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml"),
title=pyprefix("Checking code quality"),
command=f"ruff check --config config/ruff.toml {PY_SRC}",
)


Expand All @@ -79,7 +78,7 @@ def check_dependencies(ctx: Context) -> None:
)

ctx.run(
callables.safety.check(requirements),
tools.safety.check(requirements),
title="Checking dependencies",
command="uv pip freeze | safety check --stdin",
)
Expand All @@ -92,29 +91,26 @@ def check_docs(ctx: Context) -> None:
Path("htmlcov/index.html").touch(exist_ok=True)
with material_insiders():
ctx.run(
callables.mkdocs.build(strict=True, verbose=True),
tools.mkdocs.build(strict=True, verbose=True),
title=pyprefix("Building documentation"),
command="mkdocs build -vs",
)


@duty
def check_types(ctx: Context) -> None:
"""Check that the code is correctly typed."""
ctx.run(
callables.mypy.run(*PY_SRC_LIST, config_file="config/mypy.ini"),
tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"),
title=pyprefix("Type-checking"),
command=f"mypy --config-file config/mypy.ini {PY_SRC}",
)


@duty
def check_api(ctx: Context) -> None:
"""Check for API breaking changes."""
ctx.run(
callables.griffe.check("duty", search=["src"], color=True),
tools.griffe.check("duty", search=["src"], color=True),
title="Checking for API breaking changes",
command="griffe check -ssrc duty",
nofail=True,
)

Expand All @@ -129,7 +125,7 @@ def docs(ctx: Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""
with material_insiders():
ctx.run(
callables.mkdocs.serve(dev_addr=f"{host}:{port}"),
tools.mkdocs.serve(dev_addr=f"{host}:{port}"),
title="Serving documentation",
capture=False,
)
Expand All @@ -142,26 +138,25 @@ def docs_deploy(ctx: Context) -> None:
with material_insiders() as insiders:
if not insiders:
ctx.run(lambda: False, title="Not deploying docs without Material for MkDocs Insiders!")
ctx.run(callables.mkdocs.gh_deploy(), title="Deploying documentation")
ctx.run(tools.mkdocs.gh_deploy(), title="Deploying documentation")


@duty
def format(ctx: Context) -> None:
"""Run formatting tools on the code."""
ctx.run(
callables.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True),
tools.ruff.check(*PY_SRC_LIST, config="config/ruff.toml", fix_only=True, exit_zero=True),
title="Auto-fixing code",
)
ctx.run(callables.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code")
ctx.run(tools.ruff.format(*PY_SRC_LIST, config="config/ruff.toml"), title="Formatting code")


@duty
def build(ctx: Context) -> None:
"""Build source and wheel distributions."""
ctx.run(
callables.build.run(),
tools.build(),
title="Building source and wheel distributions",
command="pyproject-build",
pty=PTY,
)

Expand All @@ -173,9 +168,8 @@ def publish(ctx: Context) -> None:
ctx.run("false", title="No distribution files found")
dists = [str(dist) for dist in Path("dist").iterdir()]
ctx.run(
callables.twine.upload(*dists, skip_existing=True),
tools.twine.upload(*dists, skip_existing=True),
title="Publish source and wheel distributions to PyPI",
command="twine upload -r pypi --skip-existing dist/*",
pty=PTY,
)

Expand All @@ -199,9 +193,9 @@ def release(ctx: Context, version: str = "") -> None:
@duty(silent=True, aliases=["coverage"])
def cov(ctx: Context) -> None:
"""Report coverage as text and HTML."""
ctx.run(callables.coverage.combine, nofail=True)
ctx.run(callables.coverage.report(rcfile="config/coverage.ini"), capture=False)
ctx.run(callables.coverage.html(rcfile="config/coverage.ini"))
ctx.run(tools.coverage.combine(), nofail=True)
ctx.run(tools.coverage.report(rcfile="config/coverage.ini"), capture=False)
ctx.run(tools.coverage.html(rcfile="config/coverage.ini"))


@duty
Expand All @@ -214,7 +208,6 @@ def test(ctx: Context, match: str = "") -> None:
py_version = f"{sys.version_info.major}{sys.version_info.minor}"
os.environ["COVERAGE_FILE"] = f".coverage.{py_version}"
ctx.run(
callables.pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"),
tools.pytest("-n", "auto", "tests", config_file="config/pytest.ini", select=match, color="yes"),
title=pyprefix("Running tests"),
command=f"pytest -c config/pytest.ini -n auto -k{match!r} --color=yes tests",
)
36 changes: 12 additions & 24 deletions src/duty/callables/__init__.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,12 @@
"""Module containing callables for many tools.
Tip: Call to developers!
If you are the author or maintainer of one of the tools we support
(or more generally if you are the author/maintainer of a Python CLI/library),
we kindly request that you add such a callable to your code base. Why?
- Most of the time, all `duty` can do is hook into the CLI entrypoint
for the lack of a better alternative. This is not ideal because
we have to translate function arguments to CLI arguments,
that are then parsed again and translated back to Python objects
by the tool itself. This is not efficient.
- It is not feasible for `duty` to maintain callables for different versions
of these tools. Having the callables maintained in the tools
themselves would make this support transparent.
- We believe it simply provides a better user- *and* developer-experience.
Clear separation of concerns: don't intertwine logic into the CLI parser.
Easy to maintain, easy to test. The CLI parser just has to translate CLI args
to their equivalent Python arguments.
Tips for writing such a library entry point:
- Make it equivalent to the CLI entry point: every flag and option must have an equivalent parameter.
Slight customizations can be made to support `--flag` / `--no-flag` with single parameters.
- Use only built-in types: don't make users import and use objects from your API.
For example, accept a list of strings, not a list of `MyCustomClass` instances.
These callables are **deprecated** in favor of our new [tools][duty.tools].
"""

from __future__ import annotations

import warnings

from failprint.lazy import lazy

from duty.callables import (
Expand All @@ -51,3 +30,12 @@
)

__all__ = ["lazy"]

warnings.warn(
"Callables are deprecated in favor of our new `duty.tools`. "
"They are easier to use and provide more functionality "
"like automatically computing `command` values in `ctx.run()` calls. "
"Old callables will be removed in a future version.",
DeprecationWarning,
stacklevel=1,
)
7 changes: 6 additions & 1 deletion src/duty/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
from __future__ import annotations

import os
from contextlib import contextmanager
from contextlib import contextmanager, suppress
from typing import Any, Callable, Iterator, List, Union

from failprint.runners import run as failprint_run

from duty.exceptions import DutyFailure
from duty.tools import Tool

CmdType = Union[str, List[str], Callable]

Expand Down Expand Up @@ -67,6 +68,10 @@ def run(self, cmd: CmdType, **options: Any) -> str:
final_options = dict(self._options)
final_options.update(options)

if "command" not in final_options and isinstance(cmd, Tool):
with suppress(ValueError):
final_options["command"] = cmd.cli_command

allow_overrides = final_options.pop("allow_overrides", True)
workdir = final_options.pop("workdir", None)

Expand Down
48 changes: 48 additions & 0 deletions src/duty/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Module containing callables for many tools."""

from __future__ import annotations

from failprint.lazy import lazy

from duty.tools._autoflake import autoflake
from duty.tools._base import LazyStderr, LazyStdout, Tool
from duty.tools._black import black
from duty.tools._blacken_docs import blacken_docs
from duty.tools._build import build
from duty.tools._coverage import coverage
from duty.tools._flake8 import flake8
from duty.tools._git_changelog import git_changelog
from duty.tools._griffe import griffe
from duty.tools._interrogate import interrogate
from duty.tools._isort import isort
from duty.tools._mkdocs import mkdocs
from duty.tools._mypy import mypy
from duty.tools._pytest import pytest
from duty.tools._ruff import ruff
from duty.tools._safety import safety
from duty.tools._ssort import ssort
from duty.tools._twine import twine

__all__ = [
"Tool",
"LazyStdout",
"LazyStderr",
"lazy",
"autoflake",
"black",
"blacken_docs",
"build",
"coverage",
"flake8",
"git_changelog",
"griffe",
"interrogate",
"isort",
"mkdocs",
"mypy",
"pytest",
"ruff",
"safety",
"ssort",
"twine",
]
Loading

0 comments on commit 55c9b9f

Please sign in to comment.