Skip to content

Commit 478d322

Browse files
feat: implement scanForPythonVersions function and tests for discovering Python version matches across various files
1 parent 5237a7c commit 478d322

File tree

12 files changed

+141
-3
lines changed

12 files changed

+141
-3
lines changed

docs/tasks.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Use Context7 MCP for up to date documentation.
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

120-
12. [ ] **Scanner module**
120+
12. [x] **Scanner module**
121121
Collect matches with file, position, `X.Y.Z`, `X.Y`.
122122
Verify: Snapshot test over fixture repo.
123123

src/scanning/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export { discoverFiles } from './glob-discovery';
22
export { findPythonVersionMatches, pythonVersionPatterns } from './patterns/python-version';
33
export type { VersionMatch } from './patterns/python-version';
4+
5+
export { scanForPythonVersions } from './scanner';
6+
export type { ScanOptions, ScanResult } from './scanner';

src/scanning/patterns/python-version.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export const pythonVersionPatterns: PatternDefinition[] = [
2727
const isYaml = normalized.endsWith('.yml') || normalized.endsWith('.yaml');
2828
return isWorkflowFile && isYaml;
2929
},
30-
regexes: [new RegExp(`python-version\\s*:\\s*["]?(?<version>${VERSION_PATTERN})["]?`, 'gi')],
30+
regexes: [
31+
new RegExp(String.raw`python-version\s*:\s*['"]?(?<version>${VERSION_PATTERN})['"]?`, 'gi'),
32+
],
3133
},
3234
{
3335
id: 'dockerfile-from',
@@ -36,7 +38,7 @@ export const pythonVersionPatterns: PatternDefinition[] = [
3638
regexes: [
3739
new RegExp(`FROM\\s+[^\\s]*python[^\\s:]*:(?<version>${VERSION_PATTERN})`, 'gi'),
3840
new RegExp(
39-
`\\b(?:ARG|ENV)\\s+PYTHON[_-]?VERSION\\s*=\\s*["]?(?<version>${VERSION_PATTERN})["]?`,
41+
String.raw`\b(?:ARG|ENV)\s+PYTHON[_-]?VERSION\s*=\s*['"]?(?<version>${VERSION_PATTERN})['"]?`,
4042
'gi',
4143
),
4244
],

src/scanning/scanner.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { readFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
import { discoverFiles } from './glob-discovery';
5+
import { findPythonVersionMatches, type VersionMatch } from './patterns/python-version';
6+
7+
export interface ScanOptions {
8+
/** Absolute root directory to scan. */
9+
root: string;
10+
/** Glob patterns to include relative to the root. */
11+
patterns: string[];
12+
/** Additional ignore patterns. */
13+
ignore?: string[];
14+
/** Follow symbolic links. Defaults to false. */
15+
followSymbolicLinks?: boolean;
16+
}
17+
18+
export interface ScanResult {
19+
filesScanned: number;
20+
matches: VersionMatch[];
21+
}
22+
23+
async function readFileSafe(filePath: string): Promise<string | null> {
24+
try {
25+
return await readFile(filePath, 'utf8');
26+
} catch (error) {
27+
const err = error as { code?: string };
28+
if (err && err.code === 'ENOENT') {
29+
return null;
30+
}
31+
throw error;
32+
}
33+
}
34+
35+
export async function scanForPythonVersions(options: ScanOptions): Promise<ScanResult> {
36+
const { root, patterns, ignore, followSymbolicLinks } = options;
37+
const relativeFiles = await discoverFiles({
38+
root,
39+
patterns,
40+
ignore,
41+
followSymbolicLinks,
42+
});
43+
44+
const matches: VersionMatch[] = [];
45+
let filesScanned = 0;
46+
47+
for (const relative of relativeFiles) {
48+
const absolute = path.join(root, relative);
49+
const content = await readFileSafe(absolute);
50+
if (content === null) {
51+
continue;
52+
}
53+
54+
filesScanned += 1;
55+
const fileMatches = findPythonVersionMatches(relative, content);
56+
matches.push(
57+
...fileMatches.map((match) => ({
58+
...match,
59+
file: relative,
60+
})),
61+
);
62+
}
63+
64+
matches.sort((a, b) => {
65+
const fileCompare = a.file.localeCompare(b.file);
66+
if (fileCompare !== 0) {
67+
return fileCompare;
68+
}
69+
if (a.line !== b.line) {
70+
return a.line - b.line;
71+
}
72+
if (a.column !== b.column) {
73+
return a.column - b.column;
74+
}
75+
return 0;
76+
});
77+
78+
return { filesScanned, matches };
79+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
jobs:
2+
test:
3+
steps:
4+
- uses: actions/setup-python@v4
5+
with:
6+
python-version: '3.12.2'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.10.12
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
python 3.9.18
2+
nodejs 20.12.2

tests/fixtures/scanner/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM python:3.13.4-slim
2+
ARG PYTHON_VERSION="3.8.19"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
dependencies:
2+
- python=3.12.1
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
requires-python = ">=3.7"
2+
python = "==3.7.17"
3+
4+
[tool.pyright]
5+
pythonVersion = "3.7.17"

0 commit comments

Comments
 (0)