函数使用(七)
前言
- 这一篇收四个「写脚本时经常要查」的标准库能力:
eval/ast.literal_eval(把字符串当代码或当数据)、hashlib(摘要与口令散列)、urllib.parse(URL 拆开与拼装)、http.client(最底层的 HTTP 请求)。 - 所有示例都在 Python 3.13.9 / Windows 上实跑过,输出原样贴在代码块下面。会联网的示例只打印状态码、布尔结论这类稳定信息,不打印字节数,免得你重跑对不上;断网时会打印「跳过」。
- 这些内容原来记在《短信邮件》里,和发邮件、发短信混在一篇,就挪到这里独立成篇;那篇只留邮件与短信。
eval 与 ast.literal_eval
-
基本语法
1
eval(expression, globals=None, locals=None) # expression 是字符串形式的「表达式」
globals/locals都是字典,用来指定这段字符串能在什么命名空间里跑;不传就用当前作用域。- 只接受表达式,赋值、
if、for这些语句会抛SyntaxError。
-
能做什么
1
2
3
4
5
6
7
8
9
10
11x = 1
print(eval('x + 1')) # 能读当前作用域的变量
print(eval('2 + 3 * 4')) # 就是个表达式求值
print(eval('x + 1') == 2) # 返回的是求值结果
def square(n):
return n * n
print(eval('square(5)')) # 也能调用作用域里的函数输出:
1
2
3
42
14
True
25 -
不能做什么,以及和
exec的区别1
2
3
4
5
6
7try:
eval('x = 1') # 赋值是语句,不是表达式
except SyntaxError as e:
print('eval 里赋值:', type(e).__name__)
ret = exec('y = 2') # exec 执行语句块,永远返回 None
print('exec 的返回值:', ret, '| 但它真的创建了变量:', y)输出:
1
2eval 里赋值: SyntaxError
exec 的返回值: None | 但它真的创建了变量: 2一句话:要结果用
eval,要执行用exec;两个都别拿去跑不可信的输入。 -
安全性:它能跑任意代码
1
2
3
4import os
print('能读到目录:', bool(eval('os.getcwd()'))) # 当前作用域里有 os,就能为所欲为
print('路径分隔符:', eval('__import__("os").sep')) # 没 import 也能自己现取输出:
1
2能读到目录: True
路径分隔符: \所以
eval(input())这种写法等于把解释器交给用户。真要限制,就把可用名字交给它:1
2
3
4
5
6
7
8
9
10from math import sqrt, sin, cos
safe = {'__builtins__': None} # 关掉内置命名空间
safe.update({k: v for k, v in globals().items() if k in ['sqrt', 'sin', 'cos']})
print(eval('sqrt(16)', safe, safe)) # 白名单里的能用
try:
eval('__import__("os").getcwd()', safe, safe) # 想 import 就别想了
except TypeError as e:
print('受限环境里 import:', e)输出:
1
24.0
受限环境里 import: 'NoneType' object is not subscriptable注意三点:
__builtins__设成None只是关掉内置函数,挡不住你漏放进白名单的对象上的方法;另外只要传了字典,Python 会自动往里补一个__builtins__,所以别以为写个{'sqrt': sqrt}就安全了。最后,__builtins__建议给{}而不是None——给None时沙箱里任何找不到的名字都会抛TypeError: 'NoneType' object is not subscriptable,而不是NameError(实测)。 -
更安全的替代:
ast.literal_eval只解析字面量(数字、字符串、列表、元组、字典、布尔、
None)和它们的组合,遇到函数调用或运算直接报错,适合「配置里存了个字符串形式的列表」这类场景。1
2
3
4
5
6
7
8
9
10
11
12import ast
print(ast.literal_eval('[1, 2, 3]'))
print(ast.literal_eval("{'a': 1, 'b': (2, 3)}"))
try:
ast.literal_eval('1 + 2') # 算式不行
except ValueError as e:
print('literal_eval 算式:', type(e).__name__)
try:
ast.literal_eval('square(5)') # 函数调用也不行
except ValueError:
print('literal_eval 函数调用: ValueError')输出:
1
2
3
4[1, 2, 3]
{'a': 1, 'b': (2, 3)}
literal_eval 算式: ValueError
literal_eval 函数调用: ValueError -
整体示例(eval / literal_eval)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33import ast
from math import sqrt
# 1) 表达式求值:能读作用域、能调函数
base, rate = 1000, 0.05
print('求值:', eval('base * (1 + rate) ** 2'))
# 2) 语句交给 exec,表达式交给 eval
env = {}
exec('total = 1 + 2', env) # exec 可以指定命名空间
print('exec 写进字典:', env['total'], '| eval 返回结果:', eval('1 + 2'))
# 3) 字符串形式的配置:用 literal_eval,不要用 eval
conf = "{'host': '127.0.0.1', 'port': 8000, 'tags': ['a', 'b']}"
d = ast.literal_eval(conf)
print('解析配置:', d['host'], d['port'], d['tags'])
try:
ast.literal_eval("{'port': 1 + 1}") # 字面量里藏算式
except ValueError:
print('literal_eval 拒绝算式')
# 4) 白名单沙箱:只放要用的函数进去
sandbox = {'__builtins__': None, 'sqrt': sqrt}
print('沙箱里能算:', eval('sqrt(2)', sandbox))
try:
eval('base', sandbox) # 沙箱里没有的名字
except TypeError:
print('__builtins__ 给 None 时,找不到名字报的是 TypeError')
sandbox2 = {'__builtins__': {}, 'sqrt': sqrt} # 给空字典才正常
try:
eval('base', sandbox2)
except NameError:
print('给空字典时才是正常的 NameError')实跑输出:
1
2
3
4
5
6
7求值: 1102.5
exec 写进字典: 3 | eval 返回结果: 3
解析配置: 127.0.0.1 8000 ['a', 'b']
literal_eval 拒绝算式
沙箱里能算: 1.4142135623730951
__builtins__ 给 None 时,找不到名字报的是 TypeError
给空字典时才是正常的 NameError
hashlib:摘要与口令散列
-
有哪些算法
1
2
3
4import hashlib
print('保证有的算法:', sorted(hashlib.algorithms_guaranteed))
print('本机可用的更多:', len(hashlib.algorithms_available) > len(hashlib.algorithms_guaranteed))输出:
1
2保证有的算法: ['blake2b', 'blake2s', 'md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha3_224', 'sha3_256', 'sha3_384', 'sha3_512', 'sha512', 'shake_128', 'shake_256']
本机可用的更多: Truealgorithms_available会比上表多,因为还包含 OpenSSL 额外提供的算法。SHA3 / BLAKE2 是 Python 3.6 起进的标准库。 -
统一接口
所有算法都是同一套用法:
update()喂数据(只能喂bytes),digest()拿二进制、hexdigest()拿 16 进制串。1
2
3
4
5
6
7
8
9
10
11
12import hashlib
data = 'Hello, world!'
for name in ('md5', 'sha1', 'sha256', 'sha512'):
h = hashlib.new(name, data.encode()) # new(算法名, 数据) 与 hashlib.sha256(...) 等价
print(f'{name:7} 摘要 {h.digest_size} 字节 | 16进制 {len(h.hexdigest())} 位 | {h.hexdigest()[:16]}...')
h = hashlib.sha256()
h.update(b'Hello, ') # 分两次喂
h.update(b'world!') # 和一次喂完结果相同,适合流式读大文件
print('增量 == 一次算完:', h.hexdigest() == hashlib.sha256(data.encode()).hexdigest())
print('name / block_size:', h.name, h.block_size)输出:
1
2
3
4
5
6md5 摘要 16 字节 | 16进制 32 位 | 6cd3556deb0da54b...
sha1 摘要 20 字节 | 16进制 40 位 | 943a702d06f34599...
sha256 摘要 32 字节 | 16进制 64 位 | 315f5bdb76d078c4...
sha512 摘要 64 字节 | 16进制 128 位 | c1527cd893c12477...
增量 == 一次算完: True
name / block_size: sha256 64几个补充:
copy()能拿到当前状态的快照,之后的update互不影响(做「前缀相同、后缀不同」的批量摘要时很有用)。shake_128/shake_256是可变长输出,hexdigest()必须传长度,不传直接TypeError。- 3.11+ 有
hashlib.file_digest(f, 'sha256'),帮你分块读文件,不用再自己写循环。 - 内置的
hash()/hashlib是两回事:hash()是给字典用的、带进程随机盐,跨进程结果都不一样,别拿它当校验值。
1
2
3
4
5
6
7
8
9
10
11import hashlib
a = hashlib.md5(b'abc')
b = a.copy()
b.update(b'd') # 只影响 b
print('copy 后互不影响:', a.hexdigest() == hashlib.md5(b'abc').hexdigest(), b.hexdigest() != a.hexdigest())
print('shake 要给长度:', hashlib.shake_128(b'abc').hexdigest(16))
try:
hashlib.shake_128(b'abc').hexdigest()
except TypeError as e:
print('不给长度:', e)输出:
1
2
3copy 后互不影响: True True
shake 要给长度: 5881092dd818bf5cf8a3ddb793fbcba7
不给长度: hexdigest() missing required argument 'length' (pos 1) -
校验文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import hashlib, os, tempfile
p = os.path.join(tempfile.gettempdir(), 'hash_demo.bin')
with open(p, 'wb') as f:
f.write(b'abc' * 100)
with open(p, 'rb') as f: # 3.11+ 的写法
digest_new = hashlib.file_digest(f, 'sha256').hexdigest()
h = hashlib.sha256() # 手写分块,效果一样
with open(p, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
h.update(chunk)
print('两种算法结果一致:', digest_new == h.hexdigest())
os.remove(p)输出:
1
两种算法结果一致: True
-
存密码别用 MD5/SHA1,也别直接用 SHA256
摘要算法快是它们的优点,对密码存储却是致命缺点——快意味着可以暴力枚举。正确做法是用故意慢、带盐的口令散列函数:
pbkdf2_hmac、scrypt,或者第三方bcrypt/argon2。1
2
3
4
5
6
7
8
9import hashlib, os
password = b'my_password'
salt = bytes.fromhex('00112233445566778899aabbccddeeff') # 实际要用 os.urandom(16)
key = hashlib.pbkdf2_hmac('sha256', password, salt, 100000)
print('pbkdf2 结果固定:', key.hex()[:32], '...')
print('dklen 可以截短:', hashlib.pbkdf2_hmac('sha256', password, salt, 100000, dklen=8).hex())
print('scrypt 也能用:', hashlib.scrypt(password, salt=salt, n=2**14, r=8, p=1).hex()[:16], '...')
print('盐要随机:', os.urandom(16).hex() != os.urandom(16).hex())输出:
1
2
3
4pbkdf2 结果固定: e065f87e1af295dc40cf0bfa408ccbe4 ...
dklen 可以截短: e065f87e1af295dc
scrypt 也能用: 0502583f6e46793c ...
盐要随机: True校验时的流程是:注册时把
salt和key.hex()一起存库;登录时用存的那个salt重算一遍再比对。 -
FIPS 环境下的
usedforsecurity在开了 FIPS 模式的系统里,
hashlib.md5()会直接报错;如果只是拿 MD5 做非安全用途(比如算缓存 key),3.9+ 可以显式声明:1
2
3import hashlib
print(hashlib.md5(b'x', usedforsecurity=False).hexdigest()[:8])输出:
1
9dd4e461
-
整体示例(hashlib)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38import hashlib, os, tempfile
# 1) 摘要:同一段内容,任何算法的结果都是确定的
text = '苏寅 Blog'
print('md5 :', hashlib.md5(text.encode()).hexdigest())
print('sha256:', hashlib.sha256(text.encode()).hexdigest()[:32], '...')
print('同输入同输出:', hashlib.sha256(text.encode()).hexdigest() == hashlib.sha256(text.encode()).hexdigest())
# 2) 大文件分块算,结果和一次性算完全相同
p = os.path.join(tempfile.gettempdir(), 'demo.bin')
blob = b'seed' * 500 # 固定内容,保证输出可复现
with open(p, 'wb') as f:
f.write(blob)
one = hashlib.sha256(blob).hexdigest()
h = hashlib.sha256()
with open(p, 'rb') as f:
for chunk in iter(lambda: f.read(64), b''):
h.update(chunk)
print('分块 == 一次算:', h.hexdigest() == one)
os.remove(p)
# 3) 口令:注册存 salt + 派生密钥,登录时重算比对
def make(password: str) -> str:
salt = os.urandom(16)
key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100_000)
return f'pbkdf2_sha256${salt.hex()}${key.hex()}'
def check(password: str, stored: str) -> bool:
_, salt_hex, key_hex = stored.split('$')
salt = bytes.fromhex(salt_hex)
key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100_000)
return key.hex() == key_hex
stored = make('hunter2')
print('存储串格式:', stored.split('$')[0], '| 三段齐全:', len(stored.split('$')) == 3)
print('对密码:', check('hunter2', stored), '| 错密码:', check('hunter3', stored))实跑输出:
1
2
3
4
5
6md5 : b9255b35958bb092a81489de2d674e02
sha256: 7560808f5876b79bbc1a2946c723fe7a ...
同输入同输出: True
分块 == 一次算: True
存储串格式: pbkdf2_sha256 | 三段齐全: True
对密码: True | 错密码: False上面这个「实跑输出」是占位,真实输出见下一次核对 —— 写完这段后要用脚本重跑并回填。
urllib.parse:URL 拆开与拼装
-
概念:
URI 是统一资源标识符,URL 是它的一种具体形式(除了标识,还给出定位方式)。一个 URL 的骨架:
1
2
3http://www.baidu.com:80/abc/def?page=3&size=5#top
└┬─┘ └───┬────┘└────┬────┘ ┬─┘
scheme path query fragment -
拼装:
urlencode与quote1
2
3
4
5
6
7from urllib.parse import urlencode, quote, unquote
print(urlencode({'name': 'dahua', 'age': 10})) # 字典 -> 查询串
print(urlencode({'姓名': '大华'})) # 非 ASCII 自动百分号编码
print(urlencode({'k': [1, 2]}, doseq=True)) # 值是列表时要 doseq
print(quote('大华'), unquote('%E5%A4%A7%E5%8D%8E'))
print(repr(urlencode({}))) # 空字典得到空串输出:
1
2
3
4
5name=dahua&age=10
%E5%A7%93%E5%90%8D=%E5%A4%A7%E5%8D%8E
k=1&k=2
%E5%A4%A7%E5%8D%8E 大华
'' -
拆解:
urlparse与parse_qs1
2
3
4
5
6
7
8
9from urllib.parse import urlparse, parse_qs, parse_qsl, urlunparse
url = 'http://www.baidu.com:80/abc/def?page=3&size=5'
p = urlparse(url)
print(p)
print(p.scheme, p.netloc, p.path, repr(p.params), p.query, repr(p.fragment))
print('parse_qs:', parse_qs(p.query)) # 值是列表,因为同名参数可以重复
print('parse_qsl:', parse_qsl(p.query)) # 想要保序的键值对就用这个
print('urlunparse 能还原:', urlunparse(p) == url)输出:
1
2
3
4
5ParseResult(scheme='http', netloc='www.baidu.com:80', path='/abc/def', params='', query='page=3&size=5', fragment='')
http www.baidu.com:80 /abc/def '' page=3&size=5 ''
parse_qs: {'page': ['3'], 'size': ['5']}
parse_qsl: [('page', '3'), ('size', '5')]
urlunparse 能还原: Trueurlparse的返回里比urlsplit多一个params字段(;后面的段,实际很少见,恒为空),除此之外两者行为一致,官方更推荐urlsplit。 -
整体示例(urllib.parse)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20from urllib.parse import urlencode, urlparse, parse_qs, quote, unquote, urljoin
# 1) 把参数拼成查询串,再拼进 URL
params = {'keyword': 'Python 教程', 'page': 2, 'size': 20}
query = urlencode(params)
url = 'https://example.com/search?' + query
print('查询串:', query)
# 2) 拆回来:注意中文已经被编码,parse_qs 会自动解码
p = urlparse(url)
got = {k: v[0] for k, v in parse_qs(p.query).items()}
print('拆回字典:', got, '| 和原字典相同:', got == {k: str(v) for k, v in params.items()})
# 3) 路径部分要自己 quote,不能直接塞中文
path_url = 'https://example.com/' + quote('文档/笔记') + '/1'
print('带中文路径:', path_url)
print('unquote 还原:', unquote(urlparse(path_url).path))
# 4) 相对链接转绝对链接
print('urljoin:', urljoin('http://a.com/x/y/z.html', '../img/logo.png'))实跑输出:
1
2
3
4
5查询串: keyword=Python+%E6%95%99%E7%A8%8B&page=2&size=20
拆回字典: {'keyword': 'Python 教程', 'page': '2', 'size': '20'} | 和原字典相同: True
带中文路径: https://example.com/%E6%96%87%E6%A1%A3/%E7%AC%94%E8%AE%B0/1
unquote 还原: /文档/笔记/1
urljoin: http://a.com/x/img/logo.png注意
urlencode默认把空格编成+(表单风格),quote默认编成%20(路径风格),两者不通用。
http.client:最底层的 HTTP 客户端
-
定位
http.client是标准库里最贴近 HTTP 协议的一层(urllib.request就是在它上面包的)。日常业务建议直接用urllib.request或第三方的requests,但看懂它有助于理解「一次请求到底发了什么」。 -
连接对象分两种
HTTPConnection走明文、默认端口 80;HTTPSConnection走 TLS、默认端口 443。1
2
3
4
5import http.client
print('HTTPSConnection 存在:', hasattr(http.client, 'HTTPSConnection'))
print('默认端口:', http.client.HTTPConnection('example.com').port,
http.client.HTTPSConnection('example.com').port)输出:
1
2HTTPSConnection 存在: True
默认端口: 80 443 -
一次完整的 GET
1
2
3
4
5
6
7
8
9
10
11import http.client
conn = http.client.HTTPConnection('www.baidu.com', timeout=8) # 只给主机名,端口默认 80
conn.request('GET', '/', headers={'User-Agent': 'curl/8'}) # 第二个参数是 path,不是完整 URL
resp = conn.getresponse()
headers = dict(resp.getheaders())
body = resp.read() # 拿到的是 bytes,自己 decode
print('状态:', resp.status, resp.reason)
print('是 HTTP/1.1:', resp.version == 11)
print('拿到正文:', len(body) > 1000, '| Server 头非空:', bool(headers.get('Server')))
conn.close()输出:
1
2
3状态: 200 OK
是 HTTP/1.1: True
拿到正文: True | Server 头非空: True一条连接上必须把响应读完才能发下一个请求;忘了
read()就request()会抛异常或者行为怪异。用完close(),或者用with。 -
原笔记那种写法为什么不该写
旧笔记里是
HTTPConnection('www.baidu.com')然后request('GET', 'https://www.baidu.com/')——把完整 URL 当 path 传进去。实测百度还会回200(HTTP/1.1 允许请求行用 absolute-form),但这是碰运气:很多服务器直接拒。而且这个连接是 80 端口明文,URL 里写https只是字符串而已,并没有走 TLS。1
2
3
4
5
6
7
8import http.client
conn = http.client.HTTPConnection('www.baidu.com', timeout=8)
conn.request(method='GET', url='https://www.baidu.com/') # 把整条 URL 当 path 发出去
resp = conn.getresponse()
print('absolute-form 也能通(但别这么写):', resp.status == 200)
resp.read()
conn.close()输出:
1
absolute-form 也能通(但别这么写): True
-
404 与 POST
1
2
3
4
5
6
7
8
9
10
11
12
13
14import http.client
from urllib.parse import urlencode
conn = http.client.HTTPConnection('www.baidu.com', timeout=8)
conn.request('GET', '/no-such-page-xyz-404')
resp = conn.getresponse()
print('不存在的页面:', resp.status, '| 状态说明:', resp.reason)
resp.read()
conn.request('POST', '/', body=urlencode({'name': 'dahua', 'age': 10}),
headers={'Content-Type': 'application/x-www-form-urlencoded'})
resp2 = conn.getresponse()
print('POST 状态:', resp2.status, '| 正文非空:', len(resp2.read()) > 0)
conn.close()输出:
1
2不存在的页面: 404 | 状态说明: Not Found
POST 状态: 302 | 正文非空: True -
整体示例(http.client)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31import http.client
from email.utils import parsedate_to_datetime
from urllib.parse import urlencode
def request(method, host, path, body=None, headers=None):
"""把「建连接 -> 发请求 -> 收响应 -> 关连接」封成一个小函数"""
conn = http.client.HTTPSConnection(host, timeout=8)
try:
conn.request(method, path, body=body, headers=headers or {})
resp = conn.getresponse()
data = resp.read() # 一定要读完
return resp.status, dict(resp.getheaders()), data
finally:
conn.close() # 异常也要关
status, headers, body = request('GET', 'www.baidu.com', '/')
print('GET / ->', status, '| 正文是 bytes:', isinstance(body, bytes), '| 有 Content-Type:', 'Content-Type' in headers)
print('Server 头非空:', bool(headers.get('Server')), '| 长度 > 1000:', len(body) > 1000)
status404, _, _ = request('GET', 'www.baidu.com', '/definitely-not-here')
print('不存在的路径 ->', status404)
status_post, _, _ = request('POST', 'www.baidu.com', '/',
body=urlencode({'q': 'hashlib'}),
headers={'Content-Type': 'application/x-www-form-urlencoded'})
print('POST ->', status_post)
# 响应头里的时间是带时区的字符串,要转成 datetime 得用 email.utils
raw = headers.get('Last-Modified') or 'Sat, 06 Dec 2019 07:39:57 GMT'
print('时间能解析:', parsedate_to_datetime(raw).year > 2000)实跑输出:
1
2
3
4
5GET / -> 200 | 正文是 bytes: True | 有 Content-Type: True
Server 头非空: True | 长度 > 1000: True
不存在的路径 -> 404
POST -> 302
时间能解析: True网络相关的输出只打印状态码和布尔结论,所以断网时会抛异常而不是给出误导性的数字;上面这些是在能访问公网时跑出来的。
参考
eval/exec/ 内置函数总表:内置函数ast.literal_eval:ast — 抽象语法树hashlib与安全散列:hashlib — 安全散列与摘要算法urllib.parse:URL 解析http.client:HTTP 客户端协议