diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 30fc61cb..8a9d01b3 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -1,9 +1,17 @@ import * as cache from '@actions/cache'; import * as core from '@actions/core'; +import * as io from '@actions/io'; + +import fs from 'fs'; +import path from 'path'; + import { validateVersion, validatePythonVersionFormatForPyPy, - isCacheFeatureAvailable + isCacheFeatureAvailable, + getVersionInputFromFile, + getVersionInputFromPlainFile, + getVersionInputFromTomlFile } from '../src/utils'; jest.mock('@actions/cache'); @@ -73,3 +81,48 @@ describe('isCacheFeatureAvailable', () => { expect(isCacheFeatureAvailable()).toBe(true); }); }); + +const tempDir = path.join( + __dirname, + 'runner', + path.join(Math.random().toString(36).substring(7)), + 'temp' +); + +describe('Version from file test', () => { + it.each([getVersionInputFromPlainFile, getVersionInputFromFile])( + 'Version from plain file test', + async _fn => { + await io.mkdirP(tempDir); + const pythonVersionFileName = 'python-version.file'; + const pythonVersionFilePath = path.join(tempDir, pythonVersionFileName); + const pythonVersionFileContent = '3.7'; + fs.writeFileSync(pythonVersionFilePath, pythonVersionFileContent); + expect(_fn(pythonVersionFilePath)).toEqual([pythonVersionFileContent]); + } + ); + it.each([getVersionInputFromTomlFile, getVersionInputFromFile])( + 'Version from standard pyproject.toml test', + async _fn => { + await io.mkdirP(tempDir); + const pythonVersionFileName = 'pyproject.toml'; + const pythonVersionFilePath = path.join(tempDir, pythonVersionFileName); + const pythonVersion = '>=3.7'; + const pythonVersionFileContent = `[project]\nrequires-python = "${pythonVersion}"`; + fs.writeFileSync(pythonVersionFilePath, pythonVersionFileContent); + expect(_fn(pythonVersionFilePath)).toEqual([pythonVersion]); + } + ); + it.each([getVersionInputFromTomlFile, getVersionInputFromFile])( + 'Version from poetry pyproject.toml test', + async _fn => { + await io.mkdirP(tempDir); + const pythonVersionFileName = 'pyproject.toml'; + const pythonVersionFilePath = path.join(tempDir, pythonVersionFileName); + const pythonVersion = '>=3.7'; + const pythonVersionFileContent = `[tool.poetry.dependencies]\npython = "${pythonVersion}"`; + fs.writeFileSync(pythonVersionFilePath, pythonVersionFileContent); + expect(_fn(pythonVersionFilePath)).toEqual([pythonVersion]); + } + ); +}); diff --git a/src/setup-python.ts b/src/setup-python.ts index 69ea9d36..dc802697 100644 --- a/src/setup-python.ts +++ b/src/setup-python.ts @@ -5,7 +5,14 @@ import * as path from 'path'; import * as os from 'os'; import fs from 'fs'; import {getCacheDistributor} from './cache-distributions/cache-factory'; -import {isCacheFeatureAvailable, logWarning, IS_MAC} from './utils'; +import { + isCacheFeatureAvailable, + logWarning, + IS_MAC, + getVersionInputFromFile, + getVersionInputFromPlainFile, + getVersionInputFromTomlFile +} from './utils'; function isPyPyVersion(versionSpec: string) { return versionSpec.startsWith('pypy'); @@ -22,43 +29,48 @@ async function cacheDependencies(cache: string, pythonVersion: string) { await cacheDistributor.restoreCache(); } -function resolveVersionInput() { - const versions = core.getMultilineInput('python-version'); - let versionFile = core.getInput('python-version-file'); - - if (versions.length && versionFile) { - core.warning( - 'Both python-version and python-version-file inputs are specified, only python-version will be used.' +function resolveVersionInputFromDefaultFile(): string[] { + const couples: [string, (versionFile: string) => string[]][] = [ + ['.python-version', getVersionInputFromPlainFile], + ['pyproject.toml', getVersionInputFromTomlFile] + ]; + for (const [versionFile, _fn] of couples) { + logWarning( + `Neither 'python-version' nor 'python-version-file' inputs were supplied. Attempting to find '${versionFile}' file.` ); + if (fs.existsSync(versionFile)) { + return _fn(versionFile); + } else { + logWarning(`${versionFile} doesn't exist.`); + } } + return []; +} + +function resolveVersionInput() { + let versions = core.getMultilineInput('python-version'); + const versionFile = core.getInput('python-version-file'); if (versions.length) { - return versions; - } - - if (versionFile) { - if (!fs.existsSync(versionFile)) { - throw new Error( - `The specified python version file at: ${versionFile} doesn't exist.` + if (versionFile) { + core.warning( + 'Both python-version and python-version-file inputs are specified, only python-version will be used.' ); } - const version = fs.readFileSync(versionFile, 'utf8'); - core.info(`Resolved ${versionFile} as ${version}`); - return [version]; + } else { + if (versionFile) { + if (!fs.existsSync(versionFile)) { + throw new Error( + `The specified python version file at: ${versionFile} doesn't exist.` + ); + } + versions = getVersionInputFromFile(versionFile); + } else { + versions = resolveVersionInputFromDefaultFile(); + } } - logWarning( - "Neither 'python-version' nor 'python-version-file' inputs were supplied. Attempting to find '.python-version' file." - ); - versionFile = '.python-version'; - if (fs.existsSync(versionFile)) { - const version = fs.readFileSync(versionFile, 'utf8'); - core.info(`Resolved ${versionFile} as ${version}`); - return [version]; - } - - logWarning(`${versionFile} doesn't exist.`); - + versions = Array.from(versions, version => version.split(',').join(' ')); return versions; } diff --git a/src/utils.ts b/src/utils.ts index 5a5866ea..77fa8093 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,6 +4,7 @@ import * as core from '@actions/core'; import fs from 'fs'; import * as path from 'path'; import * as semver from 'semver'; +import * as toml from 'toml'; import * as exec from '@actions/exec'; export const IS_WINDOWS = process.platform === 'win32'; @@ -181,3 +182,65 @@ export async function getOSInfo() { return osInfo; } } + +/** + * Python version extracted from the TOML file. + * If the `project` key is present at the root level, the version is assumed to + * be specified according to PEP 621 in `project.requires-python`. + * Otherwise, if the `tool` key is present at the root level, the version is + * assumed to be specified using poetry under `tool.poetry.dependencies.python`. + * If none is present, returns an empty list. + */ +export function getVersionInputFromTomlFile(versionFile: string): string[] { + core.debug(`Trying to resolve version form ${versionFile}`); + + const pyprojectFile = fs.readFileSync(versionFile, 'utf8'); + const pyprojectConfig = toml.parse(pyprojectFile); + const versions = []; + + if ('project' in pyprojectConfig) { + // standard project metadata (PEP 621) + const projectMetadata = pyprojectConfig['project']; + if ('requires-python' in projectMetadata) { + versions.push(projectMetadata['requires-python']); + } + } else { + // python poetry + if ('tool' in pyprojectConfig) { + const toolMetadata = pyprojectConfig['tool']; + if ('poetry' in toolMetadata) { + const poetryMetadata = toolMetadata['poetry']; + if ('dependencies' in poetryMetadata) { + const dependenciesMetadata = poetryMetadata['dependencies']; + if ('python' in dependenciesMetadata) { + versions.push(dependenciesMetadata['python']); + } + } + } + } + } + + core.info(`Extracted ${versions} from ${versionFile}`); + return versions; +} + +/** + * Python version extracted from a plain text file. + */ +export function getVersionInputFromPlainFile(versionFile: string): string[] { + core.debug(`Trying to resolve version form ${versionFile}`); + const version = fs.readFileSync(versionFile, 'utf8'); + core.info(`Resolved ${versionFile} as ${version}`); + return [version]; +} + +/** + * Python version extracted from a plain or TOML file. + */ +export function getVersionInputFromFile(versionFile: string): string[] { + if (versionFile.endsWith('.toml')) { + return getVersionInputFromTomlFile(versionFile); + } else { + return getVersionInputFromPlainFile(versionFile); + } +}