From 0fd815b7cae40478f7d34c6003be7525b2ca2687 Mon Sep 17 00:00:00 2001
From: renesat <self@renesat.me>
Date: Sat, 12 Jul 2025 02:31:35 +0200
Subject: [PATCH] update datalad buildsupport scrypts

---
 _datalad_buildsupport/formatters.py |  18 ++-
 _datalad_buildsupport/setup.py      | 227 +++++++++++++++++++++++-----
 2 files changed, 200 insertions(+), 45 deletions(-)

diff --git a/_datalad_buildsupport/formatters.py b/_datalad_buildsupport/formatters.py
index 5ac01de..fb21875 100644
--- a/_datalad_buildsupport/formatters.py
+++ b/_datalad_buildsupport/formatters.py
@@ -7,7 +7,10 @@
 
 import argparse
 import datetime
+import os
 import re
+import time
+from textwrap import wrap
 
 
 class ManPageFormatter(argparse.HelpFormatter):
@@ -24,7 +27,7 @@ def __init__(self,
                  authors=None,
                  version=None
                  ):
-
+        from datalad import cfg
         super(ManPageFormatter, self).__init__(
             prog,
             indent_increment=indent_increment,
@@ -33,7 +36,10 @@ def __init__(self,
 
         self._prog = prog
         self._section = 1
-        self._today = datetime.date.today().strftime('%Y\\-%m\\-%d')
+        self._today = datetime.datetime.fromtimestamp(
+            cfg.obtain('datalad.source.epoch'),
+            datetime.timezone.utc
+        ).strftime('%Y\\-%m\\-%d')
         self._ext_sections = ext_sections
         self._version = version
 
@@ -75,7 +81,7 @@ def _mk_title(self, prog):
 
     def _mk_name(self, prog, desc):
         """
-        this method is in consitent with others ... it relies on
+        this method is in consistent with others ... it relies on
         distribution
         """
         desc = desc.splitlines()[0] if desc else 'it is in the name'
@@ -195,7 +201,9 @@ def _mk_synopsis(self, parser):
                                    parser._mutually_exclusive_groups, '')
 
         usage = usage.replace('%s ' % self._prog, '')
-        usage = 'Synopsis\n--------\n::\n\n  %s %s\n' \
+        usage = '\n'.join(wrap(
+            usage, break_on_hyphens=False, subsequent_indent=6*' '))
+        usage = 'Synopsis\n--------\n::\n\n  %s %s\n\n' \
                 % (self._markup(self._prog), usage)
         return usage
 
@@ -251,7 +259,7 @@ def _mk_options(self, parser):
 
     def _format_action(self, action):
         # determine the required width and the entry label
-        action_header = self._format_action_invocation(action)
+        action_header = self._format_action_invocation(action, doubledash='-\\-')
 
         if action.help:
             help_text = self._expand_help(action)
diff --git a/_datalad_buildsupport/setup.py b/_datalad_buildsupport/setup.py
index 27e0821..e3ba793 100644
--- a/_datalad_buildsupport/setup.py
+++ b/_datalad_buildsupport/setup.py
@@ -8,19 +8,51 @@
 
 import datetime
 import os
-
-from os.path import (
-    dirname,
-    join as opj,
+import platform
+import sys
+from os import (
+    linesep,
+    makedirs,
 )
-from setuptools import Command, DistutilsOptionError
-from setuptools.config import read_configuration
-
-import versioneer
+from os.path import dirname
+from os.path import join as opj
+from os.path import sep as pathsep
+from os.path import splitext
+
+import setuptools
+from genericpath import exists
+from packaging.version import Version
+from setuptools import (
+    Command,
+    find_namespace_packages,
+    findall,
+    setup,
+)
+from setuptools.errors import OptionError
 
 from . import formatters as fmt
 
 
+def _path_rel2file(*p):
+    # dirname instead of joining with pardir so it works if
+    # datalad_build_support/ is just symlinked into some extension
+    # while developing
+    return opj(dirname(dirname(__file__)), *p)
+
+
+def get_version(name):
+    """Determine version via importlib_metadata
+
+    Parameters
+    ----------
+    name: str
+      Name of the folder (package) where from to read version.py
+    """
+    # delay import so we do not require it for a simple setup stage
+    from importlib.metadata import version as importlib_version
+    return importlib_version(name)
+
+
 class BuildManPage(Command):
     # The BuildManPage code was originally distributed
     # under the same License of Python
@@ -29,33 +61,27 @@ class BuildManPage(Command):
     description = 'Generate man page from an ArgumentParser instance.'
 
     user_options = [
-        ('manpath=', None,
-         'output path for manpages (relative paths are relative to the '
-         'datalad package)'),
-        ('rstpath=', None,
-         'output path for RST files (relative paths are relative to the '
-         'datalad package)'),
+        ('manpath=', None, 'output path for manpages'),
+        ('rstpath=', None, 'output path for RST files'),
         ('parser=', None, 'module path to an ArgumentParser instance'
          '(e.g. mymod:func, where func is a method or function which return'
          'a dict with one or more arparse.ArgumentParser instances.'),
-        ('cmdsuite=', None, 'module path to an extension command suite '
-         '(e.g. mymod:command_suite) to limit the build to the contained '
-         'commands.'),
     ]
 
     def initialize_options(self):
         self.manpath = opj('build', 'man')
         self.rstpath = opj('docs', 'source', 'generated', 'man')
-        self.parser = 'datalad.cmdline.main:setup_parser'
-        self.cmdsuite = None
+        self.parser = 'datalad.cli.parser:setup_parser'
 
     def finalize_options(self):
         if self.manpath is None:
-            raise DistutilsOptionError('\'manpath\' option is required')
+            raise OptionError('\'manpath\' option is required')
         if self.rstpath is None:
-            raise DistutilsOptionError('\'rstpath\' option is required')
+            raise OptionError('\'rstpath\' option is required')
         if self.parser is None:
-            raise DistutilsOptionError('\'parser\' option is required')
+            raise OptionError('\'parser\' option is required')
+        self.manpath = _path_rel2file(self.manpath)
+        self.rstpath = _path_rel2file(self.rstpath)
         mod_name, func_name = self.parser.split(':')
         fromlist = mod_name.split('.')
         try:
@@ -64,18 +90,10 @@ def finalize_options(self):
                 ['datalad'],
                 formatter_class=fmt.ManPageFormatter,
                 return_subparsers=True,
-                # ignore extensions only for the main package to avoid pollution
-                # with all extension commands that happen to be installed
-                help_ignore_extensions=self.distribution.get_name() == 'datalad')
+                help_ignore_extensions=True)
 
         except ImportError as err:
             raise err
-        if self.cmdsuite:
-            mod_name, suite_name = self.cmdsuite.split(':')
-            mod = __import__(mod_name, fromlist=mod_name.split('.'))
-            suite = getattr(mod, suite_name)
-            self.cmdlist = [c[2] if len(c) > 2 else c[1].replace('_', '-').lower()
-                            for c in suite[1]]
 
         self.announce('Writing man page(s) to %s' % self.manpath)
         self._today = datetime.date.today()
@@ -125,12 +143,9 @@ def run(self):
         #appname = self._parser.prog
         appname = 'datalad'
 
-        cfg = read_configuration(
-            opj(dirname(dirname(__file__)), 'setup.cfg'))['metadata']
-
         sections = {
             'Authors': """{0} is developed by {1} <{2}>.""".format(
-                appname, cfg['author'], cfg['author_email']),
+                appname, dist.get_author(), dist.get_author_email()),
         }
 
         for cls, opath, ext in ((fmt.ManPageFormatter, self.manpath, '1'),
@@ -138,8 +153,6 @@ def run(self):
             if not os.path.exists(opath):
                 os.makedirs(opath)
             for cmdname in getattr(self, 'cmdline_names', list(self._parser)):
-                if hasattr(self, 'cmdlist') and cmdname not in self.cmdlist:
-                    continue
                 p = self._parser[cmdname]
                 cmdname = "{0}{1}".format(
                     'datalad ' if cmdname != 'datalad' else '',
@@ -147,7 +160,7 @@ def run(self):
                 format = cls(
                     cmdname,
                     ext_sections=sections,
-                    version=versioneer.get_version())
+                    version=get_version(getattr(self, 'mod_name', appname)))
                 formatted = format.format_man_page(p)
                 with open(opj(opath, '{0}.{1}'.format(
                         cmdname.replace(' ', '-'),
@@ -156,6 +169,42 @@ def run(self):
                     f.write(formatted)
 
 
+class BuildRSTExamplesFromScripts(Command):
+    description = 'Generate RST variants of example shell scripts.'
+
+    user_options = [
+        ('expath=', None, 'path to look for example scripts'),
+        ('rstpath=', None, 'output path for RST files'),
+    ]
+
+    def initialize_options(self):
+        self.expath = opj('docs', 'examples')
+        self.rstpath = opj('docs', 'source', 'generated', 'examples')
+
+    def finalize_options(self):
+        if self.expath is None:
+            raise OptionError('\'expath\' option is required')
+        if self.rstpath is None:
+            raise OptionError('\'rstpath\' option is required')
+        self.expath = _path_rel2file(self.expath)
+        self.rstpath = _path_rel2file(self.rstpath)
+        self.announce('Converting example scripts')
+
+    def run(self):
+        opath = self.rstpath
+        if not os.path.exists(opath):
+            os.makedirs(opath)
+
+        from glob import glob
+        for example in glob(opj(self.expath, '*.sh')):
+            exname = os.path.basename(example)[:-3]
+            with open(opj(opath, '{0}.rst'.format(exname)), 'w') as out:
+                fmt.cmdline_example_to_rst(
+                    open(example),
+                    out=out,
+                    ref='_example_{0}'.format(exname))
+
+
 class BuildConfigInfo(Command):
     description = 'Generate RST documentation for all config items.'
 
@@ -168,7 +217,8 @@ def initialize_options(self):
 
     def finalize_options(self):
         if self.rstpath is None:
-            raise DistutilsOptionError('\'rstpath\' option is required')
+            raise OptionError('\'rstpath\' option is required')
+        self.rstpath = _path_rel2file(self.rstpath)
         self.announce('Generating configuration documentation')
 
     def run(self):
@@ -176,8 +226,8 @@ def run(self):
         if not os.path.exists(opath):
             os.makedirs(opath)
 
-        from datalad.interface.common_cfg import definitions as cfgdefs
         from datalad.dochelpers import _indent
+        from datalad.interface.common_cfg import definitions as cfgdefs
 
         categories = {
             'global': {},
@@ -218,3 +268,100 @@ def run(self):
                         desc_tmpl += 'undocumented\n'
                     v.update(docs)
                     rst.write(_indent(desc_tmpl.format(**v), '    '))
+
+
+def get_long_description_from_README():
+    """Read README.md, convert to .rst using pypandoc
+
+    If pypandoc is not available or fails - just output original .md.
+
+    Returns
+    -------
+    dict
+      with keys long_description and possibly long_description_content_type
+      for newer setuptools which support uploading of markdown as is.
+    """
+    # PyPI used to not render markdown. Workaround for a sane appearance
+    # https://github.com/pypa/pypi-legacy/issues/148#issuecomment-227757822
+    # is still in place for older setuptools
+
+    README = opj(_path_rel2file('README.md'))
+
+    ret = {}
+    if Version(setuptools.__version__) >= Version('38.6.0'):
+        # check than this
+        ret['long_description'] = open(README).read()
+        ret['long_description_content_type'] = 'text/markdown'
+        return ret
+
+    # Convert or fall-back
+    try:
+        import pypandoc
+        return {'long_description': pypandoc.convert(README, 'rst')}
+    except (ImportError, OSError) as exc:
+        # attempting to install pandoc via brew on OSX currently hangs and
+        # pypandoc imports but throws OSError demanding pandoc
+        print(
+                "WARNING: pypandoc failed to import or thrown an error while "
+                "converting"
+                " README.md to RST: %r   .md version will be used as is" % exc
+        )
+        return {'long_description': open(README).read()}
+
+
+def findsome(subdir, extensions):
+    """Find files under subdir having specified extensions
+
+    Leading directory (datalad) gets stripped
+    """
+    return [
+        f.split(pathsep, 1)[1] for f in findall(opj('datalad', subdir))
+        if splitext(f)[-1].lstrip('.') in extensions
+    ]
+
+
+def datalad_setup(name, **kwargs):
+    """A helper for a typical invocation of setuptools.setup.
+
+    If not provided in kwargs, following fields will be autoset to the defaults
+    or obtained from the present on the file system files:
+
+    - author
+    - author_email
+    - packages -- all found packages which start with `name`
+    - long_description -- converted to .rst using pypandoc README.md
+    - version -- parsed `__version__` within `name/version.py`
+
+    Parameters
+    ----------
+    name: str
+        Name of the Python package
+    **kwargs:
+        The rest of the keyword arguments passed to setuptools.setup as is
+    """
+    # Simple defaults
+    for k, v in {
+        'author': "The DataLad Team and Contributors",
+        'author_email': "team@datalad.org"
+    }.items():
+        if kwargs.get(k) is None:
+            kwargs[k] = v
+
+    # More complex, requiring some function call
+
+    # Only recentish versions of find_packages support include
+    # packages = find_packages('.', include=['datalad*'])
+    # so we will filter manually for maximal compatibility
+    if kwargs.get('packages') is None:
+        # Use find_namespace_packages() in order to include folders that
+        # contain data files but no Python code
+        kwargs['packages'] = [pkg for pkg in find_namespace_packages('.') if pkg.startswith(name)]
+    if kwargs.get('long_description') is None:
+        kwargs.update(get_long_description_from_README())
+
+    cmdclass = kwargs.get('cmdclass', {})
+    # Check if command needs some module specific handling
+    for v in cmdclass.values():
+        if hasattr(v, 'handle_module'):
+            getattr(v, 'handle_module')(name, **kwargs)
+    return setup(name=name, **kwargs)

