about summary refs log tree commit diff
diff options
context:
space:
mode:
authorNguyễn Gia Phong <cnx@loang.net>2025-06-15 21:53:53 +0900
committerNguyễn Gia Phong <cnx@loang.net>2025-06-15 21:53:53 +0900
commitc3d356c8b80659468a0a7429636a128c156509ae (patch)
tree8d7af71b623e1cfb360ee935f809411059e0ca76
parent637e73f023107c142c1eecc187c18a5581c10794 (diff)
downloadscadere-c3d356c8b80659468a0a7429636a128c156509ae.tar.gz
Add examples to help and man pages
-rw-r--r--README.md15
-rw-r--r--src/scadere/__init__.py34
-rw-r--r--src/scadere/check.py21
-rw-r--r--src/scadere/listen.py34
-rw-r--r--tst/test_help.py24
5 files changed, 106 insertions, 22 deletions
diff --git a/README.md b/README.md
index 9355b66..d1f40f4 100644
--- a/README.md
+++ b/README.md
@@ -27,11 +27,18 @@ It is recommended to run `scadere-check` as a cron job.
 
 ```console
 $ scadere-listen --help
-Usage: scadere-listen [-h] [-v] INPUT URL [[HOST][:PORT]]
+Usage: scadere-listen [-h] [-v] PATH URL [[HOST][:PORT]]
 
-Serve the TLS certificate expiration feed from INPUT file
-for base URL at HOST:PORT, where HOST defaults to localhost
-and PORT is selected randomly if not specified.
+Serve at URL Atom feeds for TLS certificate renewal reminder.
+It is possible for clients to filter domains
+using one or more "domain" URL queries.
+
+The certificate information is read from the file at PATH,
+which is generated by scadere-check(1).
+
+The server listens for TCP connections coming to HOST:PORT,
+where HOST defaults to localhost and PORT is selected randomly
+if not specified.
 
 Options:
   -h, --help     show this help message and exit
diff --git a/src/scadere/__init__.py b/src/scadere/__init__.py
index ada0f29..f2b41bd 100644
--- a/src/scadere/__init__.py
+++ b/src/scadere/__init__.py
@@ -18,10 +18,15 @@
 
 from argparse import HelpFormatter, ONE_OR_MORE
 
-__all__ = ['__version__', 'GNUHelpFormatter', 'NetLoc']
+__all__ = ['__version__', 'GNUHelpFormatter', 'NetLoc', 'format_examples']
 __version__ = '0.1.0'
 
 
+EXAMPLE_PREFIX = ' ' * 2
+# help2man's implementation detail
+EXAMPLE_DESCRIPTION_PREFIX = ' ' * 20
+
+
 class GNUHelpFormatter(HelpFormatter):
     """Help formatter for ArgumentParser following GNU Coding Standards."""
 
@@ -33,6 +38,24 @@ class GNUHelpFormatter(HelpFormatter):
         """Substitute 'Options:' for 'options:'."""
         super().start_section(heading.capitalize())
 
+    def _fill_text(self, text, width, indent):
+        """Preserve examples' formatting."""
+        desc_position = max(len(EXAMPLE_DESCRIPTION_PREFIX),
+                            min(self._action_max_length+2,
+                                self._max_help_position))
+        desc_width = width - desc_position
+        desc_indent = indent + ' '*desc_position
+        example_indent = indent + EXAMPLE_PREFIX
+        parts = []
+        for line in text.splitlines():
+            if line.startswith(EXAMPLE_DESCRIPTION_PREFIX):
+                parts.append(super()._fill_text(line, desc_width, desc_indent))
+            elif line.startswith(EXAMPLE_PREFIX):
+                parts.append(example_indent+line.strip())
+            else:  # not example
+                parts.append(super()._fill_text(line, width, indent))
+        return '\n'.join(parts)
+
     def _format_args(self, action, default_metavar):
         """Substitute 'METAVAR...' for 'METAVAR [METAVAR ...]'."""
         if action.nargs == ONE_OR_MORE:
@@ -67,3 +90,12 @@ class NetLoc:
             return string, self.default_port
         hostname, port = string.rsplit(':', 1)
         return hostname, int(port)  # ValueError to be handled by argparse
+
+
+def format_examples(examples):
+    """Format example commands and their description ."""
+    lines = ['Examples:']
+    for example, description in examples:
+        lines.append(EXAMPLE_PREFIX+example)
+        lines.append(EXAMPLE_DESCRIPTION_PREFIX+description)
+    return '\n'.join(lines)
diff --git a/src/scadere/check.py b/src/scadere/check.py
index 0937e75..380f71c 100644
--- a/src/scadere/check.py
+++ b/src/scadere/check.py
@@ -27,7 +27,7 @@ from sys import argv, stderr, stdout
 from unicodedata import category as unicode_category
 from uuid import uuid4
 
-from . import __version__, GNUHelpFormatter, NetLoc
+from . import __version__, GNUHelpFormatter, NetLoc, format_examples
 
 __all__ = ['main']
 
@@ -94,12 +94,20 @@ def check(netlocs, after, output, fake_ca=None):
                       base64_from_str(ca), file=output)
 
 
-def main(arguments=argv[1:]):
+def main(prog='scadere-check', arguments=argv[1:]):
     """Run TLS checker."""
-    description = ('Check TLS certificate expiration of HOST,'
-                   ' where PORT defaults to 443.')
-    parser = ArgumentParser(prog='scadere-check', allow_abbrev=False,
-                            description=description,
+    desc = ('Check TLS certificate expiration of HOST,'
+            ' where PORT defaults to 443.\n\n'
+            'The output is intended to be used by scadere-listen(1).')
+    examples = [((f'{prog} --output=/var/lib/scadere/certificates'
+                  ' example.com example.net'),
+                 ('Check if TLS certificates used by example.com:443'
+                  ' and example.net:443 are either invalid'
+                  ' or expiring within the next week,'
+                  ' then write the result to /var/lib/scadere/certificates.'))]
+
+    parser = ArgumentParser(prog=prog, allow_abbrev=False, description=desc,
+                            epilog=format_examples(examples),
                             formatter_class=GNUHelpFormatter)
     parser.add_argument('-v', '--version', action='version',
                         version=f'%(prog)s {__version__}')
@@ -110,6 +118,7 @@ def main(arguments=argv[1:]):
     parser.add_argument('-o', '--output', metavar='PATH',
                         type=FileType('w'), default=stdout,
                         help='output file (default to stdout)')
+
     args = parser.parse_args(arguments)
     with args.output:  # pragma: no cover
         after = datetime.now(timezone.utc) + timedelta(days=args.days)
diff --git a/src/scadere/listen.py b/src/scadere/listen.py
index 649a7cd..2888524 100644
--- a/src/scadere/listen.py
+++ b/src/scadere/listen.py
@@ -32,7 +32,7 @@ from xml.etree.ElementTree import (Element as xml_element,
                                    indent, tostring as string_from_xml)
 from sys import argv
 
-from . import __version__, GNUHelpFormatter, NetLoc
+from . import __version__, GNUHelpFormatter, NetLoc, format_examples
 from .check import base64_from_str
 
 __all__ = ['main']
@@ -312,21 +312,37 @@ def with_trailing_slash(base_url):
     return base_url if base_url.endswith('/') else f'{base_url}/'
 
 
-def main(arguments=argv[1:]):
+def main(prog='scadere-listen', arguments=argv[1:]):
     """Launch server."""
-    description = ('Serve the TLS certificate expiration feed'
-                   ' from INPUT file for base URL at HOST:PORT,'
-                   ' where HOST defaults to localhost and PORT'
-                   ' is selected randomly if not specified.')
-    parser = ArgumentParser(prog='scadere-listen', allow_abbrev=False,
-                            description=description,
+    desc = ('Serve at URL Atom feeds for TLS certificate renewal reminder.'
+            '  It is possible for clients to filter domains'
+            ' using one or more "domain" URL queries.\n\n'
+            'The certificate information is read from the file at PATH,'
+            ' which is generated by scadere-check(1).\n\n'
+            'The server listens for TCP connections coming to HOST:PORT,'
+            ' where HOST defaults to localhost'
+            ' and PORT is selected randomly if not specified.\n\n')
+    examples = [((f'{prog} /var/lib/scadere/certificates'
+                  ' https://scadere.example/ :4433'),
+                 ('Serve renewal reminder feed using information'
+                  ' from /var/lib/scadere/certificates on localhost:4433,'
+                  ' to be reverse proxied to https://scadere.example/')),
+                ('https://scadere.example/',
+                 'Atom feed for all checked TLS certificates'),
+                ('https://scadere.example/?domain=example.com&domain=net',
+                 ('Atom feed for checked TLS certificates for example.com,'
+                  ' its subdomains, and domains under the TLD NET'))]
+
+    parser = ArgumentParser(prog=prog, allow_abbrev=False, description=desc,
+                            epilog=format_examples(examples),
                             formatter_class=GNUHelpFormatter)
     parser.add_argument('-v', '--version', action='version',
                         version=f'%(prog)s {__version__}')
-    parser.add_argument('certs', metavar='INPUT', type=Path)
+    parser.add_argument('certs', metavar='PATH', type=Path)
     parser.add_argument('base_url', metavar='URL', type=with_trailing_slash)
     parser.add_argument('netloc', metavar='[HOST][:PORT]', nargs='?',
                         type=NetLoc(None), default=('localhost', None))
+
     args = parser.parse_args(arguments)
     run(listen(args.certs, args.base_url, *args.netloc))  # pragma: no cover
 
diff --git a/tst/test_help.py b/tst/test_help.py
index 3e5ce87..2a6c06a 100644
--- a/tst/test_help.py
+++ b/tst/test_help.py
@@ -22,7 +22,7 @@ from io import StringIO
 from hypothesis import example, given
 from pytest import fixture, mark, raises
 
-from scadere import NetLoc
+from scadere import EXAMPLE_PREFIX, EXAMPLE_DESCRIPTION_PREFIX, NetLoc
 from scadere.check import main as check
 from scadere.listen import main as listen
 
@@ -31,7 +31,7 @@ from scadere.listen import main as listen
 def help_string(request):
     string = StringIO()
     with suppress(SystemExit), redirect_stdout(string):
-        request.param(['--help'])
+        request.param(arguments=['--help'])
     return string.getvalue()
 
 
@@ -57,6 +57,26 @@ def test_long_option(help_string, short, long, metavar):
     assert f'{short} {metavar}, {long}={metavar}' in help_string
 
 
+@mark.parametrize('help_string', [check, listen], indirect=True)
+def test_examples(help_string):
+    index = help_string.find('\n\nExamples:\n')
+    assert index >= 0
+    lines = help_string[index:].removeprefix('\n\nExamples:\n').splitlines()
+
+    assert EXAMPLE_DESCRIPTION_PREFIX.startswith(EXAMPLE_PREFIX)
+    assert not lines[0].startswith(EXAMPLE_DESCRIPTION_PREFIX)
+    assert lines[-1].startswith(EXAMPLE_DESCRIPTION_PREFIX)
+
+    must_be_desc = False
+    for line in lines:
+        if must_be_desc:
+            assert line.startswith(EXAMPLE_DESCRIPTION_PREFIX)
+            must_be_desc = False
+        else:
+            assert line.startswith(EXAMPLE_PREFIX)
+            must_be_desc = not line.startswith(EXAMPLE_DESCRIPTION_PREFIX)
+
+
 @example('a.example:b', None)  # string is unlikely to match .*:\D+
 @example('a.example:98', None)  # string is unlikely to match .*:\d+
 @given(...)