diff --git a/README.md b/README.md index 9a0c047..3c3bc99 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,17 @@ ## How to create your own package based on this package. +Use the following command to create a new package based on this package: + ```bash -python init.py +python init.py your-package-name ``` -and follow the prompts. +It will replace the current package name (`lazy_config`) with your own package name in the code base. + +However, you need to modify the author name and email in `pyproject.toml`. + +Then you should use `pip install -e .[dev]` to install the package in editable mode. After the project is created, you can delete the `init.py` file and start working on your project. diff --git a/init.py b/init.py new file mode 100644 index 0000000..a38bd0c --- /dev/null +++ b/init.py @@ -0,0 +1,269 @@ +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() \ No newline at end of file