Skip to content

Commit 6f54f80

Browse files
isc-jlechtneJames Lechtner
andauthored
Publishing a module with python dependencies (#1021)
* Initial commit for checks checks to run * Cleaning up changes to be ready for a PR to be reviewed * Adding publishing test to ProcessPythonWheel suite * Updating changelog to say -export-python-deps in publish is an addition as opposed to a fix * Making sure python dependencies of dependency modules are also exported * Updating changelog to keep up with main * One more change to changelog to keep up with main * These files shouldn't be here * Fixing the bug * Removing extraneous write statement * Adding more test cases for publishing with python dependencies * Commenting out failing package test case * Re-adding publish-with-deps which I accidentally delted before * Minor changes to start addressing review comments * [In Progress] Adding checking for files in packaged tarball to tests * [Completing] Adding checking for files in packaged tarball to tests --------- Co-authored-by: James Lechtner <James.Lechtner@intersystems.com>
1 parent 01cbaf3 commit 6f54f80

File tree

14 files changed

+336
-18
lines changed

14 files changed

+336
-18
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [0.10.6] - Unreleased
99

10+
### Added
11+
- #1024: Added flag -export-python-deps to publish command
12+
1013
### Fixed
1114
- #996: Ensure COS commands execute in exec under a dedicated, isolated context
1215
- #1002: When listing configured repositories, only show the TokenAuthMethod when a token is defined.
16+
- #1024: Modules with PythonWheels have wheels packaged correctly under -export-python-deps
1317

1418
## [0.10.5] - 2026-01-15
1519

src/cls/IPM/Lifecycle/Base.cls

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1182,10 +1182,6 @@ Method %Export(
11821182
}
11831183
}
11841184
}
1185-
set exportPythonDependencies = $get(pParams("ExportPythonDependencies"), 0)
1186-
if exportPythonDependencies {
1187-
do ..ExportPythonDependencies(.pParams)
1188-
}
11891185

11901186
// Deployed items should be exported as a studio project to a designated directory
11911187
set tDeployedProjectName = ##class(%IPM.Utils.Module).GetDeployedProjectName(..Module.Name)
@@ -1198,11 +1194,18 @@ Method %Export(
11981194
set tParams($$$DeployedProjectIndex) = tDeployedProject
11991195

12001196
set tModuleName = ""
1197+
set exportPythonDependencies = $get(pParams("ExportPythonDependencies"), 0)
12011198
for {
12021199
set tModuleName = $order(tResourceArray(tModuleName))
12031200
if (tModuleName = "") {
12041201
quit
12051202
}
1203+
if exportPythonDependencies {
1204+
set tModule = ##class(%IPM.Storage.Module).NameOpen(tModuleName)
1205+
do ..ExportPythonDependencies(tModule, .pParams)
1206+
// Recalculate resource array to pick up any python dependencies added during export
1207+
$$$ThrowOnError(..Module.GetResolvedReferences(.tResourceArray,tExportDependencies,..PhaseList,.pDependencyGraph))
1208+
}
12061209
kill tSingleModuleArray
12071210
merge tSingleModuleArray = tResourceArray(tModuleName)
12081211
do ..ExportSingleModule(.tSingleModuleArray, pTargetDirectory, .pDependencyGraph, .tParams, tVerbose)
@@ -1242,25 +1245,41 @@ Method %Export(
12421245
}
12431246

12441247
/// Downloads Python wheels for Python dependencies into the /root/wheels directory of the module. Also updates module.xml with the Python Wheel resources.
1245-
Method ExportPythonDependencies(ByRef pParams)
1248+
Method ExportPythonDependencies(
1249+
module As %IPM.Storage.Module,
1250+
ByRef pParams)
12461251
{
1247-
set root = ##class(%File).NormalizeDirectory("", ..Module.Root)
1252+
set orderedResourceList = module.GetOrderedResourceList()
1253+
set key = ""
1254+
set resourceNameList = ""
1255+
for {
1256+
set resource = orderedResourceList.GetNext(.key)
1257+
quit:key=""
1258+
set resourceNameList = resourceNameList_$listbuild($zconvert(resource.Name, "l"))
1259+
}
1260+
1261+
set root = ##class(%File).NormalizeDirectory("", module.Root)
12481262
$$$ThrowOnError(..InstallOrDownloadPythonRequirements(root, .pParams, 1))
1249-
set wheelsDir = ##class(%File).NormalizeDirectory("wheels", ..Module.Root)
1263+
set wheelsDir = ##class(%File).NormalizeDirectory("wheels", module.Root)
12501264
if '##class(%File).DirectoryExists(wheelsDir) {
1251-
$$$ThrowStatus($$$ERROR($$$GeneralError, "Wheels directory: "_wheelsDir_" not found."))
1265+
// ExportPythonDependencies called on base module + dependencies so don't want to error if one of
1266+
// the modules doesn't have a wheels directory since that might actually be the case for the module
1267+
write !, "WARNING: No wheels directory for this module: "_wheelsDir_" not found"
1268+
quit
12521269
}
12531270
set stmt = ##class(%SQL.Statement).%New()
12541271
$$$ThrowOnError(stmt.%PrepareClassQuery("%File", "FileSet"))
12551272
set rset = stmt.%Execute(wheelsDir, "*.whl")
12561273
$$$ThrowSQLIfError(rset.%SQLCODE,rset.%Message)
12571274
while rset.%Next(.sc) {
1258-
set file = rset.%Get("ItemName")
1275+
set wheelFileName = rset.%Get("ItemName")
12591276
set type = rset.%Get("Type")
1260-
if (type '= "F") || (file = "") {
1277+
if (type '= "F") || (wheelFileName = "") {
12611278
continue
12621279
}
1263-
do ##class(%IPM.StudioDocument.Module).AddPythonWheels(..Module.Name, file)
1280+
if '$listfind(resourceNameList, $zconvert(wheelFileName, "l")) {
1281+
do ##class(%IPM.StudioDocument.Module).AddPythonWheels(module.Name, wheelFileName)
1282+
}
12641283
}
12651284
$$$ThrowOnError(sc)
12661285
}
@@ -1843,6 +1862,7 @@ Method ExportSingleModule(
18431862
pVerbose As %Boolean = 0)
18441863
{
18451864
merge tParams = pParams
1865+
set exportPythonDependencies = $get(tParams("ExportPythonDependencies"), 0)
18461866

18471867
set tFullResourceName = ""
18481868
for {
@@ -1880,6 +1900,8 @@ Method ExportSingleModule(
18801900

18811901
#dim tProcessor As %IPM.ResourceProcessor.Abstract
18821902
if $data(pResourceArray(tFullResourceName,"Processor"),tProcessor) && $isobject(tProcessor) {
1903+
// If this is a python resource but -export-python-deps was not specified, skip this resource
1904+
if 'exportPythonDependencies && (tProcessor.%ClassName(1) = "%IPM.ResourceProcessor.PythonWheel") continue
18831905
kill tItemParams
18841906
merge tItemParams = pResourceArray(tFullResourceName)
18851907
set tItemHandled = 0

src/cls/IPM/Main.cls

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ This command is an alias for `module-action module-name publish`
140140
<parameter name="module" required="true" description="Name of module on which to perform publish actions" />
141141
<modifier name="repo" aliases="r" dataAlias="PublishTo" description="Namespace-unique name of repository for the module to publish to (if deployment is enabled)" />
142142
<modifier name="use-external-name" aliases="use-ext" dataAlias="UseExternalName" dataValue="1" description="Publish the package under the &lt;ExternalName&gt; of the package. If ExternalName is unspecified or illegal, an error will be thrown."/>
143+
<modifier name="export-python-deps" dataAlias="ExportPythonDependencies" dataValue="1" description="If specified, exports Python dependencies. If omitted, defaults to false." />
143144
</command>
144145

145146
<command name="update" dataPrefix="D">

src/cls/IPM/StudioDocument/Module.cls

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,9 +434,9 @@ XData AppendWheelUnderModuleTransform [ XMLNamespace = "http://www.intersystems.
434434
<xsl:variable name="ns" select="namespace-uri(..)"/>
435435
<xsl:copy><xsl:apply-templates select="@*|node()"/></xsl:copy>
436436
<xsl:element name="PythonWheel" namespace="{$ns}">
437-
<xsl:element name="Name" namespace="{$ns}">
437+
<xsl:attribute name="Name" namespace="{$ns}">
438438
<xsl:value-of select="$wheelName"/>
439-
</xsl:element>
439+
</xsl:attribute>
440440
</xsl:element>
441441

442442
</xsl:template>

tests/integration_tests/Test/PM/Integration/ProcessPythonWheel.cls

Lines changed: 229 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ Parameter PackageWithPythonDepsLocation = "package-with-python-deps";
1515

1616
Parameter ExportPackageWithPythonDepsLocation = "export-package-with-python-deps";
1717

18+
Parameter PublishWithPythonDepsLocation = "publish-with-python-deps";
19+
20+
Parameter ExportedPackagesDir = "/home/irisowner/zpm/tests/integration_tests/Test/PM/Integration/_data/python-deps-tests/export-package-with-python-deps/";
21+
22+
Method OnAfterOneTest() As %Status
23+
{
24+
// Make test environment clean by uninstalling all modules at the end of each test
25+
set sc = ##class(%IPM.Main).Shell("uninstall -all")
26+
do $$$AssertStatusOK(sc, "Successfully uninstalled all modules after test")
27+
set sc = ##class(%IPM.Main).Shell("repo -delete-all")
28+
do $$$AssertStatusOK(sc, "Successfully deleted all repositories after test")
29+
return sc
30+
}
31+
1832
/// The PythonWheel resource processor expects to install wheels from the dist directory, as opposed to source code
1933
ClassMethod GenerateWheel()
2034
{
@@ -178,25 +192,235 @@ Method TestPackageWithPythonDeps() As %Status
178192
set sc = ##class(%IPM.Main).Shell("install -v localrepo/package-with-python-deps")
179193
do $$$AssertStatusOK(sc, "Installed module from local filesystem repo")
180194

181-
set exportDir = ..GetModuleDir("python-deps-tests", ..#ExportPackageWithPythonDepsLocation)
182-
183-
set exportTarball = ..GetModuleDir("python-deps-tests", ..#ExportPackageWithPythonDepsLocation, "package-with-python-deps")
195+
set exportPath = ..GetRandomExportPath()
196+
set exportTarball = exportPath_".tgz"
184197

185198
// Run package with the python-deps export flag
186-
set sc = ##class(%IPM.Main).Shell("package package-with-python-deps -v -export-python-deps -path "_ exportTarball)
199+
set sc = ##class(%IPM.Main).Shell("package package-with-python-deps -v -export-python-deps -path "_ exportPath)
187200
do $$$AssertStatusOK(sc, "Packaged module with -export-python-deps")
188201

189202
set sc = ##class(%IPM.Main).Shell("uninstall package-with-python-deps")
190203
do $$$AssertStatusOK(sc, "Uninstalled module")
191204

192-
set sc = ##class(%IPM.Main).Shell("load -bypass-py-deps " _ exportDir _ "package-with-python-deps.tgz")
205+
206+
set wheels = { "wheels": ["ansible_core-2.19.4-py3-none-any.whl",
207+
"ansible_core-2.19.5-py3-none-any.whl",
208+
"ansible-12.2.0-py3-none-any.whl",
209+
"cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
210+
"cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl",
211+
"jinja2-3.1.6-py3-none-any.whl",
212+
"lune-1.6.4-py3-none-any.whl",
213+
"markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
214+
"packaging-25.0-py3-none-any.whl",
215+
"packaging-26.0-py3-none-any.whl",
216+
"pycparser-2.23-py3-none-any.whl",
217+
"pycparser-3.0-py3-none-any.whl",
218+
"pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
219+
"resolvelib-1.2.1-py3-none-any.whl"] }
220+
do ..AssertWheelFilesExistInPackagedModule(exportTarball, wheels)
221+
222+
set sc = ##class(%IPM.Main).Shell("load -bypass-py-deps "_exportTarball)
193223
do $$$AssertStatusOK(sc, "Loaded packaged module")
194224

195225
// Cleanup
196226
set sc = ##class(%IPM.Main).Shell("uninstall package-with-python-deps")
197227
do $$$AssertStatusOK(sc, "Uninstalled module")
228+
198229
set sc = ##class(%IPM.Main).Shell("repo -delete -n localrepo")
199230
do $$$AssertStatusOK(sc, "Deleted local filesystem repo")
200231
}
201232

233+
/// Omitting -export-python-deps flag should package without the python
234+
Method TestPackageWithoutPythonDeps() As %Status
235+
{
236+
set dir = ..GetModuleDir("python-deps-tests", ..#PackageWithPythonDepsLocation)
237+
238+
// Use a local filesystem repo to install the test module into the namespace
239+
set sc = ##class(%IPM.Main).Shell("repo -fs -name localrepo -path " _ dir)
240+
do $$$AssertStatusOK(sc, "Configured local filesystem repo")
241+
242+
set sc = ##class(%IPM.Main).Shell("install -v localrepo/package-with-python-deps")
243+
do $$$AssertStatusOK(sc, "Installed module from local filesystem repo")
244+
245+
set exportPath = ..GetRandomExportPath()
246+
set exportTarball = exportPath_".tgz"
247+
248+
// Run package without the -export-python-deps flag
249+
set sc = ##class(%IPM.Main).Shell("package package-with-python-deps -v -path "_ exportPath)
250+
do $$$AssertStatusOK(sc, "Packaged module without -export-python-deps")
251+
252+
set sc = ##class(%IPM.Main).Shell("uninstall package-with-python-deps")
253+
do $$$AssertStatusOK(sc, "Uninstalled module")
254+
255+
256+
set wheels = { "wheels": ["ansible_core-2.19.4-py3-none-any.whl",
257+
"ansible_core-2.19.5-py3-none-any.whl",
258+
"ansible-12.2.0-py3-none-any.whl",
259+
"cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
260+
"cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl",
261+
"jinja2-3.1.6-py3-none-any.whl",
262+
"lune-1.6.4-py3-none-any.whl",
263+
"markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
264+
"packaging-25.0-py3-none-any.whl",
265+
"packaging-26.0-py3-none-any.whl",
266+
"pycparser-2.23-py3-none-any.whl",
267+
"pycparser-3.0-py3-none-any.whl",
268+
"pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
269+
"resolvelib-1.2.1-py3-none-any.whl"] }
270+
do ..AssertWheelFilesExistInPackagedModule(exportTarball, wheels, 0)
271+
272+
set sc = ##class(%IPM.Main).Shell("repo -delete -n localrepo")
273+
do $$$AssertStatusOK(sc, "Deleted local filesystem repo")
274+
}
275+
276+
/// Test publishing with the -export-python-deps flag
277+
Method TestPublishWithPythonDeps() As %Status
278+
{
279+
280+
// 1. Test the case of python dependencies defined via requirements.txt
281+
set moduleNameReq = "publish-with-python-just-reqs"
282+
set wheelsReq = ["docx2md-1.0.5-py3-none-any.whl",
283+
"lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl",
284+
"pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl"]
285+
set wheels = {}
286+
do wheels.%Set(moduleNameReq, wheelsReq)
287+
do ..PublishTestHandler(moduleNameReq, wheels)
288+
289+
// 2. Test the case of python dependencies defined via wheel resources in module.xml
290+
set moduleNameWhl = "publish-with-python-just-wheels"
291+
set wheelsWhl = ["numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl"]
292+
set wheels = {}
293+
do wheels.%Set(moduleNameWhl, wheelsWhl)
294+
do ..PublishTestHandler(moduleNameWhl, wheels)
295+
296+
// 3. Test the case of python dependencies both defined in module.xml and requirements.txt
297+
set moduleNameWhlReq = "publish-with-python-wheels-and-reqs"
298+
set wheelsWhlReq = ["colorama-0.4.6-py2.py3-none-any.whl",
299+
"six-1.17.0-py2.py3-none-any.whl"]
300+
set wheels = {}
301+
do wheels.%Set(moduleNameWhlReq, wheelsWhlReq)
302+
do ..PublishTestHandler(moduleNameWhlReq, wheels)
303+
304+
305+
306+
// 4. Test the three cases above but as dependencies of a base module being installed
307+
set moduleNameDeps = "publish-with-python-deps-base"
308+
// There are no wheels defined for this base module but each of the dependencies do have wheels to check
309+
set wheels = {}
310+
do wheels.%Set(moduleNameReq, wheelsReq)
311+
do wheels.%Set(moduleNameWhl, wheelsWhl)
312+
do wheels.%Set(moduleNameWhlReq, wheelsWhlReq)
313+
do ..PublishTestHandler(moduleNameDeps, wheels)
314+
}
315+
316+
/// Handler method for publish test suite
317+
Method PublishTestHandler(
318+
moduleName As %String,
319+
wheels As %DynamicObject)
320+
{
321+
// Create a filesystem repository
322+
set dir = ..GetModuleDir("python-deps-tests", ..#PublishWithPythonDepsLocation)
323+
set sc = ##class(%IPM.Main).Shell("repo -fs -name localrepo -path "_dir)
324+
do $$$AssertStatusOK(sc, "Configured local filesystem repo")
325+
326+
// Install module from local filesystem repo
327+
set sc = ##class(%IPM.Main).Shell("install -v localrepo/"_moduleName)
328+
do $$$AssertStatusOK(sc, "Installed module from local filesystem repo")
329+
330+
// Create a remote repository for publishing
331+
set sc = ##class(%IPM.Main).Shell("repo -r -name remote -url http://registry:52773/registry -username admin -password SYS")
332+
do $$$AssertStatusOK(sc, "Configured remote repo")
333+
334+
// Publish module to remote repository
335+
set sc = ##class(%IPM.Main).Shell("publish -v "_moduleName_" -r remote -export-python-deps -export-deps 1")
336+
do $$$AssertStatusOK(sc, "Published module "_moduleName_" to remote repository")
337+
338+
// Uninstall module that came from filesystem repository
339+
set sc = ##class(%IPM.Main).Shell("uninstall "_moduleName)
340+
do $$$AssertStatusOK(sc, "Uninstalled module successfully")
341+
342+
// Install published module from remote repository
343+
set sc = ##class(%IPM.Main).Shell("install -v remote/"_moduleName)
344+
do $$$AssertStatusOK(sc, "Successfully installed published module "_moduleName_" from remote repository")
345+
346+
// Confirm wheels were included in packaging/publishing
347+
do ..AssertWheelResourcesExistForModule(wheels)
348+
349+
// Uninstall the module used for this test
350+
set sc = ##class(%IPM.Main).Shell("uninstall "_moduleName)
351+
do $$$AssertStatusOK(sc, "Uninstalled module "_moduleName_" successfully")
352+
353+
// Remove the repositories used for this test
354+
set sc = ##class(%IPM.Main).Shell("repo -delete -n localrepo")
355+
do $$$AssertStatusOK(sc, "Removed local filesystem repo")
356+
set sc = ##class(%IPM.Main).Shell("repo -delete -n remote")
357+
do $$$AssertStatusOK(sc, "Removed remote repo")
358+
}
359+
360+
/// Used in package and publish tests to get a known, random directory path for where module gets packaged to
361+
Method GetRandomExportPath()
362+
{
363+
set randomDir = $translate($system.Encryption.Base64Encode($system.Encryption.GenCryptRand(6)),"+/=")
364+
return ..#ExportedPackagesDir_randomDir_"/package"
365+
}
366+
367+
/// Checks to see if the physical files exist in a packaged module
368+
///
369+
/// Arguments:
370+
/// - exportTarball: path to the packaged tarball
371+
/// - wheels: Object of <relative path>-<wheel name> pairs array of file names to check for in the unpackaged module
372+
/// Ex: { "/": ["wheel-1.whl","wheel-2.whl"], "wheels":["wheel-3.whl","wheel-4.whl"] }
373+
/// - doesExist: set to 0 if we want to check that files explicitly do not exist (in the case -export-python-deps was not provided)
374+
Method AssertWheelFilesExistInPackagedModule(
375+
exportTarball As %String,
376+
wheels As %DynamicObject,
377+
doesExist As %Boolean = 1)
378+
{
379+
set exportDir = ##class(%File).GetDirectory(exportTarball)
380+
set sc = ##class(%IPM.General.Archive).Extract(exportTarball, exportDir)
381+
do $$$AssertStatusOK(sc, "Extracted packaged tarball contents to scan for wheel files")
382+
383+
384+
set wheelsIter = wheels.%GetIterator()
385+
while wheelsIter.%GetNext(.relativePath, .wheelArr) {
386+
set directory = ##class(%File).NormalizeDirectory(relativePath, exportDir)
387+
set wheelArrIter = wheelArr.%GetIterator()
388+
while wheelArrIter.%GetNext(, .wheelName) {
389+
set fileName = ##class(%File).NormalizeFilename(wheelName, directory)
390+
set exists = ##class(%File).Exists(fileName)
391+
if (doesExist) {
392+
do $$$AssertTrue(exists, wheelName_" exists in packaged contents")
393+
}
394+
else {
395+
do $$$AssertNotTrue(exists, wheelName_" not included in packaged contents")
396+
}
397+
}
398+
}
399+
}
400+
401+
/// Given an object of <module name>-<wheel list> pairs,
402+
/// compiles a list of resources for the module and iterates over the wheel list to check if that wheel is a resource of the module
403+
///
404+
/// Ex: wheels = { "module1": ["wheel-1.whl","wheel-2.whl"], "module2":["wheel-3.whl","wheel-4.whl"] }
405+
Method AssertWheelResourcesExistForModule(wheels As %DynamicObject)
406+
{
407+
set wheelsIter = wheels.%GetIterator()
408+
while wheelsIter.%GetNext(.moduleName, .moduleWheels) {
409+
set module = ##class(%IPM.Storage.Module).NameOpen(moduleName)
410+
set resourceList = ""
411+
set key = ""
412+
for {
413+
set resource = module.Resources.GetNext(.key)
414+
quit:key=""
415+
set resourceList = resourceList_$listbuild($zconvert(resource.Name, "l"))
416+
}
417+
418+
set moduleWheelsIter = moduleWheels.%GetIterator()
419+
while moduleWheelsIter.%GetNext(, .wheel) {
420+
set wheelIsResource = ($listfind(resourceList, wheel) > 0)
421+
do $$$AssertTrue(wheelIsResource, "Wheel resource "_wheel_" is a part of the installed module "_moduleName)
422+
}
423+
}
424+
}
425+
202426
}

0 commit comments

Comments
 (0)