Skip to content

Commit c4e7e7e

Browse files
authored
Python script only packages FE support (#48946)
**Related issue:** Resolves #48394 Adds `.py` to the custom-package upload form and software details page, accepted and detected as a script-only package (advanced options follow `.sh`/`.ps1`), shown with the Python icon and per-platform file-type tooltips, and mapped to the `py_packages` source that renders as "Script-only package (macOS & Linux)". # Checklist for submitter - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## Manual QA - [x] Verified live in the browser against a dev server
1 parent fb9966e commit c4e7e7e

18 files changed

Lines changed: 181 additions & 20 deletions

File tree

frontend/components/ActivityDetails/InstallDetails/SoftwareScriptDetailsModal/SoftwareScriptDetailsModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** This component is intentionally separate from SoftwareInstallDetailsModal
2-
* because it handles script-only package installs (e.g. sh_packages or ps1_packages)
2+
* because it handles script-only package installs (e.g. sh_packages, ps1_packages, or py_packages)
33
*
44
* Key differences from SoftwareInstallDetailsModal:
55
* - Uses Script/Run/Rerun language in UI instead of Install/Retry.

frontend/interfaces/package_type.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const fleetMaintainedPackageTypes = ["dmg", "zip"] as const;
22
const unixPackageTypes = ["pkg", "deb", "rpm", "dmg", "zip", "tar.gz"] as const;
33
const windowsPackageTypes = ["msi", "exe", "zip"] as const;
4-
const scriptOnlyPackageTypes = ["sh", "ps1"] as const;
4+
const scriptOnlyPackageTypes = ["sh", "ps1", "py"] as const;
55
const iosIpadosPackageTypes = ["ipa"] as const;
66
export const packageTypes = [
77
...unixPackageTypes,

frontend/interfaces/setup.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export type SetupStepStatus = typeof SETUP_STEP_STATUSES[number];
1313
/** These type extends onto API returned software steps */
1414
export const SETUP_STEP_TYPES = [
1515
"software_install", // API key: software
16-
"software_script_run", // API key: software, detected via source === "sh_packages" || "ps1_packages"
16+
"software_script_run", // API key: software, detected via a script package source (see SCRIPT_PACKAGE_SOURCES)
1717
"script_run", // API key: scripts
1818
];
1919

@@ -24,7 +24,7 @@ export interface ISetupStep {
2424
status: SetupStepStatus;
2525
type: SetupStepType;
2626
error?: string | null;
27-
source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "apps")
27+
source?: SoftwareSource; // Software source (e.g., "sh_packages", "ps1_packages", "py_packages", "apps")
2828
display_name?: string | null;
2929
icon_url?: string | null;
3030
}

frontend/interfaces/software.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ export const SOURCE_TYPE_CONVERSION = {
299299
vscode_extensions: "IDE extension", // vscode_extensions can include any vscode-based editor (e.g., Cursor, Trae, Windsurf), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
300300
sh_packages: "Script-only package (macOS & Linux)",
301301
ps1_packages: "Script-only package (Windows)",
302+
py_packages: "Script-only package (macOS & Linux)",
302303
jetbrains_plugins: "IDE extension", // jetbrains_plugins can include any JetBrains IDE (e.g., IntelliJ, PyCharm, WebStorm), so we rely instead on the `extension_for` field computed by Fleet server and fallback to this value if it is not present.
303304
go_binaries: "Binary (Go)",
304305
} as const;
@@ -332,11 +333,16 @@ export const INSTALLABLE_SOURCE_PLATFORM_CONVERSION = {
332333
vscode_extensions: null,
333334
sh_packages: "linux", // 4.76 Added support for Linux hosts only
334335
ps1_packages: "windows",
336+
py_packages: "linux", // stored as linux; also runs on macOS via the unix-like install exception
335337
jetbrains_plugins: null,
336338
go_binaries: null,
337339
} as const;
338340

339-
export const SCRIPT_PACKAGE_SOURCES = ["sh_packages", "ps1_packages"];
341+
export const SCRIPT_PACKAGE_SOURCES = [
342+
"sh_packages",
343+
"ps1_packages",
344+
"py_packages",
345+
];
340346

341347
/** Sources that don't map cleanly to versions or hosts in software inventory.
342348
* UI behavior for these sources:

frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tests.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,6 +1817,60 @@ describe("Activity Feed", () => {
18171817
expect(screen.getByText("Script-only Software")).toBeInTheDocument();
18181818
});
18191819

1820+
it("renders py script package ran status in InstalledSoftware activity", () => {
1821+
const activity = createMockActivity({
1822+
type: ActivityType.InstalledSoftware,
1823+
actor_full_name: "Script Admin",
1824+
details: {
1825+
software_title: "Python Script Software",
1826+
source: "py_packages",
1827+
status: "installed",
1828+
software_package: "install.py",
1829+
host_display_name: "Example Host",
1830+
},
1831+
});
1832+
1833+
render(<GlobalActivityItem activity={activity} isPremiumTier />);
1834+
expect(screen.getByText(/ran/i)).toBeInTheDocument();
1835+
expect(screen.getByText("Python Script Software")).toBeInTheDocument();
1836+
});
1837+
1838+
it("renders py script package pending run status in InstalledSoftware activity", () => {
1839+
const activity = createMockActivity({
1840+
type: ActivityType.InstalledSoftware,
1841+
actor_full_name: "Script Admin",
1842+
details: {
1843+
software_title: "Python Script Software",
1844+
source: "py_packages",
1845+
status: "pending_install",
1846+
software_package: "install.py",
1847+
host_display_name: "Example Host",
1848+
},
1849+
});
1850+
1851+
render(<GlobalActivityItem activity={activity} isPremiumTier />);
1852+
expect(screen.getByText(/told Fleet to run/i)).toBeInTheDocument();
1853+
expect(screen.getByText("Python Script Software")).toBeInTheDocument();
1854+
});
1855+
1856+
it("renders py script package failed run status in InstalledSoftware activity", () => {
1857+
const activity = createMockActivity({
1858+
type: ActivityType.InstalledSoftware,
1859+
actor_full_name: "Script Admin",
1860+
details: {
1861+
software_title: "Python Script Software",
1862+
source: "py_packages",
1863+
status: "failed_install",
1864+
software_package: "install.py",
1865+
host_display_name: "Example Host",
1866+
},
1867+
});
1868+
1869+
render(<GlobalActivityItem activity={activity} isPremiumTier />);
1870+
expect(screen.getByText(/failed to run/i)).toBeInTheDocument();
1871+
expect(screen.getByText("Python Script Software")).toBeInTheDocument();
1872+
});
1873+
18201874
it("renders addedNdesScepProxy activity correctly", () => {
18211875
const activity = createMockActivity({
18221876
type: ActivityType.AddedNdesScepProxy,

frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import TooltipTruncatedText from "components/TooltipTruncatedText";
1111
import TruncatedTextList from "components/TruncatedTextList";
1212
import { IconNames } from "components/icons";
1313
import { ILabelSoftwareTitle } from "interfaces/label";
14-
import { InstallerType } from "interfaces/software";
14+
import { InstallerType, SoftwareSource } from "interfaces/software";
1515
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
1616

1717
const baseClass = "library-item-accordion";
@@ -48,6 +48,9 @@ export interface ILibraryItemAccordionProps {
4848
isLatestFmaVersion?: boolean;
4949
/** Hide the version entirely (script-only packages). */
5050
isScriptPackage?: boolean;
51+
/** Software source, threaded to the installer widget to pick the file icon
52+
* (e.g. `file-py` for `py_packages`). */
53+
source?: SoftwareSource;
5154
isTarballPackage?: boolean;
5255
/** Apple App Store app whose platform is iOS or iPadOS. Drops the
5356
* "policy automation" leg from the info-icon tooltip — `automatic_install`
@@ -119,6 +122,7 @@ const LibraryItemAccordion = ({
119122
isFma = false,
120123
isLatestFmaVersion,
121124
isScriptPackage = false,
125+
source,
122126
isTarballPackage = false,
123127
isIosOrIpadosApp = false,
124128
isActive,
@@ -523,6 +527,7 @@ const LibraryItemAccordion = ({
523527
isFma={isFma}
524528
isLatestFmaVersion={isLatestFmaVersion}
525529
isScriptPackage={isScriptPackage}
530+
source={source}
526531
androidPlayStoreId={androidPlayStoreId}
527532
hideInstallerType
528533
// Inactive rows surface a single hover tooltip (the rollback hint);

frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ describe("InstallerDetailsWidget", () => {
3434
expect(screen.queryByTestId("software-icon")).not.toBeInTheDocument();
3535
});
3636

37+
it("renders the Python icon for a py_packages script package", () => {
38+
render(<InstallerDetailsWidget {...defaultProps} source="py_packages" />);
39+
expect(screen.queryByTestId("file-py-graphic")).toBeInTheDocument();
40+
expect(screen.queryByTestId("file-pkg-graphic")).not.toBeInTheDocument();
41+
});
42+
43+
it("renders the generic package icon for other script sources", () => {
44+
render(<InstallerDetailsWidget {...defaultProps} source="sh_packages" />);
45+
expect(screen.queryByTestId("file-pkg-graphic")).toBeInTheDocument();
46+
expect(screen.queryByTestId("file-py-graphic")).not.toBeInTheDocument();
47+
});
48+
3749
it("renders the software name", () => {
3850
render(<InstallerDetailsWidget {...defaultProps} />);
3951
expect(screen.getByText("Test Software")).toBeInTheDocument();

frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { internationalTimeFormat } from "utilities/helpers";
88
import { addedFromNow } from "utilities/date_format";
99
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
1010
import { useCheckTruncatedElement } from "hooks/useCheckTruncatedElement";
11-
import { InstallerType } from "interfaces/software";
11+
import { InstallerType, SoftwareSource } from "interfaces/software";
1212

1313
import { isAndroidWebApp } from "pages/SoftwarePage/helpers";
1414

@@ -74,6 +74,8 @@ interface IInstallerDetailsWidgetProps {
7474
isFma: boolean;
7575
isLatestFmaVersion?: boolean;
7676
isScriptPackage: boolean;
77+
/** Software source, used to pick the file icon (e.g. `file-py` for `py_packages`). */
78+
source?: SoftwareSource;
7779
androidPlayStoreId?: string;
7880
customDetails?: string;
7981
/** Suppress the leading installer-type label ("Custom package", "App Store (VPP)",
@@ -99,6 +101,7 @@ const InstallerDetailsWidget = ({
99101
isFma,
100102
isLatestFmaVersion = false,
101103
isScriptPackage,
104+
source,
102105
androidPlayStoreId,
103106
customDetails,
104107
hideInstallerType = false,
@@ -113,6 +116,9 @@ const InstallerDetailsWidget = ({
113116
}
114117
return <SoftwareIcon name="appleAppStore" size="medium" />;
115118
}
119+
if (source === "py_packages") {
120+
return <Graphic name="file-py" />;
121+
}
116122
return <Graphic name="file-pkg" />;
117123
};
118124

frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ const SoftwareTitleDetailsPage = ({
305305
isFma={isFma}
306306
isLatestFmaVersion={row.isActive && isLatestFmaVersion}
307307
isScriptPackage={isScriptPackage}
308+
source={title.source}
308309
isTarballPackage={title.source === "tgz_packages"}
309310
isIosOrIpadosApp={isIpadOrIphoneSoftwareSource(title.source)}
310311
isActive={row.isActive}

frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,43 @@ describe("SoftwareTitleDetailsPage helpers", () => {
138138
isSelfService: true,
139139
});
140140
});
141+
it("marks a py_packages title as a script package", () => {
142+
const softwareTitle: ISoftwareTitleDetails = {
143+
id: 1,
144+
name: "Test Script",
145+
icon_url: null,
146+
versions: [],
147+
software_package: {
148+
labels_include_any: null,
149+
labels_exclude_any: null,
150+
labels_include_all: null,
151+
name: "install.py",
152+
title_id: 2,
153+
version: "",
154+
self_service: false,
155+
uploaded_at: "2021-01-01T00:00:00Z",
156+
status: {
157+
installed: 1,
158+
pending_install: 0,
159+
pending_uninstall: 0,
160+
failed_install: 0,
161+
failed_uninstall: 0,
162+
},
163+
install_script: "#!/usr/bin/env python3\nprint('hi')",
164+
uninstall_script: "",
165+
icon_url: null,
166+
automatic_install_policies: [],
167+
url: "",
168+
},
169+
app_store_app: null,
170+
source: "py_packages",
171+
hosts_count: 0,
172+
};
173+
const packageCardInfo = getInstallerCardInfo(softwareTitle);
174+
expect(packageCardInfo.source).toEqual("py_packages");
175+
expect(packageCardInfo.isScriptPackage).toBe(true);
176+
expect(packageCardInfo.name).toEqual("install.py");
177+
});
141178
it("returns the correct data for an app store app (and with a custom display name)", () => {
142179
const softwareTitle: ISoftwareTitleDetails = {
143180
id: 1,

0 commit comments

Comments
 (0)