From 79aeff5776e443beac0e01fb898df652b86535a7 Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Thu, 28 May 2026 17:36:20 +0200 Subject: [PATCH 01/11] Provide a new implementation of compute-PATCHVERSION.py in Python 3 --- rpm/compute-PATCHVERSION.py | 1 + scripts/python/kutil/config.py | 335 +++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+) create mode 120000 rpm/compute-PATCHVERSION.py mode change 100644 => 100755 scripts/python/kutil/config.py diff --git a/rpm/compute-PATCHVERSION.py b/rpm/compute-PATCHVERSION.py new file mode 120000 index 000000000000..0b6a727b9286 --- /dev/null +++ b/rpm/compute-PATCHVERSION.py @@ -0,0 +1 @@ +../scripts/python/kutil/config.py \ No newline at end of file diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py old mode 100644 new mode 100755 index 95e12cab7994..b7056f20efd7 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -1,7 +1,46 @@ +#! /usr/bin/env python3 +""" +Synopsis: determine the current kernel source version from a series of patches + as being defined in series.conf + +Usage: {appname} [-hVvp] + -h, --help this message + -V, --version print version and exit + -v, --verbose verbose mode (cumulative) + -p, --patches dir base directory, holding rpm/config.sh, series.conf + and patches.*, referenced in series.conf + +Description: +The executable part of this script replaces the old compute-PATCHVERSION.sh +script. It is expected to be executed in the kernel-source base folder, e.g.: + + ./rpm/compute-PATCHVERSION.py + +Otherwise provide the --patches argument with the preferred base directory. + +This file is typically a symlink to ../scripts/python/kutil/config.py. + +It fetches the kernel source version from ./rpm/config.sh, then parses the +./series.conf file, collecting all patch files, and tracks for any changes in +top level Makefiles to the four version defining symbols: VERSION, PATCHLEVEL, +SUBLEVEL, and EXTRAVERSION. The result should consitute the latest kernel +patch level. + +Version: {version} +Copyright: (c)2026 by {company} +Author: {author} +License: {license} +""" +# +# vim:set et ts=8 sw=4: +# + import configparser import subprocess import os +# some commonly used functions + def uniq(lst): # fairly slow but does not require any special property of elements nor ordered dictionaries return [x for i, x in enumerate(lst) if i == lst.index(x)] @@ -149,3 +188,299 @@ def get_package_archs(package_tar_up_dir, limit_packages=None): assert '%' not in l # will need to do more macro expansion otherwise archs += l.split(' ') return sorted(list(set(archs))) + +# here starts the new compute-PATCHVERSION.py implementation + +if __name__ == "__main__": + import os + import re + import sys + import shlex + import getopt + import signal + + __version__ = '0.1' + __company__ = 'SUSE LLC' + __author__ = 'Hans-Peter Jansen ' + __license__ = 'GNU GPL v2 - see http://www.gnu.org/licenses/gpl2.txt for details' + + class gpar: + """Global parameter class""" + appdir, appname = os.path.split(sys.argv[0]) + if appdir == '.': + appdir = os.getcwd() + if appname.endswith('.py'): + appname = appname[:-3] + pid = os.getpid() + version = __version__ + company = __company__ + author = __author__ + license = __license__ + loglevel = 0 + basedir = '.' + + + stdout = lambda *msg: print(*msg, file = sys.stdout, flush = True) + stderr = lambda *msg: print(*msg, file = sys.stderr, flush = True) + + + def vout(lvl, *msg): + """Verbose output""" + if lvl <= gpar.loglevel: + stderr(*msg) + + + def exit(ret = 0, msg = None, usage = False): + """Terminate process with optional message and usage""" + if msg: + stderr('{}: {}'.format(gpar.appname, msg)) + if usage: + stderr(__doc__.format(**gpar.__dict__)) + sys.exit(ret) + + + def parse_config_sh(config_sh): + """Parse config.sh file and return a dict with key value pairs""" + config = {} + lnnr = 0 + with open(config_sh, 'r') as f: + for line in f: + lnnr += 1 + line = line.strip() + if not line or line.startswith('#'): + continue + + if '=' in line: + key, raw_value = line.split('=', 1) + key = key.strip() + # shlex removes the outer quotation marks cleanly + parsed_tokens = shlex.split(raw_value) + value = parsed_tokens[0] if parsed_tokens else "" + config[key] = value + else: + raise ValueError('line {} malformed in {}: "{}"'.format(lnnr, config_sh, line)) + + return config + + + class SrcVersion: + """Class, defining a source code version allows parsing from string, updating single values + and returns the resulting version as string repr""" + def __init__(self, version_str): + self.version = '0' + self.patchlevel = '0' + self.sublevel = '0' + self.extraversion = '' + self._partlist = ('version', 'patchlevel', 'sublevel', 'extraversion') + pattern = re.compile(r''' + ^ # Start of line + (?P\d+) # Required: version number + \. # Required: version dot + (?P\d+) # Required: patchlevel number + (?: # Start of non-capturing group + \.(?P\d+) # Optional: sublevel number + )? # End of group + (?P.*) # Optional: extra version + $ # End of line + ''', re.VERBOSE) + + match = re.match(pattern, version_str) + if not match: + raise ValueError('Invalid version str: "{}". Expecting X[.Y][.Z][-extra]'.format(version_str)) + for part in self._partlist: + value = match.group(part) + if value is not None: + self.update(part, value) + + def update(self, part, value): + """Update a specific version component to a new value""" + if part in self._partlist: + setattr(self, part, value) + + def __str__(self): + return '{version}.{patchlevel}.{sublevel}{extraversion}'.format(**self.__dict__) + + + def parse_series_conf(basedir, series_conf): + """Parse the series.conf file, taking guards into account, and return a list of patch files""" + pattern = re.compile(r''' + ^ # Start of line + (?: # Start of non-capturing group for sign and symbol + (?P[+-]) # Required if group matches: Matches a single '+' or '-' sign + (?P[a-zA-Z0-9]+)? # Optional: Matches alphanumeric symbol only after a sign + )? # End of group: The entire sign+symbol block is optional + \s* # Optional: Ignores any subsequent whitespace characters + (?P\S+) # Required: Matches the filename (one or more non-whitespace characters) + $ # End of line + ''', re.VERBOSE) + + patches = [] + lnnr = 0 + with open(series_conf, 'r') as f: + for line in f: + lnnr += 1 + line = line.strip() + if not line or line.startswith('#'): + continue + + m = re.match(pattern, line) + if m: + guard = m['sign'] + if guard: + # guarded line + if guard == '+': + vout(2, '{}: patch in line {} flagged: {}'.format(series_conf, lnnr, line)) + else: + # guard == '-': + vout(2, '{}: patch in line {} excluded: {}'.format(series_conf, lnnr, line)) + patch = os.path.join(basedir, m['patch']) + if not os.access(patch, os.R_OK): + raise ValueError('{}: patch {} in line {} not readable'.format(series_conf, patch, lnnr)) + if patch in patches: + raise ValueError('{}: patch {} in line {} named twice'.format(series_conf, patch, lnnr)) + patches.append(patch) + else: + raise ValueError('{}: line {} malformed: "{}"'.format(series_conf, lnnr, line)) + + return patches + + + def parse_makefiles(diff_text): + """Locate changes to the toplevel Makefile in a unified diff file + return applied changesets to the linux kernel version variables""" + # match top level Makefile + makefile_target = re.compile(r''' + ^ # Anchor a start of line + (---|\+\+\+) # Match either +++ or --- + \s+ # Skip blinks + (?P[^\/]+/Makefile) # Extract Makefile with single slash + ( |\t|$) # May end in a blank, tab or end of line + ''', re.VERBOSE) + # match variable change pattern + var_pattern = re.compile(r''' + ^ # Anchor at start of line + (?P[-+]) # Required: action is either '+' or '-' + \s* # Skip optional blanks + (?PVERSION|PATCHLEVEL|SUBLEVEL|EXTRAVERSION) # Required: key value is one of these + \s*=\s* # Required: assignment with optional blanks + (?P.*) # Required: any value, even an empty one + ''', re.VERBOSE) + + in_makefile = False + changes = [] + + for line in diff_text.splitlines(): + if line.startswith(('--- ', '+++ ')): + match = makefile_target.match(line) + if match: + # we're in a toplevel Makefile diff section now + in_makefile = True + current_file = match.group('path') + vout(4, 'parse_makefiles: {}'.format(current_file)) + else: + # we're in some other files modification context + in_makefile = False + + if not in_makefile: + continue + + # extract version variable changes + match = var_pattern.match(line) + if match: + changes.append( + { + # which Makefile + 'file': current_file, + # either + (added) or - (removed) + 'action': match.group('action'), + # which variable (and avoid shouting loudly) + 'variable': match.group('key').lower(), + # added (new) or removed (old) value + 'value': match.group('value').strip(), + } + ) + + return changes + + + def compute(): + """Compute patchversion from config.sh, series.conf and patch files""" + ret = 0 + vout(3, 'started with pid {pid} in {appdir}'.format(**gpar.__dict__)) + basedir = gpar.basedir + if not os.path.isdir(basedir): + exit(1, 'patches basedir {} not found'.format(basedir)) + + # fetch key value pairs from config.sh + config_sh = os.path.join(basedir, 'rpm/config.sh') + config = parse_config_sh(config_sh) + vout(2, 'config.sh: {}'.format(config)) + + # determine kernel base source code version + src_version = SrcVersion(config['SRCVERSION']) + vout(1, 'base source version is: {}'.format(src_version)) + + # fetch patch files from series.conf + series_conf = os.path.join(basedir, 'series.conf') + patches = parse_series_conf(basedir, series_conf) + vout(4, 'patches: {}'.format(patches)) + + # collect Makefile changesets from patch files + changes = [] + for pfn in patches: + with open(pfn, 'r') as f: + changeset = parse_makefiles(f.read()) + if changeset: + vout(3, 'parse_matches: {}: {}'.format(pfn, changeset)) + changes.append(changeset) + + # iterate over all changesets, and apply the additions + # TODO: do we need to care about the removals? + # e.g. we do not handle removal of extraversion from within a patchset + for changeset in changes: + for ch in changeset: + vout(3, '{}'.format(ch)) + if ch['action'] == '+': + src_version.update(ch['variable'], ch['value']) + + # provide the result on stdout + stdout(src_version) + + return ret + + + def main(argv = None): + """Command line interface and console script entry point.""" + if argv is None: + argv = sys.argv[1:] + + try: + optlist, args = getopt.getopt(argv, 'hVvp:', + ('help', 'version', 'verbose', 'patches=') + ) + except getopt.error as msg: + exit(1, msg, True) + + for opt, par in optlist: + if opt in ('-h', '--help'): + exit(usage = True) + elif opt in ('-V', '--version'): + exit(msg = 'version {}'.format(gpar.version)) + elif opt in ('-v', '--verbose'): + gpar.loglevel += 1 + elif opt in ('-p', '--patches'): + gpar.basedir = par + + # ignore broken pipe errors (SIGPIPE) + signal.signal(signal.SIGPIPE, signal.SIG_DFL) + + try: + return compute() + except (ValueError, IOError) as exc: + stderr('Sorry, we hit a snag: {}'.format(exc)) + return 1 + except KeyboardInterrupt: + return 3 # SIGQUIT + + if __name__ == '__main__': + sys.exit(main()) From e840f08635db6c26271345ed190b2ea6ff6df40e Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Thu, 28 May 2026 19:16:24 +0200 Subject: [PATCH 02/11] compute-PATCHVERSION.py: handle trailing garbarge in series.conf --- scripts/python/kutil/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index b7056f20efd7..7aed836f10c4 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -311,6 +311,7 @@ def parse_series_conf(basedir, series_conf): )? # End of group: The entire sign+symbol block is optional \s* # Optional: Ignores any subsequent whitespace characters (?P\S+) # Required: Matches the filename (one or more non-whitespace characters) + .*? # ignore any trailing garbarge $ # End of line ''', re.VERBOSE) From fc80fc48e80d4aec2d3c8381d2d33ad379201489 Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Fri, 29 May 2026 17:38:19 +0200 Subject: [PATCH 03/11] compute-PATCHVERSION.py: apply some low hanging optimization fruits --- scripts/python/kutil/config.py | 34 ++++++++++++++-------------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index 7aed836f10c4..e3075833b6f4 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -311,7 +311,7 @@ def parse_series_conf(basedir, series_conf): )? # End of group: The entire sign+symbol block is optional \s* # Optional: Ignores any subsequent whitespace characters (?P\S+) # Required: Matches the filename (one or more non-whitespace characters) - .*? # ignore any trailing garbarge + .*? # Optional: Ignore any trailing garbarge $ # End of line ''', re.VERBOSE) @@ -335,10 +335,6 @@ def parse_series_conf(basedir, series_conf): # guard == '-': vout(2, '{}: patch in line {} excluded: {}'.format(series_conf, lnnr, line)) patch = os.path.join(basedir, m['patch']) - if not os.access(patch, os.R_OK): - raise ValueError('{}: patch {} in line {} not readable'.format(series_conf, patch, lnnr)) - if patch in patches: - raise ValueError('{}: patch {} in line {} named twice'.format(series_conf, patch, lnnr)) patches.append(patch) else: raise ValueError('{}: line {} malformed: "{}"'.format(series_conf, lnnr, line)) @@ -360,7 +356,7 @@ def parse_makefiles(diff_text): # match variable change pattern var_pattern = re.compile(r''' ^ # Anchor at start of line - (?P[-+]) # Required: action is either '+' or '-' + \+ # Required: we care about additions ('+') only \s* # Skip optional blanks (?PVERSION|PATCHLEVEL|SUBLEVEL|EXTRAVERSION) # Required: key value is one of these \s*=\s* # Required: assignment with optional blanks @@ -385,15 +381,14 @@ def parse_makefiles(diff_text): if not in_makefile: continue + if line.startswith((' ', '@@')): + continue + # extract version variable changes match = var_pattern.match(line) if match: changes.append( { - # which Makefile - 'file': current_file, - # either + (added) or - (removed) - 'action': match.group('action'), # which variable (and avoid shouting loudly) 'variable': match.group('key').lower(), # added (new) or removed (old) value @@ -430,19 +425,18 @@ def compute(): changes = [] for pfn in patches: with open(pfn, 'r') as f: - changeset = parse_makefiles(f.read()) - if changeset: - vout(3, 'parse_matches: {}: {}'.format(pfn, changeset)) - changes.append(changeset) - - # iterate over all changesets, and apply the additions - # TODO: do we need to care about the removals? - # e.g. we do not handle removal of extraversion from within a patchset + patch_data = f.read() + if 'Makefile' in patch_data: + changeset = parse_makefiles(patch_data) + if changeset: + vout(3, 'parse_matches: {}: {}'.format(pfn, changeset)) + changes.append(changeset) + + # iterate over all changesets, and apply them for changeset in changes: for ch in changeset: vout(3, '{}'.format(ch)) - if ch['action'] == '+': - src_version.update(ch['variable'], ch['value']) + src_version.update(ch['variable'], ch['value']) # provide the result on stdout stdout(src_version) From ba9d38b071bf0492c7a10e172b3bff9edd9f5f71 Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Fri, 29 May 2026 18:00:52 +0200 Subject: [PATCH 04/11] compute-PATCHVERSION.py: separate basedir and patchdir parameter --- scripts/python/kutil/config.py | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index e3075833b6f4..6f460e9af75b 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -3,12 +3,14 @@ Synopsis: determine the current kernel source version from a series of patches as being defined in series.conf -Usage: {appname} [-hVvp] +Usage: {appname} [-hVvb:p:] -h, --help this message -V, --version print version and exit -v, --verbose verbose mode (cumulative) - -p, --patches dir base directory, holding rpm/config.sh, series.conf - and patches.*, referenced in series.conf + -b, --basedir dir base directory, holding rpm/config.sh, series.conf, + default: '{basedir}' + -p, --patches dir directory, where patches.* reside, referenced in + series.conf, default: '{patches}' Description: The executable part of this script replaces the old compute-PATCHVERSION.sh @@ -16,8 +18,6 @@ ./rpm/compute-PATCHVERSION.py -Otherwise provide the --patches argument with the preferred base directory. - This file is typically a symlink to ../scripts/python/kutil/config.py. It fetches the kernel source version from ./rpm/config.sh, then parses the @@ -218,6 +218,7 @@ class gpar: license = __license__ loglevel = 0 basedir = '.' + patches = '.' stdout = lambda *msg: print(*msg, file = sys.stdout, flush = True) @@ -334,8 +335,7 @@ def parse_series_conf(basedir, series_conf): else: # guard == '-': vout(2, '{}: patch in line {} excluded: {}'.format(series_conf, lnnr, line)) - patch = os.path.join(basedir, m['patch']) - patches.append(patch) + patches.append(m['patch']) else: raise ValueError('{}: line {} malformed: "{}"'.format(series_conf, lnnr, line)) @@ -399,11 +399,9 @@ def parse_makefiles(diff_text): return changes - def compute(): + def compute(basedir, patchdir): """Compute patchversion from config.sh, series.conf and patch files""" ret = 0 - vout(3, 'started with pid {pid} in {appdir}'.format(**gpar.__dict__)) - basedir = gpar.basedir if not os.path.isdir(basedir): exit(1, 'patches basedir {} not found'.format(basedir)) @@ -424,6 +422,7 @@ def compute(): # collect Makefile changesets from patch files changes = [] for pfn in patches: + pfn = os.path.join(patchdir, pfn) with open(pfn, 'r') as f: patch_data = f.read() if 'Makefile' in patch_data: @@ -450,8 +449,8 @@ def main(argv = None): argv = sys.argv[1:] try: - optlist, args = getopt.getopt(argv, 'hVvp:', - ('help', 'version', 'verbose', 'patches=') + optlist, args = getopt.getopt(argv, 'hVvb:p:', + ('help', 'version', 'verbose', 'basedir=', 'patches=') ) except getopt.error as msg: exit(1, msg, True) @@ -463,14 +462,17 @@ def main(argv = None): exit(msg = 'version {}'.format(gpar.version)) elif opt in ('-v', '--verbose'): gpar.loglevel += 1 - elif opt in ('-p', '--patches'): + elif opt in ('-b', '--basedir'): gpar.basedir = par + elif opt in ('-p', '--patches'): + gpar.patches = par # ignore broken pipe errors (SIGPIPE) signal.signal(signal.SIGPIPE, signal.SIG_DFL) + vout(3, 'started with pid {pid} in {appdir}'.format(**gpar.__dict__)) try: - return compute() + return compute(gpar.basedir, gpar.patches) except (ValueError, IOError) as exc: stderr('Sorry, we hit a snag: {}'.format(exc)) return 1 From e30bdca07a204458bf3c68cb647f7196ed785b12 Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Sun, 31 May 2026 12:06:50 +0200 Subject: [PATCH 05/11] compute-PATCHVERSION.py: optimize parse_series_conf --- scripts/python/kutil/config.py | 43 ++++++++++++++++------------------ 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index 6f460e9af75b..5fc384c0b093 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -304,40 +304,37 @@ def __str__(self): def parse_series_conf(basedir, series_conf): """Parse the series.conf file, taking guards into account, and return a list of patch files""" - pattern = re.compile(r''' - ^ # Start of line - (?: # Start of non-capturing group for sign and symbol - (?P[+-]) # Required if group matches: Matches a single '+' or '-' sign - (?P[a-zA-Z0-9]+)? # Optional: Matches alphanumeric symbol only after a sign - )? # End of group: The entire sign+symbol block is optional - \s* # Optional: Ignores any subsequent whitespace characters - (?P\S+) # Required: Matches the filename (one or more non-whitespace characters) - .*? # Optional: Ignore any trailing garbarge - $ # End of line - ''', re.VERBOSE) - patches = [] lnnr = 0 with open(series_conf, 'r') as f: - for line in f: + for line in f.read().splitlines(): lnnr += 1 line = line.strip() if not line or line.startswith('#'): continue - m = re.match(pattern, line) - if m: - guard = m['sign'] - if guard: - # guarded line + # split lines into list of elements + e = line.split() + if len(e) == 1: + # common case, just a patch + patch = e[0] + elif e[0].startswith(('+', '-')): + # guarded line + try: + guard, patch = e[:2] + except IndexError: + raise ValueError('{}: guarded patch in line {} malformed: {}'.format(series_conf, lnnr, line)) + else: if guard == '+': - vout(2, '{}: patch in line {} flagged: {}'.format(series_conf, lnnr, line)) + vout(2, '{}: patch in line {} flagged by {}: {}'.format(series_conf, lnnr, guard, patch)) else: # guard == '-': - vout(2, '{}: patch in line {} excluded: {}'.format(series_conf, lnnr, line)) - patches.append(m['patch']) - else: - raise ValueError('{}: line {} malformed: "{}"'.format(series_conf, lnnr, line)) + vout(2, '{}: patch in line {} excluded by {}: {}'.format(series_conf, lnnr, guard, patch)) + continue + # check special cases + if len(e) > 1: + vout(3, '{}: excess elements in line {}: {}'.format(series_conf, lnnr, e)) + patches.append(patch) return patches From 433259e82449014b56d8fa66efb4515ab8ba693b Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Sun, 31 May 2026 12:14:36 +0200 Subject: [PATCH 06/11] compute-PATCHVERSION.py: make generic routines available Don't hide some generically useful routines behind the 'if __name__ == "__main__"' condition --- scripts/python/kutil/config.py | 238 ++++++++++++++++----------------- 1 file changed, 119 insertions(+), 119 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index 5fc384c0b093..09e85340affe 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -191,6 +191,125 @@ def get_package_archs(package_tar_up_dir, limit_packages=None): # here starts the new compute-PATCHVERSION.py implementation +class SrcVersion: + """Class, defining a source code version, allows parsing from string, updating single values + and returns the resulting version as string repr""" + def __init__(self, version_str): + self.version = '0' + self.patchlevel = '0' + self.sublevel = '0' + self.extraversion = '' + self._partlist = ('version', 'patchlevel', 'sublevel', 'extraversion') + pattern = re.compile(r''' + ^ # Start of line + (?P\d+) # Required: version number + \. # Required: version dot + (?P\d+) # Required: patchlevel number + (?: # Start of non-capturing group + \.(?P\d+) # Optional: sublevel number + )? # End of group + (?P.*) # Optional: extra version + $ # End of line + ''', re.VERBOSE) + + match = re.match(pattern, version_str) + if not match: + raise ValueError('Invalid version str: "{}". Expecting X[.Y][.Z][-extra]'.format(version_str)) + for part in self._partlist: + value = match.group(part) + if value is not None: + self.update(part, value) + + def update(self, part, value): + """Update a specific version component to a new value""" + if part in self._partlist: + setattr(self, part, value) + + def __str__(self): + return '{version}.{patchlevel}.{sublevel}{extraversion}'.format(**self.__dict__) + + +def parse_config_sh(config_sh): + """Parse config.sh file and return a dict with key value pairs""" + config = {} + lnnr = 0 + with open(config_sh, 'r') as f: + for line in f: + lnnr += 1 + line = line.strip() + if not line or line.startswith('#'): + continue + + if '=' in line: + key, raw_value = line.split('=', 1) + key = key.strip() + # shlex removes the outer quotation marks cleanly + parsed_tokens = shlex.split(raw_value) + value = parsed_tokens[0] if parsed_tokens else "" + config[key] = value + else: + raise ValueError('line {} malformed in {}: "{}"'.format(lnnr, config_sh, line)) + + return config + + +def parse_makefiles(diff_text): + """Locate changes to the toplevel Makefile in a unified diff file + return applied changesets to the linux kernel version variables""" + # match top level Makefile + makefile_target = re.compile(r''' + ^ # Anchor a start of line + (---|\+\+\+) # Match either +++ or --- + \s+ # Skip blinks + (?P[^\/]+/Makefile) # Extract Makefile with single slash + ( |\t|$) # May end in a blank, tab or end of line + ''', re.VERBOSE) + # match variable change pattern + var_pattern = re.compile(r''' + ^ # Anchor at start of line + \+ # Required: we care about additions ('+') only + \s* # Skip optional blanks + (?PVERSION|PATCHLEVEL|SUBLEVEL|EXTRAVERSION) # Required: key value is one of these + \s*=\s* # Required: assignment with optional blanks + (?P.*) # Required: any value, even an empty one + ''', re.VERBOSE) + + in_makefile = False + changes = [] + + for line in diff_text.splitlines(): + if line.startswith(('--- ', '+++ ')): + match = makefile_target.match(line) + if match: + # we're in a toplevel Makefile diff section now + in_makefile = True + current_file = match.group('path') + vout(4, 'parse_makefiles: {}'.format(current_file)) + else: + # we're in some other files modification context + in_makefile = False + + if not in_makefile: + continue + + if line.startswith((' ', '@@')): + continue + + # extract version variable changes + match = var_pattern.match(line) + if match: + changes.append( + { + # which variable (and avoid shouting loudly) + 'variable': match.group('key').lower(), + # added (new) or removed (old) value + 'value': match.group('value').strip(), + } + ) + + return changes + + if __name__ == "__main__": import os import re @@ -240,68 +359,6 @@ def exit(ret = 0, msg = None, usage = False): sys.exit(ret) - def parse_config_sh(config_sh): - """Parse config.sh file and return a dict with key value pairs""" - config = {} - lnnr = 0 - with open(config_sh, 'r') as f: - for line in f: - lnnr += 1 - line = line.strip() - if not line or line.startswith('#'): - continue - - if '=' in line: - key, raw_value = line.split('=', 1) - key = key.strip() - # shlex removes the outer quotation marks cleanly - parsed_tokens = shlex.split(raw_value) - value = parsed_tokens[0] if parsed_tokens else "" - config[key] = value - else: - raise ValueError('line {} malformed in {}: "{}"'.format(lnnr, config_sh, line)) - - return config - - - class SrcVersion: - """Class, defining a source code version allows parsing from string, updating single values - and returns the resulting version as string repr""" - def __init__(self, version_str): - self.version = '0' - self.patchlevel = '0' - self.sublevel = '0' - self.extraversion = '' - self._partlist = ('version', 'patchlevel', 'sublevel', 'extraversion') - pattern = re.compile(r''' - ^ # Start of line - (?P\d+) # Required: version number - \. # Required: version dot - (?P\d+) # Required: patchlevel number - (?: # Start of non-capturing group - \.(?P\d+) # Optional: sublevel number - )? # End of group - (?P.*) # Optional: extra version - $ # End of line - ''', re.VERBOSE) - - match = re.match(pattern, version_str) - if not match: - raise ValueError('Invalid version str: "{}". Expecting X[.Y][.Z][-extra]'.format(version_str)) - for part in self._partlist: - value = match.group(part) - if value is not None: - self.update(part, value) - - def update(self, part, value): - """Update a specific version component to a new value""" - if part in self._partlist: - setattr(self, part, value) - - def __str__(self): - return '{version}.{patchlevel}.{sublevel}{extraversion}'.format(**self.__dict__) - - def parse_series_conf(basedir, series_conf): """Parse the series.conf file, taking guards into account, and return a list of patch files""" patches = [] @@ -339,63 +396,6 @@ def parse_series_conf(basedir, series_conf): return patches - def parse_makefiles(diff_text): - """Locate changes to the toplevel Makefile in a unified diff file - return applied changesets to the linux kernel version variables""" - # match top level Makefile - makefile_target = re.compile(r''' - ^ # Anchor a start of line - (---|\+\+\+) # Match either +++ or --- - \s+ # Skip blinks - (?P[^\/]+/Makefile) # Extract Makefile with single slash - ( |\t|$) # May end in a blank, tab or end of line - ''', re.VERBOSE) - # match variable change pattern - var_pattern = re.compile(r''' - ^ # Anchor at start of line - \+ # Required: we care about additions ('+') only - \s* # Skip optional blanks - (?PVERSION|PATCHLEVEL|SUBLEVEL|EXTRAVERSION) # Required: key value is one of these - \s*=\s* # Required: assignment with optional blanks - (?P.*) # Required: any value, even an empty one - ''', re.VERBOSE) - - in_makefile = False - changes = [] - - for line in diff_text.splitlines(): - if line.startswith(('--- ', '+++ ')): - match = makefile_target.match(line) - if match: - # we're in a toplevel Makefile diff section now - in_makefile = True - current_file = match.group('path') - vout(4, 'parse_makefiles: {}'.format(current_file)) - else: - # we're in some other files modification context - in_makefile = False - - if not in_makefile: - continue - - if line.startswith((' ', '@@')): - continue - - # extract version variable changes - match = var_pattern.match(line) - if match: - changes.append( - { - # which variable (and avoid shouting loudly) - 'variable': match.group('key').lower(), - # added (new) or removed (old) value - 'value': match.group('value').strip(), - } - ) - - return changes - - def compute(basedir, patchdir): """Compute patchversion from config.sh, series.conf and patch files""" ret = 0 From d9ce34904b0e74b9f831fe5645f2c124d5bdcc8a Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Sun, 31 May 2026 14:21:29 +0200 Subject: [PATCH 07/11] compute-PATCHVERSION.py: fix a glitch in parse_series_conf and remove a stray and useless vout in parse_makefiles. --- scripts/python/kutil/config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index 09e85340affe..3da9b53509e2 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -284,7 +284,6 @@ def parse_makefiles(diff_text): # we're in a toplevel Makefile diff section now in_makefile = True current_file = match.group('path') - vout(4, 'parse_makefiles: {}'.format(current_file)) else: # we're in some other files modification context in_makefile = False @@ -374,7 +373,7 @@ def parse_series_conf(basedir, series_conf): e = line.split() if len(e) == 1: # common case, just a patch - patch = e[0] + patches.append(e[0]) elif e[0].startswith(('+', '-')): # guarded line try: @@ -384,14 +383,14 @@ def parse_series_conf(basedir, series_conf): else: if guard == '+': vout(2, '{}: patch in line {} flagged by {}: {}'.format(series_conf, lnnr, guard, patch)) + patches.append(patch) else: # guard == '-': vout(2, '{}: patch in line {} excluded by {}: {}'.format(series_conf, lnnr, guard, patch)) - continue + # check special cases if len(e) > 1: vout(3, '{}: excess elements in line {}: {}'.format(series_conf, lnnr, e)) - patches.append(patch) return patches From b7160ab05b57c33d7e3ab427da38fa34d28c9335 Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Sun, 31 May 2026 14:24:11 +0200 Subject: [PATCH 08/11] compute-PATCHVERSION.py: add --verify consistency checks --- scripts/python/kutil/config.py | 36 +++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index 3da9b53509e2..0a0622d4cbe1 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -337,6 +337,7 @@ class gpar: loglevel = 0 basedir = '.' patches = '.' + verify = False stdout = lambda *msg: print(*msg, file = sys.stdout, flush = True) @@ -417,14 +418,14 @@ def compute(basedir, patchdir): # collect Makefile changesets from patch files changes = [] - for pfn in patches: - pfn = os.path.join(patchdir, pfn) - with open(pfn, 'r') as f: + for patch in patches: + patch = os.path.join(patchdir, patch) + with open(patch, 'r') as f: patch_data = f.read() if 'Makefile' in patch_data: changeset = parse_makefiles(patch_data) if changeset: - vout(3, 'parse_matches: {}: {}'.format(pfn, changeset)) + vout(3, 'parse_matches: {}: {}'.format(patch, changeset)) changes.append(changeset) # iterate over all changesets, and apply them @@ -433,6 +434,26 @@ def compute(basedir, patchdir): vout(3, '{}'.format(ch)) src_version.update(ch['variable'], ch['value']) + # verify consistency + if gpar.verify: + patchdict = {} + for patch in patches: + patch = os.path.join(patchdir, patch) + patchpath, patchfile = os.path.split(patch) + patchlist = patchdict.setdefault(patchpath, set()) + if patchfile in patchlist: + vout(1, '{}: duplicate patch {} in {}'.format(series_conf, patchfile, patchpath)) + #vout(1, '{}: {}'.format(series_conf, patchlist)) + else: + patchlist.add(patchfile) + patchdirs = sorted(patchdict.keys()) + vout(1, '{}: patchdirs: {}'.format(series_conf, patchdirs)) + for patchpath in patchdirs: + files = set([f for f in list_files(patchpath) if f.endswith('.patch')]) + unused = files - patchdict[patchpath] + if unused: + vout(1, '{}: {}: unused: {}'.format(series_conf, patchpath, sorted(unused))) + # provide the result on stdout stdout(src_version) @@ -446,7 +467,7 @@ def main(argv = None): try: optlist, args = getopt.getopt(argv, 'hVvb:p:', - ('help', 'version', 'verbose', 'basedir=', 'patches=') + ('help', 'version', 'verbose', 'basedir=', 'patches=', 'verify') ) except getopt.error as msg: exit(1, msg, True) @@ -462,6 +483,11 @@ def main(argv = None): gpar.basedir = par elif opt in ('-p', '--patches'): gpar.patches = par + elif opt in ('--verify'): + gpar.verify = True + + if gpar.verify and gpar.loglevel == 0: + gpar.loglevel = 1 # ignore broken pipe errors (SIGPIPE) signal.signal(signal.SIGPIPE, signal.SIG_DFL) From 057b7c1423d8432da2f23931ac5182d651567f8f Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Sun, 31 May 2026 14:43:24 +0200 Subject: [PATCH 09/11] compute-PATCHVERSION.py: fix another glitch in parse_series_conf guard handling broken --- scripts/python/kutil/config.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index 0a0622d4cbe1..ce557f0c5d15 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -382,12 +382,12 @@ def parse_series_conf(basedir, series_conf): except IndexError: raise ValueError('{}: guarded patch in line {} malformed: {}'.format(series_conf, lnnr, line)) else: - if guard == '+': - vout(2, '{}: patch in line {} flagged by {}: {}'.format(series_conf, lnnr, guard, patch)) + if guard[0] == '+': + vout(1, '{}: patch in line {} flagged by {}: {}'.format(series_conf, lnnr, guard, patch)) patches.append(patch) else: - # guard == '-': - vout(2, '{}: patch in line {} excluded by {}: {}'.format(series_conf, lnnr, guard, patch)) + # guard[0] == '-': + vout(1, '{}: patch in line {} excluded by {}: {}'.format(series_conf, lnnr, guard, patch)) # check special cases if len(e) > 1: From 6275e0822cfe6a36c1c942d4b945c19507f88e53 Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Sun, 31 May 2026 16:10:41 +0200 Subject: [PATCH 10/11] compute-PATCHVERSION.py: finalize, ready for production Final clean-up pass, all sensical pylint concerns addressed Reorg includes correctly for generic functions More comments --- scripts/python/kutil/config.py | 115 +++++++++++++++++---------------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index ce557f0c5d15..e5584002d507 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -1,16 +1,17 @@ #! /usr/bin/env python3 """ Synopsis: determine the current kernel source version from a series of patches - as being defined in series.conf + as being referenced from series.conf Usage: {appname} [-hVvb:p:] -h, --help this message -V, --version print version and exit -v, --verbose verbose mode (cumulative) - -b, --basedir dir base directory, holding rpm/config.sh, series.conf, - default: '{basedir}' + -b, --basedir dir base directory, holding rpm/config.sh, series.conf + [default: '{basedir}'] -p, --patches dir directory, where patches.* reside, referenced in - series.conf, default: '{patches}' + series.conf [default: '{patches}'] + --verify do some consistency tests Description: The executable part of this script replaces the old compute-PATCHVERSION.sh @@ -21,10 +22,11 @@ This file is typically a symlink to ../scripts/python/kutil/config.py. It fetches the kernel source version from ./rpm/config.sh, then parses the -./series.conf file, collecting all patch files, and tracks for any changes in -top level Makefiles to the four version defining symbols: VERSION, PATCHLEVEL, +./series.conf file, collecting all patch files, and tracks for any changes of +top level Makefiles to the 4 version defining symbols: VERSION, PATCHLEVEL, SUBLEVEL, and EXTRAVERSION. The result should consitute the latest kernel -patch level. +patch level. Verbose levels up to 4 reveal internal states, that you probably +don't want to ever know of. Version: {version} Copyright: (c)2026 by {company} @@ -34,10 +36,15 @@ # # vim:set et ts=8 sw=4: # +# disable some pylint noise +# pylint: disable=line-too-long, missing-function-docstring, unspecified-encoding +# pylint: disable=consider-using-f-string, consider-using-sys-exit import configparser import subprocess +import shlex import os +import re # some commonly used functions @@ -58,7 +65,7 @@ def list_files(directory): if len(directory) > 1: directory = directory.rstrip('/') result = [] - for root, dirs, filenames in os.walk(directory): + for root, _, filenames in os.walk(directory): for f in filenames: result.append(os.path.join(root, f)[len(directory)+1:]) return sorted(result) @@ -105,6 +112,7 @@ def read_source_timestamp(directory): return config def read_config_sh(package_tar_up_dir): + """Returns a dict: usage: read_config_sh('./rpm/config.sh')['srcversion']""" file = os.path.join(package_tar_up_dir, 'config.sh') cp = configparser.ConfigParser(delimiters=('='), interpolation=None) with open(file, 'r') as fd: @@ -279,14 +287,7 @@ def parse_makefiles(diff_text): for line in diff_text.splitlines(): if line.startswith(('--- ', '+++ ')): - match = makefile_target.match(line) - if match: - # we're in a toplevel Makefile diff section now - in_makefile = True - current_file = match.group('path') - else: - # we're in some other files modification context - in_makefile = False + in_makefile = bool(makefile_target.match(line)) if not in_makefile: continue @@ -310,10 +311,7 @@ def parse_makefiles(diff_text): if __name__ == "__main__": - import os - import re import sys - import shlex import getopt import signal @@ -339,17 +337,17 @@ class gpar: patches = '.' verify = False + def stdout(*msg): + print(*msg, file = sys.stdout, flush = True) - stdout = lambda *msg: print(*msg, file = sys.stdout, flush = True) - stderr = lambda *msg: print(*msg, file = sys.stderr, flush = True) - + def stderr(*msg): + print(*msg, file = sys.stderr, flush = True) def vout(lvl, *msg): """Verbose output""" if lvl <= gpar.loglevel: stderr(*msg) - def exit(ret = 0, msg = None, usage = False): """Terminate process with optional message and usage""" if msg: @@ -358,8 +356,7 @@ def exit(ret = 0, msg = None, usage = False): stderr(__doc__.format(**gpar.__dict__)) sys.exit(ret) - - def parse_series_conf(basedir, series_conf): + def parse_series_conf(series_conf): """Parse the series.conf file, taking guards into account, and return a list of patch files""" patches = [] lnnr = 0 @@ -379,15 +376,16 @@ def parse_series_conf(basedir, series_conf): # guarded line try: guard, patch = e[:2] - except IndexError: - raise ValueError('{}: guarded patch in line {} malformed: {}'.format(series_conf, lnnr, line)) + except IndexError as exc: + raise ValueError('{}: guarded patch in line {} malformed: {}'.format(series_conf, lnnr, line)) from exc + if guard[0] == '+': + vout(1, '{}: patch in line {} flagged by {}: {}'.format(series_conf, lnnr, guard, patch)) + patches.append(patch) else: - if guard[0] == '+': - vout(1, '{}: patch in line {} flagged by {}: {}'.format(series_conf, lnnr, guard, patch)) - patches.append(patch) - else: - # guard[0] == '-': - vout(1, '{}: patch in line {} excluded by {}: {}'.format(series_conf, lnnr, guard, patch)) + # guard[0] == '-': + vout(1, '{}: patch in line {} excluded by {}: {}'.format(series_conf, lnnr, guard, patch)) + # remove guard element + e.pop(0) # check special cases if len(e) > 1: @@ -395,6 +393,26 @@ def parse_series_conf(basedir, series_conf): return patches + def verify(patchdir, patches): + """apply some consistency tests""" + patchdict = {} + for patch in patches: + patch = os.path.join(patchdir, patch) + patchpath, patchfile = os.path.split(patch) + patchlist = patchdict.setdefault(patchpath, set()) + if patchfile in patchlist: + vout(1, 'duplicate patch {} in {}'.format(patchfile, patchpath)) + else: + patchlist.add(patchfile) + patchdirs = sorted(patchdict.keys()) + vout(1, 'patchdirs: {}'.format(patchdirs)) + for patchpath in patchdirs: + # fetch all patches in patchpath + files = {f for f in list_files(patchpath) if f.endswith('.patch')} + # set difference: unused patches + unused = files - patchdict[patchpath] + if unused: + vout(1, '{}: unused: {}'.format(patchpath, sorted(unused))) def compute(basedir, patchdir): """Compute patchversion from config.sh, series.conf and patch files""" @@ -402,21 +420,21 @@ def compute(basedir, patchdir): if not os.path.isdir(basedir): exit(1, 'patches basedir {} not found'.format(basedir)) - # fetch key value pairs from config.sh + # fetch key, value pairs from config.sh config_sh = os.path.join(basedir, 'rpm/config.sh') config = parse_config_sh(config_sh) vout(2, 'config.sh: {}'.format(config)) # determine kernel base source code version src_version = SrcVersion(config['SRCVERSION']) - vout(1, 'base source version is: {}'.format(src_version)) + vout(1, 'base source version: {}'.format(src_version)) # fetch patch files from series.conf series_conf = os.path.join(basedir, 'series.conf') - patches = parse_series_conf(basedir, series_conf) + patches = parse_series_conf(series_conf) vout(4, 'patches: {}'.format(patches)) - # collect Makefile changesets from patch files + # collect top level Makefile changesets from patch files changes = [] for patch in patches: patch = os.path.join(patchdir, patch) @@ -425,7 +443,7 @@ def compute(basedir, patchdir): if 'Makefile' in patch_data: changeset = parse_makefiles(patch_data) if changeset: - vout(3, 'parse_matches: {}: {}'.format(patch, changeset)) + vout(2, 'parse_matches: {}: {}'.format(patch, changeset)) changes.append(changeset) # iterate over all changesets, and apply them @@ -436,37 +454,20 @@ def compute(basedir, patchdir): # verify consistency if gpar.verify: - patchdict = {} - for patch in patches: - patch = os.path.join(patchdir, patch) - patchpath, patchfile = os.path.split(patch) - patchlist = patchdict.setdefault(patchpath, set()) - if patchfile in patchlist: - vout(1, '{}: duplicate patch {} in {}'.format(series_conf, patchfile, patchpath)) - #vout(1, '{}: {}'.format(series_conf, patchlist)) - else: - patchlist.add(patchfile) - patchdirs = sorted(patchdict.keys()) - vout(1, '{}: patchdirs: {}'.format(series_conf, patchdirs)) - for patchpath in patchdirs: - files = set([f for f in list_files(patchpath) if f.endswith('.patch')]) - unused = files - patchdict[patchpath] - if unused: - vout(1, '{}: {}: unused: {}'.format(series_conf, patchpath, sorted(unused))) + verify(patchdir, patches) # provide the result on stdout stdout(src_version) return ret - def main(argv = None): """Command line interface and console script entry point.""" if argv is None: argv = sys.argv[1:] try: - optlist, args = getopt.getopt(argv, 'hVvb:p:', + optlist, _ = getopt.getopt(argv, 'hVvb:p:', ('help', 'version', 'verbose', 'basedir=', 'patches=', 'verify') ) except getopt.error as msg: From aa7a825867735d3305171f037289d12af888604c Mon Sep 17 00:00:00 2001 From: Hans-Peter Jansen Date: Mon, 1 Jun 2026 12:44:49 +0200 Subject: [PATCH 11/11] compute-PATCHVERSION.py: fix yet another glitch in parse_series_conf --- scripts/python/kutil/config.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/python/kutil/config.py b/scripts/python/kutil/config.py index e5584002d507..ec34eda6015b 100755 --- a/scripts/python/kutil/config.py +++ b/scripts/python/kutil/config.py @@ -369,10 +369,7 @@ def parse_series_conf(series_conf): # split lines into list of elements e = line.split() - if len(e) == 1: - # common case, just a patch - patches.append(e[0]) - elif e[0].startswith(('+', '-')): + if e[0].startswith(('+', '-')): # guarded line try: guard, patch = e[:2] @@ -386,6 +383,13 @@ def parse_series_conf(series_conf): vout(1, '{}: patch in line {} excluded by {}: {}'.format(series_conf, lnnr, guard, patch)) # remove guard element e.pop(0) + else: + # common case, just a patch, propably with trailing garbarge + try: + patch = e[0] + except IndexError as exc: + raise ValueError('{}: patch in line {} malformed: {}'.format(series_conf, lnnr, line)) from exc + patches.append(patch) # check special cases if len(e) > 1: