Quadratic comment split on a requirements file line (pip_requirements_parser.py:1058)

未关闭 适合新手
#30 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

@Anurag-S1ngh 已经在做这个了。

开始于 2026年10月24日。

  • #31 来自 @Anurag-S1ngh —— 未关闭

评估

难度
2/5
预计耗时
1-3 小时
新手友好度
72/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
python
领域
tooling

调研方向

慢的模式是 src/pip_requirements_parser.py 中第 1058 行附近的 COMMENT_RE,它被第 1569 行附近的注释拆分循环使用。请阅读为什么那里会丢弃空白分组,并通过 RequirementsFile.from_file() 用一个很长的空白行复现耗时。完成标准是:现有输入的拆分输出保持不变,64 KB 的空白行能在毫秒级解析完成,并且现有测试套件没有出现新的失败。

由索引模型根据 Issue 内容生成。

描述

Parsing a requirements file takes time quadratic in the length of any line that is mostly whitespace. Measured on 32.0.1 through RequirementsFile.from_file(), with one whitespace line in an otherwise ordinary three-line file:

whitespace line file size before after
4 KB 4,027 B 0.1165 s 0.0007 s
16 KB 16,027 B 1.4871 s 0.0011 s
64 KB 64,027 B 20.5483 s 0.0030 s
128 KB 128,027 B - 0.0057 s

Two controls, both measured, and together they show the cost is the shape and not the size:

  • 4,000 ordinary pinned lines with trailing comments - a 149,780-byte file, more than twice the size - costs 0.7479 s, against 20.5483 s for the 64,027-byte hostile one.
  • The same 64 KB of whitespace followed by a # costs 0.0008 s. The cost needs the match to fail.

Who this matters to. The package exists to parse requirements files without installing anything - the README stresses that it works offline - which is the SBOM and software-composition-analysis use: reading requirements files out of third-party or unknown codebases. Dependents include CycloneDX/cyclonedx-python and BentoML. So the payload is one long whitespace line in an otherwise ordinary file, and a small commit to a scanned repository costs minutes of scanner CPU. (The same shape exists in pip itself, at src/pip/_internal/req/req_file.py:41, still with \s+ on current master - but pip install -r on an untrusted requirements file already runs arbitrary code, so there is nothing to protect there. Mentioned only so you know it is not a fork-only divergence.)

Cause. src/pip_requirements_parser.py:1058:

COMMENT_RE = re.compile(r"(^|\s+)(#.*)$")

applied at :1569, once per line:

parts = [l.strip() for l in COMMENT_RE.split(line) if l.strip()]

split tries every offset, and at each one \s+ consumes the rest of the whitespace run before failing to find the #. O(n) work at O(n) start positions. The two COMMENT_RE.match() uses in join_lines() (:1539, :1540) are anchored and stay linear - measured at 2.5 ms on a 256 KB line - so only the split is affected.

Fix - one character, and no cap. The caller strips every part and drops the empty ones, so whatever the whitespace group captured is discarded. A single whitespace character therefore produces the same list as a run, and makes each start position cost O(1):

 COMMENT_RE = re.compile(r"(^|\s+)(#.*)$")
+
+COMMENT_SPLIT_RE = re.compile(r"(^|\s)(#.*)$")
...
-        parts = [l.strip() for l in COMMENT_RE.split(line) if l.strip()]
+        parts = [l.strip() for l in COMMENT_SPLIT_RE.split(line) if l.strip()]

COMMENT_RE itself is left alone, because the two match() uses in join_lines() do rely on the run: with a single \s they would stop recognising " # comment" as a comment line.

This has no divergence class. Over 311,161 inputs - every string of length 0 to 5 over # space tab \x0b \x0c \r a = . -, 200,000 random strings over the same alphabet, hand-written real requirement lines, and whitespace runs either side of 200 characters - the list the caller builds is identical in every case. Zero divergences, no cap to justify.

Two alternatives were tested and should not be used.

  1. Bounding the run ((^|\s{1,200})) is also exactly equivalent on those inputs, but it is about 90 times slower than the single-character fix - 0.1253 s against 0.0014 s on a 64 KB run - and it introduces a cap that has to be justified. There is no reason to take that trade.
  2. Narrowing the class ((^|[ \t]{1,200})) changes behaviour and must not be used. \s covers \x0b, \x0c and \r, and [ \t] does not, so on the same 311,161 inputs it differs on 21,427 - every one of them because of those characters. The failure is not cosmetic: "a\x0b#b" currently splits into ['a', '#b'] and under that pattern stays ['a\x0b#b'], so the comment is no longer separated and the comment text is carried into the requirement line.

Verification.

  • Differential as described above, compared at the caller's output rather than at the regex.
  • Project test suite on both trees: 8,246 passed, 20 failed, 5 skipped, 1 xfailed, the 20 failures identical by name. They are pre-existing error-message comparisons - the vendored packaging in this environment words "Invalid requirement" differently from the checked-in expectations - and they fail on unmodified 32.0.1.
  • Control test: ten real lines split identically and a three-line file is parsed to the right requirement names, plus a timing assertion on a 16 KB whitespace line. On 32.0.1 the splitting assertions pass and the timing assertion fails at 1.3453 s; patched, both pass in 0.002 s.
  • patch -p1 verified against the 32.0.1 sdist.

For context rather than as prior art: pypa/pip #3662 (2016, closed 2019) discusses this expression, but as a Jython maximum recursion depth exceeded error with no cost figure, and pip declined to change it on the grounds that "keeping existing behaviour is more important". Nobody in that thread identified the CPython behaviour, and the expression is unchanged in pip today.

I am happy to open a pull request with the patch if that is easier.

Found with AI assistance (Claude) and verified by hand.

Patch: full patch
--- a/src/pip_requirements_parser.py
+++ b/src/pip_requirements_parser.py
@@ -1057,6 +1057,15 @@
 SCHEME_RE = re.compile(r"^(http|https|file):", re.I)
 COMMENT_RE = re.compile(r"(^|\s+)(#.*)$")
 
+# Used only by split_comments(), which strips every part and drops the empty
+# ones, so what the whitespace group captures is discarded. A single whitespace
+# character therefore gives the same result as a run, and makes each start
+# position cost O(1): with "\s+" every offset in a run of whitespace starts an
+# attempt that consumes the rest of the line before failing to find the "#", so
+# splitting a line is quadratic in its length. COMMENT_RE itself is unchanged,
+# because the two match() uses in join_lines() rely on the run.
+COMMENT_SPLIT_RE = re.compile(r"(^|\s)(#.*)$")
+
 SUPPORTED_OPTIONS: List[Callable[..., optparse.Option]] = [
     index_url,
     extra_index_url,
@@ -1566,7 +1575,7 @@
     Yield TextLine or Commentline
     """
     for line_number, line in lines_enum:
-        parts = [l.strip() for l in COMMENT_RE.split(line) if l.strip()]
+        parts = [l.strip() for l in COMMENT_SPLIT_RE.split(line) if l.strip()]
 
         if len(parts) == 1:
             part = parts[0]
主要语言
Python
星标
20
派生
16
PR 合并指标
30 天内没有已合并 PR

环境准备

这个项目没有提供开发容器、Dockerfile 或贡献指南,环境需要你自己搭建:先看它的 README,通用步骤见我们的新手贡献指南。

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

aboutcode-org/pip-requirements-parser 的其他 Issue

查看 aboutcode-org/pip-requirements-parser 的全部 Issue

相似的 Issue

更多 Python Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。