Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support uv-managed projects #850

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,17 @@ jobs:
- name: Install pipenv / poetry
run: python -m pip install pipenv poetry

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

- name: Install dependencies
if: steps.cacheNpm.outputs.cache-hit != 'true'
run: |
npm update --no-save
npm update --save-dev --no-save
- name: Install serverless
run: npm install serverless@${{ matrix.sls-version }}

- name: Validate Prettier formatting
run: npm run prettier-check:updated
- name: Validate ESLint rules
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
83 changes: 83 additions & 0 deletions lib/uv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
const fse = require('fs-extra');
const path = require('path');
const spawn = require('child-process-ext/spawn');
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...');
}

try {
await getUvVersion();
fse.ensureDirSync(path.join(this.servicePath, '.serverless'));
const requirementsPath = path.join(
this.servicePath,
'.serverless/requirements.txt'
);
await spawn(
'uv',
['export', '--no-dev', '--frozen', '--no-hashes', '-o', requirementsPath],
{
cwd: this.servicePath,
}
);
} finally {
generateRequirementsProgress && generateRequirementsProgress.remove();
}
}

module.exports = { uvToRequirements };
123 changes: 119 additions & 4 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ test(
process.chdir('tests/base');
const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);
const { stderr } = sls(['package'], {
const { stdout } = sls(['package'], {
noThrow: true,
env: {
dockerizePip: true,
Expand All @@ -216,7 +216,7 @@ test(
},
});
t.true(
stderr.includes(
stdout.includes(
Copy link
Contributor Author

@jfgordon2 jfgordon2 Oct 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something weird is going on here that, despite being thrown as throw new serverless.classes.Error it is being output on stdout instead of stderr. I'm uncertain of the cause, but I don't think it's related to this plugin.

`-v ${__dirname}${sep}tests${sep}base${sep}custom_ssh:/root/.ssh/custom_ssh:z`
),
'docker command properly resolved'
Expand Down 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 Expand Up @@ -1742,12 +1857,12 @@ test('poetry py3.9 fails packaging if poetry.lock is missing and flag requirePoe

const { stdout: path } = npm(['pack', '../..']);
npm(['i', path]);
const { stderr } = sls(['package'], {
const { stdout } = sls(['package'], {
env: { requirePoetryLockFile: 'true', slim: 'true' },
noThrow: true,
});
t.true(
stderr.includes(
stdout.includes(
'poetry.lock file not found - set requirePoetryLockFile to false to disable this error'
),
'flag works and error is properly reported'
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/individually/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/individually_mixed_runtime/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"
}
}
Loading