Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- #1052: In a namespace with mapped IPM, the `info` command works again and the intro message displays the IPM version and where its mapped from
- #1102: %IPM.Storage.QualifiedModuleInfo:%New() will now copy over version properties when passed in a resolvedReference
- #1112: Packaging a module with a globals resource now respects SourcesRoot, placing the exported file at the correct path in the tarball
- #1097: The Test resource processor now supports nested tests and installing Python dependencies from requirements.txt will correctly override wheels

## [0.10.6] - 2026-02-24

Expand Down
16 changes: 12 additions & 4 deletions src/cls/IPM/General/History.cls
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,18 @@ ClassMethod DeleteHistoryGlobally(
set ns = rs.%Get("Nsp")
set $namespace = ns
if ##class(%Dictionary.ClassDefinition).%Exists($listbuild("%IPM.General.History")) {
set subcount = ..DeleteHistory(.filter)
set count = count + subcount
if verbose {
write !, "Deleted " _ subcount _ " record(s) from " _ ns, !
// Use try/catch to handle permission errors in locked-down namespaces
try {
set subcount = ..DeleteHistory(.filter)
set count = count + subcount
if verbose {
write !, "Deleted " _ subcount _ " record(s) from " _ ns, !
}
} catch ex {
// Ignore permission errors in locked namespaces
if verbose {
write !, "Skipped " _ ns _ " (no permission): " _ ex.DisplayString(), !
}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/cls/IPM/General/HistoryTemp.cls
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Method %OnClose() As %Status [ Private, ServerOnly = 1 ]
// Mark as finalized and record should be locked
set ..PersistedTwin.Finalized = 1
}
// Ensure the persisted twin is saved
// Ensure the persisted twin is saved (automatically included by saving this temp object)
set sc = ..%Save()

// Delete this temp entry as it's no longer needed
Expand Down
68 changes: 68 additions & 0 deletions src/cls/IPM/Lifecycle/Base.cls
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,11 @@ Method InstallOrDownloadPythonRequirements(
set processType = "requirements.txt"
do ..Log(processType _ " START")
write:tVerbose !

// Clean up existing package installations before pip install
// This is necessary because pip with --python-version doesn't check/uninstall existing packages
do ..CleanupPythonInstallation(pythonRequirements, target, tVerbose)

set command = ..ResolvePipCaller(.pParams) _ $listbuild("install", "-r", "requirements.txt", "-t", target, "--python-version", tPyVersion, "--only-binary=:all:") _ $listfromstring(tExtraPipFlags, " ")
if tVerbose {
write !, "Running "
Expand Down Expand Up @@ -916,6 +921,69 @@ ClassMethod DetectPipCaller(
throw ##class(%Exception.General).%New("Could not find a suitable pip caller. Consider setting UseStandalonePip and PipCaller")
}

/// Cleans up the existing Python package installation in the specified target directory based on the given requirements.txt file
ClassMethod CleanupPythonInstallation(
filename As %String,
targetDirectory As %String,
verbose As %Boolean)
{
set file = ##class(%Stream.FileCharacter).%New()
set file.Filename = filename
while 'file.AtEnd {
set line = $zstrip(file.ReadLine(), "<>W")
// Skip comments and empty lines
continue:line=""
continue:$extract(line,1)="#"

// Extract package name (before any version operator)
set packageName = line
for operator = "==", ">=", "<=", "!=", "~=", ">", "<" {
set packageName = $piece(packageName, operator, 1)
}
set packageName = $zstrip(packageName, "<>W")

if packageName '= "" {
// Remove package directory: <target>/<package>/
set packageDir = ##class(%File).NormalizeDirectory(packageName, targetDirectory)
if ##class(%File).DirectoryExists(packageDir) {
set removed = ##class(%File).RemoveDirectoryTree(packageDir)
if verbose && removed {
write !, "Removed old package directory: ", packageDir
}
}

// Remove dist-info directories: <target>/<package>-*.dist-info/
set pattern = targetDirectory _ packageName _ "-*.dist-info"
set distInfo = $zsearch(pattern)
while distInfo '= "" {
if ##class(%File).DirectoryExists(distInfo) {
set removed = ##class(%File).RemoveDirectoryTree(distInfo)
if verbose && removed {
write !, "Removed old dist-info: ", distInfo
}
}
set distInfo = $zsearch("")
}

// Also handle underscore variant: <package>_<name>-*.dist-info/
set packageUnderscore = $replace(packageName, "-", "_")
if packageUnderscore '= packageName {
set pattern = targetDirectory _ packageUnderscore _ "-*.dist-info"
set distInfo = $zsearch(pattern)
while distInfo '= "" {
if ##class(%File).DirectoryExists(distInfo) {
set removed = ##class(%File).RemoveDirectoryTree(distInfo)
if verbose && removed {
write !, "Removed old dist-info: ", distInfo
}
}
set distInfo = $zsearch("")
}
}
}
}
}

Method %Validate(ByRef pParams) As %Status
{
// NOTE: Resource processor classes and their attributes are validated in OnBeforePhase,
Expand Down
14 changes: 12 additions & 2 deletions src/cls/IPM/ResourceProcessor/Test.cls
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ Method OnPhase(
{
set tSC = $$$OK
try {
// Initialize test result accumulator for this phase if not already initialized (i.e., we're at top level, not nested)
// This way nested Shell calls append to parent's AllResults instead of wiping it
if '$data(^||%UnitTest.Manager.AllResultsCount) {
kill ^||%UnitTest.Manager.AllResults
set ^||%UnitTest.Manager.AllResultsCount = 0
}

// Track where this phase's results start (for nested phases)
set phaseStartIndex = $get(^||%UnitTest.Manager.AllResultsCount, 0)

if ..TestsShouldRun(pPhase,.pParams) {
// In test/verify phase, run unit tests.
set tVerbose = $get(pParams("Verbose"), 0)
Expand Down Expand Up @@ -203,8 +213,8 @@ Method OnPhase(

// By default, detect and report unit test failures as an error from this phase
if $get(pParams("UnitTest","FailuresAreFatal"),1) {
do ##class(%IPM.Test.Manager).OutputFailures()
set tSC = ##class(%IPM.Test.Manager).GetLastStatus()
do ##class(%IPM.Test.Manager).OutputFailures(phaseStartIndex)
set tSC = ##class(%IPM.Test.Manager).GetAllTestsStatus(,phaseStartIndex)
$$$ThrowOnError(tSC)
}
write !
Expand Down
169 changes: 101 additions & 68 deletions src/cls/IPM/Test/Manager.cls
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@ ClassMethod RunTest(
qspec As %String,
ByRef userparam) As %Status
{
kill ^||%UnitTest.Manager.LastResult
quit ##super(.testspec,.qspec,.userparam)
}

/// Does the default behavior, then stashes the latest run index
/// Does the default behavior, then accumulates the test run index
Method SaveResult(duration)
{
do ##super(.duration)
set ^||%UnitTest.Manager.LastResult = i%LogIndex

// Accumulate ALL test LogIndexes in an array
// Uses $increment to append so nested calls add to the array
set count = $increment(^||%UnitTest.Manager.AllResultsCount)
set ^||%UnitTest.Manager.AllResults(count) = i%LogIndex

quit
}

Expand Down Expand Up @@ -48,91 +52,120 @@ ClassMethod LoadTestDirectory(
quit tSC
}

/// Returns $$$OK if the last unit test run was successful, or an error if it was unsuccessful.
ClassMethod GetLastStatus(Output pFailureCount As %Integer) As %Status
/// Check all test LogIndexes accumulated in AllResults and return aggregated status
/// Returns error if any test had failures
/// startIndex: Only check results from this index onwards (for nested phases, e.g. calling `zpm verify` inside `zpm verify`)
ClassMethod GetAllTestsStatus(
Output failureCount As %Integer,
startIndex As %Integer = 0) As %Status
{
set tSC = $$$OK
set sc = $$$OK
set failureCount = 0
try {
if '$data(^||%UnitTest.Manager.LastResult,tLogIndex)#2 {
set tLogIndex = $order(^UnitTest.Result(""),-1)
}
kill ^||%UnitTest.Manager.LastResult // Clean up
if tLogIndex {
set tRes = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestAssert where Status = 0 "_
"and TestMethod->TestCase->TestSuite->TestInstance->InstanceIndex = ?",tLogIndex)
if (tRes.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(tRes.%SQLCODE,tRes.%Message)
}
do tRes.%Next(.tSC)
$$$ThrowOnError(tSC)
set pFailureCount = tRes.%GetData(1)
if (pFailureCount > 0) {
set tSC = $$$ERROR($$$GeneralError,$$$FormatText("%1 assertion(s) failed.",pFailureCount))
} else {
// Double check that no other failures were reported - e.g., failures loading that would lead to no assertions passing or failing!
set tRes = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestSuite where Status = 0 "_
"and TestInstance->InstanceIndex = ?",tLogIndex)
if (tRes.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(tRes.%SQLCODE,tRes.%Message)
set testCount = $get(^||%UnitTest.Manager.AllResultsCount, 0)

// Check tracked test LogIndexes from startIndex onwards
// This ensures nested phases only see their own results, not parent's
for i=(startIndex+1):1:testCount {
set logIndex = $get(^||%UnitTest.Manager.AllResults(i))
if (logIndex '= "") {
// Query for assertion failures in this test run
set res = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestAssert where Status = 0 "_
"and TestMethod->TestCase->TestSuite->TestInstance->InstanceIndex = ?",logIndex)
if (res.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(res.%SQLCODE,res.%Message)
}
do tRes.%Next(.tSC)
$$$ThrowOnError(tSC)
set pFailureCount = tRes.%GetData(1)
if (pFailureCount > 0) {
set tSC = $$$ERROR($$$GeneralError,$$$FormatText("%1 test suite(s) failed.",pFailureCount))
do res.%Next(.sc)
$$$ThrowOnError(sc)
set failures = res.%GetData(1)
set failureCount = failureCount + failures

// Also check for test suite failures (e.g., loading errors)
if (failures = 0) {
set res = ##class(%SQL.Statement).%ExecDirect(,"select count(*) "_
"from %UnitTest_Result.TestSuite where Status = 0 "_
"and TestInstance->InstanceIndex = ?",logIndex)
if (res.%SQLCODE < 0) {
throw ##class(%Exception.SQL).CreateFromSQLCODE(res.%SQLCODE,res.%Message)
}
do res.%Next(.sc)
$$$ThrowOnError(sc)
set failures = res.%GetData(1)
set failureCount = failureCount + failures
}
}
} else {
set tSC = $$$ERROR($$$GeneralError,"No unit test results recorded.")
}

if (failureCount > 0) {
set sc = $$$ERROR($$$GeneralError, failureCount_" assertion(s) failed.")
}

// Only clean up AllResults at top level (startIndex=0), not in nested phases
if (startIndex = 0) {
kill ^||%UnitTest.Manager.AllResults
kill ^||%UnitTest.Manager.AllResultsCount
}
} catch e {
set tSC = e.AsStatus()
set sc = e.AsStatus()
}
quit tSC
quit sc
}

ClassMethod OutputFailures()
/// Output test failures from accumulated results
/// startIndex: Only output failures from this index onwards (for nested phases)
ClassMethod OutputFailures(startIndex As %Integer = 0)
{
set tSC = $$$OK
set sc = $$$OK
try {
if '$data(^||%UnitTest.Manager.LastResult,tLogIndex)#2 {
set tLogIndex = $order(^UnitTest.Result(""),-1)
}
kill ^||%UnitTest.Manager.LastResult // Clean up
if 'tLogIndex {
quit
set testCount = $get(^||%UnitTest.Manager.AllResultsCount, 0)

// Output failures from all tracked test LogIndexes from startIndex onwards
// This ensures parent phase outputs failures from both parent and nested tests
for i=(startIndex+1):1:testCount {
set logIndex = $get(^||%UnitTest.Manager.AllResults(i))
if (logIndex '= "") {
do ..OutputFailuresForLogIndex(logIndex)
}
}
set tLogGN = $name(^UnitTest.Result(tLogIndex))
set tRoot = ""

} catch e {
set sc = e.AsStatus()
}
quit sc
}

/// Helper method to output failures for a single LogIndex
ClassMethod OutputFailuresForLogIndex(logIndex As %Integer)
{
if 'logIndex {
quit
}
set logGN = $name(^UnitTest.Result(logIndex))
set root = ""
for {
set root = $order(@logGN@(root))
quit:root=""
set suite = ""
for {
set tRoot = $order(@tLogGN@(tRoot))
quit:tRoot=""
set tSuite = ""
set suite = $order(@logGN@(root, suite))
quit:suite=""
set method = ""
for {
set tSuite = $order(@tLogGN@(tRoot, tSuite))
quit:tSuite=""
set tMethod = ""
set method = $order(@logGN@(root, suite, method))
quit:method=""

set assert = ""
for {
set tMethod = $order(@tLogGN@(tRoot, tSuite, tMethod))
quit:tMethod=""

set tAssert = ""
for {
set tAssert = $order(@tLogGN@(tRoot, tSuite, tMethod, tAssert), 1, tAssertInfo)
quit:tAssert=""
set $listbuild(status, type, text) = tAssertInfo
continue:status
write !,$$$FormattedLine($$$Red, "FAILED " _ tSuite _ ":" _ tMethod), ": " _ type _ " - " _ text
}
set assert = $order(@logGN@(root, suite, method, assert), 1, assertInfo)
quit:assert=""
set $listbuild(status, type, text) = assertInfo
continue:status
write !,$$$FormattedLine($$$Red, "FAILED " _ suite _ ":" _ method), ": " _ type _ " - " _ text
}
}
}
} catch e {
set tSC = e.AsStatus()
}
quit tSC
}

}
Loading