From 1aaa42be84c5d711502c2c8c9cd98700447a5127 Mon Sep 17 00:00:00 2001 From: Flaykky Date: Sun, 7 Jun 2026 00:34:04 +0300 Subject: [PATCH 1/3] [F] Fix multiple crash bugs in the Python fallback termenv.py: - unix_detect_ansi_mode: `TERM`/`COLORTERM` can be absent from the environment (CI, cron, some IDEs). `os.environ.get('TERM')` returns None in that case, so subsequent `term.startswith(...)` and `'256color' in term` calls raised AttributeError. Changed to `os.environ.get('TERM') or ''` for both variables. - unix_read_osc: same None dereference on line 99 (`term.startswith`). Applied the same `or ''` guard. - unix_read_osc: `code.lstrip(start)` was used to strip the OSC prefix, but `str.lstrip` strips individual *characters*, not a prefix string, so it could silently over-consume leading bytes of the actual payload. Replaced with an explicit `code[len(start):]` slice after a `startswith` check. - windows_detect_ansi_mode: `map(int, platform.version().split('.'))` crashes with ValueError/TypeError on non-standard version strings. Wrapped in try/except with a safe fallback of `'rgb'`. `int(os.environ.get('ANSICON_VER'))` raised when the var was unset or non-numeric; guarded with `.isdigit()` before converting. color_util.py: - RGB.to_ansi: for `mode == 'ansi'` it forwarded to `to_ansi_16` which is an unimplemented stub (`raise NotImplementedError`). For `mode == 'default'` and any other unknown mode the function fell through and returned None, causing TypeError when callers concatenated the result into strings (e.g. in presets.py). Both cases now fall back to `to_ansi_8bit`, which is a correct and safe 256-color degradation. Return type annotation updated to `str`. neofetch_util.py: - ensure_git_bash: `git_path` returned by the `if_file(...)` chain can be None when no Git Bash installation is found on Windows. The subsequent `git_path.is_file()` then raised AttributeError instead of printing the friendly error message. Fixed to `if not git_path or not git_path.is_file():`. - get_distro_ascii: `run_neofetch_cmd` can return None when neofetch is missing or exits non-zero without raising. The following `.replace()` call would then crash with AttributeError. Added an explicit None guard that prints an error and exits cleanly. - run(): the backend dispatcher had no else/fallback branch, so an unknown or mistyped backend silently returned None and produced no output. Added `raise ValueError(f"Unknown backend: {backend!r}")`. types.py: - BackendLiteral was `Literal["neofetch", "fastfetch"]`, missing `"qwqfetch"` and `"fastfetch-old"` which are both accepted by the argparse parser and the run() dispatcher. Stale types caused false type-checker warnings for valid inputs. Updated to include all four supported backends. main.py: - select_lightness: the prompt advertises `.45` and `0.45` as valid decimal inputs, but the parser called `int(lightness)` first, which raises ValueError for any non-integer float, landing in the error path before the `float()` branch was ever reached. Reordered to: handle `%` suffix first, then `float()` for everything else, dividing by 100 only when the value exceeds 1. - June/pride-month check: `os.isatty(sys.stdout.fileno())` raises io.UnsupportedOperation when stdout is piped or replaced (e.g. during testing or when output is redirected). Replaced with the pattern already used in termenv.py: `hasattr(sys.stdout, 'isatty') and sys.stdout.isatty()`. Co-Authored-By: Claude Opus 4.8 --- hyfetch/color_util.py | 7 ++++--- hyfetch/main.py | 11 +++++++---- hyfetch/neofetch_util.py | 6 +++++- hyfetch/termenv.py | 24 ++++++++++++++++-------- hyfetch/types.py | 2 +- 5 files changed, 33 insertions(+), 17 deletions(-) diff --git a/hyfetch/color_util.py b/hyfetch/color_util.py index 42e02d7d..7eebaaa5 100644 --- a/hyfetch/color_util.py +++ b/hyfetch/color_util.py @@ -198,15 +198,16 @@ class RGB: """ raise NotImplementedError() - def to_ansi(self, mode: AnsiMode | None = None, foreground: bool = True): + def to_ansi(self, mode: AnsiMode | None = None, foreground: bool = True) -> str: if not mode: mode = GLOBAL_CFG.color_mode if mode == 'rgb': return self.to_ansi_rgb(foreground) if mode == '8bit': return self.to_ansi_8bit(foreground) - if mode == 'ansi': - return self.to_ansi_16(foreground) + # 'ansi' (16-color) is not yet implemented; fall back to 8bit which always works. + # 'default' and any unknown mode also fall through here as a safe degradation. + return self.to_ansi_8bit(foreground) def lighten(self, multiplier: float) -> 'RGB': """ diff --git a/hyfetch/main.py b/hyfetch/main.py index cff94c10..39ddc394 100755 --- a/hyfetch/main.py +++ b/hyfetch/main.py @@ -289,10 +289,12 @@ def create_config() -> Config: return def_lightness try: - if lightness.endswith('%') or int(lightness) > 1: - lightness = int(lightness[:-1]) / 100 if lightness.endswith('%') else int(lightness) / 100 + if lightness.endswith('%'): + lightness = int(lightness[:-1]) / 100 else: - lightness = float(lightness) + # Accept plain floats (.45, 0.45) and integers treated as percentages (45 → 0.45) + value = float(lightness) + lightness = value / 100 if value > 1 else value assert 0 <= lightness <= 1 return lightness @@ -533,7 +535,8 @@ def run(): now = datetime.datetime.now() june_path = CACHE_PATH / f'animation-displayed-{now.year}' show_for_june = False - if now.month == 6 and now.year not in config.pride_month_shown and not june_path.is_file() and os.isatty(sys.stdout.fileno()): + stdout_is_tty = hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() + if now.month == 6 and now.year not in config.pride_month_shown and not june_path.is_file() and stdout_is_tty: show_for_june = True if (args.june or show_for_june) and not config.pride_month_disable: diff --git a/hyfetch/neofetch_util.py b/hyfetch/neofetch_util.py index 872bfb12..ef9bd2c4 100644 --- a/hyfetch/neofetch_util.py +++ b/hyfetch/neofetch_util.py @@ -226,7 +226,7 @@ def ensure_git_bash() -> Path: or if_file("C:/Program Files/Git/bin/bash.exe") or if_file("C:/Program Files (x86)/Git/bin/bash.exe")) - if not git_path.is_file(): + if not git_path or not git_path.is_file(): printc("&cError: Git Bash installation not found") sys.exit(127) @@ -299,6 +299,9 @@ def get_distro_ascii(distro: str | None = None) -> str: cmd += f' --ascii_distro {distro}' asc = run_neofetch_cmd(cmd, True) + if not asc: + printc("&cError: Failed to get ASCII art from neofetch") + sys.exit(1) # Unescape backslashes here because backslashes are escaped in neofetch for printf asc = asc.replace('\\\\', '\\') @@ -319,6 +322,7 @@ def run(asc: str, backend: BackendLiteral, args: str = ''): return run_fastfetch(asc, args, legacy=True) if backend == "qwqfetch": return run_qwqfetch(asc, args) + raise ValueError(f"Unknown backend: {backend!r}") def run_qwqfetch(asc: str, args: str = ''): diff --git a/hyfetch/termenv.py b/hyfetch/termenv.py index 97f1346c..482259a7 100644 --- a/hyfetch/termenv.py +++ b/hyfetch/termenv.py @@ -22,8 +22,8 @@ def unix_detect_ansi_mode() -> AnsiMode | None: if hasattr(sys.stdout, 'isatty') and not sys.stdout.isatty(): return 'ansi' - term = os.environ.get('TERM') - color_term = os.environ.get('COLORTERM') + term = os.environ.get('TERM') or '' + color_term = os.environ.get('COLORTERM') or '' if color_term == 'truecolor' or color_term == '24bit': if term.startswith('screen') and os.environ.get('TERM_PROGRAM') != 'tmux': @@ -61,12 +61,16 @@ def windows_detect_ansi_mode() -> AnsiMode | None: if os.environ.get("ConEmuANSI") == "ON": return 'rgb' - release, _, build = map(int, platform.version().split('.')) + try: + release, _, build = map(int, platform.version().split('.')) + except (ValueError, TypeError): + # If the version string is unparseable, assume a modern Windows with full color support. + return 'rgb' if build < 10586 or release < 10: # No ANSI support before Windows 10 build 10586. if os.environ.get('ANSICON'): - conv = os.environ.get('ANSICON_VER') - if int(conv) < 181: + conv = os.environ.get('ANSICON_VER') or '' + if conv.isdigit() and int(conv) < 181: return 'ansi' return '8bit' return 'ansi' @@ -95,7 +99,7 @@ def unix_read_osc(seq: int) -> str: # screen/tmux can't support OSC, because they can be connected to multiple # terminals concurrently. - term = os.environ.get('TERM') + term = os.environ.get('TERM') or '' if term.startswith("screen") or term.startswith("tmux"): raise OSCException("Screen/tmux not supported") @@ -152,8 +156,12 @@ def unix_read_osc(seq: int) -> str: if not code.startswith(start): raise OSCException("Received response is not an OSC response") - # Strip starting code and termination code - code = code.lstrip(start).rstrip("\x1b\\").rstrip('\a') + # Strip starting prefix and trailing termination sequence + code = code[len(start):] + if code.endswith("\x1b\\"): + code = code[:-2] + elif code.endswith('\a'): + code = code[:-1] return code diff --git a/hyfetch/types.py b/hyfetch/types.py index 2e6287db..71ee6014 100644 --- a/hyfetch/types.py +++ b/hyfetch/types.py @@ -5,6 +5,6 @@ except ImportError: AnsiMode = Literal['default', 'ansi', '8bit', 'rgb'] LightDark = Literal['light', 'dark'] -BackendLiteral = Literal["neofetch", "fastfetch"] +BackendLiteral = Literal["qwqfetch", "neofetch", "fastfetch", "fastfetch-old"] ColorAlignMode = Literal['horizontal', 'vertical', 'custom'] ColorSpacing = Literal['equal', 'weighted'] From 720e62815a1881073e4b6fa4062cbc0941a66979 Mon Sep 17 00:00:00 2001 From: Flaykky Date: Sun, 7 Jun 2026 00:34:48 +0300 Subject: [PATCH 2/3] [+] Improve shell completion: substring matching + descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the three dynamic completion functions in cli_options.rs (complete_preset, complete_mode, complete_backend) only returned candidates whose names *started with* the typed prefix, and returned no hint text alongside each candidate. This meant that, for example, typing `hyfetch -p trans` found `transgender` but typing `hyfetch -p gender` found nothing, even though `gender` uniquely identifies `transgender`. The interactive flag picker (used during `hyfetch --config`) already performs substring matching; the shell completion was inconsistent with it. Changes: - Extracted a shared `ranked_completions()` helper that drives all three functions. It uses `str::contains` instead of `str::starts_with` so any substring of a candidate name triggers a match. - Results are sorted so that prefix matches appear before other substring matches, then by position of the match within the name, then alphabetically — identical ordering to the interactive picker's `filter_flag_indices`. - Each completion entry now carries a short human-readable description ("pride flag preset", "color mode", "fetch backend") surfaced by shells that display hints next to candidates (e.g. zsh with `complete_help`, fish). - Added three unit tests under `#[cfg(feature = "autocomplete")]`: complete_preset_substring – "gender" matches "transgender" complete_preset_prefix_ranked_first – "trans" puts prefix match first complete_preset_descriptions – every result has a non-empty hint All five tests (including the pre-existing check_options and models::test) pass with `cargo test -p hyfetch --features autocomplete`. Co-Authored-By: Claude Opus 4.8 --- crates/hyfetch/src/cli_options.rs | 103 +++++++++++++++++++++--------- 1 file changed, 73 insertions(+), 30 deletions(-) diff --git a/crates/hyfetch/src/cli_options.rs b/crates/hyfetch/src/cli_options.rs index bf250b7d..e68c318e 100644 --- a/crates/hyfetch/src/cli_options.rs +++ b/crates/hyfetch/src/cli_options.rs @@ -231,47 +231,51 @@ BACKEND={{{backends}}}", .version(env!("CARGO_PKG_VERSION")); } +/// Build a sorted completion list that ranks prefix matches before other substring matches. +/// +/// Each entry is `(name, description)` where description is `Some(hint)` when provided. +/// Prefix matches sort before other substring matches; within each group results are sorted +/// by the position of the match and then alphabetically. +#[cfg(feature = "autocomplete")] +fn ranked_completions<'a>( + candidates: impl Iterator, + input: &str, + description: Option<&str>, +) -> Vec<(String, Option)> { + let desc = description.map(str::to_owned); + let mut matched: Vec<(bool, usize, &str)> = candidates + .filter_map(|&name| { + name.find(input).map(|pos| (name.starts_with(input), pos, name)) + }) + .collect(); + // Prefix matches first (true sorts after false, so negate), then earliest position, then name. + matched.sort_by_key(|&(is_prefix, pos, name)| (!is_prefix, pos, name)); + matched + .into_iter() + .map(|(_, _, name)| (name.to_owned(), desc.clone())) + .collect() +} + #[cfg(feature = "autocomplete")] fn complete_preset(input: &String) -> Vec<(String, Option)> { - ::VARIANTS + let all_variants: Vec<&str> = ::VARIANTS .iter() - .chain(iter::once(&"random")) - .filter_map(|&name| { - if name.starts_with(input) { - Some((name.to_owned(), None)) - } else { - None - } - }) - .collect::>() + .copied() + .chain(iter::once("random")) + .collect(); + ranked_completions(all_variants.iter(), input.as_str(), Some("pride flag preset")) } #[cfg(feature = "autocomplete")] fn complete_mode(input: &String) -> Vec<(String, Option)> { - AnsiMode::VARIANTS - .iter() - .filter_map(|&name| { - if name.starts_with(input) { - Some((name.to_owned(), None)) - } else { - None - } - }) - .collect::>() + let variants: Vec<&str> = AnsiMode::VARIANTS.to_vec(); + ranked_completions(variants.iter(), input.as_str(), Some("color mode")) } #[cfg(feature = "autocomplete")] fn complete_backend(input: &String) -> Vec<(String, Option)> { - Backend::VARIANTS - .iter() - .filter_map(|&name| { - if name.starts_with(input) { - Some((name.to_owned(), None)) - } else { - None - } - }) - .collect::>() + let variants: Vec<&str> = Backend::VARIANTS.to_vec(); + ranked_completions(variants.iter(), input.as_str(), Some("fetch backend")) } #[cfg(test)] @@ -282,4 +286,43 @@ mod tests { fn check_options() { options().check_invariants(false) } + + #[cfg(feature = "autocomplete")] + #[test] + fn complete_preset_substring() { + // "gender" is a substring of "transgender" but not a prefix → must still match + let results = complete_preset(&"gender".to_owned()); + let names: Vec<&str> = results.iter().map(|(n, _)| n.as_str()).collect(); + assert!( + names.contains(&"transgender"), + "substring 'gender' should match 'transgender', got: {names:?}" + ); + } + + #[cfg(feature = "autocomplete")] + #[test] + fn complete_preset_prefix_ranked_first() { + // "trans" is a prefix of "transgender" → it should appear before any non-prefix matches + let results = complete_preset(&"trans".to_owned()); + assert!(!results.is_empty(), "expected at least one result for 'trans'"); + let first = results[0].0.as_str(); + assert!( + first.starts_with("trans"), + "first result should be a prefix match, got: {first}" + ); + } + + #[cfg(feature = "autocomplete")] + #[test] + fn complete_preset_descriptions() { + // Every completion result should carry a non-empty description + let results = complete_preset(&"rain".to_owned()); + assert!(!results.is_empty(), "expected at least one result for 'rain'"); + for (name, desc) in &results { + assert!( + desc.as_deref().is_some_and(|d| !d.is_empty()), + "completion for '{name}' should have a description" + ); + } + } } From c518b86a35be82b0f5023198722d29bdd12a6f94 Mon Sep 17 00:00:00 2001 From: Theo Haines <91698052+TheoHaines@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:53:45 +0100 Subject: [PATCH 3/3] Enhance to_ansi method with mode validation Add error handling for unsupported color modes in to_ansi method. --- hyfetch/color_util.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/hyfetch/color_util.py b/hyfetch/color_util.py index 7eebaaa5..1d711b4e 100644 --- a/hyfetch/color_util.py +++ b/hyfetch/color_util.py @@ -201,13 +201,24 @@ class RGB: def to_ansi(self, mode: AnsiMode | None = None, foreground: bool = True) -> str: if not mode: mode = GLOBAL_CFG.color_mode + + # If the mode is none, raise an error + if mode is None: + raise ValueError("GLOBAL_CFG.color_mode is not set") + if mode == 'rgb': return self.to_ansi_rgb(foreground) if mode == '8bit': return self.to_ansi_8bit(foreground) - # 'ansi' (16-color) is not yet implemented; fall back to 8bit which always works. - # 'default' and any unknown mode also fall through here as a safe degradation. - return self.to_ansi_8bit(foreground) + if mode == 'default': + # treat 'default' as 8bit (256 colors) + return self.to_ansi_8bit(foreground) + if mode == 'ansi': + # 16-color 'ansi' mode is not implemented yet. + raise NotImplementedError("'ansi' (16-color) mode is not implemented") + + # Unknown / misspelled mode in config + raise ValueError(f"Unknown color mode: {mode!r}") def lighten(self, multiplier: float) -> 'RGB': """