Skip to content

Commit 7b8f849

Browse files
feat: add Python patching support with comprehensive tests (#28)
* feat: add bun global package support - Add getBunGlobalPrefix() function to detect bun's global package path - Update getGlobalNodeModulesPaths() to include pnpm, yarn, and bun paths - Export getBunGlobalPrefix() for external use 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: export getBunGlobalPrefix from crawlers module Export the getBunGlobalPrefix function to allow checking bun's global package prefix. Also adds test coverage for the export. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: add Python patching support with comprehensive tests Add Python ecosystem support to socket-patch including: - PythonCrawler for discovering packages in site-packages via dist-info/METADATA - PURL utilities for PyPI package parsing and qualification stripping - Apply/rollback support for PyPI packages with qualifier variant fallback - Scan command integration with ecosystem summary (npm + python) Test coverage (52 new tests across 6 files): - purl-utils: stripPurlQualifiers, isPyPIPurl, isNpmPurl, parsePyPIPurl - python-crawler: canonicalizePyPIName, crawlAll, crawlBatches, findByPurls, findPythonDirs, findLocalVenvSitePackages, getSitePackagesPaths - apply-python: patch application to pypi packages, mixed npm+pypi, dry-run - apply-qualifier-fallback: variant selection, hash matching, dedup - scan-python: combined scanning, API integration, ecosystem summary - python-venv: real venv tests (skippable if python3 unavailable) Also exports canonicalizePyPIName, findPythonDirs, findLocalVenvSitePackages from crawlers module, and extends test-utils with createTestPythonPackage and pypi support in setupTestEnvironment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: restore VIRTUAL_ENV env var correctly in test cleanup Ensure finally blocks always restore process.env.VIRTUAL_ENV to its original state, including deleting it when it was originally undefined. Prevents env var leaks between tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: don't scan system Python packages in local mode Remove the python3 -c fallback from findLocalVenvSitePackages. In local mode (non-global), only project-local venvs should be scanned (VIRTUAL_ENV, .venv, venv). System-wide packages are only discovered when --global is passed, via getGlobalPythonSitePackages. This fixes e2e test failures where empty projects incorrectly found system Python packages on CI runners. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 128b279 commit 7b8f849

14 files changed

Lines changed: 2556 additions & 254 deletions

src/commands/apply-python.test.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { describe, it, before, after } from 'node:test'
2+
import assert from 'node:assert/strict'
3+
import * as path from 'path'
4+
import {
5+
createTestDir,
6+
removeTestDir,
7+
setupTestEnvironment,
8+
computeTestHash,
9+
} from '../test-utils.js'
10+
import { applyPackagePatch } from '../patch/apply.js'
11+
import * as fs from 'fs/promises'
12+
13+
// Valid UUIDs for testing
14+
const TEST_UUID_PY1 = 'aaaa1111-1111-4111-8111-111111111111'
15+
const TEST_UUID_PY2 = 'aaaa2222-2222-4222-8222-222222222222'
16+
const TEST_UUID_NPM1 = 'bbbb1111-1111-4111-8111-111111111111'
17+
18+
describe('apply command - Python packages', () => {
19+
let testDir: string
20+
21+
before(async () => {
22+
testDir = await createTestDir('apply-python-')
23+
})
24+
25+
after(async () => {
26+
await removeTestDir(testDir)
27+
})
28+
29+
it('should apply patch to pypi package', async () => {
30+
const beforeContent = 'import os\nprint("vulnerable")\n'
31+
const afterContent = 'import os\nprint("patched")\n'
32+
33+
const { blobsDir, sitePackagesDir } = await setupTestEnvironment({
34+
testDir: path.join(testDir, 'test-apply'),
35+
patches: [
36+
{
37+
purl: 'pkg:pypi/requests@2.28.0',
38+
uuid: TEST_UUID_PY1,
39+
files: {
40+
'requests/__init__.py': { beforeContent, afterContent },
41+
},
42+
},
43+
],
44+
initialState: 'before',
45+
})
46+
47+
const beforeHash = computeTestHash(beforeContent)
48+
const afterHash = computeTestHash(afterContent)
49+
50+
const result = await applyPackagePatch(
51+
'pkg:pypi/requests@2.28.0',
52+
sitePackagesDir,
53+
{ 'requests/__init__.py': { beforeHash, afterHash } },
54+
blobsDir,
55+
false,
56+
)
57+
58+
assert.equal(result.success, true)
59+
assert.equal(result.filesPatched.length, 1)
60+
61+
// Verify file was changed
62+
const content = await fs.readFile(
63+
path.join(sitePackagesDir, 'requests/__init__.py'),
64+
'utf-8',
65+
)
66+
assert.equal(content, afterContent)
67+
})
68+
69+
it('should apply patches to both pypi and npm packages', async () => {
70+
const pyBefore = 'py_before'
71+
const pyAfter = 'py_after'
72+
const npmBefore = 'npm_before'
73+
const npmAfter = 'npm_after'
74+
75+
const { blobsDir, nodeModulesDir, sitePackagesDir } =
76+
await setupTestEnvironment({
77+
testDir: path.join(testDir, 'test-mixed'),
78+
patches: [
79+
{
80+
purl: 'pkg:pypi/flask@2.3.0',
81+
uuid: TEST_UUID_PY2,
82+
files: {
83+
'flask/__init__.py': {
84+
beforeContent: pyBefore,
85+
afterContent: pyAfter,
86+
},
87+
},
88+
},
89+
{
90+
purl: 'pkg:npm/lodash@4.17.21',
91+
uuid: TEST_UUID_NPM1,
92+
files: {
93+
'package/index.js': {
94+
beforeContent: npmBefore,
95+
afterContent: npmAfter,
96+
},
97+
},
98+
},
99+
],
100+
initialState: 'before',
101+
})
102+
103+
// Apply Python patch
104+
const pyResult = await applyPackagePatch(
105+
'pkg:pypi/flask@2.3.0',
106+
sitePackagesDir,
107+
{
108+
'flask/__init__.py': {
109+
beforeHash: computeTestHash(pyBefore),
110+
afterHash: computeTestHash(pyAfter),
111+
},
112+
},
113+
blobsDir,
114+
false,
115+
)
116+
assert.equal(pyResult.success, true)
117+
118+
// Apply npm patch
119+
const npmPkgDir = path.join(nodeModulesDir, 'lodash')
120+
const npmResult = await applyPackagePatch(
121+
'pkg:npm/lodash@4.17.21',
122+
npmPkgDir,
123+
{
124+
'package/index.js': {
125+
beforeHash: computeTestHash(npmBefore),
126+
afterHash: computeTestHash(npmAfter),
127+
},
128+
},
129+
blobsDir,
130+
false,
131+
)
132+
assert.equal(npmResult.success, true)
133+
})
134+
135+
it('should skip uninstalled pypi package with not-found status', async () => {
136+
const beforeContent = 'original'
137+
const afterContent = 'patched'
138+
const beforeHash = computeTestHash(beforeContent)
139+
const afterHash = computeTestHash(afterContent)
140+
141+
const { blobsDir, sitePackagesDir } = await setupTestEnvironment({
142+
testDir: path.join(testDir, 'test-uninstalled'),
143+
patches: [],
144+
initialState: 'before',
145+
})
146+
147+
// Apply to a file that doesn't exist in site-packages
148+
const result = await applyPackagePatch(
149+
'pkg:pypi/nonexistent@1.0.0',
150+
sitePackagesDir,
151+
{ 'nonexistent/__init__.py': { beforeHash, afterHash } },
152+
blobsDir,
153+
false,
154+
)
155+
156+
assert.equal(result.success, false)
157+
assert.ok(result.error?.includes('not-found') || result.error?.includes('File not found'))
158+
})
159+
160+
it('should not modify pypi files in dry-run mode', async () => {
161+
const beforeContent = 'import six\noriginal = True\n'
162+
const afterContent = 'import six\noriginal = False\n'
163+
164+
const { blobsDir, sitePackagesDir } = await setupTestEnvironment({
165+
testDir: path.join(testDir, 'test-dryrun'),
166+
patches: [
167+
{
168+
purl: 'pkg:pypi/six@1.16.0',
169+
uuid: TEST_UUID_PY1,
170+
files: {
171+
'six.py': { beforeContent, afterContent },
172+
},
173+
},
174+
],
175+
initialState: 'before',
176+
})
177+
178+
const result = await applyPackagePatch(
179+
'pkg:pypi/six@1.16.0',
180+
sitePackagesDir,
181+
{
182+
'six.py': {
183+
beforeHash: computeTestHash(beforeContent),
184+
afterHash: computeTestHash(afterContent),
185+
},
186+
},
187+
blobsDir,
188+
true, // dry-run
189+
)
190+
191+
assert.equal(result.success, true)
192+
assert.equal(result.filesPatched.length, 0)
193+
194+
// File should be unchanged
195+
const content = await fs.readFile(
196+
path.join(sitePackagesDir, 'six.py'),
197+
'utf-8',
198+
)
199+
assert.equal(content, beforeContent)
200+
})
201+
202+
it('should skip already-patched pypi package', async () => {
203+
const beforeContent = 'original_code'
204+
const afterContent = 'patched_code'
205+
206+
const { blobsDir, sitePackagesDir } = await setupTestEnvironment({
207+
testDir: path.join(testDir, 'test-already'),
208+
patches: [
209+
{
210+
purl: 'pkg:pypi/requests@2.28.0',
211+
uuid: TEST_UUID_PY1,
212+
files: {
213+
'requests/__init__.py': { beforeContent, afterContent },
214+
},
215+
},
216+
],
217+
initialState: 'after', // Start in patched state
218+
})
219+
220+
const result = await applyPackagePatch(
221+
'pkg:pypi/requests@2.28.0',
222+
sitePackagesDir,
223+
{
224+
'requests/__init__.py': {
225+
beforeHash: computeTestHash(beforeContent),
226+
afterHash: computeTestHash(afterContent),
227+
},
228+
},
229+
blobsDir,
230+
false,
231+
)
232+
233+
assert.equal(result.success, true)
234+
assert.equal(result.filesPatched.length, 0, 'No files should be patched')
235+
assert.equal(
236+
result.filesVerified[0].status,
237+
'already-patched',
238+
)
239+
})
240+
})

0 commit comments

Comments
 (0)