Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace UiPath.Python.Tests
public static class EmbeddedPythonRuntimeBootstrap
{
private const string EmbeddedZipFileName = "python-3.14.5-embed-amd64.zip";
private const string PythonVersion = "3.14.5";
public const string PythonVersion = "3.14.5";

private static readonly string RuntimeRoot = Path.Combine(Path.GetTempPath(), "pythons", PythonVersion);
private static readonly string LockFile = Path.Combine(RuntimeRoot, ".setup.lock");
Expand Down Expand Up @@ -62,6 +62,20 @@ public static string EnsureRuntimePath()
return RuntimeRoot;
}

/// <summary>
/// The folder name Python's own per-user site (e.g. %APPDATA%\Roaming\Python\PythonXY on
/// Windows) resolves to for this embedded runtime's version, derived from
/// <see cref="PythonVersion"/> so it can't drift out of sync when that's bumped.
/// </summary>
public static string UserSiteVersionFolder
{
get
{
var parts = PythonVersion.Split('.');
return $"Python{parts[0]}{parts[1]}";
}
}

public static string GetPythonLibraryPath(string runtimePath)
{
ArgumentNullException.ThrowIfNull(runtimePath);
Expand Down
162 changes: 162 additions & 0 deletions Activities/Python/UiPath.Python.Tests/EngineStdlibDetectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using System;
using System.IO;
using UiPath.Python.Impl;
using UiPath.TestUtils;
using Xunit;
using Assert = Xunit.Assert;

namespace UiPath.Python.Tests
{
// Direct, fast tests for Engine's stdlib-detection helpers (HasStdlib and friends) — no
// Python engine involved. "Lib" (Windows) vs "lib" (POSIX) is the real, fixed folder name
// each platform's CPython build/installer produces, not a style choice — WindowsStdlibLandmark
// and PosixStdlibDirectory assert on the exact, case-sensitive string each platform's check is
// built from, since a real Directory.Exists call on this repo's Windows-only CI can't tell
// "Lib" apart from "lib" (NTFS resolves both to the same directory by default), so filesystem
// behavior alone can't prove the check asks for the right casing on either platform.
public class EngineStdlibDetectionTests : IDisposable
{
private const string Category = "Python";

private readonly string _rootDir;

public EngineStdlibDetectionTests()
{
_rootDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "engine-stdlib-tests", Guid.NewGuid().ToString("N"))).FullName;
}

public void Dispose()
{
try { Directory.Delete(_rootDir, true); } catch { /* best effort cleanup */ }
GC.SuppressFinalize(this);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void WindowsStdlibLandmark_Uses_CapitalL_Lib_Segment()
{
var landmark = Engine.WindowsStdlibLandmark(_rootDir);

Assert.Equal(Path.Combine(_rootDir, "Lib", "encodings"), landmark, StringComparer.Ordinal);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void PosixStdlibDirectory_Uses_Lowercase_Lib_Segment()
{
var libDir = Engine.PosixStdlibDirectory(_rootDir);

Assert.Equal(Path.Combine(_rootDir, "lib"), libDir, StringComparer.Ordinal);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void WindowsAndPosix_Segments_Differ_In_Case()
{
// Pins the two platforms' checks to genuinely different, non-interchangeable strings
// — guards against a future edit accidentally making both branches check the same
// (either) casing, which real Directory.Exists calls on this Windows-only CI would
// never catch on their own (NTFS folds the case difference away).
var windowsSegment = Path.GetFileName(Path.GetDirectoryName(Engine.WindowsStdlibLandmark(_rootDir)));
var posixSegment = Path.GetFileName(Engine.PosixStdlibDirectory(_rootDir));

Assert.Equal("Lib", windowsSegment, StringComparer.Ordinal);
Assert.Equal("lib", posixSegment, StringComparer.Ordinal);
Assert.NotEqual(windowsSegment, posixSegment, StringComparer.Ordinal);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void HasStdlib_True_When_Landmark_Present()
{
// Runs on this repo's actual CI/dev OS (Windows), exercising the real branch HasStdlib
// takes here — confirms the happy path independent of the case-sensitivity question
// above.
Directory.CreateDirectory(Path.Combine(_rootDir, "Lib", "encodings"));

Assert.True(EngineHasStdlib(_rootDir));
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void HasStdlib_False_When_No_Lib_Folder_At_All()
{
// Mirrors a Microsoft Store Python's app-execution-alias folder: exists, but carries
// no Lib folder whatsoever.
Assert.False(EngineHasStdlib(_rootDir));
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void HasStdlib_False_For_NullOrEmpty()
{
Assert.False(EngineHasStdlib(null));
Assert.False(EngineHasStdlib(string.Empty));
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void HasStdlib_True_When_PthFile_Present_Even_Without_Lib_Folder()
{
// The official Windows embeddable distribution (and any other CPython using ._pth-based
// isolation) resolves its stdlib from a bundled zip referenced by a *._pth file next to
// the interpreter, not an unpacked Lib folder — HasStdlib must not treat that as "no
// stdlib" just because Lib\encodings doesn't literally exist.
File.WriteAllText(Path.Combine(_rootDir, "python314._pth"), "python314.zip" + Environment.NewLine);

Assert.True(EngineHasStdlib(_rootDir));
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void ResolvePrefixFromLibraryPath_WalksUp_To_Ancestor_With_Stdlib()
{
// Models the POSIX case this fix targets: the shared library sits one or more levels
// *below* the real prefix (e.g. lib/x86_64-linux-gnu/libpythonX.Y.so under a prefix
// that only has Lib\encodings at its own root) — the immediate parent directory of the
// library is not itself a valid home, but an ancestor is. Uses the Windows landmark
// (Lib\encodings) since that's the only one this Windows-only CI can exercise via a
// real Directory.Exists call — the walk-up mechanism itself is platform-agnostic; only
// which landmark HasStdlib checks for differs by OS.
Directory.CreateDirectory(Path.Combine(_rootDir, "Lib", "encodings"));
var libDir = Directory.CreateDirectory(Path.Combine(_rootDir, "lib", "x86_64-linux-gnu")).FullName;
var libraryPath = Path.Combine(libDir, "libpython3.12.so");
File.WriteAllText(libraryPath, string.Empty);

var prefix = Engine.ResolvePrefixFromLibraryPath(libraryPath);

Assert.Equal(_rootDir, prefix);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void ResolvePrefixFromLibraryPath_Returns_Null_When_No_Ancestor_Has_Stdlib()
{
var libDir = Directory.CreateDirectory(Path.Combine(_rootDir, "lib")).FullName;
var libraryPath = Path.Combine(libDir, "libpython3.12.so");
File.WriteAllText(libraryPath, string.Empty);

var prefix = Engine.ResolvePrefixFromLibraryPath(libraryPath);

Assert.Null(prefix);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void ResolvePrefixFromLibraryPath_Null_For_NullOrEmpty()
{
Assert.Null(Engine.ResolvePrefixFromLibraryPath(null));
Assert.Null(Engine.ResolvePrefixFromLibraryPath(string.Empty));
}

// Engine.HasStdlib itself is private (only ConfigureRuntime should call it directly) —
// reached here via reflection so this suite doesn't need to widen that method's
// visibility just for testing, unlike WindowsStdlibLandmark/PosixStdlibDirectory, whose
// whole purpose is to be asserted on directly.
private static bool EngineHasStdlib(string pythonHome)
{
var method = typeof(Engine).GetMethod("HasStdlib", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
return (bool)method.Invoke(null, new object[] { pythonHome });
}
}
}
186 changes: 186 additions & 0 deletions Activities/Python/UiPath.Python.Tests/VenvDetectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using System;
using System.IO;
using UiPath.Python.Impl;
using UiPath.TestUtils;
using Xunit;
using Assert = Xunit.Assert;

namespace UiPath.Python.Tests
{
// Direct, fast tests for VenvDetection.GetVenvInfo — no Python engine involved. Covers the
// detection shapes discussed during the STUD-81085 review: venv root, its Scripts/bin
// launcher folder, and the false-positive an unbounded ancestor walk used to allow (an
// unrelated, fully-standalone installation merely sitting near someone else's pyvenv.cfg).
public class VenvDetectionTests : IDisposable
{
private const string Category = "Python";

private readonly string _rootDir;

public VenvDetectionTests()
{
_rootDir = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "venv-detection-tests", Guid.NewGuid().ToString("N"))).FullName;
}

public void Dispose()
{
try { Directory.Delete(_rootDir, true); } catch { /* best effort cleanup */ }
GC.SuppressFinalize(this);
}

private static string WriteVenvCfg(string venvDir, string home = @"C:\FakeBase", string extra = null)
{
Directory.CreateDirectory(venvDir);
var content = $"home = {home}{Environment.NewLine}version = 3.13.0{Environment.NewLine}{extra}";
File.WriteAllText(Path.Combine(venvDir, "pyvenv.cfg"), content);
return venvDir;
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void VenvRoot_Is_Detected()
{
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "myvenv"));

var venv = VenvDetection.GetVenvInfo(venvDir);

Assert.NotNull(venv);
Assert.Equal(venvDir, venv.Root);
Assert.Equal(@"C:\FakeBase", venv.Home);
}

[Theory]
[InlineData("Scripts")]
[InlineData("bin")]
[Trait(TestCategories.Category, Category)]
public void LauncherSubfolder_Is_Detected(string folderName)
{
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "myvenv"));
var launcherDir = Directory.CreateDirectory(Path.Combine(venvDir, folderName)).FullName;

var venv = VenvDetection.GetVenvInfo(launcherDir);

Assert.NotNull(venv);
Assert.Equal(venvDir, venv.Root);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void UnrelatedFolder_OneLevelBelow_WrongName_Is_Not_Detected()
{
// pyvenv.cfg one level up, but the intermediate folder isn't a real venv launcher
// name — a fully standalone install could legitimately live here and must not be
// mistaken for being inside someone else's venv.
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "someones_venv"));
var standaloneDir = Directory.CreateDirectory(Path.Combine(venvDir, "runtime")).FullName;

var venv = VenvDetection.GetVenvInfo(standaloneDir);

Assert.Null(venv);
}

[Theory]
[InlineData("Scripts")]
[InlineData("bin")]
[Trait(TestCategories.Category, Category)]
public void LauncherSubfolder_With_TrailingSeparator_Is_Detected(string folderName)
{
// Path.GetDirectoryName on a separator-terminated path only strips the trailing
// separator and returns the launcher folder itself, not its parent — detection must
// derive the parent from the trimmed path instead, or this silently fails to find the
// venv root's pyvenv.cfg.
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "myvenv"));
var launcherDir = Directory.CreateDirectory(Path.Combine(venvDir, folderName)).FullName;

var venv = VenvDetection.GetVenvInfo(launcherDir + Path.DirectorySeparatorChar);

Assert.NotNull(venv);
Assert.Equal(venvDir, venv.Root);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void UnreadablePyvenvCfg_Is_Not_Detected_And_Does_Not_Throw()
{
// A pyvenv.cfg that exists but can't be read right now (ACLs, an AV sharing
// violation, a concurrent pip rewrite) must not hard-fail detection — the engine
// should get a chance to surface its own, more specific error instead.
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "myvenv"));
var cfgFile = Path.Combine(venvDir, "pyvenv.cfg");

using (new FileStream(cfgFile, FileMode.Open, FileAccess.Read, FileShare.None))
{
var venv = VenvDetection.GetVenvInfo(venvDir);
Assert.Null(venv);
}
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void VersionInfoKey_Is_Read_As_Fallback_For_Version()
{
// virtualenv/uv write "version_info" instead of stdlib venv's "version".
var venvDir = Directory.CreateDirectory(Path.Combine(_rootDir, "myvenv")).FullName;
File.WriteAllText(Path.Combine(venvDir, "pyvenv.cfg"),
$"home = C:\\FakeBase{Environment.NewLine}version_info = 3.10.4.final.0{Environment.NewLine}");

var venv = VenvDetection.GetVenvInfo(venvDir);

Assert.NotNull(venv);
Assert.Equal("3.10.4.final.0", venv.Version);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void TwoLevelsUp_Is_Not_Detected()
{
// Even with the right launcher name one level further up, detection deliberately
// doesn't walk a second level — no real venv layout ever needs it.
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "myvenv"));
var scriptsDir = Directory.CreateDirectory(Path.Combine(venvDir, "Scripts")).FullName;
var nestedDir = Directory.CreateDirectory(Path.Combine(scriptsDir, "nested")).FullName;

var venv = VenvDetection.GetVenvInfo(nestedDir);

Assert.Null(venv);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void StrayFile_Without_HomeOrVersion_Is_Not_Treated_As_Venv()
{
var dir = Directory.CreateDirectory(Path.Combine(_rootDir, "notavenv")).FullName;
File.WriteAllText(Path.Combine(dir, "pyvenv.cfg"), "some-unrelated-key = value" + Environment.NewLine);

var venv = VenvDetection.GetVenvInfo(dir);

Assert.Null(venv);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void SystemSitePackages_Flag_Is_Captured()
{
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "myvenv"), extra: "include-system-site-packages = true" + Environment.NewLine);

var venv = VenvDetection.GetVenvInfo(venvDir);

Assert.NotNull(venv);
Assert.True(venv.IncludeSystemSitePackages);
Assert.False(venv.ShouldDisableUserSite);
}

[Fact]
[Trait(TestCategories.Category, Category)]
public void Default_DoesNotIncludeSystemSitePackages_ShouldDisableUserSite()
{
var venvDir = WriteVenvCfg(Path.Combine(_rootDir, "myvenv"));

var venv = VenvDetection.GetVenvInfo(venvDir);

Assert.NotNull(venv);
Assert.False(venv.IncludeSystemSitePackages);
Assert.True(venv.ShouldDisableUserSite);
}
}
}
Loading