lazy_config/init.py
2025-05-29 19:01:53 +08:00

269 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()