Skip to content
Open
42 changes: 42 additions & 0 deletions __tests__/clean-pip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as core from '@actions/core';
import * as exec from '@actions/exec';
import {cleanPipPackages} from '../src/clean-pip';

describe('cleanPipPackages', () => {
let infoSpy: jest.SpyInstance;
let setFailedSpy: jest.SpyInstance;
let execSpy: jest.SpyInstance;

beforeEach(() => {
infoSpy = jest.spyOn(core, 'info');
infoSpy.mockImplementation(() => undefined);

setFailedSpy = jest.spyOn(core, 'setFailed');
setFailedSpy.mockImplementation(() => undefined);

execSpy = jest.spyOn(exec, 'exec');
execSpy.mockImplementation(() => Promise.resolve(0));
});

afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});

it('should successfully clean up pip packages', async () => {
await cleanPipPackages();

expect(execSpy).toHaveBeenCalledWith('bash', expect.any(Array));
expect(setFailedSpy).not.toHaveBeenCalled();
});

it('should handle errors and set failed status', async () => {
const error = new Error('Exec failed');
execSpy.mockImplementation(() => Promise.reject(error));

await cleanPipPackages();

expect(execSpy).toHaveBeenCalledWith('bash', expect.any(Array));
expect(setFailedSpy).toHaveBeenCalledWith('Failed to clean up pip packages.');
});
});
61 changes: 60 additions & 1 deletion __tests__/cache-save.test.ts → __tests__/post-python.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as core from '@actions/core';
import * as cache from '@actions/cache';
import * as cleanPip from '../src/clean-pip';
import * as exec from '@actions/exec';
import {run} from '../src/cache-save';
import {run} from '../src/post-python';
import {State} from '../src/cache-distributions/cache-distributor';

describe('run', () => {
Expand All @@ -21,10 +22,13 @@ describe('run', () => {
let saveStateSpy: jest.SpyInstance;
let getStateSpy: jest.SpyInstance;
let getInputSpy: jest.SpyInstance;
let getBooleanInputSpy: jest.SpyInstance;
let setFailedSpy: jest.SpyInstance;

// cache spy
let saveCacheSpy: jest.SpyInstance;
// cleanPipPackages spy
let cleanPipPackagesSpy: jest.SpyInstance;

// exec spy
let getExecOutputSpy: jest.SpyInstance;
Expand Down Expand Up @@ -59,6 +63,9 @@ describe('run', () => {
getInputSpy = jest.spyOn(core, 'getInput');
getInputSpy.mockImplementation(input => inputs[input]);

getBooleanInputSpy = jest.spyOn(core, 'getBooleanInput');
getBooleanInputSpy.mockImplementation(input => inputs[input]);

getExecOutputSpy = jest.spyOn(exec, 'getExecOutput');
getExecOutputSpy.mockImplementation((input: string) => {
if (input.includes('pip')) {
Expand All @@ -70,6 +77,9 @@ describe('run', () => {

saveCacheSpy = jest.spyOn(cache, 'saveCache');
saveCacheSpy.mockImplementation(() => undefined);

cleanPipPackagesSpy = jest.spyOn(cleanPip, 'cleanPipPackages');
cleanPipPackagesSpy.mockImplementation(() => undefined);
});

describe('Package manager validation', () => {
Expand Down Expand Up @@ -258,6 +268,55 @@ describe('run', () => {
});
});

describe('run with postclean option', () => {

it('should clean pip packages when postclean is true', async () => {
inputs['cache'] = '';
inputs['postclean'] = true;

await run();

expect(getBooleanInputSpy).toHaveBeenCalledWith('postclean');
expect(cleanPipPackagesSpy).toHaveBeenCalled();
expect(setFailedSpy).not.toHaveBeenCalled();
});

it('should save cache and clean pip packages when both are enabled', async () => {
inputs['cache'] = 'pip';
inputs['postclean'] = true;
inputs['python-version'] = '3.10.0';
getStateSpy.mockImplementation((name: string) => {
if (name === State.CACHE_MATCHED_KEY) {
return requirementsHash;
} else if (name === State.CACHE_PATHS) {
return JSON.stringify([__dirname]);
} else {
return pipFileLockHash;
}
});

await run();

expect(getInputSpy).toHaveBeenCalled();
expect(getBooleanInputSpy).toHaveBeenCalledWith('postclean');
expect(saveCacheSpy).toHaveBeenCalled();
expect(cleanPipPackagesSpy).toHaveBeenCalled();
expect(setFailedSpy).not.toHaveBeenCalled();
});

it('should not clean pip packages when postclean is false', async () => {
inputs['cache'] = 'pip';
inputs['postclean'] = false;
inputs['python-version'] = '3.10.0';

await run();

expect(getBooleanInputSpy).toHaveBeenCalledWith('postclean');
expect(cleanPipPackagesSpy).not.toHaveBeenCalled();
expect(setFailedSpy).not.toHaveBeenCalled();
});
});

afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
Expand Down
8 changes: 7 additions & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ inputs:
description: "Used to specify the version of pip to install with the Python. Supported format: major[.minor][.patch]."
pip-install:
description: "Used to specify the packages to install with pip after setting up Python. Can be a requirements file or package names."
preclean:
description: "When 'true', removes all existing pip packages before installing new ones."
default: false
postclean:
description: "When 'true', removes all pip packages installed by this action after the action completes."
default: false
outputs:
python-version:
description: "The installed Python or PyPy version. Useful when given a version range as input."
Expand All @@ -43,7 +49,7 @@ outputs:
runs:
using: 'node24'
main: 'dist/setup/index.js'
post: 'dist/cache-save/index.js'
post: 'dist/post-python/index.js'
post-if: success()
branding:
icon: 'code'
Expand Down
90 changes: 83 additions & 7 deletions dist/cache-save/index.js → dist/post-python/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -87805,7 +87805,69 @@ exports.CACHE_DEPENDENCY_BACKUP_PATH = '**/pyproject.toml';

/***/ }),

/***/ 3579:
/***/ 8106:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {

"use strict";

var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.cleanPipPackages = cleanPipPackages;
const core = __importStar(__nccwpck_require__(7484));
const exec_1 = __nccwpck_require__(5236);
// Shared helper to uninstall all pip packages in the current environment.
async function cleanPipPackages() {
core.info('Cleaning up pip packages');
try {
// uninstall all currently installed packages (if any)
// Use a shell so we can pipe the output of pip freeze into xargs
await (0, exec_1.exec)('bash', [
'-c',
'test $(which python) != "/usr/bin/python" -a $(python -m pip freeze | wc -l) -gt 0 && python -m pip freeze | xargs python -m pip uninstall -y || true'
]);
core.info('Successfully cleaned up pip packages');
}
catch (error) {
core.setFailed('Failed to clean up pip packages.');
}
}


/***/ }),

/***/ 3332:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {

"use strict";
Expand Down Expand Up @@ -87850,16 +87912,28 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.run = run;
const core = __importStar(__nccwpck_require__(7484));
const cache = __importStar(__nccwpck_require__(5116));
const clean_pip_1 = __nccwpck_require__(8106);
const fs_1 = __importDefault(__nccwpck_require__(9896));
const cache_distributor_1 = __nccwpck_require__(2326);
// Added early exit to resolve issue with slow post action step:
// - https://github.com/actions/setup-node/issues/878
// https://github.com/actions/cache/pull/1217
// See: https://github.com/actions/setup-node/issues/878
// See: https://github.com/actions/cache/pull/1217
async function run(earlyExit) {
try {
const cache = core.getInput('cache');
if (cache) {
await saveCache(cache);
// Optionally clean up pip packages after the post-action if requested.
// This mirrors the `preclean` behavior used in the main action.
const postcleanPip = core.getBooleanInput('postclean');
if (cache || postcleanPip) {
if (cache) {
await saveCache(cache);
}
if (postcleanPip) {
await (0, clean_pip_1.cleanPipPackages)();
}
// Preserve early-exit behavior for the post action when requested.
// Some CI setups may want the post step to exit early to avoid long-running
// processes during cleanup. If enabled, exit with success immediately.
if (earlyExit) {
process.exit(0);
}
Expand Down Expand Up @@ -87913,7 +87987,9 @@ function isCacheDirectoryExists(cacheDirectory) {
}, false);
return result;
}
run(true);
// Invoke the post action runner. No early-exit — this must complete so the cache
// can be saved during the post step.
run();


/***/ }),
Expand Down Expand Up @@ -89867,7 +89943,7 @@ module.exports = /*#__PURE__*/JSON.parse('[[[0,44],"disallowed_STD3_valid"],[[45
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module is referenced by other modules so it can't be inlined
/******/ var __webpack_exports__ = __nccwpck_require__(3579);
/******/ var __webpack_exports__ = __nccwpck_require__(3332);
/******/ module.exports = __webpack_exports__;
/******/
/******/ })()
Expand Down
67 changes: 67 additions & 0 deletions dist/setup/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -96694,6 +96694,68 @@ class PoetryCache extends cache_distributor_1.default {
exports["default"] = PoetryCache;


/***/ }),

/***/ 8106:
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {

"use strict";

var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.cleanPipPackages = cleanPipPackages;
const core = __importStar(__nccwpck_require__(7484));
const exec_1 = __nccwpck_require__(5236);
// Shared helper to uninstall all pip packages in the current environment.
async function cleanPipPackages() {
core.info('Cleaning up pip packages');
try {
// uninstall all currently installed packages (if any)
// Use a shell so we can pipe the output of pip freeze into xargs
await (0, exec_1.exec)('bash', [
'-c',
'test $(which python) != "/usr/bin/python" -a $(python -m pip freeze | wc -l) -gt 0 && python -m pip freeze | xargs python -m pip uninstall -y || true'
]);
core.info('Successfully cleaned up pip packages');
}
catch (error) {
core.setFailed('Failed to clean up pip packages.');
}
}


/***/ }),

/***/ 1663:
Expand Down Expand Up @@ -97906,6 +97968,7 @@ const fs_1 = __importDefault(__nccwpck_require__(9896));
const cache_factory_1 = __nccwpck_require__(665);
const utils_1 = __nccwpck_require__(1798);
const exec_1 = __nccwpck_require__(5236);
const clean_pip_1 = __nccwpck_require__(8106);
function isPyPyVersion(versionSpec) {
return versionSpec.startsWith('pypy');
}
Expand Down Expand Up @@ -98007,6 +98070,10 @@ async function run() {
if (cache && (0, utils_1.isCacheFeatureAvailable)()) {
await cacheDependencies(cache, pythonVersion);
}
const precleanPip = core.getBooleanInput('preclean');
if (precleanPip) {
await (0, clean_pip_1.cleanPipPackages)();
}
const pipInstall = core.getInput('pip-install');
if (pipInstall) {
await installPipPackages(pipInstall);
Expand Down
Loading