diff --git a/src/Cli/func/Actions/LocalActions/PackAction/PythonPackSubcommandAction.cs b/src/Cli/func/Actions/LocalActions/PackAction/PythonPackSubcommandAction.cs
index aa21bb6a7..e4b86210a 100644
--- a/src/Cli/func/Actions/LocalActions/PackAction/PythonPackSubcommandAction.cs
+++ b/src/Cli/func/Actions/LocalActions/PackAction/PythonPackSubcommandAction.cs
@@ -44,7 +44,7 @@ protected internal override void ValidateFunctionApp(string functionAppRoot, Pac
dir => PackValidationHelper.RunInvalidFlagComboValidation(
options.NoBuild && BuildNativeDeps,
"Invalid options: --no-build cannot be used with --build-native-deps."),
- dir => PackValidationHelper.RunRequiredFilesValidation(dir, new[] { "requirements.txt" }, "Validate Folder Structure"),
+ dir => RunPythonDependencyFilesValidation(dir),
dir =>
{
// Validate .python_packages directory exists and is not empty
@@ -173,5 +173,31 @@ public static void RunPythonProgrammingModelValidation(string directory)
throw new CliException(modelError);
}
}
+
+ ///
+ /// Runs a Python dependency files validation and displays results.
+ /// Validates that the project has either requirements.txt OR pyproject.toml (with optional uv.lock).
+ /// Throws CliException if validation fails.
+ ///
+ internal static void RunPythonDependencyFilesValidation(string directory)
+ {
+ var hasRequirementsTxt = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.RequirementsTxt));
+ var hasPyProjectToml = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.PyProjectToml));
+ var hasUvLock = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.UvLock));
+
+ var hasDependencyFiles = hasRequirementsTxt || hasPyProjectToml;
+
+ PackValidationHelper.DisplayValidationResult(
+ "Validate Folder Structure",
+ hasDependencyFiles);
+
+ if (!hasDependencyFiles)
+ {
+ PackValidationHelper.DisplayValidationEnd();
+ throw new CliException(
+ $"Required dependency file(s) not found in {directory}. " +
+ $"Python projects require either '{Constants.RequirementsTxt}' or '{Constants.PyProjectToml}' (optionally with '{Constants.UvLock}').");
+ }
+ }
}
}
diff --git a/src/Cli/func/Common/Constants.cs b/src/Cli/func/Common/Constants.cs
index 9a53376f6..e25bf2365 100644
--- a/src/Cli/func/Common/Constants.cs
+++ b/src/Cli/func/Common/Constants.cs
@@ -22,6 +22,8 @@ internal static partial class Constants
public const string FunctionsWorkerRuntime = "FUNCTIONS_WORKER_RUNTIME";
public const string FunctionsWorkerRuntimeVersion = "FUNCTIONS_WORKER_RUNTIME_VERSION";
public const string RequirementsTxt = "requirements.txt";
+ public const string PyProjectToml = "pyproject.toml";
+ public const string UvLock = "uv.lock";
public const string PythonGettingStarted = "getting_started.md";
public const string PySteinFunctionAppPy = "function_app.py";
public const string FunctionJsonFileName = "function.json";
diff --git a/src/Cli/func/Helpers/PythonHelpers.cs b/src/Cli/func/Helpers/PythonHelpers.cs
index 7e379e471..423fb2285 100644
--- a/src/Cli/func/Helpers/PythonHelpers.cs
+++ b/src/Cli/func/Helpers/PythonHelpers.cs
@@ -10,6 +10,14 @@
namespace Azure.Functions.Cli.Helpers
{
+ public enum PythonDependencyManager
+ {
+ Unknown,
+ Pip,
+ Poetry,
+ Uv
+ }
+
public static class PythonHelpers
{
private static readonly string _pythonDefaultExecutableVar = "languageWorkers:python:defaultExecutablePath";
@@ -19,6 +27,49 @@ public static class PythonHelpers
public static string VirtualEnvironmentPath => Environment.GetEnvironmentVariable("VIRTUAL_ENV");
+ ///
+ /// Determines which Python dependency manager to use based on the files present in the directory.
+ /// Priority: uv (if pyproject.toml + uv.lock) > poetry (if pyproject.toml only) > pip (if requirements.txt)
+ ///
+ public static PythonDependencyManager DetectPythonDependencyManager(string directory)
+ {
+ var hasPyProjectToml = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.PyProjectToml));
+ var hasUvLock = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.UvLock));
+ var hasRequirementsTxt = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.RequirementsTxt));
+
+ // Priority 1: uv (requires both pyproject.toml and uv.lock)
+ if (hasPyProjectToml && hasUvLock)
+ {
+ return PythonDependencyManager.Uv;
+ }
+
+ // Priority 2: poetry (requires pyproject.toml without uv.lock)
+ if (hasPyProjectToml)
+ {
+ return PythonDependencyManager.Poetry;
+ }
+
+ // Priority 3: pip (requires requirements.txt)
+ if (hasRequirementsTxt)
+ {
+ return PythonDependencyManager.Pip;
+ }
+
+ return PythonDependencyManager.Unknown;
+ }
+
+ ///
+ /// Checks if the directory has the necessary dependency files for Python packaging.
+ /// Returns true if requirements.txt OR (pyproject.toml and optionally uv.lock) exists.
+ ///
+ public static bool HasPythonDependencyFiles(string directory)
+ {
+ var hasRequirementsTxt = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.RequirementsTxt));
+ var hasPyProjectToml = FileSystemHelpers.FileExists(Path.Combine(directory, Constants.PyProjectToml));
+
+ return hasRequirementsTxt || hasPyProjectToml;
+ }
+
public static async Task SetupPythonProject(ProgrammingModel programmingModel, bool generatePythonDocumentation = true)
{
var pyVersion = await GetEnvironmentPythonVersion();
@@ -391,37 +442,96 @@ public static async Task ZipToSquashfsStream(Stream stream)
}
}
- private static async Task ArePackagesInSync(string requirementsTxt, string pythonPackages)
+ private static async Task ArePackagesInSync(string functionAppRoot, string pythonPackages, PythonDependencyManager dependencyManager)
{
- var md5File = Path.Combine(pythonPackages, $"{Constants.RequirementsTxt}.md5");
+ var md5FileName = GetDependencyMd5FileName(dependencyManager);
+ var md5File = Path.Combine(pythonPackages, md5FileName);
if (!FileSystemHelpers.FileExists(md5File))
{
return false;
}
var packagesMd5 = await FileSystemHelpers.ReadAllTextFromFileAsync(md5File);
- var requirementsTxtMd5 = SecurityHelpers.CalculateMd5(requirementsTxt);
+ var currentMd5 = CalculateDependencyChecksum(functionAppRoot, dependencyManager);
+
+ return packagesMd5 == currentMd5;
+ }
+
+ private static string GetDependencyFileName(PythonDependencyManager dependencyManager)
+ {
+ return dependencyManager switch
+ {
+ PythonDependencyManager.Pip => Constants.RequirementsTxt,
+ PythonDependencyManager.Poetry => Constants.PyProjectToml,
+ PythonDependencyManager.Uv => $"{Constants.PyProjectToml} + {Constants.UvLock}",
+ _ => "unknown dependency file"
+ };
+ }
+
+ private static string GetDependencyMd5FileName(PythonDependencyManager dependencyManager)
+ {
+ return dependencyManager switch
+ {
+ PythonDependencyManager.Pip => $"{Constants.RequirementsTxt}.md5",
+ PythonDependencyManager.Poetry => $"{Constants.PyProjectToml}.md5",
+ PythonDependencyManager.Uv => "uv.md5",
+ _ => "dependencies.md5"
+ };
+ }
+
+ private static string CalculateDependencyChecksum(string functionAppRoot, PythonDependencyManager dependencyManager)
+ {
+ switch (dependencyManager)
+ {
+ case PythonDependencyManager.Pip:
+ var reqTxtFile = Path.Combine(functionAppRoot, Constants.RequirementsTxt);
+ return SecurityHelpers.CalculateMd5(reqTxtFile);
+
+ case PythonDependencyManager.Poetry:
+ var pyProjectFile = Path.Combine(functionAppRoot, Constants.PyProjectToml);
+ return SecurityHelpers.CalculateMd5(pyProjectFile);
+
+ case PythonDependencyManager.Uv:
+ // For uv, combine checksums of both pyproject.toml and uv.lock
+ var pyProjectFile2 = Path.Combine(functionAppRoot, Constants.PyProjectToml);
+ var uvLockFile = Path.Combine(functionAppRoot, Constants.UvLock);
+ var pyProjectMd5 = SecurityHelpers.CalculateMd5(pyProjectFile2);
+ var uvLockMd5 = SecurityHelpers.CalculateMd5(uvLockFile);
+ return $"{pyProjectMd5}:{uvLockMd5}";
+
+ default:
+ return string.Empty;
+ }
+ }
- return packagesMd5 == requirementsTxtMd5;
+ private static async Task StoreDependencyChecksum(string functionAppRoot, string packagesLocation, PythonDependencyManager dependencyManager)
+ {
+ var md5FileName = GetDependencyMd5FileName(dependencyManager);
+ var md5FilePath = Path.Combine(packagesLocation, md5FileName);
+ var checksum = CalculateDependencyChecksum(functionAppRoot, dependencyManager);
+ await FileSystemHelpers.WriteAllTextToFileAsync(md5FilePath, checksum);
}
internal static async Task GetPythonDeploymentPackage(IEnumerable files, string functionAppRoot, bool buildNativeDeps, BuildOption buildOption, string additionalPackages)
{
- var reqTxtFile = Path.Combine(functionAppRoot, Constants.RequirementsTxt);
- if (!FileSystemHelpers.FileExists(reqTxtFile))
+ // Detect which dependency manager to use based on files present
+ var dependencyManager = DetectPythonDependencyManager(functionAppRoot);
+
+ if (dependencyManager == PythonDependencyManager.Unknown)
{
- throw new CliException($"{Constants.RequirementsTxt} is not found. " +
- $"{Constants.RequirementsTxt} is required for python function apps. Please make sure to generate one before publishing.");
+ throw new CliException($"No Python dependency files found. " +
+ $"Python function apps require either '{Constants.RequirementsTxt}' or '{Constants.PyProjectToml}' (optionally with '{Constants.UvLock}').");
}
var packagesLocation = Path.Combine(functionAppRoot, Constants.ExternalPythonPackages);
if (FileSystemHelpers.DirectoryExists(packagesLocation))
{
- // Only update packages if checksum of requirements.txt does not match
+ // Only update packages if checksum of dependency files does not match
// If build option is remote, we don't need to verify if packages are in sync, as we need to delete them regardless
- if (buildOption != BuildOption.Remote && await ArePackagesInSync(reqTxtFile, packagesLocation))
+ if (buildOption != BuildOption.Remote && await ArePackagesInSync(functionAppRoot, packagesLocation, dependencyManager))
{
- ColoredConsole.WriteLine(WarningColor($"Directory {Constants.ExternalPythonPackages} already in sync with {Constants.RequirementsTxt}. Skipping restoring dependencies..."));
+ var dependencyFileName = GetDependencyFileName(dependencyManager);
+ ColoredConsole.WriteLine(WarningColor($"Directory {Constants.ExternalPythonPackages} already in sync with {dependencyFileName}. Skipping restoring dependencies..."));
return await ZipHelper.CreateZip(files.Union(FileSystemHelpers.GetFiles(packagesLocation)), functionAppRoot, Enumerable.Empty());
}
@@ -436,7 +546,7 @@ internal static async Task GetPythonDeploymentPackage(IEnumerable GetPythonDeploymentPackage(IEnumerable());
}
+ private static async Task RestorePythonRequirements(string functionAppRoot, string packagesLocation, PythonDependencyManager dependencyManager)
+ {
+ switch (dependencyManager)
+ {
+ case PythonDependencyManager.Pip:
+ await RestorePythonRequirementsWithPip(functionAppRoot, packagesLocation);
+ break;
+
+ case PythonDependencyManager.Poetry:
+ await RestorePythonRequirementsWithPoetry(functionAppRoot, packagesLocation);
+ break;
+
+ case PythonDependencyManager.Uv:
+ await RestorePythonRequirementsWithUv(functionAppRoot, packagesLocation);
+ break;
+
+ default:
+ throw new CliException("Unable to determine Python dependency manager. Please ensure you have requirements.txt or pyproject.toml in your project.");
+ }
+ }
+
+ private static async Task RestorePythonRequirementsWithPip(string functionAppRoot, string packagesLocation)
+ {
+ var pythonWorkerInfo = await GetEnvironmentPythonVersion();
+ AssertPythonVersion(pythonWorkerInfo, errorIfNoVersion: true);
+ var pythonExe = pythonWorkerInfo.ExecutablePath;
+
+ var requirementsTxt = Path.Combine(functionAppRoot, Constants.RequirementsTxt);
+ var exe = new Executable(pythonExe, $"-m pip download -r \"{requirementsTxt}\" --dest \"{packagesLocation}\"");
+ var sbErrors = new StringBuilder();
+
+ ColoredConsole.WriteLine($"{pythonExe} -m pip download -r {requirementsTxt} --dest {packagesLocation}");
+ var exitCode = await exe.RunAsync(o => ColoredConsole.WriteLine(o), e => sbErrors.AppendLine(e));
+
+ if (exitCode != 0)
+ {
+ var errorMessage = "There was an error restoring dependencies. " + sbErrors.ToString();
+ throw new CliException(errorMessage);
+ }
+ }
+
+ private static async Task RestorePythonRequirementsWithPoetry(string functionAppRoot, string packagesLocation)
+ {
+ // Check if poetry is installed
+ if (!CommandChecker.CommandExists("poetry"))
+ {
+ throw new CliException("Poetry is not installed. Please install poetry to use pyproject.toml for dependency management. " +
+ "Alternatively, generate a requirements.txt file from your pyproject.toml.");
+ }
+
+ var pythonWorkerInfo = await GetEnvironmentPythonVersion();
+ AssertPythonVersion(pythonWorkerInfo, errorIfNoVersion: true);
+
+ // Use poetry to export dependencies to a temporary requirements.txt, then use pip to download
+ var tempRequirementsTxt = Path.Combine(Path.GetTempPath(), $"requirements-{Guid.NewGuid()}.txt");
+ try
+ {
+ // Export dependencies from poetry - run from function app root where pyproject.toml is located
+ var poetryExe = new Executable("poetry", $"export -f requirements.txt --output \"{tempRequirementsTxt}\" --without-hashes", workingDirectory: functionAppRoot);
+ var sbPoetryErrors = new StringBuilder();
+
+ ColoredConsole.WriteLine($"poetry export -f requirements.txt --output {tempRequirementsTxt} --without-hashes");
+ var poetryExitCode = await poetryExe.RunAsync(o => ColoredConsole.WriteLine(o), e => sbPoetryErrors.AppendLine(e));
+
+ if (poetryExitCode != 0)
+ {
+ throw new CliException("There was an error exporting dependencies from poetry. " + sbPoetryErrors.ToString());
+ }
+
+ // Download packages using pip
+ var pythonExe = pythonWorkerInfo.ExecutablePath;
+ var pipExe = new Executable(pythonExe, $"-m pip download -r \"{tempRequirementsTxt}\" --dest \"{packagesLocation}\"");
+ var sbPipErrors = new StringBuilder();
+
+ ColoredConsole.WriteLine($"{pythonExe} -m pip download -r {tempRequirementsTxt} --dest {packagesLocation}");
+ var pipExitCode = await pipExe.RunAsync(o => ColoredConsole.WriteLine(o), e => sbPipErrors.AppendLine(e));
+
+ if (pipExitCode != 0)
+ {
+ throw new CliException("There was an error downloading dependencies. " + sbPipErrors.ToString());
+ }
+ }
+ finally
+ {
+ // Clean up temporary file
+ if (FileSystemHelpers.FileExists(tempRequirementsTxt))
+ {
+ FileSystemHelpers.FileDelete(tempRequirementsTxt);
+ }
+ }
+ }
+
+ private static async Task RestorePythonRequirementsWithUv(string functionAppRoot, string packagesLocation)
+ {
+ // Check if uv is installed
+ if (!CommandChecker.CommandExists("uv"))
+ {
+ throw new CliException("uv is not installed. Please install uv to use uv.lock for dependency management. " +
+ "Alternatively, generate a requirements.txt file from your pyproject.toml.");
+ }
+
+ var pythonWorkerInfo = await GetEnvironmentPythonVersion();
+ AssertPythonVersion(pythonWorkerInfo, errorIfNoVersion: true);
+
+ // Use uv to export dependencies to a temporary requirements.txt, then use pip to download
+ var tempRequirementsTxt = Path.Combine(Path.GetTempPath(), $"requirements-{Guid.NewGuid()}.txt");
+ try
+ {
+ // Export dependencies from uv - run from function app root where pyproject.toml and uv.lock are located
+ var uvExe = new Executable("uv", $"export --format requirements-txt --output-file \"{tempRequirementsTxt}\" --no-hashes", workingDirectory: functionAppRoot);
+ var sbUvErrors = new StringBuilder();
+
+ ColoredConsole.WriteLine($"uv export --format requirements-txt --output-file {tempRequirementsTxt} --no-hashes");
+ var uvExitCode = await uvExe.RunAsync(o => ColoredConsole.WriteLine(o), e => sbUvErrors.AppendLine(e));
+
+ if (uvExitCode != 0)
+ {
+ throw new CliException("There was an error exporting dependencies from uv. " + sbUvErrors.ToString());
+ }
+
+ // Download packages using pip
+ var pythonExe = pythonWorkerInfo.ExecutablePath;
+ var pipExe = new Executable(pythonExe, $"-m pip download -r \"{tempRequirementsTxt}\" --dest \"{packagesLocation}\"");
+ var sbPipErrors = new StringBuilder();
+
+ ColoredConsole.WriteLine($"{pythonExe} -m pip download -r {tempRequirementsTxt} --dest {packagesLocation}");
+ var pipExitCode = await pipExe.RunAsync(o => ColoredConsole.WriteLine(o), e => sbPipErrors.AppendLine(e));
+
+ if (pipExitCode != 0)
+ {
+ throw new CliException("There was an error downloading dependencies. " + sbPipErrors.ToString());
+ }
+ }
+ finally
+ {
+ // Clean up temporary file
+ if (FileSystemHelpers.FileExists(tempRequirementsTxt))
+ {
+ FileSystemHelpers.FileDelete(tempRequirementsTxt);
+ }
+ }
+ }
+
private static async Task RestorePythonRequirementsPackapp(string functionAppRoot, string packagesLocation)
{
var packApp = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "tools", "python", "packapp");
@@ -490,7 +742,66 @@ private static async Task RestorePythonRequirementsPackapp(string functionAppRoo
}
}
- private static async Task RestorePythonRequirementsDocker(string functionAppRoot, string packagesLocation, string additionalPackages)
+ private static async Task RestorePythonRequirementsDocker(string functionAppRoot, string packagesLocation, string additionalPackages, PythonDependencyManager dependencyManager)
+ {
+ // For Docker builds with native dependencies, we need requirements.txt
+ // If using poetry or uv, we'll export to requirements.txt first
+ string requirementsTxtPath = Path.Combine(functionAppRoot, Constants.RequirementsTxt);
+ bool createdTempRequirementsTxt = false;
+
+ try
+ {
+ if (dependencyManager == PythonDependencyManager.Poetry)
+ {
+ // Export from poetry to requirements.txt - run from function app root
+ if (!CommandChecker.CommandExists("poetry"))
+ {
+ throw new CliException("Poetry is not installed. Please install poetry or use --no-build flag.");
+ }
+
+ var poetryExe = new Executable("poetry", $"export -f requirements.txt --output \"{requirementsTxtPath}\" --without-hashes", workingDirectory: functionAppRoot);
+ var sbErrors = new StringBuilder();
+ var exitCode = await poetryExe.RunAsync(o => ColoredConsole.WriteLine(o), e => sbErrors.AppendLine(e));
+
+ if (exitCode != 0)
+ {
+ throw new CliException("There was an error exporting dependencies from poetry. " + sbErrors.ToString());
+ }
+ createdTempRequirementsTxt = true;
+ }
+ else if (dependencyManager == PythonDependencyManager.Uv)
+ {
+ // Export from uv to requirements.txt - run from function app root
+ if (!CommandChecker.CommandExists("uv"))
+ {
+ throw new CliException("uv is not installed. Please install uv or use --no-build flag.");
+ }
+
+ var uvExe = new Executable("uv", $"export --format requirements-txt --output-file \"{requirementsTxtPath}\" --no-hashes", workingDirectory: functionAppRoot);
+ var sbErrors = new StringBuilder();
+ var exitCode = await uvExe.RunAsync(o => ColoredConsole.WriteLine(o), e => sbErrors.AppendLine(e));
+
+ if (exitCode != 0)
+ {
+ throw new CliException("There was an error exporting dependencies from uv. " + sbErrors.ToString());
+ }
+ createdTempRequirementsTxt = true;
+ }
+
+ // Now proceed with Docker build using requirements.txt
+ await RestorePythonRequirementsDockerInternal(functionAppRoot, packagesLocation, additionalPackages);
+ }
+ finally
+ {
+ // Clean up temporary requirements.txt if we created it
+ if (createdTempRequirementsTxt && FileSystemHelpers.FileExists(requirementsTxtPath))
+ {
+ FileSystemHelpers.FileDelete(requirementsTxtPath);
+ }
+ }
+ }
+
+ private static async Task RestorePythonRequirementsDockerInternal(string functionAppRoot, string packagesLocation, string additionalPackages)
{
// Configurable settings
var pythonDockerImageSetting = Environment.GetEnvironmentVariable(Constants.PythonDockerImageVersionSetting);
diff --git a/test/Cli/Func.UnitTests/ActionsTests/PackAction/PythonPackSubcommandActionTests.cs b/test/Cli/Func.UnitTests/ActionsTests/PackAction/PythonPackSubcommandActionTests.cs
index 98dd60d75..af76e4951 100644
--- a/test/Cli/Func.UnitTests/ActionsTests/PackAction/PythonPackSubcommandActionTests.cs
+++ b/test/Cli/Func.UnitTests/ActionsTests/PackAction/PythonPackSubcommandActionTests.cs
@@ -2,6 +2,7 @@
// Licensed under the MIT License. See LICENSE in the project root for license information.
using Azure.Functions.Cli.Actions.LocalActions.PackAction;
+using Azure.Functions.Cli.Common;
using Newtonsoft.Json.Linq;
using Xunit;
@@ -105,5 +106,40 @@ public void ValidatePythonProgrammingModel_NoV1OrV2Files_ReturnsFalse()
Assert.False(result);
Assert.Contains("Did not find either", errorMessage);
}
+
+ [Fact]
+ public void RunPythonDependencyFilesValidation_WithRequirementsTxt_Passes()
+ {
+ File.WriteAllText(Path.Combine(_tempDirectory, Constants.RequirementsTxt), "flask==2.0.0");
+ // Should not throw
+ PythonPackSubcommandAction.RunPythonDependencyFilesValidation(_tempDirectory);
+ }
+
+ [Fact]
+ public void RunPythonDependencyFilesValidation_WithPyProjectToml_Passes()
+ {
+ File.WriteAllText(Path.Combine(_tempDirectory, Constants.PyProjectToml), "[tool.poetry]\nname = \"test\"");
+ // Should not throw
+ PythonPackSubcommandAction.RunPythonDependencyFilesValidation(_tempDirectory);
+ }
+
+ [Fact]
+ public void RunPythonDependencyFilesValidation_WithPyProjectTomlAndUvLock_Passes()
+ {
+ File.WriteAllText(Path.Combine(_tempDirectory, Constants.PyProjectToml), "[tool.poetry]\nname = \"test\"");
+ File.WriteAllText(Path.Combine(_tempDirectory, Constants.UvLock), "version = 1");
+ // Should not throw
+ PythonPackSubcommandAction.RunPythonDependencyFilesValidation(_tempDirectory);
+ }
+
+ [Fact]
+ public void RunPythonDependencyFilesValidation_WithNoDependencyFiles_Throws()
+ {
+ var exception = Assert.Throws(() =>
+ PythonPackSubcommandAction.RunPythonDependencyFilesValidation(_tempDirectory));
+ Assert.Contains("Required dependency file(s) not found", exception.Message);
+ Assert.Contains("requirements.txt", exception.Message);
+ Assert.Contains("pyproject.toml", exception.Message);
+ }
}
}
diff --git a/test/Cli/Func.UnitTests/HelperTests/PythonHelperTests.cs b/test/Cli/Func.UnitTests/HelperTests/PythonHelperTests.cs
index 0d49631de..5b4e48e83 100644
--- a/test/Cli/Func.UnitTests/HelperTests/PythonHelperTests.cs
+++ b/test/Cli/Func.UnitTests/HelperTests/PythonHelperTests.cs
@@ -91,6 +91,164 @@ public void AssertPythonVersion(string pythonVersion, bool expectException)
Assert.Throws(() => PythonHelpers.AssertPythonVersion(worker));
}
}
+
+ [Fact]
+ public void DetectPythonDependencyManager_WithRequirementsTxt_ReturnsPip()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, Constants.RequirementsTxt), "flask==2.0.0");
+ var result = PythonHelpers.DetectPythonDependencyManager(tempDir);
+ Assert.Equal(PythonDependencyManager.Pip, result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void DetectPythonDependencyManager_WithPyProjectToml_ReturnsPoetry()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, Constants.PyProjectToml), "[tool.poetry]\nname = \"test\"");
+ var result = PythonHelpers.DetectPythonDependencyManager(tempDir);
+ Assert.Equal(PythonDependencyManager.Poetry, result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void DetectPythonDependencyManager_WithPyProjectTomlAndUvLock_ReturnsUv()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, Constants.PyProjectToml), "[tool.poetry]\nname = \"test\"");
+ File.WriteAllText(Path.Combine(tempDir, Constants.UvLock), "version = 1");
+ var result = PythonHelpers.DetectPythonDependencyManager(tempDir);
+ Assert.Equal(PythonDependencyManager.Uv, result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void DetectPythonDependencyManager_WithPyProjectTomlUvLockAndRequirementsTxt_ReturnsUv()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, Constants.PyProjectToml), "[tool.uv]\nname = \"test\"");
+ File.WriteAllText(Path.Combine(tempDir, Constants.UvLock), "version = 1");
+ File.WriteAllText(Path.Combine(tempDir, Constants.RequirementsTxt), "flask==2.0.0");
+ var result = PythonHelpers.DetectPythonDependencyManager(tempDir);
+ // uv takes priority when both pyproject.toml and uv.lock are present
+ Assert.Equal(PythonDependencyManager.Uv, result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void DetectPythonDependencyManager_WithPyProjectTomlAndRequirementsTxt_ReturnsPoetry()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ // When both pyproject.toml and requirements.txt are present but NO uv.lock,
+ // poetry takes priority over pip
+ File.WriteAllText(Path.Combine(tempDir, Constants.PyProjectToml), "[tool.poetry]\nname = \"test\"");
+ File.WriteAllText(Path.Combine(tempDir, Constants.RequirementsTxt), "flask==2.0.0");
+ var result = PythonHelpers.DetectPythonDependencyManager(tempDir);
+ Assert.Equal(PythonDependencyManager.Poetry, result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void DetectPythonDependencyManager_WithNoFiles_ReturnsUnknown()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var result = PythonHelpers.DetectPythonDependencyManager(tempDir);
+ Assert.Equal(PythonDependencyManager.Unknown, result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void HasPythonDependencyFiles_WithRequirementsTxt_ReturnsTrue()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, Constants.RequirementsTxt), "flask==2.0.0");
+ var result = PythonHelpers.HasPythonDependencyFiles(tempDir);
+ Assert.True(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void HasPythonDependencyFiles_WithPyProjectToml_ReturnsTrue()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ File.WriteAllText(Path.Combine(tempDir, Constants.PyProjectToml), "[tool.poetry]\nname = \"test\"");
+ var result = PythonHelpers.HasPythonDependencyFiles(tempDir);
+ Assert.True(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
+
+ [Fact]
+ public void HasPythonDependencyFiles_WithNoFiles_ReturnsFalse()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+ Directory.CreateDirectory(tempDir);
+ try
+ {
+ var result = PythonHelpers.HasPythonDependencyFiles(tempDir);
+ Assert.False(result);
+ }
+ finally
+ {
+ Directory.Delete(tempDir, true);
+ }
+ }
}
public sealed class SkipIfPythonNonExistFact : FactAttribute