This commit is contained in:
StellaOps Bot
2025-12-09 00:20:52 +02:00
parent 3d01bf9edc
commit bc0762e97d
261 changed files with 14033 additions and 4427 deletions

View File

@@ -201,27 +201,19 @@ internal sealed class PythonImportGraph
/// </summary>
public IReadOnlyList<string>? GetTopologicalOrder()
{
var inDegree = new Dictionary<string, int>(StringComparer.Ordinal);
var dependencyCounts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var module in _modules.Keys)
{
inDegree[module] = 0;
}
foreach (var edges in _edges.Values)
{
foreach (var edge in edges)
{
if (inDegree.ContainsKey(edge.To))
{
inDegree[edge.To]++;
}
}
var count = _edges.TryGetValue(module, out var edges)
? edges.Count
: 0;
dependencyCounts[module] = count;
}
var queue = new Queue<string>();
foreach (var (module, degree) in inDegree)
foreach (var (module, count) in dependencyCounts)
{
if (degree == 0)
if (count == 0)
{
queue.Enqueue(module);
}
@@ -233,23 +225,27 @@ internal sealed class PythonImportGraph
var module = queue.Dequeue();
result.Add(module);
if (_edges.TryGetValue(module, out var edges))
if (!_reverseEdges.TryGetValue(module, out var dependents))
{
foreach (var edge in edges)
continue;
}
foreach (var edge in dependents)
{
if (!dependencyCounts.TryGetValue(edge.From, out var remaining))
{
if (inDegree.ContainsKey(edge.To))
{
inDegree[edge.To]--;
if (inDegree[edge.To] == 0)
{
queue.Enqueue(edge.To);
}
}
continue;
}
remaining--;
dependencyCounts[edge.From] = remaining;
if (remaining == 0)
{
queue.Enqueue(edge.From);
}
}
}
// If not all modules are in result, there's a cycle
return result.Count == _modules.Count ? result : null;
}
@@ -437,27 +433,23 @@ internal sealed class PythonImportGraph
{
var parts = sourceModulePath.Split('.');
// Calculate the package to start from
// Level 1 (.) = current package
// Level 2 (..) = parent package
var levelsUp = import.RelativeLevel;
// If source is not a package (__init__.py), we need to go one more level up
var sourceVirtualPath = _modules.TryGetValue(sourceModulePath, out var node) ? node.VirtualPath : null;
var isSourcePackage = sourceVirtualPath?.EndsWith("__init__.py", StringComparison.Ordinal) == true;
if (!isSourcePackage)
// Base package is the containing package of the source module
var packageParts = isSourcePackage ? parts : parts[..^1];
// RelativeLevel counts from the package boundary; level 1 = current package
var levelsUp = Math.Max(import.RelativeLevel - 1, 0);
if (levelsUp > packageParts.Length)
{
levelsUp++;
return null;
}
if (levelsUp > parts.Length)
{
return null; // Invalid relative import (goes beyond top-level package)
}
var baseParts = parts[..^(levelsUp)];
var basePackage = string.Join('.', baseParts);
var basePartsLength = packageParts.Length - levelsUp;
var basePackage = basePartsLength <= 0
? string.Empty
: string.Join('.', packageParts.Take(basePartsLength));
if (string.IsNullOrEmpty(import.Module))
{

View File

@@ -11,8 +11,9 @@ internal sealed partial class PythonSourceImportExtractor
private readonly List<PythonImport> _imports = new();
private bool _inTryBlock;
private bool _inTypeCheckingBlock;
private int _functionDepth;
private int _classDepth;
private int? _typeCheckingIndent;
private readonly Stack<int> _functionIndentStack = new();
private readonly Stack<int> _classIndentStack = new();
public PythonSourceImportExtractor(string sourceFile)
{
@@ -37,11 +38,15 @@ internal sealed partial class PythonSourceImportExtractor
var lines = content.Split('\n');
var lineNumber = 0;
var continuedLine = string.Empty;
var parenBuffer = string.Empty;
var inParenContinuation = false;
foreach (var rawLine in lines)
foreach (var rawLineOriginal in lines)
{
lineNumber++;
var line = rawLine.TrimEnd('\r');
var rawLine = rawLineOriginal.TrimEnd('\r');
var indent = CountIndentation(rawLine);
var line = rawLine;
// Handle line continuations
if (line.EndsWith('\\'))
@@ -54,7 +59,7 @@ internal sealed partial class PythonSourceImportExtractor
continuedLine = string.Empty;
// Track context
UpdateContext(fullLine.TrimStart());
UpdateContext(fullLine.TrimStart(), indent);
// Skip comments and empty lines
var trimmed = fullLine.TrimStart();
@@ -75,6 +80,29 @@ internal sealed partial class PythonSourceImportExtractor
continue;
}
// Handle parenthesized from-imports spanning multiple lines
if (inParenContinuation)
{
parenBuffer += " " + trimmed;
if (trimmed.Contains(')'))
{
inParenContinuation = false;
ExtractImports(parenBuffer, lineNumber);
parenBuffer = string.Empty;
}
continue;
}
if (trimmed.StartsWith("from ", StringComparison.Ordinal) &&
trimmed.Contains("import", StringComparison.Ordinal) &&
trimmed.Contains('(') &&
!trimmed.Contains(')'))
{
inParenContinuation = true;
parenBuffer = trimmed;
continue;
}
// Try to extract imports
ExtractImports(trimmed, lineNumber);
}
@@ -82,8 +110,24 @@ internal sealed partial class PythonSourceImportExtractor
return this;
}
private void UpdateContext(string line)
private void UpdateContext(string line, int indent)
{
// unwind function/class stacks based on indentation
while (_functionIndentStack.Count > 0 && indent <= _functionIndentStack.Peek())
{
_functionIndentStack.Pop();
}
while (_classIndentStack.Count > 0 && indent <= _classIndentStack.Peek())
{
_classIndentStack.Pop();
}
if (_typeCheckingIndent is not null && indent <= _typeCheckingIndent.Value)
{
_typeCheckingIndent = null;
}
// Track try blocks
if (line.StartsWith("try:") || line == "try")
{
@@ -94,31 +138,30 @@ internal sealed partial class PythonSourceImportExtractor
_inTryBlock = false;
}
// Track TYPE_CHECKING blocks
if (line.Contains("TYPE_CHECKING") && line.Contains("if"))
{
_inTypeCheckingBlock = true;
}
// Track function depth
if (line.StartsWith("def ") || line.StartsWith("async def "))
{
_functionDepth++;
_functionIndentStack.Push(indent);
}
// Track class depth (for nested classes)
if (line.StartsWith("class "))
{
_classDepth++;
_classIndentStack.Push(indent);
}
// Reset context at module level definitions
if ((line.StartsWith("def ") || line.StartsWith("class ") || line.StartsWith("async def ")) &&
!line.StartsWith(" ") && !line.StartsWith("\t"))
// Track TYPE_CHECKING blocks
if (line.StartsWith("if", StringComparison.Ordinal) && line.Contains("TYPE_CHECKING", StringComparison.Ordinal))
{
_typeCheckingIndent = indent;
}
_inTypeCheckingBlock = _typeCheckingIndent is not null && indent > _typeCheckingIndent.Value;
// Reset TYPE_CHECKING when hitting new top-level if
if (_typeCheckingIndent is null && indent == 0 && line.StartsWith("if ", StringComparison.Ordinal))
{
_inTypeCheckingBlock = false;
_functionDepth = 0;
_classDepth = 0;
}
}
@@ -212,7 +255,7 @@ internal sealed partial class PythonSourceImportExtractor
LineNumber: lineNumber,
Confidence: PythonImportConfidence.Definitive,
IsConditional: _inTryBlock,
IsLazy: _functionDepth > 0,
IsLazy: _functionIndentStack.Count > 0,
IsTypeCheckingOnly: _inTypeCheckingBlock));
}
}
@@ -241,7 +284,7 @@ internal sealed partial class PythonSourceImportExtractor
LineNumber: lineNumber,
Confidence: PythonImportConfidence.Definitive,
IsConditional: _inTryBlock,
IsLazy: _functionDepth > 0,
IsLazy: _functionIndentStack.Count > 0,
IsTypeCheckingOnly: _inTypeCheckingBlock));
return;
}
@@ -294,7 +337,7 @@ internal sealed partial class PythonSourceImportExtractor
LineNumber: lineNumber,
Confidence: PythonImportConfidence.Definitive,
IsConditional: _inTryBlock,
IsLazy: _functionDepth > 0,
IsLazy: _functionIndentStack.Count > 0,
IsTypeCheckingOnly: _inTypeCheckingBlock));
}
@@ -324,7 +367,7 @@ internal sealed partial class PythonSourceImportExtractor
LineNumber: lineNumber,
Confidence: PythonImportConfidence.High,
IsConditional: _inTryBlock,
IsLazy: _functionDepth > 0,
IsLazy: _functionIndentStack.Count > 0,
IsTypeCheckingOnly: _inTypeCheckingBlock));
}
@@ -342,7 +385,7 @@ internal sealed partial class PythonSourceImportExtractor
LineNumber: lineNumber,
Confidence: PythonImportConfidence.High,
IsConditional: _inTryBlock,
IsLazy: _functionDepth > 0,
IsLazy: _functionIndentStack.Count > 0,
IsTypeCheckingOnly: _inTypeCheckingBlock));
}
@@ -359,10 +402,32 @@ internal sealed partial class PythonSourceImportExtractor
LineNumber: lineNumber,
Confidence: PythonImportConfidence.Medium,
IsConditional: _inTryBlock,
IsLazy: _functionDepth > 0,
IsLazy: _functionIndentStack.Count > 0,
IsTypeCheckingOnly: _inTypeCheckingBlock));
}
private static int CountIndentation(string line)
{
var count = 0;
foreach (var c in line)
{
if (c == ' ')
{
count++;
}
else if (c == '\t')
{
count += 4;
}
else
{
break;
}
}
return count;
}
private static int FindCommentStart(string line)
{
var inSingleQuote = false;

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Linq;
namespace StellaOps.Scanner.Analyzers.Lang.Python.Internal.VirtualFileSystem;
@@ -276,10 +277,10 @@ internal sealed partial class PythonInputNormalizer
var version = parts[1].Trim();
if (!string.IsNullOrEmpty(version))
{
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"pyvenv.cfg",
PythonVersionConfidence.Definitive));
PythonVersionConfidence.Definitive);
}
}
}
@@ -304,8 +305,7 @@ internal sealed partial class PythonInputNormalizer
var content = await File.ReadAllTextAsync(path, cancellationToken).ConfigureAwait(false);
// Look for requires-python in [project] section
var requiresPythonMatch = RequiresPythonPattern().Match(content);
if (requiresPythonMatch.Success)
foreach (Match requiresPythonMatch in RequiresPythonPattern().Matches(content))
{
var version = requiresPythonMatch.Groups["version"].Value.Trim().Trim('"', '\'');
if (!string.IsNullOrEmpty(version))
@@ -314,17 +314,16 @@ internal sealed partial class PythonInputNormalizer
version.StartsWith(">", StringComparison.Ordinal);
version = Regex.Replace(version, @"^[><=!~]+", string.Empty).Trim();
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"pyproject.toml",
PythonVersionConfidence.High,
isMinimum));
isMinimum);
}
}
// Look for python_requires in [tool.poetry] or similar
var pythonMatch = PythonVersionTomlPattern().Match(content);
if (pythonMatch.Success)
foreach (Match pythonMatch in PythonVersionTomlPattern().Matches(content))
{
var version = pythonMatch.Groups["version"].Value.Trim().Trim('"', '\'');
if (!string.IsNullOrEmpty(version))
@@ -333,11 +332,11 @@ internal sealed partial class PythonInputNormalizer
version.StartsWith(">=", StringComparison.Ordinal);
version = Regex.Replace(version, @"^[\^><=!~]+", string.Empty).Trim();
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"pyproject.toml",
PythonVersionConfidence.High,
isMinimum));
isMinimum);
}
}
}
@@ -367,11 +366,11 @@ internal sealed partial class PythonInputNormalizer
var isMinimum = version.StartsWith(">=", StringComparison.Ordinal);
version = Regex.Replace(version, @"^[><=!~]+", string.Empty).Trim();
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"setup.py",
PythonVersionConfidence.High,
isMinimum));
isMinimum);
}
}
}
@@ -401,11 +400,11 @@ internal sealed partial class PythonInputNormalizer
var isMinimum = version.StartsWith(">=", StringComparison.Ordinal);
version = Regex.Replace(version, @"^[><=!~]+", string.Empty).Trim();
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"setup.cfg",
PythonVersionConfidence.High,
isMinimum));
isMinimum);
}
}
}
@@ -433,10 +432,10 @@ internal sealed partial class PythonInputNormalizer
if (match.Success)
{
var version = match.Groups["version"].Value;
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"runtime.txt",
PythonVersionConfidence.Definitive));
PythonVersionConfidence.Definitive);
}
}
catch (IOException)
@@ -462,10 +461,10 @@ internal sealed partial class PythonInputNormalizer
if (fromMatch.Success)
{
var version = fromMatch.Groups["version"].Value;
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"Dockerfile",
PythonVersionConfidence.High));
PythonVersionConfidence.High);
return;
}
@@ -474,10 +473,10 @@ internal sealed partial class PythonInputNormalizer
if (envMatch.Success)
{
var version = envMatch.Groups["version"].Value;
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
"Dockerfile",
PythonVersionConfidence.Medium));
PythonVersionConfidence.Medium);
}
}
catch (IOException)
@@ -509,10 +508,10 @@ internal sealed partial class PythonInputNormalizer
{
// Convert py311 to 3.11
var formatted = $"{version[0]}.{version[1..]}";
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
formatted,
"tox.ini",
PythonVersionConfidence.Medium));
PythonVersionConfidence.Medium);
}
}
}
@@ -553,10 +552,10 @@ internal sealed partial class PythonInputNormalizer
if (match.Success)
{
var version = match.Groups["version"].Value;
_versionTargets.Add(new PythonVersionTarget(
AddVersionTarget(
version,
$"lib/{dirName}",
PythonVersionConfidence.Medium));
PythonVersionConfidence.Medium);
}
}
}
@@ -659,6 +658,46 @@ internal sealed partial class PythonInputNormalizer
}
}
private void AddVersionTarget(string version, string source, PythonVersionConfidence confidence, bool isMinimum = false)
{
if (string.IsNullOrWhiteSpace(version) || string.IsNullOrWhiteSpace(source))
{
return;
}
var normalizedVersion = version.Trim();
var normalizedSource = source.Trim();
var existingIndex = _versionTargets.FindIndex(target =>
string.Equals(target.Version, normalizedVersion, StringComparison.OrdinalIgnoreCase) &&
string.Equals(target.Source, normalizedSource, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
var existing = _versionTargets[existingIndex];
var effectiveConfidence = (PythonVersionConfidence)Math.Max((int)existing.Confidence, (int)confidence);
var effectiveIsMinimum = existing.IsMinimum || isMinimum;
if (existing.Confidence == effectiveConfidence && existing.IsMinimum == effectiveIsMinimum)
{
return;
}
_versionTargets[existingIndex] = new PythonVersionTarget(
normalizedVersion,
normalizedSource,
effectiveConfidence,
effectiveIsMinimum);
return;
}
_versionTargets.Add(new PythonVersionTarget(
normalizedVersion,
normalizedSource,
confidence,
isMinimum));
}
private void DetectZipapps()
{
if (!Directory.Exists(_rootPath))
@@ -779,7 +818,7 @@ internal sealed partial class PythonInputNormalizer
[GeneratedRegex(@"requires-python\s*=\s*[""']?(?<version>[^""'\n]+)", RegexOptions.IgnoreCase)]
private static partial Regex RequiresPythonPattern();
[GeneratedRegex(@"python\s*=\s*[""'](?<version>[^""']+)[""']", RegexOptions.IgnoreCase)]
[GeneratedRegex(@"^\s*python\s*=\s*[""'](?<version>[^""']+)[""']", RegexOptions.IgnoreCase | RegexOptions.Multiline)]
private static partial Regex PythonVersionTomlPattern();
[GeneratedRegex(@"python_requires\s*=\s*[""'](?<version>[^""']+)[""']", RegexOptions.IgnoreCase)]