Skip to content

Commit 6b4aa83

Browse files
author
Jeff Gordon
committed
feat: support uv-managed projects
1 parent 57f7c6b commit 6b4aa83

File tree

19 files changed

+629
-7
lines changed

19 files changed

+629
-7
lines changed

.github/workflows/validate.yml

+5-2
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,17 @@ jobs:
6363
- name: Install pipenv / poetry
6464
run: python -m pip install pipenv poetry
6565

66-
- name: Install serverless
67-
run: npm install -g serverless@${{ matrix.sls-version }}
66+
- name: Install uv
67+
uses: astral-sh/setup-uv@v3
6868

6969
- name: Install dependencies
7070
if: steps.cacheNpm.outputs.cache-hit != 'true'
7171
run: |
7272
npm update --no-save
7373
npm update --save-dev --no-save
74+
- name: Install serverless
75+
run: npm install serverless@${{ matrix.sls-version }}
76+
7477
- name: Validate Prettier formatting
7578
run: npm run prettier-check:updated
7679
- name: Validate ESLint rules

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ yarn.lock
4949

5050
# Lockfiles
5151
*.lock
52+
!uv.lock
5253

5354
# Distribution / packaging
5455
.Python

README.md

+11
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ custom:
150150
- lambda_dependencies
151151
```
152152

153+
## :sparkles::rocket::sparkles: uv support
154+
155+
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
156+
`dockerizePip`. If you don't want this plugin to generate it for you, set the following option:
157+
158+
```yaml
159+
custom:
160+
pythonRequirements:
161+
useUv: false
162+
```
163+
153164
### Poetry with git dependencies
154165

155166
Poetry by default generates the exported requirements.txt file with `-e` and that breaks pip with `-t` parameter

index.js

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const { injectAllRequirements } = require('./lib/inject');
1313
const { layerRequirements } = require('./lib/layer');
1414
const { installAllRequirements } = require('./lib/pip');
1515
const { pipfileToRequirements } = require('./lib/pipenv');
16+
const { uvToRequirements } = require('./lib/uv');
1617
const { cleanup, cleanupCache } = require('./lib/clean');
1718
BbPromise.promisifyAll(fse);
1819

@@ -37,6 +38,7 @@ class ServerlessPythonRequirements {
3738
fileName: 'requirements.txt',
3839
usePipenv: true,
3940
usePoetry: true,
41+
useUv: true,
4042
pythonBin:
4143
process.platform === 'win32'
4244
? 'python.exe'
@@ -226,6 +228,7 @@ class ServerlessPythonRequirements {
226228
}
227229
return BbPromise.bind(this)
228230
.then(pipfileToRequirements)
231+
.then(uvToRequirements)
229232
.then(addVendorHelper)
230233
.then(installAllRequirements)
231234
.then(packRequirements)

lib/pip.js

+20
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ function generateRequirementsFile(
8686
`Parsed requirements.txt from Pipfile in ${targetFile}...`
8787
);
8888
}
89+
} else if (
90+
options.useUv &&
91+
fse.existsSync(path.join(servicePath, 'uv.lock'))
92+
) {
93+
filterRequirementsFile(
94+
path.join(servicePath, '.serverless/requirements.txt'),
95+
targetFile,
96+
pluginInstance
97+
);
98+
if (log) {
99+
log.info(`Parsed requirements.txt from uv.lock in ${targetFile}`);
100+
} else {
101+
serverless.cli.log(
102+
`Parsed requirements.txt from uv.lock in ${targetFile}...`
103+
);
104+
}
89105
} else {
90106
filterRequirementsFile(requirementsPath, targetFile, pluginInstance);
91107
if (log) {
@@ -591,6 +607,10 @@ function requirementsFileExists(servicePath, options, fileName) {
591607
return true;
592608
}
593609

610+
if (options.useUv && fse.existsSync(path.join(servicePath, 'uv.lock'))) {
611+
return true;
612+
}
613+
594614
if (fse.existsSync(fileName)) {
595615
return true;
596616
}

lib/uv.js

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const fse = require('fs-extra');
2+
const path = require('path');
3+
const spawn = require('child-process-ext/spawn');
4+
const { EOL } = require('os');
5+
const semver = require('semver');
6+
7+
async function getUvVersion() {
8+
try {
9+
const res = await spawn('uv', ['--version'], {
10+
cwd: this.servicePath,
11+
});
12+
13+
const stdoutBuffer =
14+
(res.stdoutBuffer && res.stdoutBuffer.toString().trim()) || '';
15+
16+
const version = stdoutBuffer.split(' ')[1];
17+
18+
if (semver.valid(version)) {
19+
return version;
20+
} else {
21+
throw new this.serverless.classes.Error(
22+
`Unable to parse uv version!`,
23+
'PYTHON_REQUIREMENTS_UV_VERSION_ERROR'
24+
);
25+
}
26+
} catch (e) {
27+
const stderrBufferContent =
28+
(e.stderrBuffer && e.stderrBuffer.toString()) || '';
29+
30+
if (stderrBufferContent.includes('command not found')) {
31+
throw new this.serverless.classes.Error(
32+
`uv not found! Install it according to the uv docs.`,
33+
'PYTHON_REQUIREMENTS_UV_NOT_FOUND'
34+
);
35+
} else {
36+
throw e;
37+
}
38+
}
39+
}
40+
41+
/**
42+
* uv to requirements.txt
43+
*/
44+
async function uvToRequirements() {
45+
if (
46+
!this.options.useUv ||
47+
!fse.existsSync(path.join(this.servicePath, 'uv.lock'))
48+
) {
49+
return;
50+
}
51+
52+
let generateRequirementsProgress;
53+
if (this.progress && this.log) {
54+
generateRequirementsProgress = this.progress.get(
55+
'python-generate-requirements-uv'
56+
);
57+
generateRequirementsProgress.update(
58+
'Generating requirements.txt from uv.lock'
59+
);
60+
this.log.info('Generating requirements.txt from uv.lock');
61+
} else {
62+
this.serverless.cli.log('Generating requirements.txt from uv.lock...');
63+
}
64+
65+
let res;
66+
67+
try {
68+
await getUvVersion();
69+
res = await spawn('uv', ['export', '--no-dev', '--frozen', '--no-hashes'], {
70+
cwd: this.servicePath,
71+
});
72+
73+
fse.ensureDirSync(path.join(this.servicePath, '.serverless'));
74+
fse.writeFileSync(
75+
path.join(this.servicePath, '.serverless/requirements.txt'),
76+
removeEditableFlagFromRequirementsString(res.stdoutBuffer)
77+
);
78+
} finally {
79+
generateRequirementsProgress && generateRequirementsProgress.remove();
80+
}
81+
}
82+
83+
/**
84+
*
85+
* @param requirementBuffer
86+
* @returns Buffer with editable flags remove
87+
*/
88+
function removeEditableFlagFromRequirementsString(requirementBuffer) {
89+
const flagStr = '-e ';
90+
const commentLine = '#';
91+
const lines = requirementBuffer.toString('utf8').split(EOL);
92+
const newLines = [];
93+
for (let i = 0; i < lines.length; i++) {
94+
if (lines[i].startsWith(flagStr)) {
95+
newLines.push(lines[i].substring(flagStr.length));
96+
}
97+
if (lines[i].startsWith(commentLine)) {
98+
continue;
99+
}
100+
}
101+
return Buffer.from(newLines.join(EOL));
102+
}
103+
104+
module.exports = { uvToRequirements };

test.js

+115
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,98 @@ test("pipenv py3.9 doesn't package bottle with noDeploy option", async (t) => {
615615
t.end();
616616
});
617617

618+
test('uv py3.9 can package flask with default options', async (t) => {
619+
process.chdir('tests/uv');
620+
const { stdout: path } = npm(['pack', '../..']);
621+
npm(['i', path]);
622+
sls(['package'], { env: {} });
623+
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
624+
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
625+
t.true(zipfiles.includes(`boto3${sep}__init__.py`), 'boto3 is packaged');
626+
t.false(
627+
zipfiles.includes(`pytest${sep}__init__.py`),
628+
'dev-package pytest is NOT packaged'
629+
);
630+
t.end();
631+
});
632+
633+
test('uv py3.9 can package flask with slim option', async (t) => {
634+
process.chdir('tests/uv');
635+
const { stdout: path } = npm(['pack', '../..']);
636+
npm(['i', path]);
637+
sls(['package'], { env: { slim: 'true' } });
638+
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
639+
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
640+
t.deepEqual(
641+
zipfiles.filter((filename) => filename.endsWith('.pyc')),
642+
[],
643+
'no pyc files packaged'
644+
);
645+
t.true(
646+
zipfiles.filter((filename) => filename.endsWith('__main__.py')).length > 0,
647+
'__main__.py files are packaged'
648+
);
649+
t.end();
650+
});
651+
652+
test('uv py3.9 can package flask with slim & slimPatterns options', async (t) => {
653+
process.chdir('tests/uv');
654+
655+
copySync('_slimPatterns.yml', 'slimPatterns.yml');
656+
const { stdout: path } = npm(['pack', '../..']);
657+
npm(['i', path]);
658+
sls(['package'], { env: { slim: 'true' } });
659+
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
660+
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
661+
t.deepEqual(
662+
zipfiles.filter((filename) => filename.endsWith('.pyc')),
663+
[],
664+
'no pyc files packaged'
665+
);
666+
t.deepEqual(
667+
zipfiles.filter((filename) => filename.endsWith('__main__.py')),
668+
[],
669+
'__main__.py files are NOT packaged'
670+
);
671+
t.end();
672+
});
673+
674+
test('uv py3.9 can package flask with zip option', async (t) => {
675+
process.chdir('tests/uv');
676+
const { stdout: path } = npm(['pack', '../..']);
677+
npm(['i', path]);
678+
sls(['package'], { env: { zip: 'true', pythonBin: getPythonBin(3) } });
679+
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
680+
t.true(
681+
zipfiles.includes('.requirements.zip'),
682+
'zipped requirements are packaged'
683+
);
684+
t.true(zipfiles.includes(`unzip_requirements.py`), 'unzip util is packaged');
685+
t.false(
686+
zipfiles.includes(`flask${sep}__init__.py`),
687+
"flask isn't packaged on its own"
688+
);
689+
t.end();
690+
});
691+
692+
test("uv py3.9 doesn't package bottle with noDeploy option", async (t) => {
693+
process.chdir('tests/uv');
694+
const { stdout: path } = npm(['pack', '../..']);
695+
npm(['i', path]);
696+
perl([
697+
'-p',
698+
'-i.bak',
699+
'-e',
700+
's/(pythonRequirements:$)/\\1\\n noDeploy: [bottle]/',
701+
'serverless.yml',
702+
]);
703+
sls(['package'], { env: {} });
704+
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
705+
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
706+
t.false(zipfiles.includes(`bottle.py`), 'bottle is NOT packaged');
707+
t.end();
708+
});
709+
618710
test('non build pyproject.toml uses requirements.txt', async (t) => {
619711
process.chdir('tests/non_build_pyproject');
620712
const { stdout: path } = npm(['pack', '../..']);
@@ -963,6 +1055,29 @@ test('pipenv py3.9 can package flask with slim & slimPatterns & slimPatternsAppe
9631055
t.end();
9641056
});
9651057

1058+
test('uv py3.9 can package flask with slim & slimPatterns & slimPatternsAppendDefaults=false option', async (t) => {
1059+
process.chdir('tests/uv');
1060+
copySync('_slimPatterns.yml', 'slimPatterns.yml');
1061+
const { stdout: path } = npm(['pack', '../..']);
1062+
npm(['i', path]);
1063+
1064+
sls(['package'], {
1065+
env: { slim: 'true', slimPatternsAppendDefaults: 'false' },
1066+
});
1067+
const zipfiles = await listZipFiles('.serverless/sls-py-req-test.zip');
1068+
t.true(zipfiles.includes(`flask${sep}__init__.py`), 'flask is packaged');
1069+
t.true(
1070+
zipfiles.filter((filename) => filename.endsWith('.pyc')).length >= 1,
1071+
'pyc files are packaged'
1072+
);
1073+
t.deepEqual(
1074+
zipfiles.filter((filename) => filename.endsWith('__main__.py')),
1075+
[],
1076+
'__main__.py files are NOT packaged'
1077+
);
1078+
t.end();
1079+
});
1080+
9661081
test('poetry py3.9 can package flask with slim & slimPatterns & slimPatternsAppendDefaults=false option', async (t) => {
9671082
process.chdir('tests/poetry');
9681083
copySync('_slimPatterns.yml', 'slimPatterns.yml');

tests/base/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"author": "",
1010
"license": "ISC",
1111
"dependencies": {
12-
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
12+
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
1313
}
1414
}

tests/non_build_pyproject/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"author": "",
1010
"license": "ISC",
1111
"dependencies": {
12-
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
12+
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
1313
}
1414
}

tests/non_poetry_pyproject/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"author": "",
1010
"license": "ISC",
1111
"dependencies": {
12-
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
12+
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
1313
}
1414
}

tests/pipenv/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"author": "",
1010
"license": "ISC",
1111
"dependencies": {
12-
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
12+
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
1313
}
1414
}

tests/poetry/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"author": "",
1010
"license": "ISC",
1111
"dependencies": {
12-
"serverless-python-requirements": "file:serverless-python-requirements-6.0.1.tgz"
12+
"serverless-python-requirements": "file:serverless-python-requirements-6.1.1.tgz"
1313
}
1414
}

tests/uv/.python-version

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.9

tests/uv/_slimPatterns.yml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
slimPatterns:
2+
- '**/__main__.py'

tests/uv/handler.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import requests
2+
3+
4+
def hello(event, context):
5+
return requests.get('https://httpbin.org/get').json()

0 commit comments

Comments
 (0)