Skip to content

Commit 5237a7c

Browse files
feat: add findPythonVersionMatches function and tests for detecting Python version patterns in various files
1 parent 5c5770f commit 5237a7c

File tree

4 files changed

+301
-1
lines changed

4 files changed

+301
-1
lines changed

docs/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ Use Context7 MCP for up to date documentation.
113113
Lib: `fast-glob`. Ignore `node_modules`, `.git`, `dist`.
114114
Verify: Unit test ensures correct file set.
115115

116-
11. [ ] **Regex matchers**
116+
11. [x] **Regex matchers**
117117
Patterns for workflows, Dockerfiles, `.python-version`, `.tool-versions`, `runtime.txt`, `tox.ini`, `pyproject.toml`, `Pipfile`, `environment.yml`.
118118
Verify: Positive/negative unit tests per pattern.
119119

src/scanning/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export { discoverFiles } from './glob-discovery';
2+
export { findPythonVersionMatches, pythonVersionPatterns } from './patterns/python-version';
3+
export type { VersionMatch } from './patterns/python-version';
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
export interface VersionMatch {
2+
file: string;
3+
line: number;
4+
column: number;
5+
matched: string;
6+
major: number;
7+
minor: number;
8+
patch: number;
9+
}
10+
11+
interface PatternDefinition {
12+
id: string;
13+
description: string;
14+
isFileSupported: (filePath: string) => boolean;
15+
regexes: RegExp[];
16+
}
17+
18+
const VERSION_PATTERN = '\\d+\\.\\d+\\.\\d+';
19+
20+
export const pythonVersionPatterns: PatternDefinition[] = [
21+
{
22+
id: 'workflow-python-version',
23+
description: 'python-version inputs inside GitHub Actions workflows',
24+
isFileSupported: (filePath) => {
25+
const normalized = normalizePath(filePath).toLowerCase();
26+
const isWorkflowFile = normalized.includes('.github/workflows/');
27+
const isYaml = normalized.endsWith('.yml') || normalized.endsWith('.yaml');
28+
return isWorkflowFile && isYaml;
29+
},
30+
regexes: [new RegExp(`python-version\\s*:\\s*["]?(?<version>${VERSION_PATTERN})["]?`, 'gi')],
31+
},
32+
{
33+
id: 'dockerfile-from',
34+
description: 'Python base images in Dockerfiles',
35+
isFileSupported: isDockerfile,
36+
regexes: [
37+
new RegExp(`FROM\\s+[^\\s]*python[^\\s:]*:(?<version>${VERSION_PATTERN})`, 'gi'),
38+
new RegExp(
39+
`\\b(?:ARG|ENV)\\s+PYTHON[_-]?VERSION\\s*=\\s*["]?(?<version>${VERSION_PATTERN})["]?`,
40+
'gi',
41+
),
42+
],
43+
},
44+
{
45+
id: 'python-version-file',
46+
description: '.python-version files containing a sole version',
47+
isFileSupported: (filePath) => getBasename(filePath) === '.python-version',
48+
regexes: [new RegExp(`^\\s*(?<version>${VERSION_PATTERN})\\s*$`, 'gim')],
49+
},
50+
{
51+
id: 'tool-versions',
52+
description: 'python entries inside .tool-versions',
53+
isFileSupported: (filePath) => getBasename(filePath) === '.tool-versions',
54+
regexes: [new RegExp(`^python[^\\S\\r\\n]+(?<version>${VERSION_PATTERN})\\b`, 'gim')],
55+
},
56+
{
57+
id: 'runtime-txt',
58+
description: 'Heroku-style runtime.txt files',
59+
isFileSupported: (filePath) => getBasename(filePath) === 'runtime.txt',
60+
regexes: [new RegExp(`^python-(?<version>${VERSION_PATTERN})\\b`, 'gim')],
61+
},
62+
{
63+
id: 'pyproject-python',
64+
description: 'python/requirements entries inside pyproject.toml',
65+
isFileSupported: (filePath) => getBasename(filePath) === 'pyproject.toml',
66+
regexes: [
67+
new RegExp(
68+
`\\b(?:requires-python|python(?:[_-]?version)?|pythonVersion)\\s*=\\s*["](?:==)?(?<version>${VERSION_PATTERN})["]`,
69+
'gi',
70+
),
71+
],
72+
},
73+
{
74+
id: 'tox-ini',
75+
description: 'tox.ini basepython/python_version fields',
76+
isFileSupported: (filePath) => getBasename(filePath) === 'tox.ini',
77+
regexes: [
78+
new RegExp(`^\\s*python_version\\s*=\\s*(?<version>${VERSION_PATTERN})\\b`, 'gim'),
79+
new RegExp(`^\\s*basepython\\s*=\\s*python(?<version>${VERSION_PATTERN})\\b`, 'gim'),
80+
],
81+
},
82+
{
83+
id: 'pipfile',
84+
description: 'Pipfile python version declarations',
85+
isFileSupported: (filePath) => getBasename(filePath) === 'pipfile',
86+
regexes: [
87+
new RegExp(`^\\s*python_full_version\\s*=\\s*["](?<version>${VERSION_PATTERN})["]`, 'gim'),
88+
new RegExp(`^\\s*python_version\\s*=\\s*["](?<version>${VERSION_PATTERN})["]`, 'gim'),
89+
],
90+
},
91+
{
92+
id: 'environment-yml',
93+
description: 'Conda environment python dependencies',
94+
isFileSupported: (filePath) => {
95+
const base = getBasename(filePath);
96+
return base === 'environment.yml' || base === 'environment.yaml';
97+
},
98+
regexes: [new RegExp(`(?:^|\\s|-)python(?:==|=)(?<version>${VERSION_PATTERN})\\b`, 'gi')],
99+
},
100+
];
101+
102+
function normalizePath(filePath: string): string {
103+
return filePath.replace(/\\\\/g, '/');
104+
}
105+
106+
function getBasename(filePath: string): string {
107+
const normalized = normalizePath(filePath);
108+
const index = normalized.lastIndexOf('/');
109+
const base = index === -1 ? normalized : normalized.slice(index + 1);
110+
return base.toLowerCase();
111+
}
112+
113+
function isDockerfile(filePath: string): boolean {
114+
const base = getBasename(filePath);
115+
return base === 'dockerfile' || base.endsWith('.dockerfile');
116+
}
117+
118+
function cloneRegex(regex: RegExp): RegExp {
119+
const flags = regex.flags.includes('g') ? regex.flags : `${regex.flags}g`;
120+
return new RegExp(regex.source, flags);
121+
}
122+
123+
function indexToPosition(content: string, index: number): { line: number; column: number } {
124+
let line = 1;
125+
let column = 1;
126+
127+
for (let i = 0; i < index; i += 1) {
128+
const char = content[i];
129+
if (char === '\n') {
130+
line += 1;
131+
column = 1;
132+
continue;
133+
}
134+
135+
if (char === '\r') {
136+
if (content[i + 1] === '\n') {
137+
i += 1;
138+
}
139+
line += 1;
140+
column = 1;
141+
continue;
142+
}
143+
144+
column += 1;
145+
}
146+
147+
return { line, column };
148+
}
149+
150+
function extractVersion(match: RegExpExecArray): string | null {
151+
if (match.groups && match.groups.version) {
152+
return match.groups.version;
153+
}
154+
155+
return null;
156+
}
157+
158+
export function findPythonVersionMatches(filePath: string, content: string): VersionMatch[] {
159+
const results: VersionMatch[] = [];
160+
161+
for (const pattern of pythonVersionPatterns) {
162+
if (!pattern.isFileSupported(filePath)) {
163+
continue;
164+
}
165+
166+
for (const regex of pattern.regexes) {
167+
const globalRegex = cloneRegex(regex);
168+
let match: RegExpExecArray | null;
169+
while ((match = globalRegex.exec(content)) !== null) {
170+
const version = extractVersion(match);
171+
if (!version) {
172+
continue;
173+
}
174+
175+
const [major, minor, patch] = version.split('.').map(Number);
176+
if ([major, minor, patch].some((value) => Number.isNaN(value))) {
177+
continue;
178+
}
179+
180+
const relativeIndex = match[0].indexOf(version);
181+
const versionIndex = match.index + (relativeIndex >= 0 ? relativeIndex : 0);
182+
const position = indexToPosition(content, versionIndex);
183+
184+
results.push({
185+
file: filePath,
186+
line: position.line,
187+
column: position.column,
188+
matched: version,
189+
major,
190+
minor,
191+
patch,
192+
});
193+
}
194+
}
195+
}
196+
197+
return results.sort((a, b) => {
198+
if (a.line !== b.line) {
199+
return a.line - b.line;
200+
}
201+
202+
if (a.column !== b.column) {
203+
return a.column - b.column;
204+
}
205+
206+
if (a.matched !== b.matched) {
207+
return a.matched.localeCompare(b.matched);
208+
}
209+
210+
return 0;
211+
});
212+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { findPythonVersionMatches } from '../src/scanning/patterns/python-version';
4+
5+
describe('findPythonVersionMatches', () => {
6+
it('detects python-version inputs inside workflow files', () => {
7+
const content = `jobs:\n build:\n steps:\n - uses: actions/setup-python@v4\n with:\n python-version: "3.13.2"`;
8+
9+
const matches = findPythonVersionMatches('.github/workflows/ci.yml', content);
10+
11+
expect(matches).toHaveLength(1);
12+
expect(matches[0]?.matched).toBe('3.13.2');
13+
});
14+
15+
it('captures versions within Dockerfiles', () => {
16+
const content = `FROM python:3.13.2-slim\nARG PYTHON_VERSION="3.12.8"\nENV PYTHON_VERSION=3.12.8`;
17+
18+
const matches = findPythonVersionMatches('Dockerfile', content);
19+
20+
expect(matches.map((match) => match.matched)).toEqual(['3.13.2', '3.12.8', '3.12.8']);
21+
});
22+
23+
it('reads the version from .python-version files', () => {
24+
const matches = findPythonVersionMatches('.python-version', '3.10.14\n');
25+
26+
expect(matches).toEqual([
27+
expect.objectContaining({ matched: '3.10.14', major: 3, minor: 10, patch: 14 }),
28+
]);
29+
});
30+
31+
it('handles .tool-versions python entries', () => {
32+
const content = 'python 3.11.9\nnodejs 20.12.2';
33+
const matches = findPythonVersionMatches('.tool-versions', content);
34+
35+
expect(matches).toHaveLength(1);
36+
expect(matches[0]?.matched).toBe('3.11.9');
37+
});
38+
39+
it('detects runtime.txt versions', () => {
40+
const matches = findPythonVersionMatches('runtime.txt', 'python-3.8.19');
41+
42+
expect(matches).toEqual([expect.objectContaining({ matched: '3.8.19', minor: 8 })]);
43+
});
44+
45+
it('finds pinned versions in pyproject.toml', () => {
46+
const content = `requires-python = "==3.9.18"\npython = "==3.9.18"`;
47+
const matches = findPythonVersionMatches('pyproject.toml', content);
48+
49+
expect(matches.map((match) => match.matched)).toEqual(['3.9.18', '3.9.18']);
50+
});
51+
52+
it('captures pythonVersion entries in pyproject tool tables', () => {
53+
const content = `[tool.pyright]\npythonVersion = "3.10.8"`;
54+
const matches = findPythonVersionMatches('pyproject.toml', content);
55+
56+
expect(matches.map((match) => match.matched)).toEqual(['3.10.8']);
57+
});
58+
59+
it('finds pinned versions in tox.ini', () => {
60+
const content = `python_version = 3.7.17\nbasepython = python3.7.17`;
61+
const matches = findPythonVersionMatches('tox.ini', content);
62+
63+
expect(matches.map((match) => match.matched)).toEqual(['3.7.17', '3.7.17']);
64+
});
65+
66+
it('finds pinned versions in Pipfile', () => {
67+
const content = `[requires]\npython_full_version = "3.12.1"\npython_version = "3.12.1"`;
68+
const matches = findPythonVersionMatches('Pipfile', content);
69+
70+
expect(matches.map((match) => match.matched)).toEqual(['3.12.1', '3.12.1']);
71+
});
72+
73+
it('finds pinned versions in environment.yml', () => {
74+
const content = 'dependencies:\n - python=3.9.18\n - numpy=1.24.0';
75+
const matches = findPythonVersionMatches('environment.yml', content);
76+
77+
expect(matches.map((match) => match.matched)).toEqual(['3.9.18']);
78+
});
79+
80+
it('ignores non-patch versions', () => {
81+
const content = 'python-version: "3.13"\npython=3.13\npython 3.13';
82+
const matches = findPythonVersionMatches('.github/workflows/ci.yml', content);
83+
84+
expect(matches).toHaveLength(0);
85+
});
86+
});

0 commit comments

Comments
 (0)