Skip to content

Commit

Permalink
feat: support uv-managed projects
Browse files Browse the repository at this point in the history
  • Loading branch information
Jeff Gordon committed Oct 2, 2024
1 parent 57f7c6b commit 1f81100
Show file tree
Hide file tree
Showing 19 changed files with 625 additions and 7 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ jobs:
- name: Install setuptools
run: python -m pip install --force setuptools wheel

- name: Install pipenv / poetry
run: python -m pip install pipenv poetry
- name: Install pipenv / poetry / uv
run: python -m pip install pipenv poetry uv

- name: Install serverless
run: npm install -g serverless@${{ matrix.sls-version }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ yarn.lock

# Lockfiles
*.lock
!uv.lock

# Distribution / packaging
.Python
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,17 @@ custom:
- lambda_dependencies
```

## :sparkles::rocket::sparkles: uv support

If you include a `uv.lock` and have `uv` installed, this will use `uv` to generate requirements instead of a `requirements.txt`. It is fully compatible with all options such as `zip` and
`dockerizePip`. If you don't want this plugin to generate it for you, set the following option:

```yaml
custom:
pythonRequirements:
useUv: false
```

### Poetry with git dependencies

Poetry by default generates the exported requirements.txt file with `-e` and that breaks pip with `-t` parameter
Expand Down
3 changes: 3 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const { injectAllRequirements } = require('./lib/inject');
const { layerRequirements } = require('./lib/layer');
const { installAllRequirements } = require('./lib/pip');
const { pipfileToRequirements } = require('./lib/pipenv');
const { uvToRequirements } = require('./lib/uv');
const { cleanup, cleanupCache } = require('./lib/clean');
BbPromise.promisifyAll(fse);

Expand All @@ -37,6 +38,7 @@ class ServerlessPythonRequirements {
fileName: 'requirements.txt',
usePipenv: true,
usePoetry: true,
useUv: true,
pythonBin:
process.platform === 'win32'
? 'python.exe'
Expand Down Expand Up @@ -226,6 +228,7 @@ class ServerlessPythonRequirements {
}
return BbPromise.bind(this)
.then(pipfileToRequirements)
.then(uvToRequirements)
.then(addVendorHelper)
.then(installAllRequirements)
.then(packRequirements)
Expand Down
20 changes: 20 additions & 0 deletions lib/pip.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ function generateRequirementsFile(
`Parsed requirements.txt from Pipfile in ${targetFile}...`
);
}
} else if (
options.useUv &&
fse.existsSync(path.join(servicePath, 'uv.lock'))
) {
filterRequirementsFile(
path.join(servicePath, '.serverless/requirements.txt'),
targetFile,
pluginInstance
);
if (log) {
log.info(`Parsed requirements.txt from uv.lock in ${targetFile}`);
} else {
serverless.cli.log(
`Parsed requirements.txt from uv.lock in ${targetFile}...`
);
}
} else {
filterRequirementsFile(requirementsPath, targetFile, pluginInstance);
if (log) {
Expand Down Expand Up @@ -591,6 +607,10 @@ function requirementsFileExists(servicePath, options, fileName) {
return true;
}

if (options.useUv && fse.existsSync(path.join(servicePath, 'uv.lock'))) {
return true;
}

if (fse.existsSync(fileName)) {
return true;
}
Expand Down
103 changes: 103 additions & 0 deletions lib/uv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const fse = require('fs-extra');
const path = require('path');
const spawn = require('child-process-ext/spawn');
const { EOL } = require('os');
const semver = require('semver');

async function getUvVersion() {
try {
const res = await spawn('uv', ['--version'], {
cwd: this.servicePath,
});

const stdoutBuffer =
(res.stdoutBuffer && res.stdoutBuffer.toString().trim()) || '';

const version = stdoutBuffer.split(' ')[1];

if (semver.valid(version)) {
return version;
} else {
throw new this.serverless.classes.Error(
`Unable to parse uv version!`,
'PYTHON_REQUIREMENTS_UV_VERSION_ERROR'
);
}
} catch (e) {
const stderrBufferContent =
(e.stderrBuffer && e.stderrBuffer.toString()) || '';

if (stderrBufferContent.includes('command not found')) {
throw new this.serverless.classes.Error(
`uv not found! Install it according to the uv docs.`,
'PYTHON_REQUIREMENTS_UV_NOT_FOUND'
);
} else {
throw e;
}
}
}

/**
* uv to requirements.txt
*/
async function uvToRequirements() {
if (
!this.options.useUv ||
!fse.existsSync(path.join(this.servicePath, 'uv.lock'))
) {
return;
}

let generateRequirementsProgress;
if (this.progress && this.log) {
generateRequirementsProgress = this.progress.get(
'python-generate-requirements-uv'
);
generateRequirementsProgress.update(
'Generating requirements.txt from uv.lock'
);
this.log.info('Generating requirements.txt from uv.lock');
} else {
this.serverless.cli.log('Generating requirements.txt from uv.lock...');
}

let res;

try {
await getUvVersion();
res = await spawn(
'uv',
['export', '--no-dev', '--frozen', '--no-hashes'],
{
cwd: this.servicePath,
}
);

fse.ensureDirSync(path.join(this.servicePath, '.serverless'));
fse.writeFileSync(
path.join(this.servicePath, '.serverless/requirements.txt'),
removeEditableFlagFromRequirementsString(res.stdoutBuffer)
);
} finally {
generateRequirementsProgress && generateRequirementsProgress.remove();
}
}

/**
*
* @param requirementBuffer
* @returns Buffer with editable flags remove
*/
function removeEditableFlagFromRequirementsString(requirementBuffer) {
const flagStr = '-e ';
const lines = requirementBuffer.toString('utf8').split(EOL);
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith(flagStr)) {
lines[i] = lines[i].substring(flagStr.length);
}
}
return Buffer.from(lines.join(EOL));
}

module.exports = { uvToRequirements };
115 changes: 115 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,98 @@ test("pipenv py3.9 doesn't package bottle with noDeploy option", async (t) => {
t.end();
});

test('uv py3.9 can package flask with default options', async (t) => {
process.chdir('tests/uv');
const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);
sls(['package'], { env: {} });
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
t.true(zipfiles.includes(`boto3${sep}__init__.py`), 'boto3 is packaged');
t.false(
zipfiles.includes(`pytest${sep}__init__.py`),
'dev-package pytest is NOT packaged'
);
t.end();
});

test('uv py3.9 can package flask with slim option', async (t) => {
process.chdir('tests/uv');
const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);
sls(['package'], { env: { slim: 'true' } });
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
t.deepEqual(
zipfiles.filter((filename) => filename.endsWith('.pyc')),
[],
'no pyc files packaged'
);
t.true(
zipfiles.filter((filename) => filename.endsWith('__main__.py')).length > 0,
'__main__.py files are packaged'
);
t.end();
});

test('uv py3.9 can package flask with slim & slimPatterns options', async (t) => {
process.chdir('tests/uv');

copySync('_slimPatterns.yml', 'slimPatterns.yml');
const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);
sls(['package'], { env: { slim: 'true' } });
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
t.deepEqual(
zipfiles.filter((filename) => filename.endsWith('.pyc')),
[],
'no pyc files packaged'
);
t.deepEqual(
zipfiles.filter((filename) => filename.endsWith('__main__.py')),
[],
'__main__.py files are NOT packaged'
);
t.end();
});

test('uv py3.9 can package flask with zip option', async (t) => {
process.chdir('tests/uv');
const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);
sls(['package'], { env: { zip: 'true', pythonBin: getPythonBin(3) } });
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
t.true(
zipfiles.includes('.requirements.zip'),
'zipped requirements are packaged'
);
t.true(zipfiles.includes(`unzip_requirements.py`), 'unzip util is packaged');
t.false(
zipfiles.includes(`flask${sep}__init__.py`),
"flask isn't packaged on its own"
);
t.end();
});

test("uv py3.9 doesn't package bottle with noDeploy option", async (t) => {
process.chdir('tests/uv');
const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);
perl([
'-p',
'-i.bak',
'-e',
's/(pythonRequirements:$)/\\1\\n noDeploy: [bottle]/',
'serverless.yml',
]);
sls(['package'], { env: {} });
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
t.false(zipfiles.includes(`bottle.py`), 'bottle is NOT packaged');
t.end();
});

test('non build pyproject.toml uses requirements.txt', async (t) => {
process.chdir('tests/non_build_pyproject');
const { stdout: path } = npm(['pack', '../..']);
Expand Down Expand Up @@ -963,6 +1055,29 @@ test('pipenv py3.9 can package flask with slim & slimPatterns & slimPatternsAppe
t.end();
});

test('uv py3.9 can package flask with slim & slimPatterns & slimPatternsAppendDefaults=false option', async (t) => {
process.chdir('tests/uv');
copySync('_slimPatterns.yml', 'slimPatterns.yml');
const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);

sls(['package'], {
env: { slim: 'true', slimPatternsAppendDefaults: 'false' },
});
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
t.true(
zipfiles.filter((filename) => filename.endsWith('.pyc')).length >= 1,
'pyc files are packaged'
);
t.deepEqual(
zipfiles.filter((filename) => filename.endsWith('__main__.py')),
[],
'__main__.py files are NOT packaged'
);
t.end();
});

test('poetry py3.9 can package flask with slim & slimPatterns & slimPatternsAppendDefaults=false option', async (t) => {
process.chdir('tests/poetry');
copySync('_slimPatterns.yml', 'slimPatterns.yml');
Expand Down
2 changes: 1 addition & 1 deletion tests/base/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
}
}
2 changes: 1 addition & 1 deletion tests/non_build_pyproject/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
}
}
2 changes: 1 addition & 1 deletion tests/non_poetry_pyproject/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
}
}
2 changes: 1 addition & 1 deletion tests/pipenv/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
}
}
2 changes: 1 addition & 1 deletion tests/poetry/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@
"author": "",
"license": "ISC",
"dependencies": {
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
}
}
1 change: 1 addition & 0 deletions tests/uv/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.9
2 changes: 2 additions & 0 deletions tests/uv/_slimPatterns.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
slimPatterns:
- '**/__main__.py'
5 changes: 5 additions & 0 deletions tests/uv/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import requests


def hello(event, context):
return requests.get('https://httpbin.org/get').json()
14 changes: 14 additions & 0 deletions tests/uv/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "example",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
}
}
Loading

0 comments on commit 1f81100

Please sign in to comment.