import argparse import keyword import os import re import shutil import sys def main(): args = argparse.ArgumentParser() args.add_argument("new_package_name") args.add_argument("--dry-run", action="store_true") args = args.parse_args() is_valid_name, reason = is_valid_python_package_name(args.new_package_name) assert is_valid_name, reason replace_in_multiple_paths([ "lazy_config", "projects", "README.md", "pyproject.toml" ], old_word="lazy_config", new_word=args.new_package_name, file_extensions=('.py', '.md', '.toml'), dry_run=args.dry_run ) if not args.dry_run: shutil.move("lazy_config", args.new_package_name) def is_valid_python_package_name(name: str) -> tuple[bool, str]: """ 检查一个字符串是否是合格的 Python 包/模块名。 这个函数综合考虑了 Python 语法规则和一些常见的最佳实践。 Args: name (str): 要检查的字符串。 Returns: tuple[bool, str]: 一个元组,第一个元素是布尔值(True 表示合格,False 表示不合格), 第二个元素是不合格的原因或“Valid.”。 """ if not name: return False, "包/模块名不能为空。" # 将包名按点分割成各个组件 segments = name.split('.') for segment in segments: if not segment: return False, f"包/模块名 '{name}' 包含空组件 (例如 'a..b' 或开头/结尾是点)。" # 规则1: 每个组件都必须是有效的 Python 标识符。 # isidentifier() 检查了: # - 以字母或下划线开头 (不能以数字开头) # - 仅包含字母、数字或下划线 # - 非空 (虽然上面单独检查了) if not segment.isidentifier(): return False, f"组件 '{segment}' 在 '{name}' 中不是一个有效的 Python 标识符。" # 规则2: 每个组件都不能是 Python 关键字。 if keyword.iskeyword(segment): return False, f"组件 '{segment}' 在 '{name}' 中是一个 Python 关键字。" # 规则3 (强烈建议): 避免与内置模块名冲突。 # 尽管技术上可以导入,但这会导致混淆和潜在的导入问题。 if segment in sys.builtin_module_names: return False, f"组件 '{segment}' 在 '{name}' 中与一个内置的 Python 模块名冲突。" # 仅作为PEP 8风格的额外检查,不影响“合格性”,如果需要严格的PEP8,可以将其改为返回False # if not segment.islower() and not segment.startswith('__') and not segment.endswith('__'): # # 允许像 __init__ 这样的特殊名称不是小写 # # 对于普通的模块/包名,PEP 8 推荐全小写 # # return False, f"组件 '{segment}' 在 '{name}' 中不符合 PEP 8 的全小写命名约定。" # if segment.startswith('_') and not segment == '__init__': # # PEP 8 鼓励避免普通模块/包名的前导下划线 # # return False, f"组件 '{segment}' 在 '{name}' 中不符合 PEP 8 的前导下划线约定。" # if segment.endswith('_'): # # PEP 8 鼓励避免普通模块/包名的尾随下划线 # # return False, f"组件 '{segment}' 在 '{name}' 中不符合 PEP 8 的尾随下划线约定。" pass return True, "Valid." def replace_in_single_file(file_path: str, old_word: str, new_word: str, encoding: str = 'utf-8', dry_run: bool = False) -> tuple[bool, list[str]]: """ 在单个文件中替换特定全字匹配的字符串,并记录每一处替换内容。 支持 dry_run 模式。 Args: file_path (str): 文件的完整路径。 old_word (str): 要被替换的全字匹配字符串。 new_word (str): 替换成的字符串。 encoding (str): 读取和写入文件时使用的编码。 dry_run (bool): 如果为 True, 则只记录将要进行的替换,不实际修改文件。 Returns: tuple[bool, list[str]]: - bool: 是否存在匹配并进行了(或将要进行)替换。 - list[str]: 记录了所有替换操作(或模拟操作)的文本日志。 每个日志条目包含行号和高亮显示的代码行。 """ replacement_logs = [] try: if not os.path.exists(file_path): replacement_logs.append(f" Error: File not found: {file_path}") return False, replacement_logs with open(file_path, 'r', encoding=encoding) as f: content = f.read() pattern = r"\b" + re.escape(old_word) + r"\b" matches = list(re.finditer(pattern, content)) if not matches: return False, [] # 没有匹配,直接返回 def repl_func(match): match_start = match.start() line_num = content[:match_start].count('\n') + 1 line_start_idx = content.rfind('\n', 0, match_start) + 1 line_end_idx = content.find('\n', match.end()) if line_end_idx == -1: line_end_idx = len(content) original_line = content[line_start_idx:line_end_idx].strip() relative_match_start = match_start - line_start_idx relative_match_end = match.end() - line_start_idx highlighted_line = ( original_line[:relative_match_start] + f"**{original_line[relative_match_start:relative_match_end]}**" + original_line[relative_match_end:] ) replacement_logs.append(f" L{line_num:<4}: '{highlighted_line}' -> '{new_word}'") return new_word new_content = re.sub(pattern, repl_func, content) if not dry_run: with open(file_path, 'w', encoding=encoding) as f: f.write(new_content) return True, replacement_logs except Exception as e: replacement_logs.append(f" Error processing {file_path}: {e}") return False, replacement_logs # --- 新增的辅助函数 (保持不变) --- def _process_a_file(file_path: str, old_word: str, new_word: str, file_extensions: tuple[str, ...], # 变更:现在是元组 encoding: str, dry_run: bool, stats: dict): """ 辅助函数,封装了对单个文件的处理逻辑并更新统计信息。 """ # 检查文件扩展名是否符合要求 # 如果 file_extensions 是空元组,表示不限制后缀,总是匹配 if file_extensions and not file_path.endswith(file_extensions): # endswith 可以直接接受元组 stats['skipped'] += 1 return found_match, logs = replace_in_single_file( file_path, old_word, new_word, encoding, dry_run ) if logs: print(f"File: {file_path}") for log_entry in logs: print(log_entry) print("-" * (len(file_path) + 6)) if found_match: if not dry_run: print(f" ✔ Modified: {file_path}\n") else: print(f" ✔ Will modify: {file_path}\n") stats['modified'] += 1 elif not logs and os.path.exists(file_path): stats['skipped'] += 1 else: stats['errors'] += 1 # --- 修改后的主协调函数 --- def replace_in_multiple_paths(paths_to_process: list[str], old_word: str, new_word: str, file_extensions: str | tuple[str, ...] = (), # 变更:接受字符串或元组 encoding: str = 'utf-8', dry_run: bool = False): """ 根据提供的文件和文件夹路径列表,替换所有全字匹配的字符串。 支持 dry_run 模式,并允许多种文件后缀过滤。 Args: paths_to_process (list[str]): 包含要处理的文件和/或文件夹路径的列表。 old_word (str): 要被替换的全字匹配字符串。 new_word (str): 替换成的字符串。 file_extensions (str | tuple[str, ...]): 要处理的文件扩展名。可以是单个字符串 (例如 ".py"), 也可以是字符串元组 (例如 (".py", ".txt"))。 默认 (空元组) 表示处理所有文件扩展名。 encoding (str): 读取和写入文件时使用的编码。 dry_run (bool): 如果为 True, 则只打印将要进行的替换,不实际修改文件。 """ # Normalize file_extensions to always be a tuple, empty if no filter if isinstance(file_extensions, str): if file_extensions == "": normalized_file_extensions = () else: normalized_file_extensions = (file_extensions,) else: normalized_file_extensions = file_extensions action_verb = "Simulating replacement" if dry_run else "Performing replacement" print(f"--- {action_verb} ---") print(f"Searching for full word '{old_word}'") print(f"Replacing with '{new_word}'") print(f"In paths: {paths_to_process}") if normalized_file_extensions: print(f"Filtered by extensions: {','.join(normalized_file_extensions)}") else: print(f"No extension filter (processing all files).") if dry_run: print(" (DRY RUN: No files will be modified)") print("-" * 60) stats = {'modified': 0, 'skipped': 0, 'errors': 0, 'invalid_paths': 0} for path in paths_to_process: if not os.path.exists(path): print(f"‼ Warning: Path does not exist and will be skipped: '{path}'\n") stats['invalid_paths'] += 1 continue if os.path.isfile(path): _process_a_file(path, old_word, new_word, normalized_file_extensions, encoding, dry_run, stats) elif os.path.isdir(path): # print(f"Processing directory: '{path}'\n") for root, _, files in os.walk(path): for file_name in files: file_path = os.path.join(root, file_name) _process_a_file(file_path, old_word, new_word, normalized_file_extensions, encoding, dry_run, stats) else: print(f"‼ Warning: Skipping '{path}' (not a file or directory).\n") stats['invalid_paths'] += 1 print("\n" + "=" * 60) print("--- Summary ---") print(f"Total files checked: {stats['modified'] + stats['skipped'] + stats['errors']}") print(f"Files to be modified/modified: {stats['modified']}") print(f"Files skipped (word not found or extension mismatch): {stats['skipped']}") print(f"Files with errors: {stats['errors']}") print(f"Invalid paths provided: {stats['invalid_paths']}") print("=" * 60) if __name__ == "__main__": main()