diff --git a/CHANGELOG.md b/CHANGELOG.md
index 48bf3be68..955151352 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- #1024: Added flag -export-python-deps to publish command
+- #1025: implement python dependency lifecycle and reference counting
### Fixed
- #996: Ensure COS commands execute in exec under a dedicated, isolated context
diff --git a/src/cls/IPM/Lifecycle/Base.cls b/src/cls/IPM/Lifecycle/Base.cls
index 554a09815..6cca2df7e 100644
--- a/src/cls/IPM/Lifecycle/Base.cls
+++ b/src/cls/IPM/Lifecycle/Base.cls
@@ -491,6 +491,10 @@ Method %Clean(ByRef pParams) As %Status
if $$$ISERR(tSC) {
quit
}
+ // validates the python libaraies need to be uninstalled as part of the clean process
+ if $get(pParams("with-py")) {
+ do ..UninstallPythonDeps(.pParams)
+ }
}
} catch e {
set tSC = e.AsStatus()
@@ -498,6 +502,26 @@ Method %Clean(ByRef pParams) As %Status
quit tSC
}
+Method UninstallPythonDeps(pParams)
+{
+ if '##class(%IPM.Utils.Python).IsPythonEnabled() {
+ quit $$$OK
+ }
+ if '..Module.PythonDependencies.Count() {
+ quit $$$OK
+ }
+ do ..Log("Evaluating Python dependencies for removal...")
+ set key = ""
+ set ^||%IPM.PipCaller = ##class(%IPM.Utils.Python).ResolvePipCaller(.pParams)
+ for {
+ set pyRefOref = ..Module.PythonDependencies.GetNext(.key)
+ quit:key=""
+ set sc = ##class(%IPM.Storage.PythonReference).RemovePythonReference(key, .pParams)
+ $$$ThrowOnError(sc)
+ }
+ //kill ^||%IPM.PipCaller
+}
+
Method %ExportData(ByRef pParams) As %Status
{
quit $$$OK
@@ -746,16 +770,10 @@ Method InstallOrDownloadPythonRequirements(
}
set tSC = $$$OK
try {
- set target = ##class(%File).NormalizeDirectory("python", $system.Util.ManagerDirectory())
- if '$system.CLS.IsMthd("%SYS.Python", "Import") {
- throw ##class(%Exception.General).%New("Embedded Python is not available in this instance.")
- }
set processType = ""
- set tSysModule = ##class(%SYS.Python).Import("sys")
- set tPyMajor = tSysModule."version_info".major
- set tPyMinor = tSysModule."version_info".minor
- set tPyMicro = tSysModule."version_info".micro
- set tPyVersion = tPyMajor_"."_tPyMinor_"."_tPyMicro
+ set stdout = ""
+ set target = ##class(%IPM.Utils.Python).PythonManagerDir()
+ $$$ThrowOnError(##class(%IPM.Utils.Python).GetPythonVersion(.tPyVersion))
if download {
set processType = "Download Python wheels"
do ..Log(processType _ " START")
@@ -783,6 +801,8 @@ Method InstallOrDownloadPythonRequirements(
}
set tSC = ##class(%IPM.Utils.Module).RunCommand(pRoot, command,.stdout)
$$$ThrowOnError(tSC)
+ set tSC = ..SavePythonDependencies(pythonRequirements)
+ $$$ThrowOnError(tSC)
}
do ..Log(processType _ " SUCCESS")
} catch ex {
@@ -792,6 +812,41 @@ Method InstallOrDownloadPythonRequirements(
quit tSC
}
+Method SavePythonDependencies(pythonFileLocation As %String = "") As %Status
+{
+ quit:pythonFileLocation="" $$$OK
+ do ..Log("Saving python dependencies from "_pythonFileLocation)
+ set file = ##class(%File).%New(pythonFileLocation)
+ set sc = file.Open("R")
+ if $$$ISERR(sc) {
+ return sc
+ }
+ while 'file.AtEnd {
+ set lib = file.ReadLine()
+ set lib = $zstrip(lib, "<>W")
+ if lib="" continue
+ if (lib [ ".whl") || (lib [ ".wheel") {
+ set whlFile = $piece($translate(lib, "\", "/"), "/", *)
+ set libraryName = $piece(whlFile, "-")
+ } else {
+ set libraryName = $piece(lib,"==")
+ }
+ // check does this Module already have this EXACT library and version?
+ set pyRefObj =..Module.PythonDependencies.GetAt($$$lcase(libraryName))
+ if $isobject(pyRefObj) {
+ // already have this exact library and version, skip to next
+ if pyRefObj.IsVersionMatch(lib) {
+ continue
+ }
+ }
+ set pytonlibObj = ##class(%IPM.Storage.PythonReference).SavePythonDependencies(lib)
+ set sc = ..Module.PythonDependencies.SetAt(pytonlibObj, pytonlibObj.LibraryName)
+ }
+ // Save the python dependencies to the module object before reload.
+ set sc = ..Module.%Save()
+ return sc
+}
+
ClassMethod ResolvePipCaller(ByRef pParams) As %List
{
set tUseStandalonePip = ##class(%IPM.Repo.UniversalSettings).GetValue("UseStandalonePip")
diff --git a/src/cls/IPM/Main.cls b/src/cls/IPM/Main.cls
index 5fce52dfa..11ba3e5ca 100644
--- a/src/cls/IPM/Main.cls
+++ b/src/cls/IPM/Main.cls
@@ -470,6 +470,13 @@ reinstall -env /path/to/env1.json;/path/to/env2.json example-package
uninstall HS.JSON
+
+ uninstall HS.JSON -with-py
+
+
+
+ uninstall HS.JSON -r -with-py
+
@@ -481,6 +488,7 @@ reinstall -env /path/to/env1.json;/path/to/env2.json example-package
+
@@ -972,6 +980,7 @@ ClassMethod ShellInternal(
set $$$ZPMCommandToLog = tCommand
if (tCommandInfo = "quit") {
+ do ..OnBeforeShellExit()
return
} elseif (tCommandInfo = "help") {
do ..%Help(.tCommandInfo)
@@ -4075,6 +4084,15 @@ ClassMethod GetPythonInstalledLibs(Output list)
}
}
+/// Clean up any temporary data or PPG before exiting the shell
+ClassMethod OnBeforeShellExit() [ Internal ]
+{
+ try{
+ kill ^||%IPM.PipCaller
+ }catch ex{
+ }
+}
+
ClassMethod GetPythonLibrariesList(PythonPath As %String) As %String [ Language = python ]
{
import sys
diff --git a/src/cls/IPM/Storage/Module.cls b/src/cls/IPM/Storage/Module.cls
index cb5c16eca..15d0cb9b2 100644
--- a/src/cls/IPM/Storage/Module.cls
+++ b/src/cls/IPM/Storage/Module.cls
@@ -87,6 +87,8 @@ Property Deployed As %Boolean(XMLPROJECTION = "Element");
Property UpdatePackage As %Dictionary.Classname;
+Property PythonDependencies As array Of %IPM.Storage.PythonReference(XMLPROJECTION = "NONE");
+
/// Iterates through all of a module's update step classes and tries to either execute or seed those steps.
/// seedSteps: (Default) 0 = Execute Steps | 1 = Seeds Steps
Method HandleAllUpdateSteps(
@@ -1923,6 +1925,11 @@ Storage Default
UpdatePackage
+
+PythonDependencies
+subnode
+"PythonDependencies"
+
^IPM.Storage.ModuleD
ModuleDefaultData
^IPM.Storage.ModuleD
diff --git a/src/cls/IPM/Storage/PythonReference.cls b/src/cls/IPM/Storage/PythonReference.cls
new file mode 100644
index 000000000..7220a29c0
--- /dev/null
+++ b/src/cls/IPM/Storage/PythonReference.cls
@@ -0,0 +1,176 @@
+/// store the python libraries mentioned in the requirements.txt file
+Class %IPM.Storage.PythonReference Extends (%Persistent, %XML.Adaptor, %IPM.Utils.ComparisonAdaptor)
+{
+
+Parameter DEFAULTGLOBAL = "^IPM.Storage.PythonRefs";
+
+Property LibraryName As %String(MAXLEN = 255) [ Required ];
+
+Property Version As %String;
+
+Property SourceType As %String(VALUELIST = ",pypi,whl") [ InitialExpression = "pypi" ];
+
+/// The physical path to the .whl file on the server
+Property WheelPath As %String(MAXLEN = 1000);
+
+/// The specific platform tag (e.g., 'manylinux_2_17_x86_64' or 'win_amd64')
+Property PlatformTag As %String(MAXLEN = 100);
+
+Property RefCount As %Integer [ InitialExpression = 1 ];
+
+Index LibIdx On LibraryName [ IdKey, Unique ];
+
+ClassMethod SavePythonDependencies(pLibrarySpec As %String) As %IPM.Storage.PythonReference
+{
+ set libraryInfo = ..ParseLibrarySpec(pLibrarySpec)
+ if libraryInfo="" {
+ quit $$$ERROR("Invalid library specification: "_pLibrarySpec)
+ }
+ set libraryName = $listget(libraryInfo)
+ set libararyVersion = $listget(libraryInfo,2)
+ set pyRef = ##class(%IPM.Storage.PythonReference).%OpenId(libraryName)
+ if $isobject(pyRef) {
+ set pyRef.RefCount = pyRef.RefCount + 1
+ } else {
+ set pyRef = ..%New()
+ set pyRef.LibraryName = libraryName
+ }
+ if libararyVersion'="" {
+ set pyRef.Version = libararyVersion
+ }
+ if $listget(libraryInfo,3)="pypi" {
+ set pyRef.SourceType = $listget(libraryInfo,3)
+ set pyRef.PlatformTag = ""
+ set pyRef.WheelPath = ""
+ }
+ else {
+ set pyRef.SourceType = $listget(libraryInfo,3)
+ set pyRef.PlatformTag = $listget(libraryInfo,4)
+ set pyRef.WheelPath = $listget(libraryInfo,5)
+ }
+ $$$ThrowOnError(pyRef.%Save())
+ quit pyRef
+}
+
+ClassMethod ParseLibrarySpec(pLibrarySpec As %String) As %String
+{
+ if pLibrarySpec="" {
+ quit ""
+ }
+ if (pLibrarySpec[".whl")||(pLibrarySpec[".wheel") {
+ set filename = $piece($translate(pLibrarySpec, "\", "/"), "/", *)
+ set libName = $$$lcase($piece(filename, "-", 1))
+ set platformTag = $piece($piece(filename, "-", 5),".")
+ set version = $piece(filename, "-", 2)
+ quit $listbuild(libName, version,"whl",platformTag,pLibrarySpec)
+ }
+ else {
+ set filename = $$$lcase($piece(pLibrarySpec,"=="))
+ set version = $piece(pLibrarySpec,"==",2)
+ quit $listbuild(filename,version,"pypi")
+ }
+}
+
+/// Checks if this specific instance's version matches the libraryInfo
+Method IsVersionMatch(pLibrarySpec As %String) As %Boolean
+{
+ set libraryInfo = ..ParseLibrarySpec(pLibrarySpec)
+ set version = $listget(libraryInfo, 2)
+ if version = "" {
+ quit 1
+ }
+ quit ..Version = version
+}
+
+ClassMethod IsPythonDependencyInstalled(pLibrarySpec As %String) As %Boolean
+{
+ set libraryInfo = ..ParseLibrarySpec(pLibrarySpec)
+ set libraryName = $listget(libraryInfo)
+ set version = $listget(libraryInfo, 2)
+ set pyRef = ##class(%IPM.Storage.PythonReference).%OpenId(libraryName)
+ if '$isobject(pyRef) {
+ quit 0
+ }
+ if version'=""&&(pyRef.Version'=version) {
+ quit 0
+ }
+ quit $$$OK
+}
+
+ClassMethod RemovePythonReference(
+ pLibrarySpec As %String,
+ ByRef pParams) As %Status
+{
+ set libraryInfo = ..ParseLibrarySpec(pLibrarySpec)
+ set libraryName = $listget(libraryInfo)
+ set pyRef = ##class(%IPM.Storage.PythonReference).%OpenId(libraryName)
+ if '$isobject(pyRef) {
+ quit $$$OK
+ }
+ if pyRef.RefCount<=1 {
+ do ..UninstallPythonLibrary(libraryName, .pParams)
+ $$$ThrowOnError(..%DeleteId(libraryName))
+ } else {
+ set pyRef.RefCount = pyRef.RefCount - 1
+ $$$ThrowOnError(pyRef.%Save())
+ }
+ quit $$$OK
+}
+
+ClassMethod UninstallPythonLibrary(
+ pLibraryName As %List,
+ ByRef pParams)
+{
+ set verbose = $get(pParams("Verbose"))
+ set target = ##class(%File).NormalizeDirectory("python", $system.Util.ManagerDirectory())
+ if '$listvalid(pLibraryName) {
+ set pLibraryName = $listfromstring(pLibraryName)
+ }
+ if '$data(^||%IPM.PipCaller, pipCaller) {
+ set pipCaller = ##class(%IPM.Utils.Python).ResolvePipCaller(.pParams)
+ }
+ set command = pipCaller _ $listbuild("uninstall", "-y")_pLibraryName
+ if $$$isUNIX {
+ set command = command _ $listbuild("--break-system-packages")
+ }
+ if verbose {
+ write !, "Running "
+ zwrite command
+ } else {
+ set stdout = ""
+ }
+ set tSC = ##class(%IPM.Utils.Module).RunCommand(target, command,.stdout)
+ $$$ThrowOnError(tSC)
+}
+
+Storage Default
+{
+
+
+%%CLASSNAME
+
+
+Version
+
+
+SourceType
+
+
+WheelPath
+
+
+PlatformTag
+
+
+RefCount
+
+
+^IPM.Storage.PythonRefsD
+PythonReferenceDefaultData
+^IPM.Storage.PythonRefsD
+^IPM.Storage.PythonRefsI
+^IPM.Storage.PythonRefsS
+%Storage.Persistent
+}
+
+}
diff --git a/src/cls/IPM/Utils/Python.cls b/src/cls/IPM/Utils/Python.cls
new file mode 100644
index 000000000..12f6641f6
--- /dev/null
+++ b/src/cls/IPM/Utils/Python.cls
@@ -0,0 +1,46 @@
+Include %occInclude
+
+/// Provides a centralized set of utility methods for interacting with
+/// the Embedded Python engine within the IPM (InterSystems Package Manager) framework.
+/// This class handles environment verification, version retrieval, and
+/// common Python operations used during the module lifecycle (install, uninstall, etc.).
+Class %IPM.Utils.Python
+{
+
+ClassMethod GetPythonVersion(Output pyVersion As %String) As %Status
+{
+ set pyVersion = ""
+ set sc = ..IsPythonEnabled()
+ if $$$ISERR(sc) {
+ quit sc
+ }
+
+ try {
+ set tSysModule = ##class(%SYS.Python).Import("sys")
+ set vInfo = tSysModule."version_info"
+ set pyVersion = vInfo.major _ "." _ vInfo.minor _ "." _ vInfo.micro
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ return sc
+}
+
+ClassMethod IsPythonEnabled() As %Status
+{
+ if '$system.CLS.IsMthd("%SYS.Python", "Import") {
+ return $$$ERROR($$$PythonGeneralError, "Embedded Python is not available in this instance.")
+ }
+ return $$$OK
+}
+
+ClassMethod ResolvePipCaller(pParams) [ CodeMode = expression ]
+{
+##class(%IPM.Lifecycle.Base).ResolvePipCaller(.pParams)
+}
+
+ClassMethod PythonManagerDir() [ CodeMode = expression ]
+{
+##class(%File).NormalizeDirectory("python", $system.Util.ManagerDirectory())
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/PythonReferenceLifeCycle.cls b/tests/integration_tests/Test/PM/Integration/PythonReferenceLifeCycle.cls
new file mode 100644
index 000000000..75ab02e23
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/PythonReferenceLifeCycle.cls
@@ -0,0 +1,179 @@
+Class Test.PM.Integration.PythonReferenceLifeCycle Extends Test.PM.Integration.Base
+{
+
+Method TestDependencyRegistration()
+{
+ #define NormalizeDirectory(%dir) ##class(%File).NormalizeDirectory(%dir)
+ #Define NormalizeFilename(%file,%dir) ##class(%File).NormalizeFilename(%file,%dir)
+
+ set testRoot = $$$NormalizeDirectory($get(^UnitTestRoot))
+ set module1Dir = $$$NormalizeDirectory(##class(%File).GetDirectory(testRoot)_"/_data/pythonref-lifecycle/module1/")
+ set requiredLibsFile = $$$NormalizeFilename("requirements.txt",module1Dir)
+
+ set file = ##class(%File).%New(requiredLibsFile)
+ set sc = file.Open("R")
+ $$$ThrowOnError(sc)
+ while 'file.AtEnd {
+ set lib = file.ReadLine()
+ quit:lib=""
+ set lib = $zstrip(lib, "<>W")
+ set pythonLibs(lib)=""
+ }
+ do file.Close()
+
+ set status = ..RunCommand("load "_module1Dir)
+ do $$$AssertStatusOK(status,"Loaded demo-module1 module successfully. " _ module1Dir)
+
+ do ..VerifyDependenciesInstalled("demo-module1", .pythonLibs, 1)
+
+ set module2Dir = $$$NormalizeDirectory(##class(%File).GetDirectory(testRoot)_"/_data/pythonref-lifecycle/module2/")
+ set status = ..RunCommand("load "_module2Dir)
+ do $$$AssertStatusOK(status,"Loaded demo-module2 module successfully. " _ module2Dir)
+
+ do ..VerifyDependenciesInstalled("demo-module2", .pythonLibs, 2)
+
+ do ..UninstallWithModifier(.pythonLibs)
+
+ do $$$LogMessage("Reinstall the module for no-op uninstall test")
+
+ // reinstall the modules
+ set status = ..RunCommand("load "_module1Dir)
+ do $$$AssertStatusOK(status,"Loaded demo-module1 module successfully. " _ module2Dir)
+ set status = ..RunCommand("load "_module2Dir)
+ do $$$AssertStatusOK(status,"Loaded demo-module2 module successfully. " _ module2Dir)
+
+ do $$$LogMessage("Verifying the python library 'requests' installed as pypi demo-module2")
+
+ set pyRefOref = ##class(%IPM.Storage.PythonReference).%OpenId("requests")
+ if $isobject(pyRefOref) {
+ do $$$AssertTrue(pyRefOref.SourceType="pypi", "Python library 'requests' has expected SourceType of 'pypi' for demo-module2")
+ } else {
+ do $$$AssertNotTrue(1, "Python library 'requests' has not expected SourceType of 'pypi' for demo-module2")
+ }
+
+ do $$$LogMessage("Load the demo-module3 with '/home/irisowner/zpm/wheels/requests-2.32.4-py3-none-any.whl' dependency in requriements.txt")
+
+ set module3Dir = $$$NormalizeDirectory(##class(%File).GetDirectory(testRoot)_"/_data/pythonref-lifecycle/module3/")
+ set status = ..RunCommand("load "_module3Dir)
+ do $$$AssertStatusOK(status,"Loaded demo-module3 module successfully. " _ module3Dir)
+
+ do $$$LogMessage("Verifying the whl dependency is overwritten for demo-module3")
+
+ set pyRefOref = ##class(%IPM.Storage.PythonReference).%OpenId("requests")
+ if $isobject(pyRefOref) {
+ do $$$AssertTrue(pyRefOref.SourceType="whl", "Python library 'requests' has expected SourceType of 'whl' for demo-module3. whl path is "_pyRefOref.WheelPath)
+ } else {
+ do $$$AssertNotTrue(1, "Python library 'requests' has not expected SourceType of 'whl' for demo-module3")
+ }
+
+ do ..NoOpUninstall(.pythonLibs)
+
+ do ..UninstallPyLibsEntriesManually(.pythonLibs)
+}
+
+Method VerifyDependenciesInstalled(
+ pModule As %String,
+ ByRef PythonLibs,
+ ExpectedRefCount As %Integer)
+{
+ set module = ##class(%IPM.Storage.Module).NameOpen(pModule)
+ if '$isobject(module) {
+ do $$$AssertNotTrue(0,"Module "_pModule_" is not installed.")
+ quit
+ }
+ do $$$AssertTrue(1,"Module "_pModule_" is installed.")
+ do $$$LogMessage("Verifying python dependencies for module "_pModule_"...")
+ set key=""
+ for {
+ set pyRefOref = module.PythonDependencies.GetNext(.key)
+ quit:key=""
+ set libName = $$$lcase($piece(key,"=="))
+ if $data(PythonLibs(libName)) {
+ do $$$AssertTrue(1, "Python library "_libName_" is expected to be installed for module "_pModule)
+ do $$$AssertTrue(pyRefOref.RefCount=ExpectedRefCount, "Python library "_libName_" has expected reference count of "_pyRefOref.RefCount_" for module "_pModule)
+ }else {
+ do $$$AssertNotTrue(0, "Python library "_libName_" is NOT expected to be installed for module "_pModule)
+ }
+ }
+}
+
+Method UninstallWithModifier(ByRef PythonLibs)
+{
+ do $$$LogMessage("Verifying the reference count before uninstall the demo-module1 with -with-py modifier...")
+
+ do ..VerifyPythonLibRefCount(.PythonLibs, 2)
+ do $$$LogMessage("uninstall the modules with modifier -with-py")
+ set status = ..RunCommand("uninstall demo-module1 -with-py")
+ do $$$AssertStatusOK(status,"Uninstalled demo-module1 successfully.")
+
+ do ..VerifyPythonLibRefCount(.PythonLibs, 1)
+ set status = ..RunCommand("uninstall demo-module2 -with-py")
+ do $$$AssertStatusOK(status,"Uninstalled demo-module2 successfully.")
+ do ..VerifyPythonLibExist(.PythonLibs)
+}
+
+Method VerifyPythonLibExist(ByRef PythonLibs)
+{
+ set pyLib=""
+ do $$$LogMessage("Verifying python libraries exist...")
+ for {
+ set pyLib = $order(PythonLibs(pyLib)) quit:pyLib=""
+ set libName = $$$lcase($piece(pyLib,"=="))
+ set pyRefOref = ##class(%IPM.Storage.PythonReference).%OpenId(libName)
+ do $$$AssertTrue('$isobject(pyRefOref), "Python library "_libName_" not exists in PythonReference storage.")
+ }
+ do $$$LogMessage("Verifying python libraries exist check completed.")
+}
+
+Method NoOpUninstall(ByRef PythonLibs)
+{
+ do $$$LogMessage("executing NoOpUninstall: uninstall modules without -with-py modifier")
+ for module="demo-module1","demo-module2","demo-module3" {
+ do $$$LogMessage("uninstall "_module_" the modules without removing python libraries...")
+ set status = ..RunCommand("uninstall "_module)
+ do $$$AssertStatusOK(status,"Uninstalled "_module_" successfully.")
+ do ..VerifyDependenciesInstalled(module, .PythonLibs, 2)
+ }
+}
+
+Method VerifyPythonLibRefCount(
+ ByRef PythonLibs,
+ ExpectedRefCount As %Integer)
+{
+ set pyLib=""
+ do $$$LogMessage("Verifying python library reference counts. The count should 2.")
+ for {
+ set pyLib = $order(PythonLibs(pyLib)) quit:pyLib=""
+ set libName = $$$lcase($piece(pyLib,"=="))
+ set pyRefOref = ##class(%IPM.Storage.PythonReference).%OpenId(libName)
+ do $$$AssertTrue(pyRefOref.RefCount=ExpectedRefCount, "Python library "_libName_" has expected reference count of "_pyRefOref.RefCount)
+ }
+}
+
+Method UninstallPyLibsEntriesManually(ByRef PythonLibs)
+{
+ set pyLib=""
+ do $$$LogMessage("Uninstalling python libraries entries manually...")
+ try {
+ for {
+ set pyLib = $order(PythonLibs(pyLib))
+ quit:pyLib=""
+ set libName = $$$lcase($piece(pyLib,"=="))
+ continue:'##class(%IPM.Storage.PythonReference).%ExistsId(libName)
+ do $$$LogMessage("uninstall the python library "_libName_" via pip...")
+ do ##class(%IPM.Storage.PythonReference).UninstallPythonLibrary(libName)
+ set status = ##class(%IPM.Storage.PythonReference).%DeleteId(libName)
+ do $$$AssertTrue(status,"Removed python library "_libName_" from PythonReference storage.")
+ }
+ } catch ex {
+ do $$$AssertNotTrue(1,"Exception occurred during uninstalling python libraries entries manually: "_ex.DisplayString())
+ }
+ do $$$LogMessage("Uninstalling python libraries entries completed.")
+}
+
+Method RunCommand(pCommand As %String) As %Status [ CodeMode = expression ]
+{
+##class(%IPM.Main).Shell(pCommand)
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module1/module.xml b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module1/module.xml
new file mode 100644
index 000000000..49ab2178f
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module1/module.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ demo-module1
+ 1.0.0
+ description
+ keywords
+
+ your name
+ your organization
+ 2020
+ MIT
+ notes
+
+ module
+
+
+ src
+
+
+
\ No newline at end of file
diff --git a/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module1/requirements.txt b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module1/requirements.txt
new file mode 100644
index 000000000..a9db76f91
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module1/requirements.txt
@@ -0,0 +1,2 @@
+regex
+six
\ No newline at end of file
diff --git a/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module2/module.xml b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module2/module.xml
new file mode 100644
index 000000000..9e5b06bdb
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module2/module.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ demo-module2
+ 1.0.0
+ description
+ keywords
+
+ your name
+ your organization
+ 2020
+ MIT
+ notes
+
+ module
+
+
+ src
+
+
+
\ No newline at end of file
diff --git a/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module2/requirements.txt b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module2/requirements.txt
new file mode 100644
index 000000000..8973f79b6
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module2/requirements.txt
@@ -0,0 +1,3 @@
+requests
+regex
+six
\ No newline at end of file
diff --git a/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module3/module.xml b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module3/module.xml
new file mode 100644
index 000000000..db3c81f51
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module3/module.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ demo-module3
+ 1.0.1
+ description
+ keywords
+
+ your name
+ your organization
+ 2020
+ MIT
+ notes
+
+ module
+
+
+ src
+
+
+
\ No newline at end of file
diff --git a/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module3/requirements.txt b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module3/requirements.txt
new file mode 100644
index 000000000..0d10ed092
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/pythonref-lifecycle/module3/requirements.txt
@@ -0,0 +1 @@
+/home/irisowner/zpm/wheels/requests-2.32.4-py3-none-any.whl
\ No newline at end of file