第 8 周:正则表达式与批量文件管理
本周解决两类任务:从文字中按规则提取内容,以及用程序创建目录、复制文件、重命名文件和检查结果。正则表达式负责“找文本”,os 与 shutil 负责“管理文件”。
建议用时:8-10 小时。
本周目标#
学完本周,你应该能够:
- 写出匹配数字、字母、空格和固定文字的基础正则表达式。
- 使用
re.findall()提取全部匹配内容。 - 使用
re.search()与分组读取第一个匹配。 - 根据题目要求控制匹配边界,避免多提取或漏提取。
- 使用
os.path拼接路径、判断文件和目录。 - 使用
os.makedirs()创建目录。 - 使用
os.listdir()、os.rename()批量处理文件。 - 使用
shutil.copy()、shutil.move()复制和移动文件。 - 完成标签编号提取和目录归档工具。
8.1 为什么需要正则表达式#
假设要从下面的文本提取所有 PY-2026- 后面的三位数字:
A-PY-2026-015
B-PY-2026-102
C-NET-2026-007
D-PY-2026-015
逐字符判断也可以做,但规则稍复杂后就容易写乱。正则表达式直接描述“文本长什么样”:
PY-2026- 后面跟三位数字
对应模式:
r"PY-2026-\d{3}"
8.2 原始字符串#
正则模式通常写成原始字符串:
pattern = r"\d{3}"
前面的 r 表示反斜杠按原样处理。若使用普通字符串 "\\d{3}",也必须写两个反斜杠。正则中包含 \d、\w、\s 时,优先使用 r"...",减少转义混乱。
8.3 常用正则符号#
| 符号 | 含义 | 示例匹配 |
|---|---|---|
. |
任意一个字符,默认不含换行 | a、7 |
\d |
一个数字 | 0-9 |
\D |
一个非数字字符 | A、- |
\w |
字母、数字或下划线 | P、8、_ |
\W |
非字母数字下划线 | -、空格 |
\s |
一个空白字符 | 空格、制表符、换行 |
\S |
一个非空白字符 | A、7 |
[abc] |
括号中任意一个字符 | a、b、c |
[^abc] |
不是括号中字符的任意字符 | d、1 |
[A-Z] |
大写字母范围 | A-Z |
[0-9] |
数字范围 | 0-9 |
8.4 数量词#
| 数量词 | 含义 |
|---|---|
* |
前面的内容出现 0 次或多次 |
+ |
出现 1 次或多次 |
? |
出现 0 次或 1 次 |
{3} |
恰好 3 次 |
{2,5} |
2 到 5 次 |
{3,} |
至少 3 次 |
例子:
\d{3} # 恰好三位数字
[A-Z]+ # 一个或多个大写字母
colou?r # color 或 colour
8.5 锚点和边界#
| 符号 | 含义 |
|---|---|
^ |
默认表示字符串开头 |
$ |
默认表示字符串结尾,或末尾换行前 |
\b |
单词边界 |
示例:
r"^\d{3}$" # 整个字符串恰好是三位数字
re.fullmatch(r"\d{3}", text) 也可以要求整个字符串完全匹配。
注意:re.search() 只要求模式在某处出现,不要求覆盖整个字符串。
8.6 分组#
圆括号可以把一部分模式作为分组:
r"PY-2026-(\d{3})"
括号内是“末尾三位数字”。使用 findall() 时,如果模式有分组,通常只返回分组内容:
import re
text = "A-PY-2026-015"
result = re.findall(r"PY-2026-(\d{3})", text)
print(result) # ['015']
如果模式有两个分组,findall() 返回元组列表:
text = "A-15, B-27"
result = re.findall(r"([A-Z])-(\d{2})", text)
print(result) # [('A', '15'), ('B', '27')]
第一阶段会考 re.findall 直接返回什么,必须根据有没有分组判断。
8.7 re.findall():找出全部匹配#
import re
text = "电话 13800138000,备用 13900139000"
numbers = re.findall(r"\d{11}", text)
print(numbers)
# ['13800138000', '13900139000']
没有分组时,findall() 返回匹配到的完整字符串列表。没有匹配时返回空列表 []。
找所有单词#
text = "Python makes data processing easier"
words = re.findall(r"[A-Za-z]+", text)
print(words)
[A-Za-z]+ 表示连续一个或多个英文字母。
找所有三位数字#
text = "A12 345 B6789 C001"
numbers = re.findall(r"\d{3}", text)
print(numbers) # ['345', '678', '001']
6789 中会匹配前三个字符 678,这不是错误。若只希望独立的三位数字,需要添加边界条件。
8.8 re.search():只找第一个#
import re
text = "编号:PY-2026-015"
match = re.search(r"PY-2026-(\d{3})", text)
if match:
print(match.group(0)) # PY-2026-015,完整匹配
print(match.group(1)) # 015,第一个分组
else:
print("没有找到")
区别:
- 没有匹配时,
search()返回None。 - 有匹配时返回
Match对象。 group(0)是完整匹配。group(1)是第一个分组。
直接调用 match.group(1) 前必须确认 match 不为 None,否则会报 AttributeError。
8.9 match()、fullmatch() 与 search()#
import re
print(bool(re.match(r"\d{3}", "12345"))) # True,从开头匹配到 123
print(bool(re.fullmatch(r"\d{3}", "12345"))) # False,整个字符串不是三位
print(bool(re.search(r"\d{3}", "A123B"))) # True,中间找到 123
match()从字符串开头尝试匹配,但不要求到结尾。fullmatch()要求整个字符串完全匹配。search()在任意位置查找第一个匹配。
考试若说“查找是否包含”,通常是 search;若说“整个字符串格式必须为”,用 fullmatch 或加首尾锚点。
8.10 匹配模式中的特殊字符#
以下字符在正则中有特殊含义:
. ^ $ * + ? { } [ ] \ | ( )
如果只想匹配文字里的点:
# 错误理解:. 会匹配任意字符
# 正确:转义点
pattern = r"\.py$"
匹配文件名以 .py 结尾:
import re
print(bool(re.search(r"\.py$", "task1.py"))) # True
也可以使用 re.escape("特殊文字") 自动处理特殊字符。
8.11 贪婪匹配#
数量词默认尽量多匹配:
import re
text = "<A><B>"
print(re.findall(r"<.+>", text)) # ['<A><B>']
print(re.findall(r"<.+?>", text)) # ['<A>', '<B>']
在数量词后加 ? 会变成非贪婪,尽量少匹配。基础题中先明确题目到底要整段还是最小片段,不要无脑全部加 ?。
8.12 本周作品一:提取标签编号#
输入文件示例:
A-PY-2026-015
B-PY-2026-102
C-NET-2026-007
D-PY-2026-015
E-ART-2026-088
F-PY-2026-031
import re
text = """A-PY-2026-015
B-PY-2026-102
C-NET-2026-007
D-PY-2026-015
E-ART-2026-088
F-PY-2026-031"""
numbers = re.findall(r"PY-2026-(\d{3})", text)
unique_numbers = sorted(set(numbers), key=int)
print(numbers) # ['015', '102', '015', '031']
print(unique_numbers) # ['015', '031', '102']
set(numbers) 去重;key=int 按数值排序,因此不会出现按字符串排序时 "102" 跑到 "15" 前面的问题。"015" 仍保留前导零。
8.13 os.path:路径判断和拼接#
import os
base = "模拟赛"
data_file = os.path.join(base, "data", "stage2_scores.txt")
print(data_file)
print(os.path.exists(data_file))
print(os.path.isfile(data_file))
print(os.path.isdir(base))
os.path.join() 根据操作系统使用合适的分隔符,比手工写 "/" 或 "\\" 更稳妥。
常用函数:
| 函数 | 作用 |
|---|---|
os.getcwd() |
当前工作目录 |
os.listdir(path) |
列出目录中的名称 |
os.path.exists(path) |
路径是否存在 |
os.path.isfile(path) |
是否是文件 |
os.path.isdir(path) |
是否是目录 |
os.path.join(a, b) |
拼接路径 |
os.path.basename(path) |
取最后文件名 |
os.path.dirname(path) |
取上层目录 |
os.path.splitext(path) |
拆主文件名和扩展名 |
path = "模拟赛/data/stage2_scores.txt"
name, extension = os.path.splitext(os.path.basename(path))
print(name) # stage2_scores
print(extension) # .txt
8.14 创建目录#
import os
output_dir = os.path.join("模拟赛", "output", "归档", "原始数据")
os.makedirs(output_dir, exist_ok=True)
makedirs() 会创建路径中缺少的多个层级,不只最后一级。
exist_ok=True 的含义是:如果目录已经存在,不报错;如果不存在,就创建。
os.mkdir()通常只能创建一个层级,父目录必须已存在。os.makedirs()可以创建多层目录。
8.15 列出、筛选和重命名#
import os
folder = "模拟赛/output"
for name in os.listdir(folder):
full_path = os.path.join(folder, name)
if os.path.isfile(full_path) and name.endswith(".txt"):
print(name)
os.listdir() 只返回名称,不自动返回完整路径。进入子目录或判断文件时,应使用 os.path.join()。
重命名#
old_path = "模拟赛/output/old.txt"
new_path = "模拟赛/output/new.txt"
if os.path.exists(old_path):
os.rename(old_path, new_path)
os.rename() 的目标可以是同一目录中的新名字,也可以是另一个已有目录下的新路径。跨文件系统时可能失败,适合基础本地文件操作。
批量重命名#
import os
folder = "模拟赛/output/renamed"
os.makedirs(folder, exist_ok=True)
for index, name in enumerate(sorted(os.listdir(folder)), start=1):
old_path = os.path.join(folder, name)
if not os.path.isfile(old_path):
continue
extension = os.path.splitext(name)[1]
new_name = f"file_{index:02d}{extension}"
new_path = os.path.join(folder, new_name)
if old_path != new_path:
os.rename(old_path, new_path)
{index:02d} 会补成两位数字。批量重命名前应先明确排序规则,并且避免一次运行中把文件改成另一个尚未处理文件的名字,造成冲突。
8.16 复制与移动:shutil#
import os
import shutil
source = os.path.join("模拟赛", "data", "stage2_scores.txt")
target_dir = os.path.join("模拟赛", "output", "归档", "原始数据")
os.makedirs(target_dir, exist_ok=True)
shutil.copy(source, target_dir)
target_file = os.path.join(target_dir, "stage2_scores.txt")
backup_file = os.path.join(target_dir, "scores_backup.txt")
if os.path.exists(target_file):
os.rename(target_file, backup_file)
常用函数:
| 函数 | 作用 |
|---|---|
shutil.copy(src, dst) |
复制文件内容到目标 |
shutil.copy2(src, dst) |
复制内容并尽量保留时间等元数据 |
shutil.move(src, dst) |
移动文件或目录 |
shutil.rmtree(path) |
递归删除目录 |
shutil.rmtree() 会删除整个目录树,风险很高。考试和练习中不要对未确认的目录使用,尤其不要拼接用户输入的任意路径调用。
8.17 本周作品二:第二阶段文件管理#
下面是第二阶段任务四的核心步骤:
import os
import re
import shutil
labels_path = "模拟赛/data/stage2_labels.txt"
output_dir = "模拟赛/output"
archive_dir = os.path.join(output_dir, "归档", "原始数据")
number_output = os.path.join(output_dir, "第二阶段_编号.txt")
source_scores = "模拟赛/data/stage2_scores.txt"
os.makedirs(archive_dir, exist_ok=True)
with open(labels_path, "r", encoding="utf-8") as file:
text = file.read()
numbers = re.findall(r"PY-2026-(\d{3})", text)
numbers = sorted(set(numbers), key=int)
with open(number_output, "w", encoding="utf-8") as file:
for number in numbers:
print(number, file=file)
shutil.copy(source_scores, archive_dir)
copied_file = os.path.join(archive_dir, "stage2_scores.txt")
backup_file = os.path.join(archive_dir, "scores_backup.txt")
os.rename(copied_file, backup_file)
print("目录存在:", os.path.isdir(archive_dir))
print("原始副本存在:", os.path.exists(copied_file))
print("备份文件存在:", os.path.exists(backup_file))
预期三个布尔结果:
True
False
True
重命名以后,旧路径 copied_file 不存在,新路径 backup_file 才存在。
8.18 文件操作前的安全顺序#
- 确定源路径和输出目录。
- 检查源文件是否存在。
- 创建输出目录,并设置
exist_ok=True。 - 只读原始文件。
- 复制或生成新文件。
- 重命名后使用新路径检查。
- 输出目录、旧文件名和新文件名分别验证。
调试文件问题时,不要只打印 "done"。打印实际路径和每个存在性检查,能快速定位是逻辑错还是路径错。
8.19 独立练习#
A. 快速判断题#
\d{3}表示什么?re.findall(r"\d", "A1B2")返回什么?re.findall(r"(\d+)", "A12B345")返回什么?re.search()找不到时返回什么?\.在正则中表示什么?- 创建多层目录用哪个函数?
os.path.join("a", "b.txt")的作用是什么?os.listdir()返回的是完整路径还是目录中的名称?shutil.copy()与os.rename()的主要区别是什么?- 重命名后应该检查旧路径还是新路径?
B. 编程题#
- 从
PY-2026-015中提取015。 - 从文本中提取所有五位数字。
- 提取所有形如
S01、S99的编号。 - 对重复编号去重并按数值升序输出。
- 创建
output/归档/原始数据三层目录。 - 复制一个文本文件到归档目录,再重命名为备份文件。
- 列出目录中的所有
.py文件并输出完整路径。 - 把目录中的文本文件按当前名称排序,并批量加前缀
backup_。
8.20 练习答案与解释#
A. 快速判断题答案#
- 恰好连续三位数字。
['1', '2']。['12', '345']。有分组时只返回分组内容。None。- 一个普通的小数点字符,不是“任意字符”。
os.makedirs(path, exist_ok=True)。- 按当前操作系统规则把两个路径片段拼接起来。
- 目录中的名称,通常不是完整路径。
shutil.copy()复制一份,源文件保留;os.rename()改变原路径,本质上移动或改名。- 新路径。旧路径在成功重命名后通常已不存在。
B. 编程题参考答案#
提取末尾编号:
import re text = "PY-2026-015" result = re.search(r"PY-2026-(\d{3})", text) if result: print(result.group(1))提取五位数字:
import re text = "A12345 B67890 C123" print(re.findall(r"\d{5}", text))提取
S加两位数字:import re text = "S01 S99 X100 S7" print(re.findall(r"S\d{2}", text)) # ['S01', 'S99']去重并数值排序:
numbers = ["015", "102", "015", "031"] result = sorted(set(numbers), key=int) print(result) # ['015', '031', '102']创建多层目录:
import os path = os.path.join("output", "归档", "原始数据") os.makedirs(path, exist_ok=True)复制和重命名:
import os import shutil target_dir = os.path.join("output", "归档", "原始数据") os.makedirs(target_dir, exist_ok=True) shutil.copy("scores.txt", target_dir) old_path = os.path.join(target_dir, "scores.txt") new_path = os.path.join(target_dir, "scores_backup.txt") os.rename(old_path, new_path)列出
.py文件完整路径:import os folder = "." for name in os.listdir(folder): path = os.path.join(folder, name) if os.path.isfile(path) and name.endswith(".py"): print(path)批量添加前缀:
import os folder = "output" for name in sorted(os.listdir(folder)): old_path = os.path.join(folder, name) if not os.path.isfile(old_path) or not name.endswith(".txt"): continue new_name = "backup_" + name new_path = os.path.join(folder, new_name) os.rename(old_path, new_path)运行前应确认目标名称不冲突,并在可恢复的测试目录中执行。
8.21 常见错误清单#
- 正则字符串中忘记转义点,
.py把任意字符加py也匹配。 - 使用分组后误以为
findall()返回完整匹配。 - 在任意四位数字中截取前三位,却以为只匹配独立三位。
- 对字符串编号直接排序,导致前导零和长度不同的排序不符合要求。
search()结果可能为None,未检查就调用group()。- 拼接路径时直接手工拼分隔符。
listdir()只拿到名称,却当成完整路径使用。- 不检查源文件是否存在就复制。
- 重命名后仍去旧路径找文件。
- 对未确认目录使用
shutil.rmtree()。
8.22 过关检查#
- 能解释
.、\d、[]、+、*、?、{3}的含义。 - 能写模式提取指定前缀后的三位数字。
- 能判断
findall()在有分组和无分组时的返回形式。 - 能安全使用
search()的Match对象。 - 能使用
os.path.join()、exists()、isfile()、isdir()。 - 能使用
os.makedirs(..., exist_ok=True)创建多层目录。 - 能复制、重命名并分别验证新旧路径。
- 能按数值排序编号,而不是按字符串顺序。
- 独立完成第二阶段任务四并达到满分。
- 任何文件删除或批量重命名操作前,都能说明影响范围。