Source code for univention.ucslint.main

#!/usr/bin/env python3
#
# SPDX-FileCopyrightText: 2008-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only

"""
Univention ucslint
Check UCS packages for policy compliance.
"""

from __future__ import annotations

import re
import sys
from argparse import ArgumentParser, FileType, Namespace
from errno import ENOENT
from fnmatch import fnmatch
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from types import ModuleType
from typing import IO, TYPE_CHECKING

import univention.ucslint.base as uub


if TYPE_CHECKING:
    from collections.abc import Container


try:
    from junit_xml import TestSuite  # type: ignore
except ImportError:
    pass


RE_OVERRIDE = re.compile(
    r'''^
    (?P<module> \d+-[BEFNW]?\d+)
    (?: [:]
        (?: \s* (?P<pattern> .+?) \s*
            (?: [:] \s* (?P<linenumber> \d+)
            )?
        )?
    )?
    $''', re.VERBOSE)


Plugins = dict[str, ModuleType]


[docs] def load_plugins(opt: Namespace) -> Plugins: """ Load policy checker plugins. :param opt: Command line arguments. :returns: Mapping of plugin ID to loaded module. """ plugins: Plugins = {} plugindirs = [path.resolve() for path in (opt.plugindir or [Path('~/.ucslint').expanduser(), Path(uub.__file__).parent])] enabled = [clean_modid(x) for mod in opt.enabled_modules for x in mod.split(',')] disabled = [clean_modid(x) for mod in opt.disabled_modules for x in mod.split(',')] for plugindir in plugindirs: if not plugindir.is_dir(): if opt.debug: print(f'WARNING: plugindir {plugindir} does not exist', file=sys.stderr) else: for fn in plugindir.glob("[0-9][0-9][0-9][0-9]*.py"): code = fn.stem[0:4] if code in disabled: if opt.debug: print(f'Module {fn.stem} is disabled', file=sys.stderr) elif enabled and code not in enabled: if opt.debug: print(f'Module {fn.stem} is not enabled', file=sys.stderr) else: modname = fn.stem try: spec = spec_from_file_location(fn.stem[:-3], fn) assert spec is not None module = module_from_spec(spec) assert spec.loader spec.loader.exec_module(module) # type: ignore plugins[modname] = module except Exception as exc: print(f'ERROR: Loading module {fn} failed: {exc}', file=sys.stderr) if opt.debug: raise return plugins
[docs] class DebianPackageCheck: """ Check Debian package for policy compliance. :param path: Base directory of Debian package to check. :param plugins: Mapping of loaded plugins. :param debuglevel: Vebosity level. """ def __init__(self, path: Path, plugins: Plugins, debuglevel: int = 0): self.path = path self.pluginlist = plugins self.debuglevel = debuglevel self.msglist: list[uub.UPCMessage] = [] self.msgidlist: dict[str, tuple[int, str]] = {} self.overrides: set[tuple[str, str | None, int | None]] = set()
[docs] def check(self) -> None: """Run plugin on files in path.""" for plugin in self.pluginlist.values(): obj = plugin.UniventionPackageCheck() # type: ignore self.msgidlist.update(obj.getMsgIds()) obj.setdebug(self.debuglevel) obj.postinit(self.path) try: obj.check(self.path) except uub.UCSLintException as ex: print(ex, file=sys.stderr) self.msglist.extend(obj.result())
[docs] def check_files(self, files) -> None: """Run plugin on given files.""" for plugin in self.pluginlist.values(): obj = plugin.UniventionPackageCheck() # type: ignore obj.path = self.path self.msgidlist.update(obj.getMsgIds()) obj.setdebug(self.debuglevel) obj.postinit(self.path) try: obj.check_files(files) except uub.UCSLintException as ex: print(ex, file=sys.stderr) self.msglist.extend(obj.result())
[docs] def loadOverrides(self) -> None: """Parse :file:`debian/ucslint.overrides` file.""" self.overrides = set() fn = self.path / 'debian' / 'ucslint.overrides' try: with fn.open() as overrides: for row, line in enumerate(overrides, start=1): line = line.strip() if not line: continue if line.startswith('#'): continue result = RE_OVERRIDE.match(line) if not result: print(f'IGNORED: debian/ucslint.overrides:{row}: {line}', file=sys.stderr) continue module, pattern, linenumber = result.groups() override = (module, pattern, int(linenumber) if pattern and linenumber else None) self.overrides.add(override) except OSError as ex: if ex.errno != ENOENT: print(f'WARNING: load debian/ucslint.overrides: {ex}', file=sys.stderr)
[docs] def in_overrides(self, msg: uub.UPCMessage) -> bool: """ Check message against overrides. :param msg: Message to check. :returns: `True` when the check should be ignored, `False` otherwise. """ filepath = msg.filename.relative_to(self.path) if msg.filename else Path("") for (modulename, pattern, linenumber) in self.overrides: if modulename != msg.getId(): continue if pattern and not fnmatch(filepath.as_posix(), pattern): continue if linenumber is not None and linenumber != msg.row: continue return True return False
[docs] def printResult(self, ignore_IDs: Container[str], display_only_IDs: Container[str], display_only_categories: str, exitcode_categories: str, junit: IO[str] | None = None) -> tuple[int, int]: """ Print result of checks. :param ignore_IDs: List of message identifiers to ignore. :param display_only_IDs: List of message identifiers to display. :param display_only_categories: List of message categories to display. :param exitcode_categories: List of message categories to signal as fatal. :param junit: Generate JUnit XML output to given file. :returns: 2-tuple (incident-count, exitcode-count) """ incident_cnt = 0 exitcode_cnt = 0 self.loadOverrides() test_cases: list[uub.TestCase] = [] for msg in self.msglist: tc = msg.junit() test_cases.append(tc) if msg.getId() in ignore_IDs: tc.add_skipped_info('ignored') continue if display_only_IDs and msg.getId() not in display_only_IDs: tc.add_skipped_info('hidden') continue if self.in_overrides(msg): # ignore msg if mentioned in overrides files tc.add_skipped_info('overridden') continue msgid = msg.getId() try: lvl, _msgstr = self.msgidlist[msgid] category = uub.RESULT_INT2STR[lvl] except LookupError: category = 'FIXME' if category in display_only_categories or display_only_categories == '': print(f'{category}:{msg}') incident_cnt += 1 if category in exitcode_categories or exitcode_categories == '': exitcode_cnt += 1 if junit: ts = TestSuite("ucslint", test_cases) TestSuite.to_file(junit, [ts], prettyprint=False) return incident_cnt, exitcode_cnt
[docs] def clean_id(idstr: str) -> str: """ Format message ID string. :param idstr: message identifier. :returns: formatted message identifier. >>> clean_id('1-2') '0001-2' """ if '-' not in idstr: raise ValueError(f'no valid id ({idstr}) - missing dash') modid, msgid = idstr.strip().split('-', 1) return f'{clean_modid(modid)}-{clean_msgid(msgid)}'
[docs] def clean_modid(modid: str) -> str: """ Format module ID string. :param modid: module number. :returns: formatted module number. >>> clean_modid('1') '0001' """ if not modid.isdigit(): raise ValueError(f'modid contains invalid characters: {modid}') return f'{int(modid):04d}'
[docs] def clean_msgid(msgid: str) -> str: """ Format message ID string. :param msgid: message number. :returns: formatted message number. >>> clean_msgid('01') '1' """ if not msgid.isdigit(): raise ValueError(f'msgid contains invalid characters: {msgid}') return f'{int(msgid):d}'
[docs] def parse_args(parser: ArgumentParser) -> Namespace: """ Parse command line arguments. :returns: parsed options. """ parser.add_argument( '--debug', '-d', default=0, type=int, help='if set, debugging is activated and set to the specified level', metavar='LEVEL', ) parser.add_argument( '--modules', '-m', action='append', default=[], help='list of modules to be loaded (e.g. -m 0009,27)', dest='enabled_modules', ) parser.add_argument( '--exclude-modules', '-x', action='append', default=[], help='list of modules to be disabled (e.g. -x 9,027)', metavar='MODULES', dest='disabled_modules', ) parser.add_argument( '--display-only', '-o', action='append', default=[], help='list of IDs to be displayed (e.g. -o 9-1,0027-12)', metavar='MODULES', dest='display_only_IDs', ) parser.add_argument( '--ignore', '-i', action='append', default=[], help='list of IDs to be ignored (e.g. -i 0003-4,19-27)', metavar='MODULES', dest='ignore_IDs', ) parser.add_argument( '--skip-univention', '-U', action='append_const', const='0007-2,0010-2,0010-3,0010-4,0011-3,0011-4,0011-5,0011-13', help='Ignore Univention specific tests', dest='ignore_IDs', ) parser.add_argument( '--plugindir', '-p', action='append', default=[], type=Path, help='override plugin directory with <plugindir>', metavar='DIRECTORY', ) parser.add_argument( '--display-categories', '-c', default='', help='categories to be displayed (e.g. -c EWIS)', metavar='CATEGORIES', dest='display_only_categories', ) parser.add_argument( '--exitcode-categories', '-e', default='E', help='categories that cause an exitcode != 0 (e.g. -e EWIS)', metavar='CATEGORIES', ) parser.add_argument( '--junit-xml', '-j', type=FileType('w'), help='generate JUnit-XML output', metavar='FILE', ) args = parser.parse_args() if args.junit_xml and not uub.JUNIT: parser.error("Missing Python support for JUNIT_XML") if args.debug: print(f'Using univention.ucslint.base from {uub.__file__}') return args
[docs] def debian_dir(pkgpath: str) -> Path: """ Check if given path is base for a Debian package. :param pkgpath: base path. :returns: same path. """ p = Path(pkgpath) if not p.is_dir(): raise ValueError(f"{pkgpath!r} is no directory!") debdir = p / 'debian' if not debdir.is_dir(): raise ValueError(f"{debdir!r} does not exist or is not a directory!") return p
[docs] def run() -> None: """Run a single given check on selected files.""" parser = ArgumentParser() parser.add_argument( 'files', nargs='*', help='The files which are suitable for the selected module.', ) options = parse_args(parser) plugins = load_plugins(options) ignore_IDs = [clean_id(x) for ign in options.ignore_IDs for x in ign.split(',')] display_only_IDs = [clean_id(x) for dsp in options.display_only_IDs for x in dsp.split(',')] def group_by_package(files): """group files by traversing the filesystem up until a debian directory is found""" packages = {} for filename in files: parent_dir = Path(filename).absolute().parent while not (parent_dir / 'debian').is_dir(): parent_dir = parent_dir.parent if parent_dir == Path('/'): break packages.setdefault(parent_dir.relative_to(Path('.').absolute()), []).append(Path(filename).absolute().relative_to(Path('.').absolute())) return packages.items() fail = False for base, files in group_by_package(options.files): chk = DebianPackageCheck(base, plugins, debuglevel=options.debug) try: chk.check_files(files) except uub.UCSLintException as ex: print(ex, file=sys.stderr) _incident_cnt, exitcode_cnt = chk.printResult(ignore_IDs, display_only_IDs, options.display_only_categories, options.exitcode_categories, options.junit_xml) fail |= bool(exitcode_cnt) if fail: sys.exit(2)
[docs] def main() -> None: """Run checks.""" parser = ArgumentParser() parser.add_argument( 'pkgpath', nargs='*', type=debian_dir, default=[Path(".")], help='Source package directory', ) options = parse_args(parser) plugins = load_plugins(options) ignore_IDs = [clean_id(x) for ign in options.ignore_IDs for x in ign.split(',')] display_only_IDs = [clean_id(x) for dsp in options.display_only_IDs for x in dsp.split(',')] fail = False for pkgpath in options.pkgpath: chk = DebianPackageCheck(pkgpath, plugins, debuglevel=options.debug) try: chk.check() except uub.UCSLintException as ex: print(ex, file=sys.stderr) _incident_cnt, exitcode_cnt = chk.printResult(ignore_IDs, display_only_IDs, options.display_only_categories, options.exitcode_categories, options.junit_xml) fail |= bool(exitcode_cnt) if fail: sys.exit(2)
if __name__ == '__main__': main()