# HG changeset patch
# User Antonio Muci <
a....@inwind.it>
# Date 1740336294 -3600
# Sun Feb 23 19:44:54 2025 +0100
# Branch stable
# Node ID 8f28654cff7641a20ee1b94e4b5b798086fbe1f2
# Parent cacdb641b2d6804486ea240177e92ec43e8449d4
makefile: automate generation of help text
This commit automates the generation of the "make help" contents.
The help text now lists all the make targets in order of appearance. If a target
contains a comment starting with a double dash ("##"), the comment contents is
printed as documentation for the target.
The help text is generated via the python script contrib/list_make_targets.py,
which is configured to replicate almost exactly the appearance of the previous
help text.
This is an attempt to keep the intent of the Makefile targets more discoverable.
I have kept the original descriptions of the targets unmodified, just moving
them into "##" comments.
The following 5 targets were undocumented and are now listed in the help text
(even if their description stays empty for now): pytype, upload-tarballs,
docker-rhel7, docker-rhel8, docker-rhel9.
This is what is currently printed if running "make" (help is the default target):
```
help - document all available targets
local - build for inplace usage
tests - run all tests in the automatic test suite
pytype
app - create a py2app bundle on Mac OS X
tarball - create release tarball
upload-tarballs
clean - remove files created by other targets (except installed
files or dist source tarball)
distclean - remove all files created by other targets
update-pot - extract translatable strings
docker-rhel7
docker-rhel8
docker-rhel9
```
diff --git a/Makefile b/Makefile
--- a/Makefile
+++ b/Makefile
@@ -17,19 +17,11 @@ define check_hgpath =
endef
.PHONY: help
-help:
- @echo 'Commonly used make targets:'
- @echo ' local - build for inplace usage'
- @echo ' tests - run all tests in the automatic test suite'
- @echo ' app - create a py2app bundle on Mac OS X'
- @echo ' tarball - create release tarball'
- @echo ' clean - remove files created by other targets'
- @echo ' (except installed files or dist source tarball)'
- @echo ' distclean - remove all files created by other targets'
- @echo ' update-pot - extract translatable strings'
+help: ## document all available targets
+ @$(PYTHON) contrib/list_make_targets.py < $(MAKEFILE_LIST)
.PHONY: local
-local:
+local: ## build for inplace usage
$(PYTHON) setup.py \
build_ui \
build_py -c -d . \
@@ -37,7 +29,7 @@ local:
HGRCPATH= $(PYTHON) thg version
.PHONY: tests
-tests:
+tests: ## run all tests in the automatic test suite
$(check_hgpath)
$(PYTHON) tests/run-tests.py -m 'not largefiles' --doctest-modules \
--ignore tortoisehg/hgqt/shellconf.py \
@@ -56,7 +48,7 @@ pytype:
find .pytype/pyi -name '*.pyi' | xargs grep -l '# Caught error' | sort
.PHONY: app
-app: DISTDIR = dist/app
+app: DISTDIR = dist/app ## create a py2app bundle on Mac OS X
app: SETUPCFG = contrib/setup-py2app.cfg
app: export MACOSX_DEPLOYMENT_TARGET=10.7
app:
@@ -66,7 +58,7 @@ app:
FORCE_SETUPTOOLS= $(PYTHON) setup.py py2app -d "$(DISTDIR)"
.PHONY: tarball
-tarball:
+tarball: ## create release tarball
$(FAKEROOT) $(PYTHON) setup.py sdist
@echo
@echo '** Maybe you need to run "make upload-tarballs"'
@@ -77,16 +69,16 @@ upload-tarballs:
dist/ mercurial-scm.org:/var/www/release/tortoisehg/targz/
.PHONY: clean
-clean:
+clean: ## remove files created by other targets (except installed files or dist source tarball)
$(PYTHON) setup.py clean
$(RM) -R .pytype
.PHONY: distclean
-distclean: clean
+distclean: clean ## remove all files created by other targets
$(RM) -R build dist
.PHONY: update-pot
-update-pot:
+update-pot: ## extract translatable strings
$(PYTHON) setup.py update_pot
.PHONY: docker-rhel7
diff --git a/contrib/list_make_targets.py b/contrib/list_make_targets.py
new file mode 100755
--- /dev/null
+++ b/contrib/list_make_targets.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python
+
+"""Parse a Makefile from stdin and build an help text for each target.
+
+The help is printed on stdout with custom formatting.
+Launch with "--help" to see the documentation.
+
+License:
+ GNU General Public License v3.0 or later
+"""
+
+from __future__ import annotations
+
+import argparse
+import itertools
+import logging
+import re
+import shutil
+import sys
+import textwrap
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from collections.abc import Iterable
+
+logger = logging.getLogger(__name__)
+
+EXAMPLE_MAKEFILE = textwrap.dedent("""\
+ t1: dep1 ## doc for t1
+ cmd1_1
+ cmd1_2
+ t1:
+ cmd1_3
+ t1: ## second line of doc for t1
+
+ t2: # single dash: does not appear in doc
+ t2: ## doc for t2
+
+ # t3 target name will appear, but its list of help lines will be empty
+ t3:
+ cmd3
+
+ long_name: ## targets will be listed in order of appearance in the Makefile, left column width will auto-adjust
+
+ t4: # comment ## doc for t4 is detected after the single-dash comment\
+""")
+
+
+class RawTextArgumentDefaultsHelpFormatter(
+ argparse.RawTextHelpFormatter,
+ argparse.ArgumentDefaultsHelpFormatter,
+):
+ pass
+
+
+def indent_from_second_line(s: str, how_many_spaces: int) -> str:
+ counter = itertools.count()
+ return textwrap.indent(s, " " * how_many_spaces, lambda s: next(counter) > 0) # noqa: ARG005
+
+
+def parse_args(args_list: list[str] = sys.argv[1:]) -> argparse.Namespace:
+ width = min(88, shutil.get_terminal_size().columns - 2)
+ parser = argparse.ArgumentParser(
+ formatter_class=lambda prog: RawTextArgumentDefaultsHelpFormatter(
+ prog,
+ width=width,
+ ),
+ description=textwrap.dedent(f"""\
+ Reads a Makefile from stdin. Extracts and prints all target names.
+
+ Additionally, if the line defining a Make target contains a comment
+ that starts with a double dash (\"##\") considers the comment an
+ help text, and prints it. Multiple helps for multiple occurrences of
+ the same target are fused toghether in order of appearance.
+
+ EXAMPLE:
+ Given the following Makefile:
+ ```
+ {indent_from_second_line(EXAMPLE_MAKEFILE, 16)}
+ ```
+
+ Executing `{sys.argv[0]} < Makefile` prints:
+ ```
+ {indent_from_second_line(main(EXAMPLE_MAKEFILE.splitlines(), 78, " - "), 16)}
+ ```
+ """),
+ )
+ parser.add_argument(
+ "width",
+ nargs="?",
+ type=int,
+ default=78,
+ help="the total width of the two columns of text produced",
+ )
+ parser.add_argument(
+ "separator",
+ nargs="?",
+ default=" - ",
+ help=textwrap.dedent("""\
+ separator to use for the first line of a target's help.
+ The subsequent lines will be separated by as many spaces as len(separator)
+ """),
+ )
+ return parser.parse_args(args_list)
+
+
+def extract_target_docs(in_stream: Iterable[str]) -> dict[str, list[str]]:
+ """Read a Makefile and extract structured data about its targets and help text.
+
+ >>> from pprint import pprint
+ >>> s1 = EXAMPLE_MAKEFILE
+ >>> pprint(extract_target_docs(s1.splitlines()), sort_dicts=False)
+ {'t1': ['doc for t1', 'second line of doc for t1'],
+ 't2': ['doc for t2'],
+ 't3': [],
+ 'long_name': ['targets will be listed in order of appearance in the Makefile, '
+ 'left column width will auto-adjust'],
+ 't4': ['doc for t4 is detected after the single-dash comment']}
+
+ >>> s2 = "invalid makefile"
+ >>> extract_target_docs(s2.splitlines())
+ {}
+ """
+ target_docs: dict[str, list[str]] = {}
+ for line in in_stream:
+ match = re.match(
+ r"^(?P<target_name>[0-9a-zA-Z_-]+):(?:(?!(##)).*?##[ ]*(?P<help_text>.*?)[ ]*|.*(?!##))$",
+ line,
+ )
+ if match is None:
+ continue
+ target_name = match.group("target_name")
+ if target_name is None:
+ msg = "target_name is None: this should never happen"
+ raise RuntimeError(msg)
+ help_text = match.group("help_text")
+ if target_name in target_docs:
+ if help_text is None:
+ continue
+ target_docs[target_name].append(help_text)
+ else:
+ if help_text is None:
+ target_docs[target_name] = []
+ continue
+ target_docs[target_name] = [help_text]
+ return target_docs
+
+
+def format_target_docs(
+ target_docs: dict[str, list[str]],
+ total_width: int,
+ first_line_separator: str,
+) -> str:
+ """Format a Makefile's help data.
+
+ >>> docs = {
+ ... 't1': ['doc for t1', 'second line of doc for t1'],
+ ... 't2': ['doc for t2'],
+ ... 't3': [],
+ ... 'long_name': ['targets will be listed in order of appearance in the Makefile, '
+ ... 'left column width will auto-adjust'],
+ ... 't4': ['doc for t4 is detected after the single-dash comment']
+ ... }
+ >>> assert docs==extract_target_docs(EXAMPLE_MAKEFILE.splitlines())
+ >>> print(format_target_docs(docs, 64, " - "))
+ t1 - doc for t1
+ second line of doc for t1
+ t2 - doc for t2
+ t3
+ long_name - targets will be listed in order of appearance in the
+ Makefile, left column width will auto-adjust
+ t4 - doc for t4 is detected after the single-dash comment
+
+ >>> print(format_target_docs(docs, 36, " "))
+ t1 doc for t1
+ second line of doc for t1
+ t2 doc for t2
+ t3
+ long_name targets will be listed in
+ order of appearance in
+ the Makefile, left column
+ width will auto-adjust
+ t4 doc for t4 is detected
+ after the single-dash
+ comment
+
+ >>> print(format_target_docs(docs, 2, " - "))
+ Traceback (most recent call last):
+ ...
+ ValueError: total width must be at least 13 with given separator (' - ')
+
+ >>> print(format_target_docs({}, 666, " "))
+ Traceback (most recent call last):
+ ...
+ ValueError: No targets found
+ """
+ if len(target_docs) == 0:
+ msg = "No targets found"
+ raise ValueError(msg)
+ max_target_len = max(len(target_name) for target_name in target_docs)
+ separator_width = len(first_line_separator)
+ left_col_width = max_target_len + separator_width
+ wrap_width = total_width - left_col_width
+ if wrap_width < 1:
+ msg = f"total width must be at least {1 + left_col_width} with given separator ('{first_line_separator}')"
+ raise ValueError(msg)
+ result = ""
+ for target_name, help_texts in target_docs.items():
+ if len(help_texts) == 0:
+ result += f"{target_name}\n"
+ continue
+ wrapped_help = []
+ for help_text in help_texts:
+ wrapped_help += textwrap.wrap(
+ help_text,
+ width=wrap_width,
+ )
+ for i, help_line in enumerate(wrapped_help):
+ left_col = (
+ f"{target_name:{max_target_len}}{first_line_separator}"
+ if i == 0
+ else ""
+ )
+ result += f"{left_col:{left_col_width}}{help_line}\n"
+ return result.removesuffix("\n")
+
+
+def main(in_stream: Iterable[str], width: int, first_line_separator: str) -> str:
+ target_docs = extract_target_docs(in_stream)
+ formatted_docs = format_target_docs(target_docs, width, first_line_separator)
+ return formatted_docs
+
+
+if __name__ == "__main__":
+ args = parse_args()
+ logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ )
+ try:
+ print(main(sys.stdin, args.width, args.separator))
+ except KeyboardInterrupt:
+ logger.warning("Interrupted")
+ sys.exit(1)
+ except ValueError as e:
+ logger.error(e) # noqa: TRY400
+ sys.exit(2)