This is an automated email from the ASF dual-hosted git repository.
kunwp1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/main by this push:
new df23815633 feat: Add Python Virtual Environment Support: Uninstalling
User Defined Packages (#5035)
df23815633 is described below
commit df238156335a37c2479ead2a56b2c0a843224ff9
Author: Sarah Asad <[email protected]>
AuthorDate: Tue May 12 21:15:03 2026 -0700
feat: Add Python Virtual Environment Support: Uninstalling User Defined
Packages (#5035)
<!--
Thanks for sending a pull request (PR)! Here are some tips for you:
1. If this is your first time, please read our contributor guidelines:
[Contributing to
Texera](https://github.com/apache/texera/blob/main/CONTRIBUTING.md)
2. Ensure you have added or run the appropriate tests for your PR
3. If the PR is work in progress, mark it a draft on GitHub.
4. Please write your PR title to summarize what this PR proposes, we
are following Conventional Commits style for PR titles as well.
5. Be sure to keep the PR description updated to reflect all changes.
-->
### What changes were proposed in this PR?
<!--
Please clarify what changes you are proposing. The purpose of this
section
is to outline the changes. Here are some tips for you:
1. If you propose a new API, clarify the use case for a new API.
2. If you fix a bug, you can clarify why it is a bug.
3. If it is a refactoring, clarify what has been changed.
3. It would be helpful to include a before-and-after comparison using
screenshots or GIFs.
4. Please consider writing useful notes for better and faster reviews.
-->
This PR is an extension of PR https://github.com/apache/texera/pull/4484
and #4902. Previously, we introduced support for creating Python Virtual
Environments (PVEs) with system-level dependencies preinstalled, along
with support for installing user-defined packages. This PR extends that
functionality by allowing users to uninstall user-installed packages
from their PVEs.
### Any related issues, documentation, discussions?
<!--
Please use this section to link other resources if not mentioned
already.
1. If this PR fixes an issue, please include `Fixes #1234`, `Resolves
#1234`
or `Closes #1234`. If it is only related, simply mention the issue
number.
2. If there is design documentation, please add the link.
3. If there is a discussion in the mailing list, please add the link.
-->
This change is part of ongoing efforts to support environment isolation
and reproducibility within Texera. Related issue includes
https://github.com/apache/texera/issues/4296. This PR closes sub-issue
#4466.
### How was this PR tested?
<!--
If tests were added, say they were added here. Or simply mention that if
the PR
is tested with existing test cases. Make sure to include/update test
cases that
check the changes thoroughly including negative and positive cases if
possible.
If it was tested in a way different from regular unit tests, please
clarify how
you tested step by step, ideally copy and paste-able, so that other
reviewers can
test and check, and descendants can verify in the future. If tests were
not added,
please describe why they were not added and/or why it was difficult to
add.
-->
Tested Manually and PveResourceSpec test file updated.
To test:
On CU click "+" Python Environments.
Input environment name.
Input package name and version.
Click "OK" and wait for pip logs.
To delete click on "Delete Icon" and click "OK"
### Was this PR authored or co-authored using generative AI tooling?
<!--
If generative AI tooling has been used in the process of authoring this
PR,
please include the phrase: 'Generated-by: ' followed by the name of the
tool
and its version. If no, write 'No'.
Please refer to the [ASF Generative Tooling
Guidance](https://www.apache.org/legal/generative-tooling.html) for
details.
-->
Co-authored using: ChatGPT (OpenAI)
---------
Co-authored-by: Kunwoo (Chris) <[email protected]>
---
.../pythonvirtualenvironment/PveManager.scala | 93 ++++++++++++++++++++++
.../pythonvirtualenvironment/PveResource.scala | 28 +++++++
.../pythonvirtualenvironment/PveResourceSpec.scala | 41 ++++++++++
.../computing-unit-selection.component.html | 33 +++++++-
.../computing-unit-selection.component.ts | 88 +++++++++++++++++++-
.../virtual-environment.service.ts | 9 +++
frontend/src/styles.scss | 59 ++++++++------
7 files changed, 320 insertions(+), 31 deletions(-)
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
index 27a3b7be7c..fce636a382 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveManager.scala
@@ -341,4 +341,97 @@ object PveManager {
queue.put(s"[user-package] $pkg")
}
}
+
+ /**
+ * Uninstalls a user-installed package from the PVE.
+ * 1. Prevents deletion of system packages
+ * 2. Updates user metadata upon success
+ * 3. Returns status messages
+ */
+ def deletePackages(
+ cuid: Int,
+ packageName: String,
+ pveName: String,
+ isLocal: Boolean
+ ): List[String] = {
+ val python = pythonBinPath(cuid, pveName).toAbsolutePath.toString
+ val metadataPath = cuidDir(cuid, pveName).resolve("user-packages.txt")
+
+ if (!Files.exists(Paths.get(python))) {
+ val msg = s"[PVE][ERR] Python executable not found for PVE: $python"
+ println(msg)
+ return List(msg)
+ }
+
+ val trimmedPackageName = packageName.trim
+ val normalizedPackageName =
trimmedPackageName.split("==")(0).trim.toLowerCase
+
+ val systemPackages =
+ if (Files.exists(getSystemPath(isLocal))) {
+ Files
+ .readAllLines(getSystemPath(isLocal))
+ .asScala
+ .map(_.trim)
+ .filter(line => line.nonEmpty && !line.startsWith("#"))
+ .map(line => line.split("==")(0).trim.toLowerCase)
+ .toSet
+ } else {
+ Set[String]()
+ }
+
+ if (systemPackages.contains(normalizedPackageName)) {
+ return List(
+ s"[PVE][ERR] $trimmedPackageName is a system package and cannot be
deleted."
+ )
+ }
+
+ try {
+ val command = Process(
+ Seq(
+ python,
+ "-u",
+ "-m",
+ "pip",
+ "uninstall",
+ "-y",
+ trimmedPackageName
+ ),
+ None,
+ pipEnv.toSeq: _*
+ )
+
+ val output = scala.collection.mutable.ListBuffer[String]()
+
+ val exitCode = command.!(
+ ProcessLogger(
+ out => {
+ println(s"[pip] $out")
+ output += s"[pip] $out"
+ },
+ err => {
+ System.err.println(s"[pip][ERR] $err")
+ output += s"[pip][ERR] $err"
+ }
+ )
+ )
+
+ if (exitCode == 0) {
+ val updatedPackages = readPackageFile(metadataPath)
+ .filterNot(line => line.split("==")(0).trim.toLowerCase ==
normalizedPackageName)
+ .sorted
+
+ Files.write(metadataPath, updatedPackages.asJava)
+
+ output += s"[pip] uninstall($trimmedPackageName) finished with exit
code $exitCode"
+ output += s"[PVE] Uninstalled $trimmedPackageName successfully"
+ } else {
+ output += s"[PVE][ERR] Failed to uninstall package:
$trimmedPackageName"
+ }
+
+ output.toList
+ } catch {
+ case e: Exception =>
+ List(s"[PVE][ERR] Failed to delete package for cuid=$cuid:
${e.getMessage}")
+ }
+ }
}
diff --git
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
index 8a6f487529..4d810678cc 100644
---
a/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
+++
b/amber/src/main/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResource.scala
@@ -23,6 +23,9 @@ import javax.ws.rs._
import javax.ws.rs.core.MediaType
import scala.jdk.CollectionConverters._
import java.util
+import javax.ws.rs.DELETE
+import javax.ws.rs.PathParam
+import javax.ws.rs.core.Response
@Path("/pve")
@Consumes(Array(MediaType.APPLICATION_JSON))
@@ -85,4 +88,29 @@ class PveResource {
def deleteEnvironments(@PathParam("cuId") cuid: Int): Unit = {
PveManager.deleteEnvironments(cuid)
}
+
+ // --------------------------------------------------
+ // Delete User Installed Package
+ // --------------------------------------------------
+ @DELETE
+ @Path("/{cuid}/{pveName}/packages/{packageName}")
+ def deletePackage(
+ @PathParam("cuid") cuid: Int,
+ @PathParam("pveName") pveName: String,
+ @PathParam("packageName") packageName: String,
+ @QueryParam("isLocal") isLocal: Boolean
+ ): Response = {
+ val messages = PveManager.deletePackages(
+ cuid,
+ packageName,
+ pveName,
+ isLocal
+ )
+
+ if (messages.exists(_.contains("[PVE][ERR]"))) {
+
Response.status(Response.Status.BAD_REQUEST).entity(messages.asJava).build()
+ } else {
+ Response.ok(messages.asJava).build()
+ }
+ }
}
diff --git
a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
index 10e952c8bd..f8b365c003 100644
---
a/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
+++
b/amber/src/test/scala/org/apache/texera/web/resource/pythonvirtualenvironment/PveResourceSpec.scala
@@ -98,6 +98,47 @@ class PveResourceSpec extends AnyFlatSpec with Matchers with
BeforeAndAfterEach
pve.get.userPackages should contain(packageSpec)
}
+ "PveManager" should "delete a user package and remove it from the PVE
package list" in {
+ PveManager.createNewPve(testCuid, queue, testPveName, isLocal = true)
+
+ val packageName = "colorama"
+ val packageVersion = "0.4.6"
+ val packageSpec = s"$packageName==$packageVersion"
+
+ queue.clear()
+
+ PveManager.installUserPackages(
+ List(packageSpec),
+ testCuid,
+ queue,
+ testPveName,
+ isLocal = true
+ )
+
+ PveManager
+ .getEnvironments(testCuid)
+ .find(_.pveName == testPveName)
+ .get
+ .userPackages should contain(packageSpec)
+
+ val deleteLogs = PveManager.deletePackages(
+ testCuid,
+ packageName,
+ testPveName,
+ isLocal = true
+ )
+
+ deleteLogs.mkString("\n") should not include "[PVE][ERR]"
+ deleteLogs.mkString("\n") should include(s"[PVE] Uninstalled $packageName
successfully")
+
+ val pve = PveManager
+ .getEnvironments(testCuid)
+ .find(_.pveName == testPveName)
+
+ pve should not be empty
+ pve.get.userPackages should not contain packageSpec
+ }
+
"PveManager" should "delete all PVEs for a computing unit" in {
PveManager.createNewPve(testCuid, queue, testPveName, isLocal = true)
diff --git
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
index 6f16073073..6167d36759 100644
---
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
+++
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.html
@@ -554,17 +554,31 @@
[ngModel]="pkg.version"
[disabled]="true" />
</div>
+ <button
+ nz-button
+ nzType="default"
+ nzShape="circle"
+ nzDanger
+ [ngClass]="{ 'highlighted-btn': pkg.deleteToggle }"
+ (click)="togglePackageDelete(envIndex, pkg)">
+ <i
+ nz-icon
+ nzType="delete"></i>
+ </button>
</div>
</div>
<!-- NEW PACKAGES -->
<div class="new-packages-section">
<div
- class="user-package-header-row"
+ class="package-row user-package-header-row"
*ngIf="pve.newPackages.length > 0">
- <div class="package-column-label">Package</div>
- <label style="visibility: hidden">Op</label>
- <div class="package-column-label">Version</div>
+ <div class="user-package-inputs">
+ <div class="package-column-label">Package</div>
+ <div></div>
+ <div class="package-column-label">Version</div>
+ <div></div>
+ </div>
</div>
<div
@@ -601,6 +615,17 @@
placeholder="Package Version"
[(ngModel)]="pve.newPackages[i].version" />
</div>
+ <button
+ nz-button
+ nzType="default"
+ nzShape="circle"
+ nzDanger
+ [ngClass]="{ 'highlighted-btn': pkg.deleteToggle }"
+ (click)="togglePackageDelete(envIndex, pkg)">
+ <i
+ nz-icon
+ nzType="delete"></i>
+ </button>
</div>
</div>
</div>
diff --git
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
index 1b3dfb2322..a2843a51bf 100644
---
a/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
+++
b/frontend/src/app/workspace/component/power-button/computing-unit-selection.component.ts
@@ -81,12 +81,14 @@ type PveUserPackageRow = {
name: string;
versionOp?: "==" | ">=" | "<=";
version?: string;
+ deleteToggle?: boolean;
};
type PveDraft = {
name: string;
userPackages: PveUserPackageRow[];
newPackages: PveUserPackageRow[];
+ deletingPackages: { name: string; version: string }[];
pipOutput: string;
prettyPipOutput: string;
expanded: boolean;
@@ -732,7 +734,21 @@ export class ComputingUnitSelectionComponent implements
OnInit {
addPackage(index: number): void {
const env = this.pves[index];
- env.newPackages.push({ name: "", version: "", versionOp: undefined });
+ env.newPackages.push({ name: "", version: "", versionOp: undefined,
deleteToggle: false });
+ }
+
+ togglePackageDelete(index: number, pkg: PveUserPackageRow): void {
+ const env = this.pves[index];
+
+ pkg.deleteToggle = !pkg.deleteToggle;
+
+ const version = pkg.version ?? "";
+
+ env.deletingPackages = env.deletingPackages.filter(p => !(p.name ===
pkg.name && p.version === version));
+
+ if (pkg.deleteToggle) {
+ env.deletingPackages.push({ name: pkg.name, version });
+ }
}
addEnvironment(): void {
@@ -740,6 +756,7 @@ export class ComputingUnitSelectionComponent implements
OnInit {
name: "",
userPackages: [],
newPackages: [],
+ deletingPackages: [],
pipOutput: "",
prettyPipOutput: "",
expanded: true,
@@ -776,6 +793,7 @@ export class ComputingUnitSelectionComponent implements
OnInit {
name: pve.pveName,
userPackages: this.parsePackageRows(pve.userPackages),
newPackages: [],
+ deletingPackages: [],
expanded: false,
isInstalling: false,
pipOutput: "",
@@ -949,6 +967,7 @@ export class ComputingUnitSelectionComponent implements
OnInit {
createVirtualEnvironment(index: number): void {
const env = this.pves[index];
const trimmedName = env.name.trim();
+ const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
if (!/^[a-zA-Z0-9]+$/.test(trimmedName)) {
this.notificationService.error("Environment name must contain only
letters and numbers.");
@@ -956,7 +975,9 @@ export class ComputingUnitSelectionComponent implements
OnInit {
}
if (env.isLocked) {
- this.installUserPackages(index);
+ this.deleteUserPackages(index, () => {
+ this.installUserPackages(index);
+ });
return;
}
@@ -968,7 +989,9 @@ export class ComputingUnitSelectionComponent implements
OnInit {
}
this.runPveWebSocket(index, "create", "Creating virtual environment...\n",
[], () => {
- this.installUserPackages(index);
+ this.deleteUserPackages(index, () => {
+ this.installUserPackages(index);
+ });
});
}
@@ -1019,6 +1042,7 @@ export class ComputingUnitSelectionComponent implements
OnInit {
if (packageArray.length === 0) {
this.pves[index].newPackages = [];
+ this.pves[index].isInstalling = false;
this.refreshUserPackages(index);
return;
}
@@ -1039,4 +1063,62 @@ export class ComputingUnitSelectionComponent implements
OnInit {
};
});
}
+
+ private deleteUserPackages(index: number, onDone?: () => void): void {
+ const cuId = this.selectedComputingUnit!.computingUnit.cuid;
+ const isLocal = this.selectedComputingUnit?.computingUnit.type === "local";
+ const pveName = this.pves[index].name.trim();
+ const packagesToDelete = [...this.pves[index].deletingPackages];
+
+ if (packagesToDelete.length === 0) {
+ onDone?.();
+ return;
+ }
+
+ this.pves[index] = {
+ ...this.pves[index],
+ pipOutput: `${this.pves[index].pipOutput ?? ""}Deleting user
packages...\n`,
+ isInstalling: true,
+ };
+
+ let deleteIndex = 0;
+
+ const deleteNext = (): void => {
+ if (deleteIndex >= packagesToDelete.length) {
+ this.pves[index].deletingPackages = [];
+ this.refreshUserPackages(index);
+ onDone?.();
+ return;
+ }
+
+ const pkg = packagesToDelete[deleteIndex];
+
+ this.workflowPveService
+ .deletePackage(cuId, pveName, pkg.name, isLocal)
+ .pipe(untilDestroyed(this))
+ .subscribe({
+ next: messages => {
+ this.pves[index].pipOutput = `${this.pves[index].pipOutput ??
""}${messages.join("\n")}\n`;
+
+ this.updatePrettyPipOutput(index);
+ this.scrollToBottomOfPipModal(index);
+
+ deleteIndex++;
+ deleteNext();
+ },
+ error: () => {
+ this.pves[index].pipOutput =
+ `${this.pves[index].pipOutput ?? ""}[PVE][ERR] Failed to delete
package: ${pkg.name}\n`;
+
+ this.updatePrettyPipOutput(index);
+ this.scrollToBottomOfPipModal(index);
+
+ deleteIndex++;
+ deleteNext();
+ },
+ });
+ };
+
+ deleteNext();
+ }
}
diff --git
a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
index 9c7c123df1..7788cba270 100644
---
a/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
+++
b/frontend/src/app/workspace/service/virtual-environment/virtual-environment.service.ts
@@ -67,6 +67,15 @@ export class WorkflowPveService {
return this.http.delete(`/pve/pves/${cuid}`);
}
+ deletePackage(cuid: number, pveName: string, packageName: string, isLocal:
boolean) {
+ const params = this.buildBaseParams().set("isLocal", isLocal.toString());
+
+ return this.http.delete<string[]>(
+
`/pve/${cuid}/${encodeURIComponent(pveName)}/packages/${encodeURIComponent(packageName)}`,
+ { params }
+ );
+ }
+
getPveWebSocketUrl(cuid: number, pveName: string, isLocal: boolean, action:
string, packages: string[] = []): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const query = encodeURIComponent(JSON.stringify(packages));
diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss
index 27b2a5362a..5f4a00952a 100644
--- a/frontend/src/styles.scss
+++ b/frontend/src/styles.scss
@@ -55,14 +55,6 @@ img {
padding-bottom: 0;
}
-.ant-select-selector {
- height: 24px !important;
-}
-
-.ant-select-selection-item {
- line-height: 24px !important;
-}
-
.ant-form-item-label {
padding-bottom: 0 !important;
}
@@ -236,6 +228,21 @@ hr {
padding: 14px !important;
}
+ .operator-select {
+ height: 24px;
+ }
+
+ .operator-select nz-select,
+ .operator-select .ant-select,
+ .operator-select .ant-select-selector {
+ height: 24px !important;
+ }
+
+ .operator-select .ant-select-selection-item,
+ .operator-select .ant-select-selection-placeholder {
+ line-height: 22px !important;
+ }
+
.ve-form {
display: flex;
flex-direction: column;
@@ -262,7 +269,7 @@ hr {
.package-row {
display: flex;
- align-items: flex-end;
+ align-items: center;
justify-content: space-between;
gap: 10px;
bottom: 0px;
@@ -281,9 +288,10 @@ hr {
.user-package-inputs {
flex: 1;
display: grid;
- grid-template-columns: 1fr 160px 1fr;
+ grid-template-columns: 1fr 160px 1fr 58px;
gap: 14px;
width: 100%;
+ align-items: center;
}
.field {
@@ -309,11 +317,6 @@ hr {
width: 100%;
}
- .ant-input,
- .ant-select-selector {
- //border-radius: 10px !important;
- }
-
.ant-input[disabled] {
background: #f5f6f8 !important;
border-color: #e6e8ec !important;
@@ -369,22 +372,30 @@ hr {
background: transparent;
}
- .user-package-header-row .package-column-label {
- font-weight: 600;
- }
-
.new-packages-section {
display: flex;
flex-direction: column;
gap: 2px;
}
+ .user-package-inputs button {
+ align-self: center;
+ justify-self: center;
+ }
+
+ .highlighted-btn {
+ background-color: #ff4d4f !important; /* Ant Design red */
+ border-color: #ff4d4f !important;
+ color: white !important;
+ }
.user-package-header-row {
- display: grid;
- grid-template-columns: 1fr 160px 1fr;
- gap: 14px;
- margin-bottom: 0;
- padding: 0;
+ border: none;
+ background: transparent;
+ align-items: center;
+ }
+
+ .user-package-header-row .package-column-label {
+ font-weight: 600;
}
.system-header {