From 366168f2c99cacc3e71ee5617f6a3b63c7a080eb Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 12 Nov 2025 16:39:41 -0500 Subject: [PATCH] chore: run hatch fmt Signed-off-by: Henry Schreiner --- .github/workflows/ci.yml | 24 +-- pybind11_mkdoc/__init__.py | 91 +++++--- pybind11_mkdoc/__main__.py | 5 +- pybind11_mkdoc/mkdoc_lib.py | 405 +++++++++++++++++++---------------- pyproject.toml | 4 + tests/long_parameter_test.py | 8 +- tests/sample_header_test.py | 12 +- 7 files changed, 295 insertions(+), 254 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbda44d..da78a72 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,14 @@ on: - 'v*' jobs: + format: + runs-on: ubuntu-latest + name: Format + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + - run: uvx hatch fmt + checks: strategy: fail-fast: false @@ -27,12 +35,10 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - - name: Install package - run: python -m pip install .[test] "clang<19" + - uses: astral-sh/setup-uv@v7 - name: Test package - run: python -m pytest --forked + run: uv run --with "clang<19" --extra test pytest --forked # Commented for now -- msys2 Clang (v15) and the clang Python package (v14) are incompatible # @@ -69,12 +75,4 @@ jobs: name: Build distribution steps: - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - - - name: Build - run: pipx run build - - - uses: actions/upload-artifact@v5 - with: - name: DistPackage - path: dist + - uses: hynek/build-and-inspect-python-package@v2 diff --git a/pybind11_mkdoc/__init__.py b/pybind11_mkdoc/__init__.py index 4ef6481..567d266 100644 --- a/pybind11_mkdoc/__init__.py +++ b/pybind11_mkdoc/__init__.py @@ -4,20 +4,16 @@ (Docs WIP). """ - import argparse import os import re -import shlex -import sys - -from .mkdoc_lib import mkdoc +from pybind11_mkdoc.mkdoc_lib import mkdoc __version__ = "2.6.2.dev1" -def _append_include_dir(args: list, include_dir: str, verbose: bool = True): +def _append_include_dir(args: list, include_dir: str, *, verbose: bool = True): """ Add an include directory to an argument list (if it exists). @@ -37,10 +33,10 @@ def _append_include_dir(args: list, include_dir: str, verbose: bool = True): if os.path.isdir(include_dir): args.append(f"-I{include_dir}") elif verbose: - print(f"Include directory {include_dir!r} does not exist!") + pass -def _append_definition(args: list, definition: str, verbose: bool = True): +def _append_definition(args: list, definition: str): """ Add a compiler definition to an argument list. @@ -61,21 +57,20 @@ def _append_definition(args: list, definition: str, verbose: bool = True): """ try: - macro, _, value = definition.partition('=') + macro, _, value = definition.partition("=") macro = macro.strip() - value = value.strip() if value else '1' + value = value.strip() if value else "1" args.append(f"-D{macro}={value}") - except ValueError as exc: + except ValueError: # most likely means there was no '=' given # check if argument is valid identifier - if re.search(r'^[A-Za-z_][A-Za-z0-9_]*', definition): + if re.search(r"^[A-Za-z_][A-Za-z0-9_]*", definition): args.append(f"-D{definition}") else: - print(f"Failed to parse definition: {definition}") - except: - print(f"Failed to parse definition: {definition}") - + pass + except Exception: + pass def main(): @@ -86,26 +81,53 @@ def main(): """ parser = argparse.ArgumentParser( - prog='pybind11_mkdoc', - description="Processes a sequence of C/C++ headers and extracts comments for use in pybind11 binding code.", - epilog="(Other compiler flags that Clang understands can also be supplied)", - allow_abbrev=False) + prog="pybind11_mkdoc", + description="Processes a sequence of C/C++ headers and extracts comments for use in pybind11 binding code.", + epilog="(Other compiler flags that Clang understands can also be supplied)", + allow_abbrev=False, + ) parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}") - parser.add_argument("-o", "--output", action="store", type=str, dest="output", metavar="", - help="Write to the specified file (default: use stdout).") - - parser.add_argument("-w", "--width", action="store", type=int, dest="width", metavar="", - help="Specify docstring width before wrapping.") - - parser.add_argument("-I", action="append", type=str, dest="include_dirs", metavar="", - help="Specify an directory to add to the list of include search paths.") - - parser.add_argument("-D", action="append", type=str, metavar="=", dest="definitions", - help="Specify a compiler definition, i.e. define to (or 1 if omitted).") - - parser.add_argument("header", type=str, nargs='+', help="A header file to process.") + parser.add_argument( + "-o", + "--output", + action="store", + type=str, + dest="output", + metavar="", + help="Write to the specified file (default: use stdout).", + ) + + parser.add_argument( + "-w", + "--width", + action="store", + type=int, + dest="width", + metavar="", + help="Specify docstring width before wrapping.", + ) + + parser.add_argument( + "-I", + action="append", + type=str, + dest="include_dirs", + metavar="", + help="Specify an directory to add to the list of include search paths.", + ) + + parser.add_argument( + "-D", + action="append", + type=str, + metavar="=", + dest="definitions", + help="Specify a compiler definition, i.e. define to (or 1 if omitted).", + ) + + parser.add_argument("header", type=str, nargs="+", help="A header file to process.") [parsed_args, unparsed_args] = parser.parse_known_args() @@ -130,8 +152,7 @@ def main(): # append argument as is and hope for the best mkdoc_args.append(arg) - for header in parsed_args.header: - mkdoc_args.append(header) + mkdoc_args.extend(header for header in parsed_args.header) mkdoc(mkdoc_args, docstring_width, mkdoc_out) diff --git a/pybind11_mkdoc/__main__.py b/pybind11_mkdoc/__main__.py index 95acc7c..f76afb9 100644 --- a/pybind11_mkdoc/__main__.py +++ b/pybind11_mkdoc/__main__.py @@ -1,5 +1,4 @@ - if __name__ == "__main__": - from . import main - main() + from pybind11_mkdoc import main + main() diff --git a/pybind11_mkdoc/mkdoc_lib.py b/pybind11_mkdoc/mkdoc_lib.py index 6f4c6b4..c34c82a 100755 --- a/pybind11_mkdoc/mkdoc_lib.py +++ b/pybind11_mkdoc/mkdoc_lib.py @@ -1,25 +1,24 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- # # Syntax: mkdoc.py [-I ..] [.. a list of header files ..] # # Extract documentation from C++ header files to use it in Python bindings # +import contextlib +import ctypes.util import os -import sys import platform import re +import sys import textwrap - -import ctypes.util - -from clang import cindex -from clang.cindex import CursorKind from collections import OrderedDict from glob import glob -from threading import Thread, Semaphore from multiprocessing import cpu_count +from threading import Semaphore, Thread + +from clang import cindex +from clang.cindex import CursorKind RECURSE_LIST = [ CursorKind.TRANSLATION_UNIT, @@ -27,7 +26,7 @@ CursorKind.CLASS_DECL, CursorKind.STRUCT_DECL, CursorKind.ENUM_DECL, - CursorKind.CLASS_TEMPLATE + CursorKind.CLASS_TEMPLATE, ] PRINT_LIST = [ @@ -41,30 +40,54 @@ CursorKind.CONVERSION_FUNCTION, CursorKind.CXX_METHOD, CursorKind.CONSTRUCTOR, - CursorKind.FIELD_DECL + CursorKind.FIELD_DECL, ] -PREFIX_BLACKLIST = [ - CursorKind.TRANSLATION_UNIT -] +PREFIX_BLACKLIST = [CursorKind.TRANSLATION_UNIT] CPP_OPERATORS = { - '<=': 'le', '>=': 'ge', '==': 'eq', '!=': 'ne', '[]': 'array', - '+=': 'iadd', '-=': 'isub', '*=': 'imul', '/=': 'idiv', '%=': - 'imod', '&=': 'iand', '|=': 'ior', '^=': 'ixor', '<<=': 'ilshift', - '>>=': 'irshift', '++': 'inc', '--': 'dec', '<<': 'lshift', '>>': - 'rshift', '&&': 'land', '||': 'lor', '!': 'lnot', '~': 'bnot', - '&': 'band', '|': 'bor', '+': 'add', '-': 'sub', '*': 'mul', '/': - 'div', '%': 'mod', '<': 'lt', '>': 'gt', '=': 'assign', '()': 'call' + "<=": "le", + ">=": "ge", + "==": "eq", + "!=": "ne", + "[]": "array", + "+=": "iadd", + "-=": "isub", + "*=": "imul", + "/=": "idiv", + "%=": "imod", + "&=": "iand", + "|=": "ior", + "^=": "ixor", + "<<=": "ilshift", + ">>=": "irshift", + "++": "inc", + "--": "dec", + "<<": "lshift", + ">>": "rshift", + "&&": "land", + "||": "lor", + "!": "lnot", + "~": "bnot", + "&": "band", + "|": "bor", + "+": "add", + "-": "sub", + "*": "mul", + "/": "div", + "%": "mod", + "<": "lt", + ">": "gt", + "=": "assign", + "()": "call", } -CPP_OPERATORS = OrderedDict( - sorted(CPP_OPERATORS.items(), key=lambda t: -len(t[0]))) +CPP_OPERATORS = OrderedDict(sorted(CPP_OPERATORS.items(), key=lambda t: -len(t[0]))) job_count = cpu_count() job_semaphore = Semaphore(job_count) errors_detected = False -docstring_width = int(70) +docstring_width = 70 class NoFilenamesError(ValueError): @@ -72,112 +95,104 @@ class NoFilenamesError(ValueError): def d(s): - return s if isinstance(s, str) else s.decode('utf8') + return s if isinstance(s, str) else s.decode("utf8") def sanitize_name(name): - name = re.sub(r'type-parameter-0-([0-9]+)', r'T\1', name) + name = re.sub(r"type-parameter-0-([0-9]+)", r"T\1", name) for k, v in CPP_OPERATORS.items(): - name = name.replace('operator%s' % k, 'operator_%s' % v) - name = re.sub('<.*>', '', name) - name = ''.join([ch if ch.isalnum() else '_' for ch in name]) - name = re.sub('_$', '', re.sub('_+', '_', name)) - return 'mkd_doc_' + name + name = name.replace(f"operator{k}", f"operator_{v}") + name = re.sub("<.*>", "", name) + name = "".join([ch if ch.isalnum() else "_" for ch in name]) + name = re.sub("_$", "", re.sub("_+", "_", name)) + return "mkd_doc_" + name def process_comment(comment): - result = '' + result = "" # Remove C++ comment syntax - leading_spaces = float('inf') + leading_spaces = float("inf") for s in comment.expandtabs(tabsize=4).splitlines(): s = s.strip() - if s.endswith('*/'): - s = s[:-2].rstrip('*') - if s.startswith('/*'): - s = s[2:].lstrip('*!<') - elif s.startswith('//'): - s = s[2:].lstrip('/!<') - elif s.startswith('*'): + if s.endswith("*/"): + s = s[:-2].rstrip("*") + if s.startswith("/*"): + s = s[2:].lstrip("*!<") + elif s.startswith("//"): + s = s[2:].lstrip("/!<") + elif s.startswith("*"): s = s[1:] if len(s) > 0: leading_spaces = min(leading_spaces, len(s) - len(s.lstrip())) - result += s + '\n' + result += s + "\n" - if leading_spaces != float('inf'): + if leading_spaces != float("inf"): result2 = "" for s in result.splitlines(): - result2 += s[leading_spaces:] + '\n' + result2 += s[leading_spaces:] + "\n" result = result2 # Doxygen tags - cpp_group = r'([^\s]+)' - param_group = r'([\[\w:,\]]+)' + cpp_group = r"([^\s]+)" + param_group = r"([\[\w:,\]]+)" s = result - s = re.sub(r'[\\@][cp]\s+%s' % cpp_group, r'``\1``', s) - s = re.sub(r'[\\@]a\s+%s' % cpp_group, r'*\1*', s) - s = re.sub(r'[\\@]e\s+%s' % cpp_group, r'*\1*', s) - s = re.sub(r'[\\@]em\s+%s' % cpp_group, r'*\1*', s) - s = re.sub(r'[\\@]b\s+%s' % cpp_group, r'**\1**', s) - s = re.sub(r'[\\@]ingroup\s+%s' % cpp_group, r'', s) - s = re.sub(r'[\\@]param%s?\s+%s' % (param_group, cpp_group), - r'\n\n$Parameter ``\2``:\n\n', s) - s = re.sub(r'[\\@]tparam%s?\s+%s' % (param_group, cpp_group), - r'\n\n$Template parameter ``\2``:\n\n', s) + s = re.sub(rf"[\\@][cp]\s+{cpp_group}", r"``\1``", s) + s = re.sub(rf"[\\@]a\s+{cpp_group}", r"*\1*", s) + s = re.sub(rf"[\\@]e\s+{cpp_group}", r"*\1*", s) + s = re.sub(rf"[\\@]em\s+{cpp_group}", r"*\1*", s) + s = re.sub(rf"[\\@]b\s+{cpp_group}", r"**\1**", s) + s = re.sub(rf"[\\@]ingroup\s+{cpp_group}", r"", s) + s = re.sub(rf"[\\@]param{param_group}?\s+{cpp_group}", r"\n\n$Parameter ``\2``:\n\n", s) + s = re.sub(rf"[\\@]tparam{param_group}?\s+{cpp_group}", r"\n\n$Template parameter ``\2``:\n\n", s) # Remove class and struct tags - s = re.sub(r'[\\@](class|struct)\s+.*', '', s) + s = re.sub(r"[\\@](class|struct)\s+.*", "", s) for in_, out_ in { - 'returns': 'Returns', - 'return': 'Returns', - 'authors': 'Authors', - 'author': 'Author', - 'copyright': 'Copyright', - 'date': 'Date', - 'remark': 'Remark', - 'sa': 'See also', - 'see': 'See also', - 'extends': 'Extends', - 'exception': 'Throws', - 'throws': 'Throws', - 'throw': 'Throws' + "returns": "Returns", + "return": "Returns", + "authors": "Authors", + "author": "Author", + "copyright": "Copyright", + "date": "Date", + "remark": "Remark", + "sa": "See also", + "see": "See also", + "extends": "Extends", + "exception": "Throws", + "throws": "Throws", + "throw": "Throws", }.items(): - s = re.sub(r'[\\@]%s\s*' % in_, r'\n\n$%s:\n\n' % out_, s) - - s = re.sub(r'[\\@]details\s*', r'\n\n', s) - s = re.sub(r'[\\@]brief\s*', r'', s) - s = re.sub(r'[\\@]short\s*', r'', s) - s = re.sub(r'[\\@]ref\s*', r'', s) - - s = re.sub(r'[\\@]code\s?(.*?)\s?[\\@]endcode', - r"```\n\1\n```\n", s, flags=re.DOTALL) - s = re.sub(r'[\\@]warning\s?(.*?)\s?\n\n', - r'\n\n$.. warning::\n\n\1\n\n', s, flags=re.DOTALL) - s = re.sub(r'[\\@]note\s?(.*?)\s?\n\n', - r'\n\n$.. note::\n\n\1\n\n', s, flags=re.DOTALL) + s = re.sub(rf"[\\@]{in_}\s*", rf"\n\n${out_}:\n\n", s) + + s = re.sub(r"[\\@]details\s*", r"\n\n", s) + s = re.sub(r"[\\@]brief\s*", r"", s) + s = re.sub(r"[\\@]short\s*", r"", s) + s = re.sub(r"[\\@]ref\s*", r"", s) + + s = re.sub(r"[\\@]code\s?(.*?)\s?[\\@]endcode", r"```\n\1\n```\n", s, flags=re.DOTALL) + s = re.sub(r"[\\@]warning\s?(.*?)\s?\n\n", r"\n\n$.. warning::\n\n\1\n\n", s, flags=re.DOTALL) + s = re.sub(r"[\\@]note\s?(.*?)\s?\n\n", r"\n\n$.. note::\n\n\1\n\n", s, flags=re.DOTALL) # Deprecated expects a version number for reST and not for Doxygen. Here the first word of the # doxygen directives is assumed to correspond to the version number - s = re.sub(r'[\\@]deprecated\s(.*?)\s?(.*?)\s?\n\n', - r'$.. deprecated:: \1\n\n\2\n\n', s, flags=re.DOTALL) - s = re.sub(r'[\\@]since\s?(.*?)\s?\n\n', - r'.. versionadded:: \1\n\n', s, flags=re.DOTALL) - s = re.sub(r'[\\@]todo\s?(.*?)\s?\n\n', - r'$.. todo::\n\n\1\n\n', s, flags=re.DOTALL) + s = re.sub(r"[\\@]deprecated\s(.*?)\s?(.*?)\s?\n\n", r"$.. deprecated:: \1\n\n\2\n\n", s, flags=re.DOTALL) + s = re.sub(r"[\\@]since\s?(.*?)\s?\n\n", r".. versionadded:: \1\n\n", s, flags=re.DOTALL) + s = re.sub(r"[\\@]todo\s?(.*?)\s?\n\n", r"$.. todo::\n\n\1\n\n", s, flags=re.DOTALL) # HTML/TeX tags - s = re.sub(r'(.*?)', r'``\1``', s, flags=re.DOTALL) - s = re.sub(r'
(.*?)
', r"```\n\1\n```\n", s, flags=re.DOTALL) - s = re.sub(r'(.*?)', r'*\1*', s, flags=re.DOTALL) - s = re.sub(r'(.*?)', r'**\1**', s, flags=re.DOTALL) - s = re.sub(r'[\\@]f\$(.*?)[\\@]f\$', r':math:`\1`', s, flags=re.DOTALL) - s = re.sub(r'
  • ', r'\n\n* ', s) - s = re.sub(r'', r'', s) - s = re.sub(r'
  • ', r'\n\n', s) - - s = s.replace('``true``', '``True``') - s = s.replace('``false``', '``False``') + s = re.sub(r"(.*?)", r"``\1``", s, flags=re.DOTALL) + s = re.sub(r"
    (.*?)
    ", r"```\n\1\n```\n", s, flags=re.DOTALL) + s = re.sub(r"(.*?)", r"*\1*", s, flags=re.DOTALL) + s = re.sub(r"(.*?)", r"**\1**", s, flags=re.DOTALL) + s = re.sub(r"[\\@]f\$(.*?)[\\@]f\$", r":math:`\1`", s, flags=re.DOTALL) + s = re.sub(r"
  • ", r"\n\n* ", s) + s = re.sub(r"", r"", s) + s = re.sub(r"
  • ", r"\n\n", s) + + s = s.replace("``true``", "``True``") + s = s.replace("``false``", "``False``") # Re-flow text wrapper = textwrap.TextWrapper() @@ -185,54 +200,55 @@ def process_comment(comment): wrapper.replace_whitespace = True wrapper.drop_whitespace = True wrapper.width = docstring_width - wrapper.initial_indent = wrapper.subsequent_indent = '' + wrapper.initial_indent = wrapper.subsequent_indent = "" - result = '' + result = "" in_code_segment = False - for x in re.split(r'(```)', s): - if x == '```': + for x in re.split(r"(```)", s): + if x == "```": if not in_code_segment: - result += '```\n' + result += "```\n" else: - result += '\n```\n\n' + result += "\n```\n\n" in_code_segment = not in_code_segment elif in_code_segment: result += x.strip() else: - for y in re.split(r'(?: *\n *){2,}', x): - wrapped = wrapper.fill(re.sub(r'\s+', ' ', y).strip()) - if len(wrapped) > 0 and wrapped[0] == '$': - result += wrapped[1:] + '\n' - wrapper.initial_indent = \ - wrapper.subsequent_indent = ' ' * 4 + for y in re.split(r"(?: *\n *){2,}", x): + wrapped = wrapper.fill(re.sub(r"\s+", " ", y).strip()) + if len(wrapped) > 0 and wrapped[0] == "$": + result += wrapped[1:] + "\n" + wrapper.initial_indent = wrapper.subsequent_indent = " " * 4 else: if len(wrapped) > 0: - result += wrapped + '\n\n' - wrapper.initial_indent = wrapper.subsequent_indent = '' - return result.rstrip().lstrip('\n') + result += wrapped + "\n\n" + wrapper.initial_indent = wrapper.subsequent_indent = "" + return result.rstrip().lstrip("\n") def extract(filename, node, prefix, output): - if not (node.location.file is None or - os.path.samefile(d(node.location.file.name), filename)): + if not (node.location.file is None or os.path.samefile(d(node.location.file.name), filename)): return 0 if node.kind in RECURSE_LIST: sub_prefix = prefix if node.kind not in PREFIX_BLACKLIST: if len(sub_prefix) > 0: - sub_prefix += '_' + sub_prefix += "_" sub_prefix += d(node.spelling) for i in node.get_children(): extract(filename, i, sub_prefix, output) if node.kind in PRINT_LIST: - comment = d(node.raw_comment) if node.raw_comment is not None else '' + comment = d(node.raw_comment) if node.raw_comment is not None else "" comment = process_comment(comment) sub_prefix = prefix if len(sub_prefix) > 0: - sub_prefix += '_' + sub_prefix += "_" if len(node.spelling) > 0: name = sanitize_name(sub_prefix + d(node.spelling)) output.append((name, filename, comment)) + return None + return None + return None class ExtractionThread(Thread): @@ -245,12 +261,10 @@ def __init__(self, filename, parameters, output): def run(self): global errors_detected - print('Processing "%s" ..' % self.filename, file=sys.stderr) try: - index = cindex.Index( - cindex.conf.lib.clang_createIndex(False, True)) + index = cindex.Index(cindex.conf.lib.clang_createIndex(False, True)) tu = index.parse(self.filename, self.parameters) - extract(self.filename, tu.cursor, '', self.output) + extract(self.filename, tu.cursor, "", self.output) except BaseException: errors_detected = True raise @@ -262,55 +276,62 @@ def read_args(args): parameters = [] filenames = [] if "-x" not in args: - parameters.extend(['-x', 'c++']) + parameters.extend(["-x", "c++"]) if not any(it.startswith("-std=") for it in args): - parameters.append('-std=c++11') - parameters.append('-Wno-pragma-once-outside-header') + parameters.append("-std=c++11") + parameters.append("-Wno-pragma-once-outside-header") - if platform.system() == 'Darwin': - dev_path = '/Applications/Xcode.app/Contents/Developer/' - lib_dir = dev_path + 'Toolchains/XcodeDefault.xctoolchain/usr/lib/' - sdk_dir = dev_path + 'Platforms/MacOSX.platform/Developer/SDKs' - libclang = lib_dir + 'libclang.dylib' + if platform.system() == "Darwin": + dev_path = "/Applications/Xcode.app/Contents/Developer/" + lib_dir = dev_path + "Toolchains/XcodeDefault.xctoolchain/usr/lib/" + sdk_dir = dev_path + "Platforms/MacOSX.platform/Developer/SDKs" + libclang = lib_dir + "libclang.dylib" if os.path.exists(libclang): cindex.Config.set_library_path(os.path.dirname(libclang)) if os.path.exists(sdk_dir): sysroot_dir = os.path.join(sdk_dir, next(os.walk(sdk_dir))[1][0]) - parameters.append('-isysroot') + parameters.append("-isysroot") parameters.append(sysroot_dir) - elif platform.system() == 'Windows': - if 'LIBCLANG_PATH' in os.environ: - library_file = os.environ['LIBCLANG_PATH'] + elif platform.system() == "Windows": + if "LIBCLANG_PATH" in os.environ: + library_file = os.environ["LIBCLANG_PATH"] if os.path.isfile(library_file): cindex.Config.set_library_file(library_file) else: - raise FileNotFoundError("Failed to find libclang.dll! " - "Set the LIBCLANG_PATH environment variable to provide a path to it.") + msg = ( + "Failed to find libclang.dll! " + "Set the LIBCLANG_PATH environment variable to provide a path to it." + ) + raise FileNotFoundError(msg) else: - library_file = ctypes.util.find_library('libclang.dll') + library_file = ctypes.util.find_library("libclang.dll") if library_file is not None: cindex.Config.set_library_file(library_file) - elif platform.system() == 'Linux': + elif platform.system() == "Linux": # LLVM switched to a monolithical setup that includes everything under # /usr/lib/llvm{version_number}/. We glob for the library and select # the highest version def folder_version(d): - return [int(ver) for ver in re.findall(r'(?