一. 前言:字串像命令,不代表可以直接執行 #
設定檔裡有這一行:
resize --width 1200 --label "拍拍君 sample.png"
你想把它拆成程式名稱與參數。
直接呼叫 command.split(),會把引號裡的檔名切成兩段。
把整串交給 shell,又會讓 $HOME、;、|、$(...) 開始工作,安全邊界瞬間變得很刺激。
Python 標準庫的 shlex 專門處理這個中間地帶:
- 按照 Unix shell 類似規則拆解字串
- 保留引號包住的空白
- 把參數安全地 quote 回命令列文字
- 建立可調整的 lexical analyzer
- 處理簡單的 shell-like 設定
它不是 shell,也不會執行命令。 這個限制反而是它最重要的優點。 如果你要學啟動程序、拿 stdout 與設定 timeout,先看 Python subprocess 完整指南。 今天只處理「文字怎麼切成 token」。
二. 安裝:標準庫已經準備好了 #
shlex 不需要 pip install。
mkdir shlex-lab
cd shlex-lab
python3 -c "import shlex; print(shlex.split('hello world'))"
也可以用 uv 固定環境:
uv init
uv python pin 3.13
uv run python -c "import shlex; print(shlex.split('hello world'))"
預期輸出:
['hello', 'world']
本文使用 Python 3.13 API;join() 需要 Python 3.8 以上。
三. shlex.split():正確看懂引號
#
先比較最常見的錯誤與正解:
import shlex
command = 'resize --label "拍拍君 sample.png" --width 1200'
print(command.split())
print(shlex.split(command))
輸出:
['resize', '--label', '"拍拍君', 'sample.png"', '--width', '1200']
['resize', '--label', '拍拍君 sample.png', '--width', '1200']
str.split() 只認空白。
shlex.split() 理解單引號、雙引號與跳脫字元:
samples = [
r"convert photo.png --name 拍拍君",
r"convert 'my photo.png' --name '拍拍君 demo'",
r'convert "my photo.png" --name "拍拍君 demo"',
r"convert my\ photo.png --name pypy",
]
for text in samples:
print(shlex.split(text))
引號是語法,不是資料,所以結果不保留外層引號。 這適合解析設定檔中的單行參數、測試案例與不需要完整 shell 能力的迷你指令欄位。
四. POSIX 模式與註解 #
split() 預設使用 POSIX mode,也可以明確指定:
text = r'''tool "hello world" '拍拍君 data' '''
print(shlex.split(text, posix=True))
print(shlex.split(text, posix=False))
POSIX mode 會移除語法用引號;non-POSIX mode 可能保留引號。
新程式建議明寫 posix=True,讓規則清楚。
註解則由 comments 控制:
text = 'deploy --env staging # tonight'
print(shlex.split(text, comments=False))
print(shlex.split(text, comments=True))
結果:
['deploy', '--env', 'staging', '#', 'tonight']
['deploy', '--env', 'staging']
引號裡的 # 仍是資料:
text = r'''tag --name "拍拍君 #1"'''
print(shlex.split(text, comments=True))
是否允許註解要由格式契約決定,不要看到 # 就自己截字串。
五. shlex.join():把 token 轉回可讀命令
#
程式內部有一組正確參數:
args = [
"resize",
"--input",
"拍拍君 photo.png",
"--label",
"it's ready",
]
用 " ".join(args) 會失去 token 邊界。
改用:
display = shlex.join(args)
print(display)
join() 適合產生可複製的 POSIX 命令顯示、process invocation log 與 debug 訊息。
它的重要性質是:
assert shlex.split(shlex.join(args)) == args
引號外觀可能改變,但 token list 應保持相同。 測試資料語意,不要測 quote 長相。
六. shlex.quote():只保護一個參數
#
quote() 接受單一字串,回傳可安全放進 POSIX shell command line 的版本:
filename = "拍拍君's report; final.txt"
print(shlex.quote(filename))
別把整條 script 一起 quote:
# 錯誤觀念:整串會變成一個 shell word
shlex.quote("cat report.txt | wc -l")
多個參數要用 join()。
真正執行外部程式時,通常直接傳 list 更安全:
import subprocess
subprocess.run(
["cat", "拍拍君 report.txt"],
check=True,
)
這樣不需要 shell,也不需要 quote。 安全工具的最高境界,常常是讓危險語法沒有出場機會。
七. 解析、驗證與執行要分層 #
請把工作流切成三層:
- 解析:文字轉成 token list
- 驗證:確認程式與參數是否允許
- 執行:以 list 交給
subprocess.run()
from __future__ import annotations
import shlex
import subprocess
ALLOWED_PROGRAMS = {"echo", "printf"}
def parse_command(text: str) -> list[str]:
args = shlex.split(text, posix=True)
if not args:
raise ValueError("命令不能是空白")
if args[0] not in ALLOWED_PROGRAMS:
raise ValueError(f"不允許執行:{args[0]}")
return args
def run_command(text: str) -> str:
result = subprocess.run(
parse_command(text),
check=True,
capture_output=True,
text=True,
shell=False,
)
return result.stdout
即使使用 shlex.split(),也不能跳過 allowlist 與參數驗證。
拆成 token 不代表每個 token 都符合產品規則。
安全不是一個函式呼叫,而是一層一層縮小權限。
八. shlex 不是 Shell 模擬器
#
text = 'echo "$HOME" | sed "s/x/y/" && echo done'
print(shlex.split(text))
shlex 不會:
- 展開
$HOME - 執行 command substitution
- 建立 pipe 或 redirect
- 套用 glob
- 執行
&&的短路邏輯
如果不需要 shell operator,最好直接拒絕:
FORBIDDEN = {"|", "||", "&", "&&", ";", ">", ">>", "<"}
def reject_shell_operators(args: list[str]) -> None:
found = FORBIDDEN.intersection(args)
if found:
names = ", ".join(sorted(found))
raise ValueError(f"不支援 shell operator:{names}")
這仍不是完整 shell 安全檢查。 最穩定的設計,是讓使用者填結構化欄位,而不是自由 shell 文字。
九. shlex.shlex:客製 Lexer
#
split() 是便利函式。
要調整 token 規則,可以建立 shlex.shlex:
text = '''
# 拍拍君的部署設定
target staging
region ap-east
message "release candidate"
'''
lexer = shlex.shlex(text, posix=True)
lexer.whitespace_split = True
lexer.commenters = "#"
print(list(lexer))
輸出:
['target', 'staging', 'region', 'ap-east', 'message', 'release candidate']
常用屬性包括:
whitespace_split:是否主要按空白切詞commenters:哪些字元開始註解wordchars:哪些字元留在一般 wordquotes:哪些字元是 quoteescape:跳脫字元
修改規則前先寫測試。 Lexer 的 bug 特別愛躲在引號、註解與空字串交界。
十. punctuation_chars 與迷你設定語法
#
若要辨認 |、&&、;,可以在建立時設定:
lexer = shlex.shlex(
"build --fast && deploy | notify",
posix=True,
punctuation_chars=True,
)
lexer.whitespace_split = True
print(list(lexer))
也能指定自己關心的字元,做有限的 key-value 語法:
def parse_config_line(text: str) -> tuple[str, str]:
lexer = shlex.shlex(
text,
posix=True,
punctuation_chars="=",
)
lexer.whitespace_split = True
lexer.commenters = "#"
tokens = list(lexer)
if len(tokens) != 3 or tokens[1] != "=":
raise ValueError("格式必須是:key = value")
key, _, value = tokens
if not key.replace("_", "").isalnum():
raise ValueError(f"不合法的 key:{key}")
return key, value
print(parse_config_line('name = "拍拍君 worker"'))
print(parse_config_line("retries = 3"))
Tokenize operator 不等於正確解讀 operator。 需求出現巢狀結構、陣列或型別時,請改用 TOML、JSON 或真正的 parser。 專案設定通常更適合 tomllib 教學 的做法。
十一. 錯誤處理與 Windows 限制 #
未關閉引號會丟出 ValueError:
try:
shlex.split('deploy --message "unfinished')
except ValueError as exc:
print(f"解析失敗:{exc}")
批次讀檔時,要補上來源行號:
from pathlib import Path
def load_commands(path: Path) -> list[list[str]]:
commands: list[list[str]] = []
lines = path.read_text(encoding="utf-8").splitlines()
for number, line in enumerate(lines, start=1):
if not line.strip():
continue
try:
args = shlex.split(line, comments=True, posix=True)
except ValueError as exc:
raise ValueError(f"{path}:{number}: {exc}") from exc
if args:
commands.append(args)
return commands
另外,shlex 的目標是 Unix shell syntax。
posix=False 不等於完整的 cmd.exe 或 PowerShell parser,quote() 也不保證適用 Windows shell。
跨平台工具應維持結構化參數:
args = [program, "--input", input_path, "--name", display_name]
subprocess.run(args, check=True, shell=False)
不要先 shlex.join(args),再把字串交給 Windows shell。
那是把 POSIX 顯示格式誤當跨平台執行格式。
十二. 測試 Round-trip 與惡意字元 #
Tokenizer 適合 table-driven tests:
import pytest
@pytest.mark.parametrize(
("text", "expected"),
[
("hello world", ["hello", "world"]),
('hello "拍拍君 world"', ["hello", "拍拍君 world"]),
(r"hello empty=''", ["hello", "empty="]),
(r"hello a\ b", ["hello", "a b"]),
("echo '; rm nope'", ["echo", "; rm nope"]),
],
)
def test_split(text: str, expected: list[str]) -> None:
assert shlex.split(text, posix=True) == expected
再測 token round-trip:
@pytest.mark.parametrize(
"args",
[
[],
["echo", ""],
["echo", "拍拍君 data.txt"],
["printf", "%s", "a;b&c"],
["tool", "it's ready", 'say "hi"'],
],
)
def test_join_round_trip(args: list[str]) -> None:
assert shlex.split(shlex.join(args)) == args
def test_unclosed_quote_is_rejected() -> None:
with pytest.raises(ValueError, match="quotation"):
shlex.split('echo "unfinished')
測試至少涵蓋空 token、空白、引號、backslash、註解、metacharacters、Unicode 與未關閉引號。 想深入參數化與 fixture,可接著看 pytest fixtures 實戰。
十三. 選擇指南與常見反模式 #
| 需求 | 建議 |
|---|---|
| 拆解簡單 Unix shell-like 參數 | shlex.split() |
| 把 argv 顯示成 POSIX 命令 | shlex.join() |
| 保護單一 POSIX shell token | shlex.quote() |
| 執行程式且不需要 shell | subprocess.run(list, shell=False) |
| 解析 TOML | tomllib |
| 解析 Bash / PowerShell / cmd.exe | 專用 parser,或避免自由 script |
幾個常見反模式:
- 用
str.split()解析帶引號參數。 shlex.split()後又join(),最後丟給shell=True。- 以為
quote()會驗證 executable 與參數語意。 - 用
shlex硬疊出巢狀程式語言。 - 把 POSIX round-trip 當成跨平台保證。
拍拍君的預設策略是:能用 list 就不要收自由命令字串;必須收字串時先 tokenize,再做 allowlist 與參數驗證;能維持 shell=False 就維持。
結語 #
shlex 很小,卻站在人類文字與程式參數的危險邊界。
今天記住:
str.split()不懂引號,shlex.split()才懂 shell-like tokenjoin()用來產生 POSIX-style 顯示文字quote()只保護單一參數- 解析不等於驗證,驗證也不等於執行
subprocess.run([...], shell=False)通常比組字串安全shlex是 lexer,不是完整 shell parser- Unix quoting 不應直接套到 Windows shell
先把文字變成明確資料,再決定程式可以做什麼。 這個小習慣會讓 CLI、automation 與部署工具安靜很多。 安靜是好事,尤其在安全事件發生前。🐍