diff --git a/.ci/check-format.sh b/.ci/check-format.sh index b37ec83..8273666 100755 --- a/.ci/check-format.sh +++ b/.ci/check-format.sh @@ -7,8 +7,26 @@ set -u -o pipefail set -x REPO_ROOT="$(git rev-parse --show-toplevel)" +# Without 'set -e', an unchecked cd would leave the file lists below being +# gathered from wherever the script happened to be invoked, and the script +# would still exit 0. +cd "${REPO_ROOT}" || exit 1 -SH_SOURCES=$(find "${REPO_ROOT}" | egrep "\.sh$") +# Ask git for the file list rather than walking the tree. 'find' also picked up +# vendored checkouts, build directories and __pycache__, which are not ours to +# format -- a local run against a working tree with any of those in it failed on +# files the project does not own. --others includes new files that are not yet +# committed, and --exclude-standard honours .gitignore and .git/info/exclude, so +# anything deliberately kept out of the repo stays out. +git_sources() { + # Filtered to what is on disk: --cached still lists a tracked file that + # has been deleted in the working tree, and shfmt and black both error + # out on a path that is not there. + git ls-files --cached --others --exclude-standard -- "$1" | sort -u | + while IFS= read -r f; do [ -f "$f" ] && printf '%s\n' "$f"; done +} + +SH_SOURCES=$(git_sources '*.sh') for file in ${SH_SOURCES}; do shfmt -d "${file}" done @@ -17,7 +35,7 @@ if [ -n "${SH_SOURCES}" ]; then SH_MISMATCH_FILE_CNT=$(shfmt -l ${SH_SOURCES} | wc -l) fi -PY_SOURCES=$(find "${REPO_ROOT}" | egrep "\.py$") +PY_SOURCES=$(git_sources '*.py') for file in ${PY_SOURCES}; do echo "Checking Python file: ${file}" black --diff "${file}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22f32f9..eb028fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -68,7 +68,7 @@ jobs: run: | set -euo pipefail python -m pip install --user --upgrade pip - python -m pip install --user setuptools wheel pytest + python -m pip install --user setuptools wheel pytest pytest-cov - name: Check out Kconfiglib source code uses: actions/checkout@v6 @@ -83,9 +83,32 @@ jobs: python -m pytest tests/test_preprocess.py \ -k "test_user_defined or test_success_failure or test_python_fn or test_kconfig_warn" else - python -m pytest tests/ --ignore=tests/test_conformance.py + # Coverage on the jobs that run the whole suite. The floor is a + # backstop against a large drop, not a ratchet; raise it as + # coverage improves. + # + # 35 is set against what these runners report, which is 40. Do not + # calibrate it from a local run on Python 3.14 or newer: coverage + # switches to the sys.monitoring backend there and scores the same + # suite at 51, and a floor taken from that number fails every job + # here. COVERAGE_CORE=ctrace reproduces the runner figure locally. + python -m pytest tests/ --ignore=tests/test_conformance.py \ + --cov=kconfiglib --cov=menuconfig --cov=guiconfig --cov=rawterm \ + --cov-report=term --cov-report=xml:coverage.xml \ + --cov-fail-under=35 fi + - name: Store coverage report + # Only the jobs that ran the full suite produce one. !cancelled() rather + # than always(), so that a coverage failure still uploads the report + # explaining it but a cancelled run doesn't stop to upload anything. + if: ${{ !cancelled() && matrix.target.headless-only != true }} + uses: actions/upload-artifact@v6 + with: + name: coverage-${{ matrix.target.os }}-py${{ matrix.target.python }} + path: coverage.xml + if-no-files-found: ignore + - name: Validate rawterm and menuconfig (Unix) # Exercises rawterm Color/Style/Region compositing, terminal init/close # (termios on Unix), and menuconfig headless mode with style parsing. diff --git a/.gitignore b/.gitignore index bcb1e73..77b6f55 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,11 @@ build/ dist/ mconf/.git/ mconf/build/ + +# Coverage reports (see the selftest CI job) +coverage.xml +.coverage + +# Output of running the tools on this repository's own Kconfig +/.config +/.config.old diff --git a/guiconfig.py b/guiconfig.py index 00e39f3..a058d33 100755 --- a/guiconfig.py +++ b/guiconfig.py @@ -1369,8 +1369,10 @@ def _img_tag(node): def _is_y_mode_choice_sym(item): # The choice mode is an upper bound on the visibility of choice symbols, so # we can check the choice symbols' own visibility to see if the choice is - # in y mode - return isinstance(item, Symbol) and item.choice and item.visibility == 2 + # in y mode. + # + # 'is not None' so that a non-choice symbol yields False rather than None + return isinstance(item, Symbol) and item.choice is not None and item.visibility == 2 def _tree_click(event): @@ -2420,7 +2422,8 @@ def _update_jump_to_matches(msglabel, search_string): ] except re.error as e: msglabel["text"] = "Bad regular expression: " + e.msg - # Clear tree + # Clear the match list too, so it keeps agreeing with the tree + _jump_to_matches = [] _jump_to_tree.set_children("") return diff --git a/kconfiglib.py b/kconfiglib.py index d4c2341..09db084 100644 --- a/kconfiglib.py +++ b/kconfiglib.py @@ -428,6 +428,84 @@ default at the same time. +The $(python,...) function +-------------------------- + +$(python,) runs a Python code string in this interpreter and expands to +"y" if it completes, "n" if it raises AssertionError or exits non-zero. It is a +Kconfiglib extension, not part of C Kconfig, and it exists so that a check that +would otherwise need a shell one-liner per platform can be written once: + + config HAVE_TOOL + def_bool $(python,assert shutil.which("some-tool")) + +The code runs with os, sys, shutil and platform in scope, plus run(argv...), +which executes a command without a shell and returns True on exit status 0. +Each call gets a fresh copy of that namespace, so assignments do not leak +between calls. + +One caveat if you enable the probe cache below: change the environment through +os.environ, not os.putenv(). Cached probe results are invalidated by watching +os.environ, and os.putenv() writes the process environment behind its back, so +a child launched by a later probe sees the new value while the cache still +believes nothing moved. os.environ assignment does both, and is what the Python +docs recommend anyway. + +Note how this differs from $(shell,...) in what it can be contained by, rather +than in what it is allowed to do. Both run arbitrary code, and a Kconfig file +is as trusted as the rest of the source tree, so neither grants a privilege the +other does not. But a $(shell,...) command is a separate process: it can be +killed, traced, or sandboxed from outside. $(python,...) runs inside the +interpreter that imported Kconfiglib, the namespace copy is shallow so the +module objects in it are shared, and through sys.modules a code string can +reach the program hosting the parse. That matters because Kconfiglib is usually +embedded in a larger build script rather than run on its own. There is no +timeout on this path either. Treat enabling it as a decision about the host +program, not just about Kconfig. + + +Caching toolchain probes +------------------------ + +Most of what a Kconfig file runs through $(shell,...), $(success,...), +$(cc-option,...) and friends is a toolchain probe: it forks a shell that forks +a compiler, just to learn whether some flag or instruction is accepted. A large +tree fires around a hundred of them, and on such a tree that is most of the +time a load takes. + +$(cc-option,...), $(cc-option-bit,...), $(ld-option,...), $(as-instr,...), +$(as-option,...) and $(rustc-option,...) compile a fixed dummy input, so their +results depend on nothing but the flag and the toolchain. They are always +remembered for the lifetime of a Kconfig instance. + +$(shell,...), $(success,...), $(failure,...) and $(if-success,...) run whatever +command they are handed, which need not be a probe and need not answer the same +way twice, so nothing is remembered for them unless you ask. Setting +KCONFIG_SHELL_CACHE to a filename both turns that on and persists every result +to the file, so that later runs spend nothing on probing at all: + + $ export KCONFIG_SHELL_CACHE=.kconfig-shell-cache + $ make menuconfig + +The file is keyed on the working directory, the encoding, and the environment, +so changing any variable a probe might read discards it. Variables that describe +the invocation rather than the toolchain -- MAKEFLAGS, MAKELEVEL, COLUMNS and +the like -- are left out, so that a cache filled by "make menuconfig" is still +good for a recursive "make". The cost of leaving them out is that a command +reading one of them directly, say $(shell,echo $MAKELEVEL), is served the +answer from whichever invocation filled the cache. Do not do that. The key cannot see everything a probe can: +replacing a compiler in place, editing a header, or updating a package database +all go unnoticed. Delete the file when that matters, and leave the whole thing +off unless your tree's $(shell,...) uses are probes. + +The file is program input, not just an optimization: what it holds becomes +$(shell,...) output and so decides symbol values. Damaged entries are dropped +on load, so a truncated or hand-edited file costs a re-probe rather than a +wrong answer, but nothing authenticates a well-formed one. Point +KCONFIG_SHELL_CACHE somewhere only the build can write, the way you would treat +any other generated build artifact. + + Extra optional warnings ----------------------- @@ -589,7 +667,7 @@ class Kconfig: Represents a Kconfig configuration, e.g. for x86 or ARM. This is the set of symbols, choices, and menu nodes appearing in the configuration. Creating any number of Kconfig objects (including for different architectures) is - safe. Kconfiglib doesn't keep any global state. + safe. Kconfiglib doesn't keep any global configuration state. The following attributes are available. They should be treated as read-only, and some are implemented through @property magic. @@ -825,9 +903,15 @@ class Kconfig: kconfig_filenames. """ + # A full collection costs the host application's whole tracked heap. Only + # pay for one after a parse retained enough objects to justify it. + _gc_reclaim_needed = False + _gc_reclaim_threshold = 10_000 + __slots__ = ( "_encoding", "_functions", + "_probe_cache", "_set_match", "_srctree_prefix", "_unset_match", @@ -987,7 +1071,31 @@ def __init__( Pass True here to allow empty / undefined macros. """ + # Every cyclic collection during a parse is a walk over a heap that + # only grows and that it cannot free, worth a sixth to a third of the + # parse on a large tree. Suppressed by zeroing the gen0 threshold + # rather than by gc.disable(), so gc.isenabled() keeps reporting what + # the caller and the Kconfig files think it should: a file is free to + # run $(python,import gc; gc.disable()) and have that survive. + import gc # Only import as needed, to save some startup time + + gc_thresholds = gc.get_threshold() + if gc.isenabled() and gc_thresholds[0] and Kconfig._gc_reclaim_needed: + Kconfig._gc_reclaim_needed = False + gc.collect() + # Re-read: a __del__ run by that collection can call + # gc.set_threshold(), and its choice should win the same way a + # $(python,...) one does + gc_thresholds = gc.get_threshold() + gc_count = gc.get_count()[0] + gc_suppressed = (0,) + gc_thresholds[1:] + try: + # First statement inside the try: an interrupt between suppressing + # collection and entering the block would otherwise skip the + # restore below and leave the host with the collector off for good + gc.set_threshold(*gc_suppressed) + self._init( filename, warn, @@ -1006,6 +1114,15 @@ def __init__( # them here. sys.exit(cmd + str(e).strip()) raise + finally: + if gc.get_count()[0] - gc_count >= Kconfig._gc_reclaim_threshold: + Kconfig._gc_reclaim_needed = True + # Only if nothing during the parse set its own thresholds. A + # $(python,...) that sets exactly (0, ...) is indistinguishable + # from our own suppression and gets undone -- the enabled bit, + # which is what gc.disable() moves, is never touched either way + if gc.get_threshold() == gc_suppressed: + gc.set_threshold(*gc_thresholds) def _init( self, filename, warn, warn_to_stderr, encoding, search_paths, allow_empty_macros @@ -1106,6 +1223,14 @@ def _init( except ImportError: pass + # Only now. Importing that module ran arbitrary top-level Python, which + # can chdir or write to os.environ, and the cache has to snapshot the + # context its probes will actually run in. Snapshotting earlier stamped + # the file with the pre-import context and then measured probes in the + # post-import one, so a later run could be served a result from a + # directory or environment it never ran in. + self._probe_cache = _ProbeCache(os.getenv("KCONFIG_SHELL_CACHE"), encoding) + # This determines whether previously unseen symbols are registered. # They shouldn't be if we parse expressions after parsing, as part of # Kconfig.eval_string(). @@ -1191,6 +1316,8 @@ def _init( # awkward during dependency loop detection self._add_choice_deps() + self._probe_cache.save() + @property def mainmenu_text(self): """ @@ -2094,12 +2221,18 @@ def eval_string(self, s): self.filename = None - self._tokens = self._tokenize("if " + s) - # Strip "if " to avoid giving confusing error messages - self._line = s - self._tokens_i = 1 # Skip the 'if' token + # Arbitrary application code may have run since the last lookup + self._probe_cache.context_may_have_changed() - return expr_value(self._expect_expr_and_eol()) + try: + self._tokens = self._tokenize("if " + s) + # Strip "if " to avoid giving confusing error messages + self._line = s + self._tokens_i = 1 # Skip the 'if' token + + return expr_value(self._expect_expr_and_eol()) + finally: + self._probe_cache.save() def unset_values(self): """ @@ -2953,7 +3086,46 @@ def _fn_val(self, args): f"to {fn}, expected {expected_args}, got {len(args) - 1}" ) - return py_fn(self, *args) + # Identity rather than set membership. A KCONFIG_FUNCTIONS module + # may register any callable, and testing one for membership runs + # its own __hash__ and __eq__, which are free to raise anything + # they like and would take the parse down with them. Both sets + # hold functions defined in this file, so the only question is + # whether py_fn is one of them. The scan costs a few hundred + # nanoseconds against a hash lookup, on a path that runs once per + # preprocessor function call. + is_probe = any(py_fn is fn for fn in _PROBE_FNS) + is_command = any(py_fn is fn for fn in _COMMAND_FNS) + + if is_probe or (is_command and self._probe_cache.enabled): + self._probe_cache.sync_context() + key = self._probe_cache.key(args) + cached = self._probe_cache.get(key) + if cached is not None: + return cached[0] + + res = py_fn(self, *args) + self._probe_cache.add(key, res) + return res + + if is_command or py_fn is _shell_fn: + # $(shell,...) is in neither set above because it caches itself + # -- it also has to replay what the command wrote to stderr -- + # and a command function lands here when the cache is off. Both + # only fork, so they can no more move the environment or the + # working directory than the probes can. Landing them below by + # omission made every following probe pay a getcwd(). + return py_fn(self, *args) + + # Anything else here may run Python in this process (a + # $(python,...) body, a KCONFIG_FUNCTIONS function) and may expand + # further macros before it returns, so the working directory is + # re-read on every lookup until it has. + self._probe_cache.enter_untrusted() + try: + return py_fn(self, *args) + finally: + self._probe_cache.leave_untrusted() # Environment variables are tried last if fn in os.environ: @@ -6306,7 +6478,11 @@ def expanded_value_w_args(self, *args): Raises a KconfigError if the expansion seems to be stuck in a loop. """ - return self.kconfig._fn_val((self.name,) + args) + self.kconfig._probe_cache.context_may_have_changed() + try: + return self.kconfig._fn_val((self.name,) + args) + finally: + self.kconfig._probe_cache.save() def __repr__(self): return "".format( @@ -7146,7 +7322,30 @@ def _error_if_fn(kconf, _, cond, msg): def _shell_fn(kconf, _, command): - import subprocess # Only import as needed, to save some startup time + # $(shell,...) runs an arbitrary command, which need not be a probe and + # need not give the same answer twice. Results are only remembered when the + # user opts in by naming a cache file. + # + # Unlike every other command-running function here, this one has no + # _SUBPROCESS_TIMEOUT, and that is deliberate rather than an oversight. The + # others answer yes or no, so cutting them off just means "no". This one's + # stdout goes straight into symbol values and out into the .config, which + # has to match the C tools character for character -- and C Kconfig waits + # forever too. Capping it would swap a hang for silently different output, + # which is the worse failure. A $(shell,...) that hangs, hangs the load. + cache = kconf._probe_cache + if cache.enabled: + cache.sync_context() + key = cache.key(("shell", command)) + cached = cache.get(key) + if cached is not None: + stdout, stderr = cached + _warn_shell_stderr(kconf, command, stderr) + return stdout + + # Only import as needed, to save some startup time -- and a run served + # entirely from the cache never gets here at all + import subprocess stdout, stderr = subprocess.Popen( command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE @@ -7158,18 +7357,31 @@ def _shell_fn(kconf, _, command): except UnicodeDecodeError as e: _decoding_error(e, kconf.filename, kconf.linenr) - if stderr: - kconf._warn( - "'{}' wrote to stderr: {}".format(command, "\n".join(stderr.splitlines())), - kconf.loc, - ) + _warn_shell_stderr(kconf, command, stderr) # Universal newlines with splitlines() (to prevent e.g. stray \r's in # command output on Windows), trailing newline removal, and # newline-to-space conversion. We decode manually above (rather than via # Popen's encoding=) so a bad byte sequence routes through # _decoding_error() with the Kconfig file and line for context. - return "\n".join(stdout.splitlines()).rstrip("\n").replace("\n", " ") + stdout = "\n".join(stdout.splitlines()).rstrip("\n").replace("\n", " ") + + # Anything the command wrote to stderr is remembered alongside its output, + # so that a run served from the cache warns exactly like the run that + # filled the cache. The location is not: it is regenerated from wherever + # the cached result is used. + if cache.enabled: + cache.add(key, stdout, stderr) + return stdout + + +def _warn_shell_stderr(kconf, command, stderr): + # No-op when the command was quiet, so that callers need no guard + if stderr: + kconf._warn( + "'{}' wrote to stderr: {}".format(command, "\n".join(stderr.splitlines())), + kconf.loc, + ) def _run_helper(*argv): @@ -7223,6 +7435,16 @@ def _python_fn(kconf, _, code=""): # Prevents indefinite hangs from stuck compilers or # linkers. Generous enough for cross-compilation on # slow systems; tight enough to catch real hangs. +# +# This covers $(success,...), $(failure,...), +# $(if-success,...) and the $(cc-option,...) family -- +# everything routed through _run_cmd()/_run_argv(). A +# timeout there is safe because those all reduce to a +# yes/no about the toolchain, and a probe that never +# answers is a probe that failed. +# +# $(shell,...) deliberately has no timeout; see +# _shell_fn(). _SUBPROCESS_TIMEOUT = 30 @@ -7383,6 +7605,279 @@ def _rustc_option_fn(kconf, _, option): return "n" +# Bumped whenever the layout of the cache file changes, so that a file written +# by an older Kconfiglib is discarded rather than misread +_PROBE_CACHE_VERSION = 3 + +# Environment variables left out of the cache fingerprint. Everything else goes +# in, since a probe can read anything, but these are set by make and by the +# shell and describe the invocation rather than the toolchain. Leaving them in +# makes the cache useless for the workflow it exists for: `make menuconfig` runs +# at MAKELEVEL 1 and a recursive `make` at 2, `make -j` adds --jobserver-fds to +# MAKEFLAGS, and a run from the shell has none of them. Each context would key +# differently, and since the file holds one key, each would evict the last. +# MANPATH is here for the same reason -- macOS make injects the SDK one -- and +# because only man reads it. +_IGNORED_ENV = frozenset( + ( + "COLUMNS", + "KCONFIG_SHELL_CACHE", + "LINES", + "MANPATH", + "MAKEFLAGS", + "MAKELEVEL", + "MAKE_TERMERR", + "MAKE_TERMOUT", + "MFLAGS", + "OLDPWD", + "SHLVL", + "_", + ) +) + + +class _ProbeCache: + # Remembers the results of toolchain probes. See "Caching toolchain probes" + # in the module docstring for which ones and why. + # + # 'enabled' says whether $KCONFIG_SHELL_CACHE named a file. It gates two + # things: persisting results across runs, and caching the probes that run + # an arbitrary command. Memoizing the dedicated probes needs no opt-in and + # happens either way. + # + # Entries are keyed by the function name and its arguments joined on NULs, + # which Kconfig text cannot contain, so two functions can never collide. + # Values are (result, stderr) pairs; only $(shell,...) ever has stderr to + # store, and it is kept so that a cached run warns like the run that filled + # the cache. + + __slots__ = ( + "_ctx_cwd", + "_ctx_dirty", + "_ctx_env", + "_ctx_untrusted", + "_dirty", + "_fingerprint", + "_path", + "_persistable", + "_pristine", + "_results", + "enabled", + ) + + def __init__(self, path, encoding): + if path and not isabs(path): + # join("", path) == path, which is what a missing cwd used to leave + path = join(_getcwd(), path) + self._path = path + self.enabled = bool(path) + self._results = {} + self._dirty = False + self._ctx_cwd = _getcwd() + self._ctx_env = dict(_raw_environ()) + self._ctx_dirty = False + self._ctx_untrusted = 0 + self._pristine = True + self._persistable = {} + self._fingerprint = None + if self.enabled: + self._fingerprint = _probe_fingerprint(encoding) + self._load() + + def key(self, args): + return "\0".join(args) + + def enter_untrusted(self): + self._ctx_untrusted += 1 + + def leave_untrusted(self): + self._ctx_untrusted -= 1 + self._ctx_dirty = True + + def context_may_have_changed(self): + # Re-entry from the host, between a parse and a later eval_string() + self._ctx_dirty = True + + def sync_context(self): + # Entries hold for one environment and one working directory. Compare + # the live ones against the ones the entries were made under, and start + # over when they moved. + # + # The environment is compared on every lookup: one dict compare at + # ~1 us, against ~120 us to hash it. That is what makes this affordable + # here rather than behind a list of functions trusted to change + # nothing, which would have to be kept exhaustive forever. The working + # directory needs a syscall, so it is re-read only when in-process + # Python has run since the last lookup -- a child cannot move ours. + env = _raw_environ() + if self._ctx_dirty or self._ctx_untrusted: + self._ctx_dirty = False + cwd = _getcwd() + else: + cwd = self._ctx_cwd + if env != self._ctx_env or cwd != self._ctx_cwd: + self._ctx_cwd = cwd + self._ctx_env = dict(env) + self._results = {} + self._pristine = False + + def get(self, key): + return self._results.get(key) + + def add(self, key, result, stderr=""): + self._results[key] = (result, stderr) + if self.enabled and self._pristine: + # Only results measured in the context the file is stamped with can + # go in it. Skipped entirely when no file was named, since save() + # would have nowhere to put them + self._persistable[key] = (result, stderr) + self._dirty = True + + def _load(self): + import json # Only import as needed, to save some startup time + + try: + with open(self._path, encoding="utf-8") as f: + cached = json.load(f) + except (OSError, ValueError): + # No cache yet, or one we can't read. Not worth complaining about + return + + if ( + not isinstance(cached, dict) + or cached.get("version") != _PROBE_CACHE_VERSION + or cached.get("fingerprint") != self._fingerprint + ): + return + + results = cached.get("results") + if not isinstance(results, dict): + return + + # The cache is ordinary build output, and anything can leave a + # half-truncated or hand-edited file behind. Keep only the entries that + # have the shape we wrote, so that a damaged file costs a re-probe + # rather than a crash or a wrong answer. + self._results = { + key: (val[0], val[1]) + for key, val in results.items() + if isinstance(val, list) + and len(val) == 2 + and isinstance(val[0], str) + and isinstance(val[1], str) + } + # A separate dict rather than an alias of _results. The two diverge the + # moment the context moves, and aliasing them until then only works + # because that rebind happens to coincide with _pristine going False + self._persistable = dict(self._results) + + def save(self): + if not self.enabled or not self._dirty: + return + + # Only import as needed, to save some startup time + import json + import tempfile + + # Written to a temporary file in the same directory and renamed into + # place, so that nothing ever reads a half-written cache. mkstemp() + # creates it exclusively under an unpredictable name, so two threads or + # two builds can't collide on it and it can't be pre-created as a + # symlink. Concurrent writers still last-write-wins, which for a cache + # costs a re-probe and nothing else. + directory = dirname(self._path) or "." + try: + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".kconfig-probe-") + except OSError: + return + + # Cleanup is keyed on whether the rename happened, not on the kind of + # failure. Anything that stops us short of it -- a full disk, but also + # a Ctrl-C between mkstemp() and os.replace() -- would otherwise leave + # a .kconfig-probe-* file behind in the build directory for good. + renamed = False + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump( + { + "version": _PROBE_CACHE_VERSION, + "fingerprint": self._fingerprint, + "results": self._persistable, + }, + f, + ) + os.replace(tmp, self._path) + renamed = True + self._dirty = False + except OSError: + pass + finally: + if not renamed: + try: + os.remove(tmp) + except OSError: + pass + + +def _raw_environ(): + # os._Environ keeps the undecoded mapping in _data (shared with os.environb + # where that exists), so comparing it detects any change os.environ can + # make without decoding 70-odd values. Falls back to a snapshot of the + # public mapping: os.environ does compare equal to a dict (Mapping.__eq__ + # builds one to do it), so returning it would also work, but then the + # comparison depends on an ABC detail and costs the same dict build anyway. + try: + return os.environ._data + except AttributeError: + return dict(os.environ) + + +def _getcwd(): + # "" when the working directory has been removed out from under us, so that + # callers get something comparable instead of an OSError + try: + return os.getcwd() + except OSError: + return "" + + +def _probe_fingerprint(encoding): + import hashlib # Only import as needed, to save some startup time + import json + + raw = json.dumps( + [ + _getcwd(), + encoding, + sorted((k, v) for k, v in os.environ.items() if k not in _IGNORED_ENV), + ], + ensure_ascii=True, + separators=(",", ":"), + ) + return hashlib.sha256(raw.encode("ascii")).hexdigest() + + +# Preprocessor functions whose result is a pure function of their arguments and +# of the toolchain, and that pay for a fork to find it out. _fn_val() always +# memoizes these. +_PROBE_FNS = frozenset( + ( + _cc_option_fn, + _cc_option_bit_fn, + _ld_option_fn, + _as_instr_fn, + _as_option_fn, + _rustc_option_fn, + ) +) + +# Preprocessor functions that run whatever command they are handed. Almost +# always a probe, but nothing says they have to be, so their results are only +# remembered when $KCONFIG_SHELL_CACHE says to. $(shell,...) is absent because +# it caches itself: it also has to replay what the command wrote to stderr. +_COMMAND_FNS = frozenset((_success_fn, _failure_fn, _if_success_fn)) + + # # Global constants # diff --git a/menuconfig.py b/menuconfig.py index b498357..7f6f6ad 100755 --- a/menuconfig.py +++ b/menuconfig.py @@ -2314,6 +2314,75 @@ def _draw_frame(win, title): _draw_title(win, title, win_width) +def _jump_to_matches(search_string): + # Returns a (matches, bad_re) pair for 'search_string': the menu nodes + # matching every whitespace-separated regex in it, and an error message if + # one of those regexes did not compile (None otherwise). + # + # Lifted out of _jump_to_dialog() so that it can be tested without a + # terminal, and so that it stays comparable to + # guiconfig._update_jump_to_matches(), which does the same job. + + try: + # We could use re.IGNORECASE here instead of lower(), but this is + # noticeably less jerky while inputting regexes like '.*debug$' (though + # the '.*' is redundant there). Those probably have bad interactions + # with re.search(), which matches anywhere in the string. + # + # It's not horrible either way. Just a bit smoother. + prefix = _kconf.config_prefix.lower() + prefix_len = len(prefix) + + regex_searches = [ + re.compile(token[prefix_len:] if token.startswith(prefix) else token).search + for token in search_string.lower().split() + ] + except re.error as e: + # Only re.compile() raises this; a compiled pattern's search() does not, + # so nothing below needs to be inside the try + return [], "Bad regular expression: " + e.msg + + matches = [] + add_match = matches.append + + # Search symbols and choices + + for node in _sorted_sc_nodes(): + # Symbol/choice + sc = node.item + + for search in regex_searches: + # Both the name and the prompt might be missing, since we're + # searching both symbols and choices + + # Does the regex match either the symbol name or the prompt (if + # any)? + if not ( + sc.name + and search(sc.name.lower()) + or node.prompt + and search(node.prompt[0].lower()) + ): + + # Give up on the first regex that doesn't match, to speed + # things up a bit when multiple regexes are entered + break + + else: + add_match(node) + + # Search menus and comments + + for node in _sorted_menu_comment_nodes(): + for search in regex_searches: + if not search(node.prompt[0].lower()): + break + else: + add_match(node) + + return matches, None + + def _jump_to_dialog(): # Implements the jump-to dialog, where symbols can be looked up via # incremental search and jumped to. @@ -2391,72 +2460,7 @@ def select_prev_match(): # The search text changed. Find new matching nodes. prev_s = s - - try: - # We could use re.IGNORECASE here instead of lower(), but - # this is noticeably less jerky while inputting regexes like - # '.*debug$' (though the '.*' is redundant there). Those - # probably have bad interactions with re.search(), which - # matches anywhere in the string. - # - # It's not horrible either way. Just a bit smoother. - prefix = _kconf.config_prefix.lower() - prefix_len = len(prefix) - - regex_searches = [ - re.compile( - token[prefix_len:] if token.startswith(prefix) else token - ).search - for token in s.lower().split() - ] - - # No exception thrown, so the regexes are okay - bad_re = None - - # List of matching nodes - matches = [] - add_match = matches.append - - # Search symbols and choices - - for node in _sorted_sc_nodes(): - # Symbol/choice - sc = node.item - - for search in regex_searches: - # Both the name and the prompt might be missing, - # since we're searching both symbols and choices - - # Does the regex match either the symbol name or - # the prompt (if any)? - if not ( - sc.name - and search(sc.name.lower()) - or node.prompt - and search(node.prompt[0].lower()) - ): - - # Give up on the first regex that doesn't - # match, to speed things up a bit when multiple - # regexes are entered - break - - else: - add_match(node) - - # Search menus and comments - - for node in _sorted_menu_comment_nodes(): - for search in regex_searches: - if not search(node.prompt[0].lower()): - break - else: - add_match(node) - - except re.error as e: - # Bad regex. Remember the error message so we can show it. - bad_re = "Bad regular expression: " + e.msg - matches = [] + matches, bad_re = _jump_to_matches(s) # Reset scroll and jump to the top of the list of matches sel_node_i = scroll = 0 @@ -3637,8 +3641,10 @@ def _value_str(node): def _is_y_mode_choice_sym(item): # The choice mode is an upper bound on the visibility of choice symbols, so # we can check the choice symbols' own visibility to see if the choice is - # in y mode - return isinstance(item, Symbol) and item.choice and item.visibility == 2 + # in y mode. + # + # 'is not None' so that a non-choice symbol yields False rather than None + return isinstance(item, Symbol) and item.choice is not None and item.visibility == 2 def _check_valid(sym, s): diff --git a/scripts/benchmark.py b/scripts/benchmark.py new file mode 100755 index 0000000..07a59be --- /dev/null +++ b/scripts/benchmark.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +# Copyright (c) 2011-2019 Ulf Magnusson +# SPDX-License-Identifier: ISC + +"""Time the phases of a Kconfig load and of the UI hot paths. + +Reports each phase separately so that a regression can be attributed rather +than just noticed. Prints a plain table by default, or JSON with --json for +storing as a CI artifact. + +There are no pass/fail thresholds here on purpose. Timings taken on shared CI +runners against an external kernel tree are too noisy to gate on until a stable +baseline exists. + +Usage: + + # This repository's own Kconfig + scripts/benchmark.py + + # The kernel tree the conformance job checks out + cd linux && ARCH=x86 SRCARCH=x86 KERNELVERSION=6.18 \\ + /path/to/scripts/benchmark.py --tree . + + scripts/benchmark.py --json > timings.json +""" + +import argparse +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")) + + +def _positive_int(value): + value = int(value) + if value <= 0: + raise argparse.ArgumentTypeError("must be positive") + return value + + +def _best(fn, repeat): + # Not timeit: its template disables the garbage collector around the timed + # loop, and how the collector behaves during a parse is exactly what the + # load phase is here to measure. + # + # Best of N. The minimum is the honest number for a CPU-bound phase: noise + # only ever adds time, so the fastest run is the one least polluted by it. + best = float("inf") + for _ in range(repeat): + start = time.perf_counter() + fn() + best = min(best, time.perf_counter() - start) + return best + + +def bench_load(kconfig_file, repeat): + """Kconfig() start to finish: parse, finalize, dep graph, loop check.""" + import kconfiglib + + kconf = None + + def once(): + nonlocal kconf + kconf = kconfiglib.Kconfig(kconfig_file, warn=False) + + seconds = _best(once, repeat) + return ( + kconf, + seconds, + { + "symbols": len(kconf.unique_defined_syms), + "choices": len(kconf.unique_choices), + "menus": len(kconf.menus), + "files": len(kconf.kconfig_filenames), + }, + ) + + +def bench_write_config(kconf, repeat): + """Rendering and writing a .config, which is what genconfig does.""" + devnull = os.devnull + # save_old=False: write_config() would otherwise try to rename os.devnull + # to os.devnull + ".old" on every repeat. The rename normally fails and is + # swallowed, but it succeeds for a privileged user, and destroying /dev/null + # to time a benchmark is not a trade worth making. It also keeps a stat and + # a rename out of the measurement. + return _best(lambda: kconf.write_config(devnull, save_old=False), repeat), {} + + +def _bench_menu_walk(kconf, repeat, walk): + # Shared so the two phases cannot drift apart while claiming to walk the + # same menus + menus = [kconf.top_node] + [n for n in kconf.menus if n.list] + + def once(): + for menu in menus: + walk(menu) + + return _best(once, repeat), {"menus_walked": len(menus)} + + +def bench_shown_nodes(kconf, repeat): + """menuconfig._shown_nodes() over every menu -- the TUI's per-redraw walk.""" + import menuconfig + + menuconfig._kconf = kconf + menuconfig._show_all = False + return _bench_menu_walk(kconf, repeat, menuconfig._shown_nodes) + + +def bench_node_str(kconf, repeat): + """menuconfig._node_str() for every node -- one call per visible row.""" + import menuconfig + + menuconfig._kconf = kconf + menuconfig._show_all = True + menuconfig._show_name = False + + nodes = list(kconf.node_iter()) + + def once(): + for n in nodes: + menuconfig._node_str(n) + + return _best(once, repeat), {"nodes": len(nodes)} + + +def bench_gui_tree_walk(kconf, repeat): + """guiconfig._shown_full_nodes() -- the part of _update_tree() that scales. + + _update_tree() itself is inseparable from a live Treeview, so this times + the tree walk it drives rather than the widget updates. A regression in + how many nodes get visited shows up here; one in Tk itself does not. + """ + try: + import guiconfig + except ImportError as e: + # guiconfig pulls in tkinter, which is an optional part of a Python + # installation. Skip this one phase rather than taking the whole run + # down with it; main() already renders a phase with no timing. + return None, {"skipped": f"{type(e).__name__}: {e}"} + + guiconfig._kconf = kconf + guiconfig._show_all = False + return _bench_menu_walk(kconf, repeat, guiconfig._shown_full_nodes) + + +def bench_rawterm_render(repeat): + """rawterm text rendering into an offscreen region, no terminal needed.""" + import rawterm + + # A Terminal built without entering raw mode. Region only stashes it and + # never calls into it while drawing, so nothing more needs to exist + term = object.__new__(rawterm.Terminal) + + height, width = 50, 120 + region = rawterm.Region(term, height, width, 0, 0) + + line = "[*] A configuration symbol with a reasonably long prompt" + style = rawterm.Style(fg=rawterm.Color.WHITE, bg=rawterm.Color.BLUE, bold=True) + + def once(): + for y in range(height): + region.write(y, 0, line, style, max_len=width) + + return _best(once, repeat), {"cells": height * width} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--tree", + default=".", + help="directory to run in (default: the current directory)", + ) + parser.add_argument( + "--kconfig", + default="Kconfig", + help="top-level Kconfig file, relative to --tree (default: Kconfig)", + ) + parser.add_argument( + "-n", + "--repeat", + type=_positive_int, + default=5, + help="runs per phase; the best is reported (default: 5)", + ) + parser.add_argument("--json", action="store_true", help="emit JSON") + args = parser.parse_args() + + os.chdir(args.tree) + if not os.path.exists(args.kconfig): + sys.exit(f"benchmark: no {args.kconfig} in {os.getcwd()}") + + results = {} + + def record(name, phase): + seconds, info = phase + results[name] = {"seconds": seconds, **info} + + # bench_load hands back the tree it just parsed, so the later phases do not + # pay to parse it again -- on a kernel tree that is the single most + # expensive thing this script does + kconf, seconds, info = bench_load(args.kconfig, args.repeat) + results["load"] = {"seconds": seconds, **info} + + record("write_config", bench_write_config(kconf, args.repeat)) + record("menuconfig_shown_nodes", bench_shown_nodes(kconf, args.repeat)) + record("menuconfig_node_str", bench_node_str(kconf, args.repeat)) + record("guiconfig_tree_walk", bench_gui_tree_walk(kconf, args.repeat)) + record("rawterm_render", bench_rawterm_render(args.repeat)) + + results["meta"] = { + "python": sys.version.split()[0], + "platform": sys.platform, + "tree": os.getcwd(), + "repeat": args.repeat, + } + + if args.json: + json.dump(results, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return + + print(f"{'phase':<26} {'best':>10} detail") + print("-" * 72) + for phase, data in results.items(): + if phase == "meta": + continue + seconds = data.get("seconds") + detail = ", ".join( + f"{k}={v}" for k, v in sorted(data.items()) if k != "seconds" + ) + if seconds is None: + print(f"{phase:<26} {'skipped':>10} {detail}") + else: + print(f"{phase:<26} {seconds * 1000:>9.2f}ms {detail}") + print("-" * 72) + meta = results["meta"] + print(f"Python {meta['python']} on {meta['platform']}, best of {meta['repeat']}") + print(f"Tree: {meta['tree']}") + + +if __name__ == "__main__": + main() diff --git a/tests/Kuirender b/tests/Kuirender new file mode 100644 index 0000000..9115aba --- /dev/null +++ b/tests/Kuirender @@ -0,0 +1,113 @@ +mainmenu "UI render test" + +# Every branch of menuconfig._value_str()/_node_str() and their guiconfig +# counterparts is reachable from this tree. See tests/test_ui_render.py. + +config MODULES + bool "Modules" + option modules + default y + +config BOOL_SYM + bool "A bool" + +config TRI_SYM + tristate "A tristate" + +config STR_SYM + string "A string" + default "hello" + +config INT_SYM + int "An int" + default 7 + +config HEX_SYM + hex "A hex" + default 0x1f + +# Pinned to y by the select, so assignable has a single element +config SELECTED + bool "Selected one" + +config SELECTOR + bool "The selector" + default y + select SELECTED + +config IMPLIED + tristate "Implied one" + +config IMPLIER + bool "The implier" + default y + imply IMPLIED + +# Three selectors, to exercise the "+N" truncation in _get_force_info() +config MANY_SELECTED + bool "Selected by several" + +config SEL_A + bool "Selector A" + default y + select MANY_SELECTED + +config SEL_B + bool "Selector B" + default y + select MANY_SELECTED + +config SEL_C + bool "Selector C" + default y + select MANY_SELECTED + +# No prompt: only shown in show-all mode +config PROMPTLESS + bool + default y + +choice + prompt "A choice" + default CHOICE_B + +config CHOICE_A + bool "Choice A" + +config CHOICE_B + bool "Choice B" + +endchoice + +menu "A menu" + +config IN_MENU + bool "In the menu" + +endmenu + +menu "Empty menu" +endmenu + +comment "A comment" + +menuconfig MENUCONFIG_SYM + bool "A menuconfig" + default y + +if MENUCONFIG_SYM + +config UNDER_MENUCONFIG + bool "Under it" + +endif + +# A plain 'config' with dependent symbols after it gets an implicit submenu. +# Its children are indented, since the parent is not a 'menuconfig'. +config IMPLICIT_PARENT + bool "Implicit parent" + default y + +config IMPLICIT_CHILD + bool "Implicit child" + depends on IMPLICIT_PARENT diff --git a/tests/conftest.py b/tests/conftest.py index ee1bf12..091cab0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,9 +23,11 @@ def _clean_env(monkeypatch): """Save and restore environment variables between tests. - Also removes KCONFIG_ALLCONFIG to prevent accidental config loading. + Also removes KCONFIG_ALLCONFIG, to prevent accidental config loading, and + KCONFIG_SHELL_CACHE, so an ambient probe cache can't reach the tests. """ monkeypatch.delenv("KCONFIG_ALLCONFIG", raising=False) + monkeypatch.delenv("KCONFIG_SHELL_CACHE", raising=False) yield diff --git a/tests/test_menuconfigstyle.py b/tests/test_menuconfigstyle.py new file mode 100644 index 0000000..4b936a6 --- /dev/null +++ b/tests/test_menuconfigstyle.py @@ -0,0 +1,167 @@ +# Copyright (c) 2011-2019 Ulf Magnusson +# SPDX-License-Identifier: ISC +# +# Tests for menuconfig's style parser -- the MENUCONFIG_STYLE environment +# variable, which is the one piece of menuconfig configuration users write by +# hand and therefore the one most likely to be malformed. +# +# _parse_color(), _style_from_def() and _parse_style() are pure string +# handling, so they run without a terminal. Every branch here either produces a +# value or warns and falls back; nothing may raise, since a bad style string +# must not stop menuconfig from starting. + +import pytest + +import menuconfig +from rawterm import Color + + +@pytest.fixture +def warnings(monkeypatch): + """Captures menuconfig._warn() output instead of writing to stderr.""" + captured = [] + monkeypatch.setattr( + menuconfig, "_warn", lambda *args: captured.append(" ".join(map(str, args))) + ) + return captured + + +# --- _parse_color ----------------------------------------------------------- + + +def test_parse_color_html(): + assert menuconfig._parse_color("#FF8000") == Color.rgb(255, 128, 0) + assert menuconfig._parse_color("#ff8000") == Color.rgb(255, 128, 0) + + +def test_parse_color_named(): + assert menuconfig._parse_color("red") == Color.RED + assert menuconfig._parse_color("brightblue") == Color.BRIGHT_BLUE + + +@pytest.mark.parametrize( + "text, index", + [("0", 0), ("123", 123), ("255", 255), ("0x10", 16)], +) +def test_parse_color_index(text, index): + """Plain and 0x-prefixed numbers both name a palette entry.""" + assert menuconfig._parse_color(text) == Color.index(index) + + +def test_parse_color_out_of_range_warns_and_falls_back(warnings): + assert menuconfig._parse_color("256") == Color.DEFAULT + assert menuconfig._parse_color("-1") == Color.DEFAULT + assert len(warnings) == 2 + assert "outside range 0..255" in warnings[0] + + +def test_parse_color_unknown_warns_and_falls_back(warnings): + assert menuconfig._parse_color("mauve") == Color.DEFAULT + assert "neither predefined nor a number" in warnings[0] + + +def test_parse_color_near_miss_html_is_not_treated_as_html(warnings): + """Five or seven hex digits is not #RRGGBB, and must not be read as one.""" + assert menuconfig._parse_color("#FF800") == Color.DEFAULT + assert menuconfig._parse_color("#FF80000") == Color.DEFAULT + assert len(warnings) == 2 + + +# --- _style_from_def -------------------------------------------------------- + + +def test_style_from_def_all_attributes(): + style = menuconfig._style_from_def("fg:red,bg:blue,bold,standout,underline") + assert style.fg == Color.RED + assert style.bg == Color.BLUE + assert style.standout + assert style.underline + # 'bold' is dropped on Windows, where it renders as a bright color + assert style.bold == (not menuconfig._IS_WINDOWS) + + +def test_style_from_def_empty_is_all_defaults(): + style = menuconfig._style_from_def("") + assert style.fg == Color.DEFAULT + assert style.bg == Color.DEFAULT + assert not style.bold + assert not style.standout + assert not style.underline + + +def test_style_from_def_unknown_attribute_warns_but_keeps_the_rest(warnings): + style = menuconfig._style_from_def("fg:red,blink,bg:blue") + assert "blink" in warnings[0] + assert style.fg == Color.RED + assert style.bg == Color.BLUE + + +def test_style_from_def_accepts_colons_in_the_color(warnings): + """Only the first colon separates, so a bad remainder still warns cleanly.""" + style = menuconfig._style_from_def("fg:not:a:color") + assert style.fg == Color.DEFAULT + assert warnings + + +# --- _parse_style ----------------------------------------------------------- + + +@pytest.fixture +def style_table(monkeypatch): + """A scratch _style dict, so tests can't corrupt the module's real one.""" + table = {"path": menuconfig._style_from_def("fg:white")} + monkeypatch.setattr(menuconfig, "_style", table) + return table + + +def test_parse_style_assignment(style_table, warnings): + menuconfig._parse_style("path=fg:red,bold", parsing_default=False) + assert style_table["path"].fg == Color.RED + assert not warnings + + +def test_parse_style_reference_copies_another_entry(style_table, warnings): + """'a=b' where b is an existing key copies b's style rather than parsing it.""" + menuconfig._parse_style("path=fg:green", parsing_default=False) + style_table["other"] = menuconfig._style_from_def("fg:blue") + menuconfig._parse_style("path=other", parsing_default=False) + assert style_table["path"] is style_table["other"] + + +def test_parse_style_unknown_key_warns(style_table, warnings): + menuconfig._parse_style("nosuchelement=fg:red", parsing_default=False) + assert "nosuchelement" in warnings[0] + # ...but the assignment still happens, matching the documented behavior + assert "nosuchelement" in style_table + + +def test_parse_style_unknown_key_is_silent_while_parsing_defaults( + style_table, warnings +): + menuconfig._parse_style("nosuchelement=fg:red", parsing_default=True) + assert not warnings + + +def test_parse_style_expands_a_builtin_template(style_table, warnings): + """A bare word names a built-in style, inlined at that point.""" + menuconfig._parse_style("monochrome", parsing_default=True) + assert len(style_table) > 1 + assert not warnings + + +def test_parse_style_unknown_template_warns(style_table, warnings): + menuconfig._parse_style("nosuchtemplate", parsing_default=False) + assert "nosuchtemplate" in warnings[0] + + +def test_parse_style_later_assignment_wins(style_table, warnings): + menuconfig._parse_style("path=fg:red path=fg:blue", parsing_default=False) + assert style_table["path"].fg == Color.BLUE + + +def test_shipped_styles_parse_without_warnings(monkeypatch, warnings): + """Every built-in style must parse cleanly, or menuconfig warns on startup.""" + for name, definition in menuconfig._STYLES.items(): + monkeypatch.setattr(menuconfig, "_style", {}) + menuconfig._parse_style(definition, parsing_default=True) + assert not warnings, f"built-in style {name!r} warned: {warnings}" diff --git a/tests/test_probecache.py b/tests/test_probecache.py new file mode 100644 index 0000000..fc120ff --- /dev/null +++ b/tests/test_probecache.py @@ -0,0 +1,614 @@ +"""Tests for the toolchain probe cache (see KCONFIG_SHELL_CACHE).""" + +import gc +import inspect +import json +import os +import sys +import weakref + +import pytest + +import kconfiglib +from kconfiglib import Kconfig, KconfigError + +KCONFIG = """\ +mainmenu "probe cache" + +config NOISY + def_bool $(shell,echo noise >&2; echo y) + +config OUT + string + default "$(shell,echo hello world)" + +config OK + def_bool $(success,true) + +config NOT_OK + def_bool $(success,false) +""" + + +# What _load() returns for KCONFIG above +EXPECTED = ["y", "hello world", "y", "n"] + + +@pytest.fixture +def tree(tmp_path, monkeypatch): + """A Kconfig tree in a scratch directory, with the cache file enabled.""" + (tmp_path / "Kconfig").write_text(KCONFIG) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + return tmp_path + + +def _load(): + c = Kconfig("Kconfig", warn_to_stderr=False) + return ( + [ + c.syms["NOISY"].str_value, + c.syms["OUT"].str_value, + c.syms["OK"].str_value, + c.syms["NOT_OK"].str_value, + ], + c.warnings, + ) + + +def test_warm_load_matches_cold_load(tree): + cold_values, cold_warnings = _load() + assert (tree / "probe-cache.json").exists() + + warm_values, warm_warnings = _load() + + # A run served from the cache has to be indistinguishable from the run that + # filled it, warnings and their locations included + assert warm_values == cold_values + assert warm_warnings == cold_warnings + assert any("wrote to stderr: noise" in w for w in cold_warnings) + + +def test_changed_environment_discards_the_cache(tree, monkeypatch): + _load() + stale = (tree / "probe-cache.json").read_text() + + secret = "changed-secret-value" + monkeypatch.setenv("ARBITRARY_PROBE_INPUT", secret) + _load() + + assert (tree / "probe-cache.json").read_text() != stale + assert secret not in (tree / "probe-cache.json").read_text() + + +def test_invocation_variables_do_not_discard_the_cache(tree, monkeypatch): + """make's own plumbing describes the invocation, not the toolchain. + + Without this, `make menuconfig` and a recursive `make` key differently and + evict each other, which is the workflow the cache exists for. + """ + _load() + stale = (tree / "probe-cache.json").read_text() + + monkeypatch.setenv("MAKELEVEL", "2") + monkeypatch.setenv("MAKEFLAGS", "s --jobserver-fds=3,4 -j") + monkeypatch.setenv("MFLAGS", "-s") + monkeypatch.setenv("COLUMNS", "132") + _load() + + # Nothing re-probed, so nothing was rewritten + assert (tree / "probe-cache.json").read_text() == stale + + +def test_unreadable_cache_is_ignored(tree): + (tree / "probe-cache.json").write_text("not json at all") + values, _ = _load() + assert values == EXPECTED + + +def test_cache_off_by_default(tree, monkeypatch): + monkeypatch.delenv("KCONFIG_SHELL_CACHE") + _load() + assert not (tree / "probe-cache.json").exists() + + +def test_cache_off_does_not_read_deleted_working_directory(tmp_path, monkeypatch): + kconfig = tmp_path / "Kconfig" + kconfig.write_text('mainmenu "absolute path"\n') + deleted_cwd = tmp_path / "deleted-cwd" + deleted_cwd.mkdir() + monkeypatch.setenv("srctree", str(tmp_path)) + + # monkeypatch.chdir records the old cwd and restores it at teardown, which + # keeps working after the directory it moved into is removed + monkeypatch.chdir(deleted_cwd) + os.rmdir(deleted_cwd) + Kconfig(str(kconfig), warn_to_stderr=False) + + +def test_relative_cache_path_survives_in_parse_chdir(tmp_path, monkeypatch): + subdir = tmp_path / "subdir" + subdir.mkdir() + (tmp_path / "Kconfig").write_text( + f'OUT := $(shell,echo y)\nCHANGE_DIR := $(python,os.chdir("{subdir}"))\n' + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + + Kconfig("Kconfig", warn_to_stderr=False) + + assert (tmp_path / "probe-cache.json").exists() + assert not (subdir / "probe-cache.json").exists() + + +def _counting_tree(tmp_path, monkeypatch): + # Two identical $(shell,...) calls, each appending to a file we can count + counter = tmp_path / "runs" + (tmp_path / "Kconfig").write_text( + 'mainmenu "memo"\n\n' + "config A\n" + f'\tdef_bool $(shell,echo x >> "{counter}"; echo y)\n\n' + "config B\n" + f'\tdef_bool $(shell,echo x >> "{counter}"; echo y)\n' + ) + monkeypatch.chdir(tmp_path) + return counter + + +def test_shell_is_not_memoized_by_default(tmp_path, monkeypatch): + """$(shell,...) runs arbitrary commands, so it must run every time.""" + counter = _counting_tree(tmp_path, monkeypatch) + + Kconfig("Kconfig", warn_to_stderr=False) + + assert counter.read_text() == "x\nx\n" + + +def test_shell_is_memoized_when_the_cache_is_on(tmp_path, monkeypatch): + counter = _counting_tree(tmp_path, monkeypatch) + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + + Kconfig("Kconfig", warn_to_stderr=False) + + # Same command twice in the Kconfig, one fork + assert counter.read_text() == "x\n" + + +def test_pure_probes_are_always_memoized(tmp_path, monkeypatch): + """$(cc-option,...) and friends are pure, and need no opt-in.""" + (tmp_path / "Kconfig").write_text( + 'mainmenu "pure"\n\nconfig A\n\tdef_bool $(cc-option,-fno-such-flag-at-all)\n' + ) + monkeypatch.chdir(tmp_path) + + c = Kconfig("Kconfig", warn_to_stderr=False) + + key = c._probe_cache.key(("cc-option", "-fno-such-flag-at-all")) + assert c._probe_cache.get(key) == ("n", "") + + +def test_probe_cache_tracks_in_parse_environment_changes(tmp_path, monkeypatch): + (tmp_path / "Kconfig").write_text( + "config A\n" + "\tdef_bool $(cc-option,-flag)\n" + 'CHANGE_CC := $(python,os.environ["CC"]="bad")\n' + "config B\n" + "\tdef_bool $(cc-option,-flag)\n" + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CC", "good") + monkeypatch.setattr( + kconfiglib, "_run_argv", lambda argv, stdin_data=None: argv[0] == "good" + ) + + c = Kconfig("Kconfig", warn_to_stderr=False) + + assert c.syms["A"].str_value == "y" + assert c.syms["B"].str_value == "n" + + +def test_parsing_preserves_gc_changes(tmp_path, monkeypatch): + (tmp_path / "Kconfig").write_text( + "config A\n\tdef_bool $(python,import gc; gc.disable())\n" + ) + monkeypatch.chdir(tmp_path) + + assert gc.isenabled() + try: + Kconfig("Kconfig", warn_to_stderr=False) + assert not gc.isenabled() + finally: + gc.enable() + + +def _tree_that_trips_the_reclaim(tmp_path, name): + """A Kconfig large enough to set Kconfig._gc_reclaim_needed here. + + Hard-coding a symbol count pins the test to how many tracked objects one + CPython happens to allocate per symbol. An interpreter that allocates + fewer would drop under _gc_reclaim_threshold, and the gate tests would + quietly stop exercising the gate rather than fail. Grow until the flag + actually trips instead. + """ + path = tmp_path / name + n = 1000 + while n <= 64000: + path.write_text("".join(f"config S{i}\n\tbool\n" for i in range(n))) + Kconfig._gc_reclaim_needed = False + Kconfig(path, warn=False) + if Kconfig._gc_reclaim_needed: + return path + n *= 2 + raise AssertionError("no tree size tripped _gc_reclaim_threshold") + + +def test_full_gc_is_gated_by_a_prior_large_parse(tmp_path, monkeypatch): + small = tmp_path / "small" + small.write_text("") + # Sized before gc.collect is stubbed out, since it needs real parses + large = _tree_that_trips_the_reclaim(tmp_path, "large") + monkeypatch.setattr(Kconfig, "_gc_reclaim_needed", False) + + collections = [] + monkeypatch.setattr(gc, "collect", lambda: collections.append(None)) + + Kconfig(small) + Kconfig(small) + assert not collections + + Kconfig(large) + assert not collections + Kconfig(small) + assert len(collections) == 1 + Kconfig(small) + assert len(collections) == 1 + + +def test_parse_reclaims_cycles_from_a_prior_tree(tmp_path): + kconfig = _tree_that_trips_the_reclaim(tmp_path, "Kconfig") + thresholds = gc.get_threshold() + gc.collect() + gc.set_threshold(1_000_000, *thresholds[1:]) + try: + old = Kconfig(kconfig) + + class Tracker: + pass + + tracker = Tracker() + old._functions["tracker"] = tracker + stale = weakref.ref(tracker) + del old, tracker + assert stale() is not None + + Kconfig(tmp_path / "Kconfig") + assert stale() is None + finally: + gc.set_threshold(*thresholds) + gc.collect() + + +def test_save_leaves_no_temporary_files(tree): + """save() writes elsewhere and renames into place. Nothing else survives.""" + _load() + assert sorted(os.listdir(tree)) == ["Kconfig", "probe-cache.json"] + + +def test_damaged_entries_are_dropped_not_trusted(tree): + """A mangled cache costs a re-probe, never a crash or a wrong answer.""" + _load() + path = tree / "probe-cache.json" + cached = json.loads(path.read_text()) + good = dict(cached["results"]) + keys = list(good) + cached["results"] = { + **good, + keys[0]: 42, # not a pair + keys[1]: ["only one"], + keys[2]: ["y", "", "extra"], + } + path.write_text(json.dumps(cached)) + + values, _ = _load() + assert values == EXPECTED + + +def test_old_cache_format_is_discarded(tree): + _load() + path = tree / "probe-cache.json" + cached = json.loads(path.read_text()) + cached["version"] = -1 + path.write_text(json.dumps(cached)) + + values, _ = _load() + assert values == EXPECTED + + +# --- the context contract ---------------------------------------------------- +# +# Probe results are only valid for the environment and working directory they +# were measured in, and $(python,...) can move both mid-parse. Each test below +# corresponds to a way that was found to break; all three produce a silently +# wrong configuration rather than an error, so none of them announce +# themselves. + + +def _cc_probe_tree(tmp_path, monkeypatch, body): + """A tree whose probes answer 'y' only while CC is 'good'.""" + (tmp_path / "Kconfig").write_text(body) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("CC", "good") + monkeypatch.setattr( + kconfiglib, "_run_argv", lambda argv, stdin_data=None: argv[0] == "good" + ) + + +def test_probe_after_the_parse_sees_a_new_environment(tmp_path, monkeypatch): + """eval_string() runs after arbitrary host code we never saw.""" + _cc_probe_tree(tmp_path, monkeypatch, "PROBE = $(cc-option,-flag)\n") + c = Kconfig("Kconfig", warn_to_stderr=False) + + assert c.eval_string("$(cc-option,-flag)") == 2 # y + + os.environ["CC"] = "bad" + assert c.eval_string("$(cc-option,-flag)") == 0 # n + + +def test_variable_expansion_after_the_parse_sees_a_new_environment( + tmp_path, monkeypatch +): + """Variable.expanded_value is the other public way back into _fn_val().""" + _cc_probe_tree(tmp_path, monkeypatch, "PROBE = $(cc-option,-flag)\n") + c = Kconfig("Kconfig", warn_to_stderr=False) + + assert c.variables["PROBE"].expanded_value == "y" + + os.environ["CC"] = "bad" + assert c.variables["PROBE"].expanded_value == "n" + + +@pytest.mark.parametrize("entry", ["eval", "variable"]) +def test_probe_after_parse_is_persisted(tmp_path, monkeypatch, entry): + _cc_probe_tree(tmp_path, monkeypatch, "PROBE = $(cc-option,-flag)\n") + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + c = Kconfig("Kconfig", warn_to_stderr=False) + + if entry == "eval": + assert c.eval_string("$(cc-option,-flag)") == 2 + else: + assert c.variables["PROBE"].expanded_value == "y" + + assert ( + "cc-option\0-flag" + in json.loads((tmp_path / "probe-cache.json").read_text())["results"] + ) + assert not c._probe_cache._dirty + + +def test_eval_string_persists_probe_before_parse_error(tmp_path, monkeypatch): + _cc_probe_tree(tmp_path, monkeypatch, "") + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + c = Kconfig("Kconfig", warn_to_stderr=False) + + with pytest.raises(KconfigError): + c.eval_string("$(cc-option,-flag) && $(unterminated") + + assert ( + "cc-option\0-flag" + in json.loads((tmp_path / "probe-cache.json").read_text())["results"] + ) + + +def test_probe_nested_inside_a_user_function_sees_the_new_environment( + tmp_path, monkeypatch +): + """A KCONFIG_FUNCTIONS function can move the environment and then expand. + + The change happens before the function returns, so anything that waits + until afterwards to notice serves the nested probe a stale result. + """ + (tmp_path / "probe_cache_fns.py").write_text( + "import os\n\n" + "def change_and_probe(kconf, _):\n" + ' os.environ["CC"] = "bad"\n' + ' return kconf.variables["PROBE"].expanded_value\n\n' + 'functions = {"change-and-probe": (change_and_probe, 0, 0)}\n' + ) + _cc_probe_tree( + tmp_path, + monkeypatch, + "PROBE = $(cc-option,-flag)\nA := $(PROBE)\nB := $(change-and-probe)\n", + ) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.setenv("KCONFIG_FUNCTIONS", "probe_cache_fns") + monkeypatch.delitem(sys.modules, "probe_cache_fns", raising=False) + + c = Kconfig("Kconfig", warn_to_stderr=False) + + assert c.variables["A"].value == "y" + assert c.variables["B"].value == "n" + + +def test_persisted_entries_belong_to_the_files_context(tmp_path, monkeypatch): + """The file is stamped with one context, so only that context's results go in. + + Without this, a probe measured after an in-parse environment change is + written under the pre-change stamp and served to the next warm run. + """ + _cc_probe_tree( + tmp_path, + monkeypatch, + "A := $(cc-option,-flag)\n" + 'CHANGE := $(python,os.environ["CC"]="bad")\n' + "B := $(cc-option,-other)\n", + ) + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + + cold = Kconfig("Kconfig", warn_to_stderr=False) + assert (cold.variables["A"].value, cold.variables["B"].value) == ("y", "n") + + # A second process would start in the original context, not in the one the + # first run's $(python,...) left behind + os.environ["CC"] = "good" + + # It must reach the same answers, rather than reusing anything measured + # after the change + warm = Kconfig("Kconfig", warn_to_stderr=False) + assert (warm.variables["A"].value, warm.variables["B"].value) == ("y", "n") + + stored = json.loads((tmp_path / "probe-cache.json").read_text())["results"] + assert "cc-option\0-other" not in stored, "post-change result was persisted" + + +@pytest.mark.parametrize("entry", ["eval", "variable"]) +def test_probe_after_the_parse_sees_a_new_working_directory( + tmp_path, monkeypatch, entry +): + """The cwd half of the context contract. + + The environment is compared on every lookup, but the working directory + costs a syscall, so it is only re-read when something may have moved it. + Between the parse and a later call, host code we never saw has run, which + is what context_may_have_changed() at those two entry points declares. + Without them the environment tests still pass and this one does not. + """ + sub = tmp_path / "sub" + sub.mkdir() + (tmp_path / "Kconfig").write_text("PROBE = $(shell,pwd)\n") + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + + c = Kconfig("Kconfig", warn_to_stderr=False) + + def probe(): + if entry == "variable": + return c.variables["PROBE"].expanded_value + return "y" if c.eval_string(f'"$(shell,pwd)" = "{os.getcwd()}"') == 2 else "n" + + before = probe() + os.chdir(sub) + after = probe() + + if entry == "variable": + assert before != after + assert after.endswith("sub") + else: + # The probe is compared against the live cwd, so a stale cached result + # from the old directory no longer matches + assert (before, after) == ("y", "y") + + +def test_context_is_snapshotted_after_the_functions_module_is_imported( + tmp_path, monkeypatch +): + """Importing KCONFIG_FUNCTIONS runs arbitrary top-level Python. + + It can chdir or write to os.environ, so the cache has to snapshot the + context its probes will actually run in. Snapshotting before the import + stamped the file with one context and measured probes in another, and a + later run was then served a result from a directory it never ran in. + """ + a, b = tmp_path / "a", tmp_path / "b" + a.mkdir() + b.mkdir() + for d in (a, b): + (d / "Kconfig").write_text("OUT := $(shell,pwd)\n") + # Triggered by a file rather than the environment, which the fingerprint + # would otherwise notice on its own + (tmp_path / "mover.py").write_text( + "import os\n" + f"if os.path.exists({str(tmp_path / 'MOVE')!r}):\n" + f" os.chdir({str(b)!r})\n" + "functions = {}\n" + ) + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.setenv("KCONFIG_FUNCTIONS", "mover") + monkeypatch.setenv("KCONFIG_SHELL_CACHE", str(tmp_path / "probe-cache.json")) + monkeypatch.chdir(a) + + def load(): + sys.modules.pop("mover", None) + os.chdir(a) + return Kconfig("Kconfig", warn_to_stderr=False).variables["OUT"].value + + # Measured in a, then a run whose import moves to b must not reuse it + assert load().endswith("/a") + (tmp_path / "MOVE").touch() + assert load().endswith("/b") + + # And the reverse: a b-measured result must not be served to a run in a + (tmp_path / "probe-cache.json").unlink() + assert load().endswith("/b") + (tmp_path / "MOVE").unlink() + assert load().endswith("/a") + + +def test_import_time_environ_write_does_not_kill_persistence(tmp_path, monkeypatch): + """A functions module setting os.environ at import is ordinary. + + With the context snapshotted before the import it looked like a mid-parse + change, so the cache went non-pristine before the first probe and the file + was never written -- silently, on every run. + """ + (tmp_path / "envmod.py").write_text( + 'import os\nos.environ["MY_SDK_ROOT"] = "/opt/sdk"\nfunctions = {}\n' + ) + (tmp_path / "Kconfig").write_text("PROBE := $(shell,echo y)\n") + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.setenv("KCONFIG_FUNCTIONS", "envmod") + monkeypatch.setenv("KCONFIG_SHELL_CACHE", "probe-cache.json") + monkeypatch.chdir(tmp_path) + + sys.modules.pop("envmod", None) + c = Kconfig("Kconfig", warn_to_stderr=False) + + assert c._probe_cache._pristine + assert (tmp_path / "probe-cache.json").exists() + + +def test_gc_thresholds_survive_an_interrupt_while_suppressing(tmp_path, monkeypatch): + """Suppression happens inside the try whose finally restores it. + + Outside it, an interrupt landing in that window left the host process with + automatic collection off for good. + """ + (tmp_path / "Kconfig").write_text('mainmenu "gc"\n\nconfig A\n\tbool\n') + monkeypatch.chdir(tmp_path) + + # The first line that runs *after* suppression takes effect. Targeting the + # set_threshold line itself would raise before it executed, which leaks + # nothing and would make this test pass either way. + src, start = inspect.getsourcelines(Kconfig.__init__) + suppress_i = next( + i for i, line in enumerate(src) if "gc.set_threshold(*gc_suppressed)" in line + ) + target = next( + start + i + for i in range(suppress_i + 1, len(src)) + if src[i].strip() and not src[i].lstrip().startswith("#") + ) + before = gc.get_threshold() + + def tracer(frame, event, arg): + if ( + event == "line" + and frame.f_code.co_filename.endswith("kconfiglib.py") + and frame.f_lineno == target + ): + sys.settrace(None) + raise KeyboardInterrupt + return tracer + + sys.settrace(tracer) + try: + Kconfig("Kconfig", warn_to_stderr=False) + except KeyboardInterrupt: + pass + finally: + sys.settrace(None) + + # Read before restoring, or the cleanup makes the assertion vacuous + after = gc.get_threshold() + gc.set_threshold(*before) + + assert after == before diff --git a/tests/test_ui.py b/tests/test_ui.py deleted file mode 100644 index e9fc14e..0000000 --- a/tests/test_ui.py +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright (c) 2011-2019 Ulf Magnusson -# SPDX-License-Identifier: ISC -# -# UI search logic tests: verify that the jump-to search in menuconfig and -# guiconfig correctly strips the config prefix and matches symbol names. - -import re - -import pytest - -from kconfiglib import Kconfig - - -def _strip_prefix(search_text, config_prefix="CONFIG_"): - """Replicate the prefix stripping logic used by both UIs. - - The 'config_prefix' parameter mirrors Kconfig.config_prefix (default - "CONFIG_"), allowing tests to exercise custom prefixes like "BR2_". - """ - prefix = config_prefix.lower() - prefix_len = len(prefix) - return [ - re.compile(token[prefix_len:] if token.startswith(prefix) else token).search - for token in search_text.lower().split() - ] - - -def _matches_symbol(regex_searches, sym_name): - """Return True if all regexes match the symbol name (same logic as UIs).""" - name_lower = sym_name.lower() - return all(search(name_lower) for search in regex_searches) - - -def test_config_prefix_stripped(): - """Searching for CONFIG_FOO should match a symbol named FOO.""" - searches = _strip_prefix("CONFIG_MODULES") - assert _matches_symbol(searches, "MODULES") - - -def test_config_prefix_case_insensitive(): - """The prefix strip is case-insensitive since the whole string is lowered.""" - for prefix in ("CONFIG_", "config_", "Config_", "cOnFiG_"): - searches = _strip_prefix(prefix + "FOO") - assert _matches_symbol(searches, "FOO"), f"failed for prefix {prefix!r}" - - -def test_no_prefix_still_works(): - """Searching without CONFIG_ prefix still works normally.""" - searches = _strip_prefix("MODULES") - assert _matches_symbol(searches, "MODULES") - - -def test_config_prefix_only_at_start(): - """CONFIG_ embedded in the middle of a token should not be stripped.""" - searches = _strip_prefix("MY_CONFIG_FOO") - assert not _matches_symbol(searches, "FOO") - assert _matches_symbol(searches, "MY_CONFIG_FOO") - - -def test_config_prefix_multiple_tokens(): - """Multiple search tokens each get their prefix stripped independently.""" - searches = _strip_prefix("CONFIG_NET CONFIG_IPV6") - # Should match only if both "net" and "ipv6" match - assert not _matches_symbol(searches, "NET") - assert not _matches_symbol(searches, "IPV6") - # A name containing both substrings would match - assert _matches_symbol(searches, "NET_IPV6") - - -def test_config_prefix_with_regex(): - """Regex patterns after CONFIG_ prefix are preserved.""" - searches = _strip_prefix("CONFIG_DEBUG.*") - assert _matches_symbol(searches, "DEBUG_INFO") - assert _matches_symbol(searches, "DEBUG") - # re.search matches anywhere, so "debug.*" finds "debug" inside "NODEBUG" - assert _matches_symbol(searches, "NODEBUG") - assert not _matches_symbol(searches, "RELEASE") - - -def test_config_prefix_with_real_kconfig(): - """End-to-end: search with CONFIG_ prefix against a real Kconfig parse.""" - c = Kconfig("tests/Kmisc") - sym_names = [s for s in c.syms if not s.startswith("UNAME_RELEASE")] - assert len(sym_names) > 0, "Kmisc should define symbols" - - target = sym_names[0] - searches = _strip_prefix("CONFIG_" + target) - assert _matches_symbol( - searches, target - ), f"CONFIG_{target} should match symbol {target}" - - -def test_bare_config_search(): - """Searching for just 'CONFIG_' (with nothing after) becomes empty regex, - which matches everything -- same as searching for empty string.""" - searches = _strip_prefix("CONFIG_") - # Empty regex matches any string - assert _matches_symbol(searches, "ANYTHING") - - -def test_config_prefix_bad_regex(): - """Stripping CONFIG_ can expose an invalid regex (e.g. CONFIG_[ becomes [). - Both UIs catch re.error and show 'Bad regular expression'. Verify that the - stripping itself doesn't suppress the error.""" - with pytest.raises(re.error): - _strip_prefix("CONFIG_[") - - -def test_custom_prefix(): - """Projects can set a custom config prefix (e.g. BR2_ for Buildroot). - The stripping logic should use the actual prefix, not hardcoded CONFIG_.""" - # With BR2_ prefix, "BR2_PACKAGE" should match symbol "PACKAGE" - searches = _strip_prefix("BR2_PACKAGE", config_prefix="BR2_") - assert _matches_symbol(searches, "PACKAGE") - # re.search finds "package" inside "br2_package" too (substring match) - assert _matches_symbol(searches, "BR2_PACKAGE") - - # CONFIG_ should NOT be stripped when the prefix is BR2_ - searches = _strip_prefix("CONFIG_FOO", config_prefix="BR2_") - assert not _matches_symbol(searches, "FOO") - assert _matches_symbol(searches, "CONFIG_FOO") - - # Empty prefix means nothing is stripped - searches = _strip_prefix("CONFIG_BAR", config_prefix="") - assert _matches_symbol(searches, "CONFIG_BAR") diff --git a/tests/test_uirender.py b/tests/test_uirender.py new file mode 100644 index 0000000..ecb8667 --- /dev/null +++ b/tests/test_uirender.py @@ -0,0 +1,291 @@ +# Copyright (c) 2011-2019 Ulf Magnusson +# SPDX-License-Identifier: ISC +# +# Row-rendering tests for menuconfig and guiconfig. +# +# _node_str()/_value_str()/_get_force_info() produce every line the user reads, +# they are the most branch-dense functions in either tool (complexity 14 in +# guiconfig), and nothing exercised them. They are pure functions of a MenuNode +# plus a few module globals, so they test directly -- no terminal, no Tk. +# +# The expected strings below were taken from the real functions and checked by +# hand against tests/Kuirender, which is built to reach every branch: each +# symbol type, pinned and unpinned values, select/imply annotations including +# the "+N" truncation, y-mode choices, empty versus non-empty menus, comments, +# and promptless symbols in show-all mode. + +import pytest + +import menuconfig +from kconfiglib import Kconfig + + +@pytest.fixture(scope="module") +def kconf(): + return Kconfig("tests/Kuirender", warn=False) + + +@pytest.fixture +def mc(kconf, monkeypatch): + """menuconfig with its module globals set up for rendering.""" + monkeypatch.setattr(menuconfig, "_kconf", kconf, raising=False) + monkeypatch.setattr(menuconfig, "_show_all", True, raising=False) + monkeypatch.setattr(menuconfig, "_show_name", False, raising=False) + return menuconfig + + +@pytest.fixture +def gc_(kconf, monkeypatch): + """guiconfig with its module globals set up for rendering. + + Imported here rather than at the top of the module: guiconfig pulls in + tkinter, which is an optional part of a Python installation. A hard import + would turn a missing tkinter into a collection error that takes down the + whole file, menuconfig tests included. + """ + guiconfig = pytest.importorskip("guiconfig") + monkeypatch.setattr(guiconfig, "_kconf", kconf, raising=False) + monkeypatch.setattr(guiconfig, "_show_all", True, raising=False) + monkeypatch.setattr(guiconfig, "_single_menu", False, raising=False) + return guiconfig + + +@pytest.fixture +def ui(request): + return request.getfixturevalue("mc" if request.param == "menuconfig" else "gc_") + + +def node(kconf, name): + return kconf.syms[name].nodes[0] + + +def named_menu(kconf, prompt): + for menu in kconf.menus: + if menu.prompt[0] == prompt: + return menu + raise AssertionError(f"no menu titled {prompt!r}") + + +# --- menuconfig._value_str: one case per branch ----------------------------- + + +@pytest.mark.parametrize( + "sym, expected", + [ + ("BOOL_SYM", "[ ]"), + ("MODULES", "[*]"), + ("TRI_SYM", "< >"), + ("IMPLIED", "<*>"), + ("STR_SYM", "(hello)"), + ("INT_SYM", "(7)"), + ("HEX_SYM", "(0x1f)"), + # Pinned to a single assignable value by a select + ("SELECTED", "-*-"), + # y-mode choice symbols + ("CHOICE_A", "( )"), + ("CHOICE_B", "(X)"), + ], +) +def test_value_str(mc, kconf, sym, expected): + assert mc._value_str(node(kconf, sym)) == expected + + +def test_value_str_is_empty_for_menus_and_comments(mc, kconf): + assert mc._value_str(named_menu(kconf, "A menu")) == "" + assert mc._value_str(kconf.comments[0]) == "" + + +# --- _get_force_info, in both tools ----------------------------------------- + + +@pytest.mark.parametrize("ui", ("menuconfig", "guiconfig"), indirect=True) +@pytest.mark.parametrize( + "sym, expected", + [ + ("SELECTED", " [selected by SELECTOR]"), + ("IMPLIED", " [implied by IMPLIER]"), + # More than two sources is truncated with a count + ("MANY_SELECTED", " [selected by SEL_A, SEL_B, +1]"), + # Not forced by anything + ("SELECTOR", None), + # Not a bool/tristate + ("STR_SYM", None), + ], +) +def test_get_force_info(ui, kconf, sym, expected): + assert ui._get_force_info(kconf.syms[sym]) == expected + + +# --- menuconfig._node_str --------------------------------------------------- + + +@pytest.mark.parametrize( + "sym, expected", + [ + ("BOOL_SYM", "[ ] A bool (NEW)"), + ("TRI_SYM", "< > A tristate (NEW)"), + ("STR_SYM", "(hello) A string (NEW)"), + ("SELECTED", "-*- Selected one (NEW) [selected by SELECTOR]"), + ( + "MANY_SELECTED", + "-*- Selected by several (NEW) [selected by SEL_A, SEL_B, +1]", + ), + # Promptless symbols show their name instead, in show-all mode + ("PROMPTLESS", "-*- "), + # A menuconfig with visible children gets the enter arrow + ("MENUCONFIG_SYM", "[*] A menuconfig (NEW) --->"), + # Choice symbols in a y-mode choice get no "(NEW)" + ("CHOICE_B", "(X) Choice B"), + ], +) +def test_menuconfig_node_str(mc, kconf, sym, expected): + assert mc._node_str(node(kconf, sym)) == expected + + +def test_menuconfig_node_str_menus_and_comments(mc, kconf): + assert mc._node_str(named_menu(kconf, "A menu")) == " A menu --->" + # An empty menu is drawn with "----", since it cannot be entered + assert mc._node_str(named_menu(kconf, "Empty menu")) == " Empty menu ----" + assert mc._node_str(kconf.comments[0]) == " *** A comment ***" + + +def test_menuconfig_node_str_y_mode_choice_shows_selection(mc, kconf): + assert mc._node_str(kconf.choices[0].nodes[0]) == " A choice (Choice B) --->" + + +def test_menuconfig_show_name_appends_symbol_name(mc, kconf, monkeypatch): + monkeypatch.setattr(mc, "_show_name", True) + assert mc._node_str(node(kconf, "BOOL_SYM")) == "[ ] A bool (NEW)" + + +def test_menuconfig_node_str_indents_inside_an_implicit_submenu(mc, kconf): + """Children of a plain 'config' are indented; a 'menuconfig' owns the level. + + IMPLICIT_CHILD depends on IMPLICIT_PARENT, which puts it in the implicit + submenu of a node that is not a menuconfig, so the indent applies. + UNDER_MENUCONFIG sits under a real menuconfig and gets none. + """ + indent = " " * mc._SUBMENU_INDENT + assert ( + mc._node_str(node(kconf, "IMPLICIT_CHILD")) + == f"[ ] {indent}Implicit child (NEW)" + ) + assert not mc._node_str(node(kconf, "BOOL_SYM")).startswith("[ ] " + indent) + assert not mc._node_str(node(kconf, "UNDER_MENUCONFIG")).startswith("[ ] " + indent) + + +# --- guiconfig._node_str ---------------------------------------------------- +# +# Same tree, different conventions: no value prefix (the GUI draws an image +# instead), strings appended after a colon, int/hex still parenthesized. + + +@pytest.mark.parametrize( + "sym, expected", + [ + ("BOOL_SYM", "A bool (NEW)"), + ("STR_SYM", "A string (NEW): hello"), + ("INT_SYM", "(7) An int (NEW)"), + ("HEX_SYM", "(0x1f) A hex (NEW)"), + ("SELECTED", "Selected one (NEW) [selected by SELECTOR]"), + ("PROMPTLESS", ""), + ("CHOICE_B", "Choice B"), + ], +) +def test_guiconfig_node_str(gc_, kconf, sym, expected): + assert gc_._node_str(node(kconf, sym)) == expected + + +def test_guiconfig_node_str_comment_and_choice(gc_, kconf): + assert gc_._node_str(kconf.comments[0]) == "*** A comment ***" + assert gc_._node_str(kconf.choices[0].nodes[0]) == "A choice (Choice B)" + + +def test_guiconfig_single_menu_mode_adds_arrows(gc_, kconf, monkeypatch): + """Only single-menu mode draws the enter arrows; tree mode never does.""" + n = node(kconf, "MENUCONFIG_SYM") + assert gc_._node_str(n) == "A menuconfig (NEW)" + + monkeypatch.setattr(gc_, "_single_menu", True) + assert gc_._node_str(n) == "A menuconfig (NEW) --->" + assert gc_._node_str(named_menu(kconf, "Empty menu")) == "Empty menu ----" + + +def test_guiconfig_choice_sym_prompt_falls_back(gc_, kconf): + """_choice_sym_prompt() falls back when the symbol isn't at that node.""" + choice_node = kconf.choices[0].nodes[0] + sym = kconf.syms["CHOICE_B"] + assert gc_._choice_sym_prompt(sym, choice_node) == "Choice B" + # A node that is not this symbol's parent still yields some prompt + assert gc_._choice_sym_prompt(sym, named_menu(kconf, "A menu")) == "Choice B" + # A symbol with no prompt anywhere yields None + assert gc_._choice_sym_prompt(kconf.syms["PROMPTLESS"], choice_node) is None + + +# --- the two tools must agree on what is changeable ------------------------- + + +@pytest.mark.parametrize( + "sym, expected", + [ + ("BOOL_SYM", True), + ("STR_SYM", True), + ("TRI_SYM", True), + # Pinned to y by a select: nothing left to choose + ("SELECTED", False), + # No prompt, so not visible and not changeable + ("PROMPTLESS", False), + ], +) +@pytest.mark.parametrize("ui", ("menuconfig", "guiconfig"), indirect=True) +def test_changeable_agrees_between_tools(ui, kconf, sym, expected): + # 'is' rather than '==': _changeable() is documented to return True/False, + # and it used to leak the None from the end of an 'and' chain instead + n = node(kconf, sym) + assert ui._changeable(n) is expected + + +@pytest.mark.parametrize("ui", ("menuconfig", "guiconfig"), indirect=True) +def test_changeable_rejects_menus_and_comments(ui, kconf): + for n in (named_menu(kconf, "A menu"), kconf.comments[0]): + assert ui._changeable(n) is False + + +# --- menuconfig._shown_nodes: what actually reaches the screen -------------- + + +def test_shown_nodes_hides_promptless_symbols_until_show_all(mc, kconf, monkeypatch): + """PROMPTLESS has no prompt, so it appears only in show-all mode.""" + monkeypatch.setattr(mc, "_show_all", False) + hidden = mc._shown_nodes(kconf.top_node) + monkeypatch.setattr(mc, "_show_all", True) + shown = mc._shown_nodes(kconf.top_node) + + promptless = node(kconf, "PROMPTLESS") + assert promptless not in hidden + assert promptless in shown + # show-all only ever adds + assert set(hidden) < set(shown) + + +def test_shown_nodes_descends_into_a_menuconfig(mc, kconf, monkeypatch): + monkeypatch.setattr(mc, "_show_all", False) + children = mc._shown_nodes(node(kconf, "MENUCONFIG_SYM")) + assert children == [node(kconf, "UNDER_MENUCONFIG")] + + +def test_shown_nodes_of_an_empty_menu_is_empty(mc, kconf, monkeypatch): + """This is what makes _node_str() draw "----" instead of "--->".""" + monkeypatch.setattr(mc, "_show_all", False) + assert mc._shown_nodes(named_menu(kconf, "Empty menu")) == [] + assert mc._shown_nodes(named_menu(kconf, "A menu")) != [] + + +@pytest.mark.parametrize("ui", ("menuconfig", "guiconfig"), indirect=True) +def test_is_y_mode_choice_sym_returns_a_real_bool(ui, kconf): + """The predicate feeding _changeable() must not leak a None.""" + assert ui._is_y_mode_choice_sym(kconf.syms["CHOICE_A"]) is True + # Not a choice symbol at all + assert ui._is_y_mode_choice_sym(kconf.syms["BOOL_SYM"]) is False + # Not a Symbol at all + assert ui._is_y_mode_choice_sym(kconf.choices[0]) is False diff --git a/tests/test_uisearch.py b/tests/test_uisearch.py new file mode 100644 index 0000000..94b882e --- /dev/null +++ b/tests/test_uisearch.py @@ -0,0 +1,295 @@ +# Copyright (c) 2011-2019 Ulf Magnusson +# SPDX-License-Identifier: ISC +# +# Jump-to search tests, driving the *real* guiconfig._update_jump_to_matches() +# rather than a reimplementation of it. +# +# This replaces tests/test_ui.py, which covered the same ground against a local +# reimplementation of the prefix-stripping and matching. A copy cannot catch a +# change to the original, which is the same reason tests/test_ui_ranges.py +# gives for calling the real _range_info(). Every case from that file is kept +# below, driven through the shipped function with a stubbed Treeview, so the +# regex handling, the multi-token AND, the menu/comment pass and the error +# paths are all exercised the way users hit them. +# +# Both tools are covered. menuconfig's copy used to be inlined in +# _jump_to_dialog(), unreachable without a terminal; it now lives in +# menuconfig._jump_to_matches(), so the same cases run against both and the two +# copies cannot drift apart unnoticed. + +import pytest + +import menuconfig +from kconfiglib import Kconfig + +# guiconfig pulls in tkinter, which is an optional part of a Python +# installation. Skip only its tests so menuconfig stays covered headlessly. +# +# ImportError rather than ModuleNotFoundError: a half-installed Tk is present +# but unloadable, and the C extension loader reports that as a plain +# ImportError. Both carry .name, so an unrelated import bug inside guiconfig +# still propagates instead of being silently turned into a skip. +try: + import guiconfig +except ImportError as e: + if e.name not in ("tkinter", "_tkinter"): + raise + guiconfig = None + + +def _names(nodes): + """How a matched node is identified in the assertions below. + + Shared by both fixtures: test_the_two_tools_agree compares their outputs, + so two copies drifting would turn that check into a false pass. + """ + return [ + getattr(n.item, "name", None) or (n.prompt[0] if n.prompt else None) + for n in nodes + ] + + +class FakeTree: + """The few Treeview methods _update_jump_to_matches() actually calls.""" + + def __init__(self): + self.children = () + self.selection = None + self.focused = None + self.items = {} + + def selection_set(self, *args): + self.selection = args + + def set_children(self, _parent, *ids): + self.children = ids + + def focus(self, item): + self.focused = item + + def item(self, item, **kwargs): + self.items[item] = kwargs + + +@pytest.fixture +def search(monkeypatch): + """Returns (run, msglabel, tree) for driving the real search.""" + if guiconfig is None: + pytest.skip("guiconfig requires tkinter") + kconf = Kconfig("tests/Kuirender", warn=False) + tree = FakeTree() + msglabel = {"text": ""} + + monkeypatch.setattr(guiconfig, "_kconf", kconf, raising=False) + monkeypatch.setattr(guiconfig, "_show_all", True, raising=False) + monkeypatch.setattr(guiconfig, "_single_menu", False, raising=False) + monkeypatch.setattr(guiconfig, "_jump_to_tree", tree, raising=False) + monkeypatch.setattr(guiconfig, "_jump_to_matches", [], raising=False) + # Cleared so one test's sort order can't leak into the next + monkeypatch.setattr(guiconfig, "_cached_sc_nodes", [], raising=False) + monkeypatch.setattr(guiconfig, "_cached_menu_comment_nodes", [], raising=False) + + def run(text): + guiconfig._update_jump_to_matches(msglabel, text) + return _names(guiconfig._jump_to_matches) + + return run, msglabel, tree + + +def test_matches_symbol_by_name(search): + run, msglabel, _ = search + assert run("BOOL_SYM") == ["BOOL_SYM"] + assert msglabel["text"] == "" + + +def test_search_is_case_insensitive(search): + run, _, _ = search + assert run("bool_sym") == ["BOOL_SYM"] + + +def test_config_prefix_is_stripped(search): + """Typing the CONFIG_ prefix still finds the symbol.""" + run, _, _ = search + assert run("CONFIG_BOOL_SYM") == ["BOOL_SYM"] + + +def test_custom_config_prefix_is_stripped(search, monkeypatch): + run, _, _ = search + monkeypatch.setattr(guiconfig._kconf, "config_prefix", "BR2_") + assert run("BR2_BOOL_SYM") == ["BOOL_SYM"] + + +def test_prefix_is_stripped_only_at_the_start_of_a_token(search): + """CONFIG_ inside a token is part of the pattern, not a prefix to remove.""" + run, _, _ = search + assert run("MY_CONFIG_BOOL_SYM") == [] + + +def test_each_token_is_stripped_independently(search): + """Both tokens lose their prefix, then all of them must match.""" + run, _, _ = search + assert run("CONFIG_SEL CONFIG_A") == ["MANY_SELECTED", "SEL_A"] + + +def test_regex_after_the_prefix_is_preserved(search): + """Stripping CONFIG_ must not disturb the pattern that follows it.""" + run, _, _ = search + assert run("CONFIG_SEL_.*") == ["SEL_A", "SEL_B", "SEL_C"] + + +def test_bare_prefix_becomes_an_empty_pattern(search): + """ "CONFIG_" alone strips to "", which matches everything.""" + run, _, _ = search + assert len(run("CONFIG_")) == len(run("")) + + +def test_prefix_of_a_different_project_is_not_stripped(search, monkeypatch): + """With BR2_ configured, CONFIG_ is just text and matches nothing here.""" + run, _, _ = search + monkeypatch.setattr(guiconfig._kconf, "config_prefix", "BR2_") + assert run("CONFIG_BOOL_SYM") == [] + + +def test_empty_prefix_strips_nothing(search, monkeypatch): + run, _, _ = search + monkeypatch.setattr(guiconfig._kconf, "config_prefix", "") + assert run("CONFIG_BOOL_SYM") == [] + assert run("BOOL_SYM") == ["BOOL_SYM"] + + +def test_matches_on_prompt_text(search): + run, _, _ = search + assert "STR_SYM" in run("A string") + + +def test_multiple_tokens_are_anded(search): + """Every token must match, so an impossible pair yields nothing.""" + run, msglabel, _ = search + assert run("selector a") == ["SEL_A"] + assert run("bool_sym hex_sym") == [] + assert msglabel["text"] == "No matches" + + +def test_menus_and_comments_are_searched(search): + run, _, _ = search + assert run("Empty menu") == ["Empty menu"] + assert run("A comment") == ["A comment"] + + +def test_regex_syntax_is_supported(search): + run, _, _ = search + assert run("^sel_[abc]$") == ["SEL_A", "SEL_B", "SEL_C"] + + +def test_bad_regex_reports_instead_of_raising(search): + """An unbalanced bracket is a message, not a traceback.""" + run, msglabel, tree = search + assert run("[unterminated") == [] + assert msglabel["text"].startswith("Bad regular expression: ") + # The result list is cleared rather than left stale + assert tree.children == () + + +def test_empty_search_matches_everything(search): + """No tokens means no filters, so every node qualifies.""" + run, _, _ = search + assert len(run("")) > 10 + + +def test_no_matches_sets_the_message(search): + run, msglabel, _ = search + assert run("zzz_no_such_symbol") == [] + assert msglabel["text"] == "No matches" + + +def test_first_match_is_selected_and_focused(search): + """The dialog opens with the first hit selected, ready for Enter.""" + run, _, tree = search + names = run("sel_") + assert names + assert tree.selection == (id(guiconfig._jump_to_matches[0]),) + assert tree.focused == id(guiconfig._jump_to_matches[0]) + + +def test_matches_are_pushed_to_the_tree(search): + """_update_jump_to_display() renders each match into the tree.""" + run, _, tree = search + run("BOOL_SYM") + assert len(tree.children) == 1 + (rendered,) = tree.items.values() + assert rendered["text"] == "A bool (NEW)" + + +# --- the same cases, against menuconfig's copy of the logic ------------------ + + +@pytest.fixture +def mc_search(monkeypatch): + """Returns a run() driving the real menuconfig._jump_to_matches().""" + kconf = Kconfig("tests/Kuirender", warn=False) + monkeypatch.setattr(menuconfig, "_kconf", kconf, raising=False) + monkeypatch.setattr(menuconfig, "_show_all", True, raising=False) + monkeypatch.setattr(menuconfig, "_cached_sc_nodes", [], raising=False) + monkeypatch.setattr(menuconfig, "_cached_menu_comment_nodes", [], raising=False) + + def run(text): + matches, bad_re = menuconfig._jump_to_matches(text) + return _names(matches), bad_re + + return run + + +@pytest.mark.parametrize( + "query, expected", + [ + ("BOOL_SYM", ["BOOL_SYM"]), + ("bool_sym", ["BOOL_SYM"]), + ("CONFIG_BOOL_SYM", ["BOOL_SYM"]), + ("MY_CONFIG_BOOL_SYM", []), + ("CONFIG_SEL CONFIG_A", ["MANY_SELECTED", "SEL_A"]), + ("CONFIG_SEL_.*", ["SEL_A", "SEL_B", "SEL_C"]), + ("^sel_[abc]$", ["SEL_A", "SEL_B", "SEL_C"]), + ("Empty menu", ["Empty menu"]), + ("A comment", ["A comment"]), + ("zzz_no_such_symbol", []), + ], +) +def test_menuconfig_search(mc_search, query, expected): + matches, bad_re = mc_search(query) + assert matches == expected + assert bad_re is None + + +def test_menuconfig_bad_regex_reports_instead_of_raising(mc_search): + matches, bad_re = mc_search("[unterminated") + assert matches == [] + assert bad_re.startswith("Bad regular expression: ") + + +def test_menuconfig_bare_prefix_matches_everything(mc_search): + assert len(mc_search("CONFIG_")[0]) == len(mc_search("")[0]) + + +def test_the_two_tools_agree(mc_search, search): + """The whole point of extracting menuconfig's copy: catch drift. + + A bad regex is in the list deliberately -- guiconfig used to leave the + previous matches in _jump_to_matches on that path while menuconfig returned + an empty list. + """ + gui_run, _, _ = search + for query in ( + "BOOL_SYM", + "sel_", + "^sel_[abc]$", + "A string", + "A comment", + "CONFIG_SEL CONFIG_A", + "", + # Deliberately straight after a query that matched, so a tool leaving + # its previous results behind on the error path is caught + "[unterminated", + "zzz_no_such_symbol", + "[also bad", + ): + assert mc_search(query)[0] == gui_run(query), f"tools disagree on {query!r}"