Skip to content

Commit 5f49df4

Browse files
committed
feat: handle package deps and pth
1 parent 822f9d8 commit 5f49df4

File tree

7 files changed

+787
-87
lines changed

7 files changed

+787
-87
lines changed

.projenrc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ const project = new awscdk.AwsCdkConstructLibrary({
2020
devDeps: ['@biomejs/biome'] /* Build dependencies for this module. */,
2121
// packageName: undefined, /* The "name" in package.json. */
2222
});
23+
project.files;
2324
project.synth();

API.md

Lines changed: 542 additions & 32 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Dockerfile renamed to resources/Dockerfile

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,20 @@
22
# The correct AWS SAM build image based on the runtime of the function will be
33
# passed as build arg. The default allows to do `docker build .` when testing.
44
ARG PYTHON_VERSION=3.7
5-
ARG UV_VERSION=0.4.20
65
ARG IMAGE=public.ecr.aws/sam/build-python${PYTHON_VERSION}
76
FROM $IMAGE
87

98
ARG PIP_INDEX_URL
109
ARG PIP_EXTRA_INDEX_URL
1110
ARG HTTPS_PROXY
12-
ARG POETRY_VERSION=1.5.1
11+
ARG UV_VERSION=0.4.20
1312

1413
ENV PIP_CACHE_DIR=/tmp/pip-cache
1514
ENV UV_CACHE_DIR=/tmp/uv-cache
1615

17-
# RUN mkdir /tmp/pip-cache && \
18-
# chmod -R 777 /tmp/pip-cache && \
19-
# mkdir /tmp/uv-cache && \
20-
# chmod -R 777 /tmp/uv-cache && \
21-
# pip install uv==${UV_VERSION} \
22-
# rm -rf /tmp/uv-cache/* /tmp/pip-cache/*
16+
RUN mkdir /tmp/pip-cache && \
17+
chmod -R 777 /tmp/pip-cache && \
18+
pip install uv==$UV_VERSION && \
19+
rm -rf /tmp/pip-cache/*
2320

2421
CMD [ "python" ]

src/bundling.ts

Lines changed: 88 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import * as path from 'node:path';
12
import {
23
AssetHashType,
34
AssetStaging,
45
type BundlingFileAccess,
56
DockerImage,
6-
type DockerRunOptions,
77
type DockerVolume,
88
} from 'aws-cdk-lib';
99
import {
@@ -12,63 +12,83 @@ import {
1212
Code,
1313
type Runtime,
1414
} from 'aws-cdk-lib/aws-lambda';
15+
import type { BundlingOptions, ICommandHooks } from './types';
1516

1617
export const HASHABLE_DEPENDENCIES_EXCLUDE = ['*.pyc'];
1718

19+
export const DEFAULT_ASSET_EXCLUDES = [
20+
'.venv/',
21+
'node_modules/',
22+
'cdk.out/',
23+
'.git/',
24+
];
25+
26+
const userCustomizePy = `
27+
import sys
28+
import os
29+
from pathlib import Path
30+
31+
base_path = Path(__file__).parent
32+
33+
for pth_file in base_path.glob('*.pth'):
34+
print('Adding', pth_file, base_path)
35+
with open(pth_file, 'r') as f:
36+
for line in f:
37+
module_path_str = str(base_path / line.strip())
38+
print('...', module_path_str)
39+
if module_path_str not in sys.path:
40+
sys.path.insert(0, module_path_str)
41+
42+
print('sys.path', sys.path)
43+
44+
`;
45+
1846
interface BundlingCommandOptions {
19-
readonly entry: string;
47+
readonly rootDir: string;
48+
readonly workspacePackage?: string;
2049
readonly inputDir: string;
2150
readonly outputDir: string;
51+
readonly assetExcludes: string[];
52+
readonly commandHooks?: ICommandHooks;
2253
}
2354

24-
export interface BundlingProps extends DockerRunOptions {
25-
readonly entry: string;
26-
readonly runtime: Runtime;
27-
55+
export interface BundlingProps extends BundlingOptions {
2856
/**
29-
* Lambda CPU architecture
30-
*
31-
* @default Architecture.X86_64
57+
* uv project root (workspace root)
3258
*/
33-
readonly architecture?: Architecture;
59+
readonly rootDir: string;
3460

3561
/**
36-
* Skip bundling process
37-
*
38-
* @default false
62+
* uv package to use for the Lambda Function
3963
*/
40-
readonly skip?: boolean;
64+
readonly workspacePackage?: string;
4165

4266
/**
43-
* Docker image to use for bundling.
44-
*
45-
* @default - Default bundling image from the sam/build-python set
67+
* Lambda runtime (must be one of the Python runtimes)
4668
*/
47-
readonly image?: DockerImage;
69+
readonly runtime: Runtime;
4870

4971
/**
50-
* Optional build arguments to pass to the bundling container when the default
51-
* image is used.
72+
* Lambda CPU architecture
5273
*
53-
* @default - {}
74+
* @default Architecture.ARM_64
5475
*/
55-
readonly buildArgs?: { [key: string]: string };
76+
readonly architecture?: Architecture;
5677

5778
/**
58-
* Specifies how to copy files to/from the docker container. BIND_MOUNT is generally faster
59-
* but VOLUME_MOUNT works with remote Docker contexts.
79+
* Skip bundling process
6080
*
61-
* @default - BundlingFileAccess.BIND_MOUNT
81+
* @default false
6282
*/
63-
readonly bundlingFileAccess?: BundlingFileAccess;
83+
readonly skip?: boolean;
6484
}
6585

6686
/**
6787
* Bundling options for Python Lambda assets
6888
*/
6989
export class Bundling {
7090
public static bundle(options: BundlingProps): AssetCode {
71-
return Code.fromAsset(options.entry, {
91+
return Code.fromAsset(options.rootDir, {
7292
assetHashType: AssetHashType.SOURCE,
7393
exclude: HASHABLE_DEPENDENCIES_EXCLUDE,
7494
bundling: options.skip ? undefined : new Bundling(options),
@@ -88,17 +108,28 @@ export class Bundling {
88108
public readonly bundlingFileAccess?: BundlingFileAccess | undefined;
89109

90110
constructor(props: BundlingProps) {
91-
const { entry, image, runtime, architecture = Architecture.X86_64 } = props;
111+
const {
112+
rootDir,
113+
workspacePackage,
114+
image,
115+
runtime,
116+
commandHooks,
117+
assetExcludes = DEFAULT_ASSET_EXCLUDES,
118+
architecture = Architecture.ARM_64,
119+
} = props;
92120

93121
const bundlingCommands = this.createBundlingCommands({
94-
entry,
122+
rootDir,
123+
workspacePackage,
124+
assetExcludes,
125+
commandHooks,
95126
inputDir: AssetStaging.BUNDLING_INPUT_DIR,
96127
outputDir: AssetStaging.BUNDLING_OUTPUT_DIR,
97128
});
98129

99130
this.image =
100131
image ??
101-
DockerImage.fromBuild(__dirname, {
132+
DockerImage.fromBuild(path.resolve(__dirname, '..', 'resources'), {
102133
buildArgs: {
103134
...props.buildArgs,
104135
IMAGE: runtime.bundlingImage.image,
@@ -123,6 +154,32 @@ export class Bundling {
123154
}
124155

125156
private createBundlingCommands(options: BundlingCommandOptions): string[] {
126-
return [`rsync -rLv ${options.inputDir}/ ${options.outputDir}`, 'uv sync'];
157+
const excludeArgs = options.assetExcludes.map((exclude) => `--exclude="${exclude}"`);
158+
const workspacePackage = options.workspacePackage;
159+
const uvCommonArgs = `--directory ${options.outputDir}`;
160+
const uvPackageArgs = workspacePackage ? `--package ${workspacePackage}` : '';
161+
const reqsFile = `/tmp/requirements${workspacePackage || ''}.txt`;
162+
const commands = [];
163+
commands.push(
164+
...options.commandHooks?.beforeBundling(options.inputDir, options.outputDir) ?? [],
165+
);
166+
commands.push(...[
167+
`while [ -e ${options.inputDir}/wait.txt ]; do sleep 2; echo Waiting; done`,
168+
`rsync -rLv ${excludeArgs.join(' ')} ${options.inputDir}/ ${options.outputDir}`,
169+
`cd ${options.outputDir}`, // uv pip install needs to be run from here for editable deps to relative paths to be resolved
170+
`VIRTUAL_ENV=/tmp/venv uv sync ${uvCommonArgs} ${uvPackageArgs} --compile-bytecode --no-dev --frozen --no-editable --link-mode=copy`,
171+
`VIRTUAL_ENV=/tmp/venv uv export ${uvCommonArgs} ${uvPackageArgs} --no-dev --frozen --no-editable > ${reqsFile}`,
172+
`uv pip install -r ${reqsFile} --target ${options.outputDir} --reinstall --compile-bytecode --link-mode=copy --editable $(grep -e "^\./" ${reqsFile})`,
173+
`sed -i 's|${options.outputDir}/|.|g' ${options.outputDir}/*.pth`,
174+
`rm -rf ${options.outputDir}/.venv`,
175+
`echo ${Buffer.from(userCustomizePy).toString('base64')} | base64 -d > ${options.outputDir}/usercustomize.py`,
176+
`while [ -e ${options.inputDir}/wait2.txt ]; do sleep 2; echo Waiting; done`,
177+
]);
178+
commands.push(
179+
...options.commandHooks?.afterBundling(options.inputDir, options.outputDir) ?? [],
180+
);
181+
console.log('Bundling commands', { options, commands });
182+
183+
return commands;
127184
}
128185
}

src/function.ts

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import * as fs from 'node:fs';
21
import * as path from 'node:path';
3-
import { type BundlingOptions, Stack } from 'aws-cdk-lib';
2+
import { Stack } from 'aws-cdk-lib';
43
import {
5-
// biome-ignore lint/suspicious/noShadowRestrictedNames: <explanation>
4+
Architecture,
5+
// biome-ignore lint/suspicious/noShadowRestrictedNames: shadows 'function'
66
Function,
77
type FunctionOptions,
88
Runtime,
@@ -11,12 +11,19 @@ import {
1111

1212
import type { Construct } from 'constructs';
1313
import { Bundling } from './bundling';
14+
import type { BundlingOptions } from './types';
1415

1516
export interface PythonFunctionProps extends FunctionOptions {
1617
/**
17-
* Path where index and function dependencies can be found
18+
* UV project root directory (workspace root)
1819
*/
19-
readonly entry: string;
20+
readonly rootDir: string;
21+
22+
/**
23+
* Optional UV project workspace, used to specify a specific package to be used
24+
* as a Lambda Function entry.
25+
*/
26+
readonly workspacePackage?: string;
2027

2128
/**
2229
* The runtime
@@ -26,7 +33,7 @@ export interface PythonFunctionProps extends FunctionOptions {
2633
readonly runtime?: Runtime;
2734

2835
/**
29-
* The path to the index file containing the handler. Relative to #entry
36+
* The path to the index file with the project or (or workspace, if specified) containing the handler.
3037
*
3138
* @default index.py
3239
*/
@@ -48,33 +55,34 @@ export interface PythonFunctionProps extends FunctionOptions {
4855
export class PythonFunction extends Function {
4956
constructor(scope: Construct, id: string, props: PythonFunctionProps) {
5057
const {
51-
index = 'index.py',
58+
workspacePackage,
5259
handler = 'handler',
5360
runtime = Runtime.PYTHON_3_12,
5461
} = props;
5562

56-
const entry = path.resolve(props.entry);
57-
const resolvedIndex = path.resolve(entry, index);
58-
if (!fs.existsSync(resolvedIndex)) {
59-
throw new Error(`Cannot find index file at ${resolvedIndex}`);
60-
}
63+
const architecture = props.architecture ?? Architecture.ARM_64;
64+
const rootDir = path.resolve(props.rootDir);
6165

62-
const moduleName = path.parse(index).name;
63-
const resolvedHandler = `${moduleName}.${handler}`.replace(/\//g, '.');
66+
let resolvedHandler = handler;
67+
if (workspacePackage) {
68+
resolvedHandler = `${workspacePackage.replace(/-/g, '_')}.${handler}`;
69+
}
6470

6571
if (runtime.family !== RuntimeFamily.PYTHON) {
6672
throw new Error('Only Python runtimes are supported');
6773
}
6874

6975
const code = Bundling.bundle({
70-
entry,
76+
rootDir,
7177
runtime,
7278
skip: !Stack.of(scope).bundlingRequired,
73-
architecture: props.architecture,
79+
architecture,
80+
workspacePackage,
7481
...props.bundling,
7582
});
7683
super(scope, id, {
7784
...props,
85+
architecture,
7886
runtime,
7987
code,
8088
handler: resolvedHandler,

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './bundling';
22
export * from './function';
3+
export * from './types';

0 commit comments

Comments
 (0)