up
This commit is contained in:
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user