feat(tools): adopt esp-pylib for idf.py logging and errors

Migrate idf.py and its actions to the shared esp-pylib library:
- add esp-pylib (and esp-pylib[ide]) to the core/ide requirements
- base FatalError on esp_pylib.errors.FatalError, keeping the idf.py ctx cleanup
- replace the local raw-ANSI helpers (red_print/yellow_print/print_warning/
  color_print) with esp_pylib.logger.log (warn/err/note/hint) across the
  idf.py actions
- install esp_pylib exception reporting at the idf.py entry point and silence
  the logger during shell completion
- update affected tests for the new prefixes/line wrapping
- add normalize_output() helper and unify terminal env in conftest to handle
  Rich line-wrapping in CI assertions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Marek Fiala
2026-07-15 20:19:07 +08:00
committed by BOT
co-authored by Cursor
parent 055ba9d3f9
commit 8c08c75f48
25 changed files with 381 additions and 318 deletions
+31 -54
View File
@@ -19,6 +19,8 @@ from typing import cast
import rich_click as click
import yaml
from esp_idf_monitor import get_ansi_converter
from esp_pylib.logger import log
from rich.markup import escape
from idf_py_actions.errors import NoSerialPortFoundError
@@ -35,6 +37,12 @@ SHELL_COMPLETE_VAR = '_IDF.PY_COMPLETE'
# was shell completion invoked?
SHELL_COMPLETE_RUN = SHELL_COMPLETE_VAR in os.environ
# During shell completion idf.py must stay silent: any diagnostic written to stdout/stderr
# corrupts the completion output. Silence the shared logger once instead of guarding every
# call site (this is what the old print_warning() helper did via its SHELL_COMPLETE_RUN check).
if SHELL_COMPLETE_RUN:
log.set_verbosity('SILENT')
# The ctx dict "abuses" how python evaluates default parameter values.
# https://docs.python.org/3/reference/compound_stmts.html#function-definitions
@@ -91,7 +99,7 @@ def idf_version_from_cmake() -> str | None:
return f'v{ver["MAJOR"]}.{ver["MINOR"]}.{ver["PATCH"]}'
except (KeyError, OSError):
sys.stderr.write('WARNING: Cannot find ESP-IDF version in version.cmake\n')
log.warn('Cannot find ESP-IDF version in version.cmake')
return None
@@ -123,7 +131,7 @@ def idf_version() -> str | None:
)
except Exception:
# if failed, then try to parse cmake.version file
sys.stderr.write('WARNING: Git version unavailable, reading from source\n')
log.warn('Git version unavailable, reading from source')
version = idf_version_from_cmake()
return version
@@ -156,32 +164,8 @@ def get_default_serial_port() -> Any:
raise FatalError(f'An exception occurred during detection of the serial port: {e}')
# function prints warning when autocompletion is not being performed
# set argument stream to sys.stderr for errors and exceptions
def print_warning(message: str, stream: TextIO | None = None) -> None:
if not SHELL_COMPLETE_RUN:
print(message, file=stream or sys.stderr)
def color_print(message: str, color: str, newline: str | None = '\n') -> None:
"""Print a message to stderr with colored highlighting"""
ansi_normal = '\033[0m'
sys.stderr.write(f'{color}{message}{ansi_normal}{newline}')
sys.stderr.flush()
def yellow_print(message: str, newline: str | None = '\n') -> None:
ansi_yellow = '\033[0;33m'
color_print(message, ansi_yellow, newline)
def red_print(message: str, newline: str | None = '\n') -> None:
ansi_red = '\033[1;31m'
color_print(message, ansi_red, newline)
def debug_print_idf_version() -> None:
print_warning(f'ESP-IDF {idf_version() or "version unknown"}')
log.note(f'ESP-IDF {idf_version() or "version unknown"}')
def _load_hints_from_directory(directory: str) -> list:
@@ -195,7 +179,7 @@ def _load_hints_from_directory(directory: str) -> list:
hints = yaml.safe_load(file)
return hints if hints else []
except (OSError, yaml.YAMLError):
yellow_print(f'HINT WARNING: Failed to load hints from "{hints_file}"')
log.warn(escape(f'Failed to load hints from "{hints_file}"'))
return []
@@ -232,11 +216,9 @@ def _load_idf_hints() -> dict:
try:
hints['modules'].append(getattr(importlib.import_module(name), 'generate_hint'))
except ModuleNotFoundError:
red_print(f'Failed to import "{name}" from "{hint_modules_dir}" as a module')
raise SystemExit(1)
log.die(escape(f'Failed to import "{name}" from "{hint_modules_dir}" as a module'))
except AttributeError:
red_print(f'Module "{name}" does not have function generate_hint.')
raise SystemExit(1)
log.die(escape(f'Module "{name}" does not have function generate_hint.'))
# Load ESP-IDF components
idf_path = os.environ.get('IDF_PATH')
@@ -304,23 +286,19 @@ def generate_hints_buffer(output: str, hints: dict) -> Generator:
try:
hint_list.append(hint['hint'].format(*hint_vars))
except KeyError as e:
red_print(f'Argument {e} missing in {hint}. Check hints.yml file.')
sys.exit(1)
log.die(escape(f'Argument {e} missing in {hint}. Check hints.yml file.'))
else:
match = re.compile(hint['re']).search(output)
except KeyError as e:
red_print(f'Argument {e} missing in {hint}. Check hints.yml file.')
sys.exit(1)
log.die(escape(f'Argument {e} missing in {hint}. Check hints.yml file.'))
except re.error as e:
red_print('{} from hints.yml have {} problem. Check hints.yml file.'.format(hint['re'], e))
sys.exit(1)
log.die(escape('{} from hints.yml have {} problem. Check hints.yml file.'.format(hint['re'], e)))
if hint_list:
for message in hint_list:
yield ' '.join(['HINT:', message])
yield from hint_list
elif match:
extra_info = ', '.join(match.groups()) if hint.get('match_to_output', '') else ''
try:
yield ' '.join(['HINT:', hint['hint'].format(extra_info)])
yield hint['hint'].format(extra_info)
except KeyError:
raise KeyError(f"Argument 'hint' missing in {hint}. Check hints.yml file.")
@@ -440,7 +418,7 @@ class RunTool:
# hints in interactive mode were already processed, don't print them again
if not self.interactive:
for hint in generate_hints(stderr_output_file, stdout_output_file):
yellow_print(hint)
log.hint(escape(hint))
raise FatalError(
f'{self.tool_name} failed with exit code {process.returncode}, '
f'output of the command is in the {stderr_output_file} and {stdout_output_file}'
@@ -469,15 +447,14 @@ class RunTool:
)
except NotImplementedError:
message = (
f"ERROR: {sys.executable} doesn't support asyncio. "
"Workaround: re-run idf.py with the '--no-hints' argument."
f"{sys.executable} doesn't support asyncio. Workaround: re-run idf.py with the '--no-hints' argument."
)
if sys.platform == 'win32':
message += (
' To fix the issue use the Windows Installer for setting up your python environment, '
'available from: https://dl.espressif.com/dl/esp-idf/'
)
sys.exit(message)
log.die(escape(message))
stderr_output_file = os.path.join(self.build_dir, log_dir_name, f'idf_py_stderr_output_{p.pid}')
stdout_output_file = os.path.join(self.build_dir, log_dir_name, f'idf_py_stdout_output_{p.pid}')
@@ -495,7 +472,7 @@ class RunTool:
# the even loop is closed and we get RuntimeError: Event loop is closed
# in the transport __del__ function because it's trying to use the closed
# even loop.
red_print(f'\n{self.tool_name} process terminated\n')
log.err(f'\n{self.tool_name} process terminated')
await p.wait() # added for avoiding None returncode
return p, stderr_output_file, stdout_output_file
@@ -612,7 +589,7 @@ class RunTool:
last_line += output
if last_line[-1] == '\n':
for hint in generate_hints_buffer(last_line, hints):
yellow_print(hint)
log.hint(escape(hint))
last_line = ''
else:
output_b = await read_stream_bytes()
@@ -641,9 +618,11 @@ class RunTool:
else:
write_stdout_bytes(forward_b)
except (OSError, RuntimeError) as e:
yellow_print(
"WARNING: The exception {} was raised and we can't capture all your {} and "
'hints on how to resolve errors can be not accurate.'.format(e, output_stream.name.strip('<>'))
log.warn(
escape(
"The exception {} was raised and we can't capture all your {} and "
'hints on how to resolve errors can be not accurate.'.format(e, output_stream.name.strip('<>'))
)
)
@@ -936,16 +915,14 @@ def merge_action_lists(*action_lists: dict, custom_actions: dict[str, Any] | Non
existing_identifiers.add(name)
existing_identifiers.update(action.get('aliases', []))
except UserWarning as e:
yellow_print(f'WARNING: {e}. External action will not be added.')
log.warn(escape(f'{e}. External action will not be added.'))
for new_opt in custom_actions.get('global_options', []):
if any(
set(new_opt.get('names', [])) & set(existing.get('names', []))
for existing in merged_actions['global_options']
):
yellow_print(
f'WARNING: Global option {new_opt["names"]} already defined. External option will not be added.'
)
log.warn(escape(f'Global option {new_opt["names"]} already defined. External option will not be added.'))
else:
merged_actions['global_options'].append(new_opt)