diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index aef6dbd..760aa71 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,6 +14,8 @@ jobs:
env:
CI: "true"
steps:
+ - name: Install uv
+ run: pip install uv
- name: Checkout
uses: actions/checkout@v4
with:
diff --git a/.projen/deps.json b/.projen/deps.json
index efa3840..2323239 100644
--- a/.projen/deps.json
+++ b/.projen/deps.json
@@ -4,6 +4,10 @@
"name": "@biomejs/biome",
"type": "build"
},
+ {
+ "name": "@types/fs-extra",
+ "type": "build"
+ },
{
"name": "@types/jest",
"type": "build"
@@ -17,6 +21,10 @@
"version": "^12",
"type": "build"
},
+ {
+ "name": "fs-extra",
+ "type": "build"
+ },
{
"name": "jest",
"type": "build"
diff --git a/.projen/tasks.json b/.projen/tasks.json
index 8eec86f..aad0b76 100644
--- a/.projen/tasks.json
+++ b/.projen/tasks.json
@@ -230,7 +230,7 @@
"description": "Run tests",
"steps": [
{
- "exec": "jest --testTimeout=300000 --passWithNoTests --updateSnapshot",
+ "exec": "jest --testTimeout=400000 --passWithNoTests --updateSnapshot",
"receiveArgs": true
}
]
@@ -269,13 +269,13 @@
},
"steps": [
{
- "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@biomejs/biome,@types/jest,@types/node,jest,jsii-diff,jsii-pacmak,projen,ts-jest,ts-node,typescript"
+ "exec": "npx npm-check-updates@16 --upgrade --target=minor --peer --no-deprecated --dep=dev,peer,prod,optional --filter=@biomejs/biome,@types/fs-extra,@types/jest,@types/node,fs-extra,jest,jsii-diff,jsii-pacmak,projen,ts-jest,ts-node,typescript"
},
{
"exec": "yarn install --check-files"
},
{
- "exec": "yarn upgrade @biomejs/biome @types/jest @types/node commit-and-tag-version jest jest-junit jsii-diff jsii-docgen jsii-pacmak jsii-rosetta jsii projen ts-jest ts-node typescript aws-cdk-lib constructs"
+ "exec": "yarn upgrade @biomejs/biome @types/fs-extra @types/jest @types/node commit-and-tag-version fs-extra jest jest-junit jsii-diff jsii-docgen jsii-pacmak jsii-rosetta jsii projen ts-jest ts-node typescript aws-cdk-lib constructs"
},
{
"exec": "npx projen"
diff --git a/.projenrc.ts b/.projenrc.ts
index ec85fe2..5a4f46b 100644
--- a/.projenrc.ts
+++ b/.projenrc.ts
@@ -1,4 +1,4 @@
-import { awscdk } from 'projen';
+import { JsonPatch, awscdk } from 'projen';
import { JobPermission } from 'projen/lib/github/workflows-model';
const project = new awscdk.AwsCdkConstructLibrary({
author: 'Eoin Shanaghy',
@@ -18,13 +18,18 @@ const project = new awscdk.AwsCdkConstructLibrary({
// cdkDependencies: [], /* CDK dependencies of this module. */
// deps: [], /* Runtime dependencies of this module. */
// description: undefined, /* The description is just a string that helps people understand the purpose of the package. */
- devDeps: ['@biomejs/biome'] /* Build dependencies for this module. */,
+ devDeps: [
+ '@biomejs/biome',
+ 'fs-extra',
+ '@types/fs-extra',
+ ] /* Build dependencies for this module. */,
// packageName: undefined, /* The "name" in package.json. */
jestOptions: {
- extraCliOptions: ['--testTimeout=300000'],
+ extraCliOptions: ['--testTimeout=400000'],
},
eslint: false,
});
+
const biomeWorkflow = project.github?.addWorkflow('biome');
biomeWorkflow?.on({
pullRequest: {
@@ -58,5 +63,15 @@ biomeWorkflow?.addJobs({
},
});
+const buildWorkflow = project.tryFindObjectFile('.github/workflows/build.yml');
+if (buildWorkflow) {
+ buildWorkflow.patch(
+ JsonPatch.add('/jobs/build/steps/0', {
+ name: 'Install uv',
+ run: 'pip install uv',
+ }),
+ );
+}
+
project.files;
project.synth();
diff --git a/API.md b/API.md
index 029babb..56d6d56 100644
--- a/API.md
+++ b/API.md
@@ -1155,6 +1155,7 @@ const bundlingOptions: BundlingOptions = { ... }
| assetHashType | aws-cdk-lib.AssetHashType | Determines how asset hash is calculated. Assets will get rebuild and uploaded only if their hash has changed. |
| buildArgs | {[ key: string ]: string} | Optional build arguments to pass to the default container. |
| bundlingFileAccess | aws-cdk-lib.BundlingFileAccess | Which option to use to copy the source files to the docker container and output files back. |
+| bundlingStrategy | BundlingStrategy | Strategy for bundling. |
| commandHooks | ICommandHooks | Command hooks. |
| image | aws-cdk-lib.DockerImage | Docker image to use for bundling. |
| outputPathSuffix | string | Output path suffix: the suffix for the directory into which the bundled output is written. |
@@ -1388,6 +1389,19 @@ Which option to use to copy the source files to the docker container and output
---
+##### `bundlingStrategy`Optional
+
+```typescript
+public readonly bundlingStrategy: BundlingStrategy;
+```
+
+- *Type:* BundlingStrategy
+- *Default:* `BundlingStrategy.SOURCE`
+
+Strategy for bundling.
+
+---
+
##### `commandHooks`Optional
```typescript
@@ -1460,6 +1474,7 @@ const bundlingProps: BundlingProps = { ... }
| assetHashType | aws-cdk-lib.AssetHashType | Determines how asset hash is calculated. Assets will get rebuild and uploaded only if their hash has changed. |
| buildArgs | {[ key: string ]: string} | Optional build arguments to pass to the default container. |
| bundlingFileAccess | aws-cdk-lib.BundlingFileAccess | Which option to use to copy the source files to the docker container and output files back. |
+| bundlingStrategy | BundlingStrategy | Strategy for bundling. |
| commandHooks | ICommandHooks | Command hooks. |
| image | aws-cdk-lib.DockerImage | Docker image to use for bundling. |
| outputPathSuffix | string | Output path suffix: the suffix for the directory into which the bundled output is written. |
@@ -1699,6 +1714,19 @@ Which option to use to copy the source files to the docker container and output
---
+##### `bundlingStrategy`Optional
+
+```typescript
+public readonly bundlingStrategy: BundlingStrategy;
+```
+
+- *Type:* BundlingStrategy
+- *Default:* `BundlingStrategy.SOURCE`
+
+Strategy for bundling.
+
+---
+
##### `commandHooks`Optional
```typescript
@@ -2985,3 +3013,25 @@ Commands are chained with `&&`.
---
+## Enums
+
+### BundlingStrategy
+
+#### Members
+
+| **Name** | **Description** |
+| --- | --- |
+| SOURCE | *No description.* |
+| GIT | *No description.* |
+
+---
+
+##### `SOURCE`
+
+---
+
+
+##### `GIT`
+
+---
+
diff --git a/package-lock.json b/package-lock.json
index 81a318b..091ecd1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,11 +10,13 @@
"license": "Apache-2.0",
"devDependencies": {
"@biomejs/biome": "^1.9.4",
+ "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.3",
"aws-cdk-lib": "2.161.1",
"commit-and-tag-version": "^12",
"constructs": "10.3.0",
+ "fs-extra": "^11.2.0",
"jest": "^29.7.0",
"jest-junit": "^15",
"jsii": "~5.5.0",
@@ -1907,6 +1909,17 @@
"@babel/types": "^7.20.7"
}
},
+ "node_modules/@types/fs-extra": {
+ "version": "11.0.4",
+ "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz",
+ "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/jsonfile": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -1955,6 +1968,16 @@
"pretty-format": "^29.0.0"
}
},
+ "node_modules/@types/jsonfile": {
+ "version": "6.1.4",
+ "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz",
+ "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
@@ -3097,6 +3120,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/codemaker/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/collect-v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
@@ -4225,9 +4263,9 @@
}
},
"node_modules/fs-extra": {
- "version": "10.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
- "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
+ "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4236,7 +4274,7 @@
"universalify": "^2.0.0"
},
"engines": {
- "node": ">=12"
+ "node": ">=14.14"
}
},
"node_modules/fs.realpath": {
@@ -6898,6 +6936,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsii-diff/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/jsii-diff/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -6985,6 +7038,21 @@
"wrap-ansi": "^7.0.0"
}
},
+ "node_modules/jsii-docgen/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/jsii-docgen/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -7177,6 +7245,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/jsii-pacmak/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/jsii-pacmak/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -7319,6 +7402,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/jsii-reflect/node_modules/fs-extra": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
+ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/jsii-reflect/node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
diff --git a/package.json b/package.json
index 2d71de4..bcac4bd 100644
--- a/package.json
+++ b/package.json
@@ -35,11 +35,13 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
+ "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.3",
"aws-cdk-lib": "2.161.1",
"commit-and-tag-version": "^12",
"constructs": "10.3.0",
+ "fs-extra": "^11.2.0",
"jest": "^29.7.0",
"jest-junit": "^15",
"jsii": "~5.5.0",
diff --git a/src/bundling.ts b/src/bundling.ts
index f6e72db..41aecf7 100644
--- a/src/bundling.ts
+++ b/src/bundling.ts
@@ -1,3 +1,5 @@
+import { execSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
import * as path from 'node:path';
import {
AssetHashType,
@@ -13,6 +15,7 @@ import {
type Runtime,
} from 'aws-cdk-lib/aws-lambda';
import type { BundlingOptions, ICommandHooks } from './types';
+import { BundlingStrategy } from './types';
export const HASHABLE_DEPENDENCIES_EXCLUDE = [
'*.pyc',
@@ -86,13 +89,69 @@ export class Bundling {
hashableAssetExclude = HASHABLE_DEPENDENCIES_EXCLUDE,
...bundlingOptions
} = options;
+ switch (options.bundlingStrategy) {
+ case BundlingStrategy.GIT:
+ return Bundling.gitStrategy(bundlingOptions);
+ default:
+ return Bundling.sourceStrategy(bundlingOptions);
+ }
+ }
+
+ /**
+ * Uses the AssetHashType.SOURCE strategy to calculate the asset hash.
+ *
+ * If there are multiple functions being created from a workspace they will all be rebuilt if the source changes.
+ *
+ * @param options
+ * @private
+ */
+ private static sourceStrategy(options: BundlingProps): AssetCode {
return Code.fromAsset(options.rootDir, {
assetHashType: AssetHashType.SOURCE,
- exclude: hashableAssetExclude,
- bundling: new Bundling(bundlingOptions),
+ exclude: options.hashableAssetExclude,
+ bundling: new Bundling(options),
});
}
+ private static gitStrategy(options: BundlingProps): AssetCode {
+ const workspacePackage = options.workspacePackage;
+ let assetHash: string;
+
+ if (!workspacePackage) {
+ assetHash = Bundling.gitHash(options.rootDir);
+ } else {
+ const uvCommand = `cd ${options.rootDir} && uv export --package ${workspacePackage} --frozen --no-editable --no-dev --no-header`;
+ const dependencies = execSync(uvCommand).toString();
+ // This includes the current workspacePackage
+ const workspaceDependencies = extractWorkspaceDependencies(dependencies);
+ const hash = createHash('sha256').update(dependencies);
+ for (const dependency of workspaceDependencies) {
+ hash.update(Bundling.gitHash(path.join(options.rootDir, dependency)));
+ }
+ assetHash = hash.digest('hex');
+ }
+
+ return Code.fromAsset(options.rootDir, {
+ assetHashType: AssetHashType.CUSTOM,
+ assetHash,
+ exclude: options.hashableAssetExclude,
+ bundling: new Bundling(options),
+ });
+ }
+
+ private static gitHash(path: string): string {
+ const gitCommands = [
+ `cd ${path}`,
+ 'git log -1 --format=%H -- .', // find the hash of the last commit that changed the files in the directory
+ 'git diff -- .', // get the diff of the files in the directory and below
+ ];
+ const gitCommand = gitCommands.join(' && ');
+ const status = execSync(gitCommand).toString().trim();
+ const assetHash = createHash('sha256').update(status).digest('hex');
+
+ return assetHash;
+ }
+
public readonly image: DockerImage;
public readonly entrypoint?: string[] | undefined;
public readonly command: string[] | undefined;
@@ -196,3 +255,24 @@ export class Bundling {
return commands;
}
}
+
+function extractWorkspaceDependencies(content: string): string[] {
+ // Split content into lines and filter out empty lines
+ const lines = content.split('\n').filter((line) => line.trim());
+
+ // Regular expression to match package lines
+ // Matches lines containing package==version followed by hash(es)
+ const packageLineRegex =
+ /^[\w-]+==[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]+)?\s*(\\|\s|$)/;
+
+ // Filter out package lines and hash lines
+ return lines.filter((line) => {
+ // Skip hash lines
+ if (line.trim().startsWith('--hash=')) {
+ return false;
+ }
+
+ // Keep lines that don't match package pattern
+ return !packageLineRegex.test(line);
+ });
+}
diff --git a/src/types.ts b/src/types.ts
index c5529b8..13eb2a5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -93,6 +93,19 @@ export interface BundlingOptions extends DockerRunOptions {
* @default - BundlingFileAccess.BIND_MOUNT
*/
readonly bundlingFileAccess?: BundlingFileAccess;
+
+ /**
+ * Strategy for bundling
+ *
+ * @default - `BundlingStrategy.SOURCE`
+ *
+ */
+ readonly bundlingStrategy?: BundlingStrategy;
+}
+
+export enum BundlingStrategy {
+ SOURCE = 'source',
+ GIT = 'git',
}
/**
diff --git a/test/function.test.ts b/test/function.test.ts
index 1f4f22a..9970aad 100644
--- a/test/function.test.ts
+++ b/test/function.test.ts
@@ -7,7 +7,9 @@ import { App, Stack } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda';
import * as cxapi from 'aws-cdk-lib/cx-api';
-import { PythonFunction } from '../src';
+import * as fsextra from 'fs-extra';
+import { BundlingStrategy, PythonFunction } from '../src';
+
const execAsync = promisify(exec);
const resourcesPath = path.resolve(__dirname, 'resources');
@@ -54,7 +56,7 @@ beforeEach(async () => {
jest.resetModules();
process.env = { ...OLD_ENV };
process.env.CDK_OUTDIR = await fs.mkdtemp(
- path.join(os.tmpdir(), 'uv-python-lambda-test-'),
+ path.join(os.tmpdir(), 'uv-python-lambda-function'),
);
});
@@ -65,124 +67,829 @@ afterEach(async () => {
process.env = OLD_ENV;
});
-test('Create a function from basic_app', async () => {
- const { app, stack } = await createStack();
+describe('When bundlingStrategy is set to BundlingStrategy.SOURCE', () => {
+ test('Create a function from basic_app', async () => {
+ const { app, stack } = await createStack();
- new PythonFunction(stack, 'basic_app', {
- rootDir: path.join(resourcesPath, 'basic_app'),
- index: 'handler.py',
- handler: 'lambda_handler',
- runtime: Runtime.PYTHON_3_12,
- architecture: await getDockerHostArch(),
- });
+ new PythonFunction(stack, 'basic_app', {
+ rootDir: path.join(resourcesPath, 'basic_app'),
+ index: 'handler.py',
+ handler: 'lambda_handler',
+ runtime: Runtime.PYTHON_3_12,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.SOURCE,
+ },
+ });
- const template = Template.fromStack(stack);
+ const template = Template.fromStack(stack);
- template.hasResourceProperties('AWS::Lambda::Function', {
- Handler: 'handler.lambda_handler',
- Runtime: 'python3.12',
- Code: {
- S3Bucket: Match.anyValue(),
- S3Key: Match.anyValue(),
- },
- });
- const functions = Object.values(
- template.findResources('AWS::Lambda::Function'),
- );
- expect(functions).toHaveLength(1);
- const contents = await getFunctionAssetContents(functions[0], app);
- expect(contents).toContain('handler.py');
-});
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'handler.lambda_handler',
+ Runtime: 'python3.12',
+ Code: {
+ S3Bucket: Match.anyValue(),
+ S3Key: Match.anyValue(),
+ },
+ });
+ const functions = getFunctions(template);
+ expect(functions).toHaveLength(1);
+ const contents = await getAssetContent(functions[0], app);
+ expect(contents).toContain('handler.py');
+ }, 40000); // need long timeout as working with file system
-test('Create a function from basic_app with no .py index extension', async () => {
- const { stack } = await createStack();
+ test('Create a function from basic_app with no .py index extension', async () => {
+ const { stack } = await createStack();
- new PythonFunction(stack, 'basic_app', {
- rootDir: path.join(resourcesPath, 'basic_app'),
- index: 'handler',
- handler: 'lambda_handler',
- runtime: Runtime.PYTHON_3_12,
- architecture: await getDockerHostArch(),
+ new PythonFunction(stack, 'basic_app', {
+ rootDir: path.join(resourcesPath, 'basic_app'),
+ index: 'handler',
+ handler: 'lambda_handler',
+ runtime: Runtime.PYTHON_3_12,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.SOURCE,
+ },
+ });
+
+ const template = Template.fromStack(stack);
+
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'handler.lambda_handler',
+ Runtime: 'python3.12',
+ Code: {
+ S3Bucket: Match.anyValue(),
+ S3Key: Match.anyValue(),
+ },
+ });
});
- const template = Template.fromStack(stack);
+ test('Create a function from basic_app when skip is true', async () => {
+ const { stack } = await createStack();
+
+ const bundlingSpy = jest
+ .spyOn(stack, 'bundlingRequired', 'get')
+ .mockReturnValue(false);
+ const architecture = await getDockerHostArch();
+
+ // To see this fail, comment out the `if (skip) { return; } code in the PythonFunction constructor
+ expect(() => {
+ new PythonFunction(stack, 'basic_app', {
+ rootDir: path.join(resourcesPath, 'basic_app'),
+ index: 'handler',
+ handler: 'lambda_handler',
+ runtime: Runtime.PYTHON_3_12,
+ architecture,
+ bundling: {
+ bundlingStrategy: BundlingStrategy.SOURCE,
+ },
+ });
+ }).not.toThrow();
- template.hasResourceProperties('AWS::Lambda::Function', {
- Handler: 'handler.lambda_handler',
- Runtime: 'python3.12',
- Code: {
- S3Bucket: Match.anyValue(),
- S3Key: Match.anyValue(),
- },
+ bundlingSpy.mockRestore();
});
+
+ test('Create a function with workspaces_app', async () => {
+ const { app, stack } = await createStack('wstest');
+
+ new PythonFunction(stack, 'workspaces_app', {
+ rootDir: path.join(resourcesPath, 'workspaces_app'),
+ workspacePackage: 'app',
+ index: 'app_handler.py',
+ handler: 'handle_event',
+ runtime: Runtime.PYTHON_3_10,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.SOURCE,
+ },
+ });
+
+ const template = Template.fromStack(stack);
+
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'app_handler.handle_event',
+ Runtime: 'python3.10',
+ Code: {
+ S3Bucket: Match.anyValue(),
+ S3Key: Match.anyValue(),
+ },
+ });
+
+ const functions = getFunctions(template);
+ expect(functions).toHaveLength(1);
+ const contents = await getAssetContent(functions[0], app);
+ for (const entry of [
+ 'app',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(contents).toContain(entry);
+ }
+ }, 40000); // need long timeout as working with file system
+
+ test('Create multiple functions with workspaces_app', async () => {
+ const { app, stack } = await createStack('wstest');
+
+ new PythonFunction(stack, 'workspaces_app', {
+ rootDir: path.join(resourcesPath, 'workspaces_app'),
+ workspacePackage: 'app',
+ index: 'app_handler.py',
+ handler: 'handle_event',
+ runtime: Runtime.PYTHON_3_10,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.SOURCE,
+ },
+ });
+
+ new PythonFunction(stack, 'workspaces_backend', {
+ rootDir: path.join(resourcesPath, 'workspaces_app'),
+ workspacePackage: 'backend',
+ index: 'backend_handler.py',
+ handler: 'handle_event',
+ runtime: Runtime.PYTHON_3_10,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.SOURCE,
+ },
+ });
+
+ const template = Template.fromStack(stack);
+
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'app_handler.handle_event',
+ });
+
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'backend_handler.handle_event',
+ });
+
+ const functions = getFunctions(template);
+ expect(functions).toHaveLength(2);
+ const appContents = await getAssetContent(functions[0], app);
+ for (const entry of [
+ 'app',
+ 'backend',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(appContents).toContain(entry);
+ }
+ expect(appContents).not.toContain('backend_handler.py');
+ expect(appContents).not.toContain('pathlib');
+ const backendContents = await getAssetContent(functions[1], app);
+ for (const entry of [
+ 'app',
+ 'backend',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'backend_handler.py',
+ ]) {
+ expect(backendContents).toContain(entry);
+ }
+ expect(backendContents).not.toContain('app_handler.py');
+ expect(backendContents).not.toContain('flask');
+ }, 30000);
});
-test('Create a function from basic_app when skip is true', async () => {
- const { stack } = await createStack();
+describe('When bundlingStrategy is set to BundlingStrategy.GIT', () => {
+ jest.setTimeout(30000); // we are doing integration tests with the file system so give tests more time
- const bundlingSpy = jest
- .spyOn(stack, 'bundlingRequired', 'get')
- .mockReturnValue(false);
- const architecture = await getDockerHostArch();
+ test('Create a function from basic_app', async () => {
+ const { app, stack } = await createStack();
- // To see this fail, comment out the `if (skip) { return; } code in the PythonFunction constructor
- expect(() => {
new PythonFunction(stack, 'basic_app', {
rootDir: path.join(resourcesPath, 'basic_app'),
- index: 'handler',
+ index: 'handler.py',
handler: 'lambda_handler',
runtime: Runtime.PYTHON_3_12,
- architecture,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.GIT,
+ },
});
- }).not.toThrow();
- bundlingSpy.mockRestore();
-});
+ const template = Template.fromStack(stack);
-test('Create a function with workspaces_app', async () => {
- const { app, stack } = await createStack('wstest');
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'handler.lambda_handler',
+ Runtime: 'python3.12',
+ Code: {
+ S3Bucket: Match.anyValue(),
+ S3Key: Match.anyValue(),
+ },
+ });
+ const functions = getFunctions(template);
+ expect(functions).toHaveLength(1);
+ const contents = await getAssetContent(functions[0], app);
+ expect(contents).toContain('handler.py');
+ });
+
+ test('Create a function from basic_app when skip is true', async () => {
+ const { stack } = await createStack();
+
+ const bundlingSpy = jest
+ .spyOn(stack, 'bundlingRequired', 'get')
+ .mockReturnValue(false);
+ const architecture = await getDockerHostArch();
- new PythonFunction(stack, 'workspaces_app', {
- rootDir: path.join(resourcesPath, 'workspaces_app'),
- workspacePackage: 'app',
- index: 'app_handler.py',
- handler: 'handle_event',
- runtime: Runtime.PYTHON_3_10,
- architecture: await getDockerHostArch(),
+ // To see this fail, comment out the `if (skip) { return; } code in the PythonFunction constructor
+ expect(() => {
+ new PythonFunction(stack, 'basic_app', {
+ rootDir: path.join(resourcesPath, 'basic_app'),
+ index: 'handler',
+ handler: 'lambda_handler',
+ runtime: Runtime.PYTHON_3_12,
+ architecture,
+ bundling: {
+ bundlingStrategy: BundlingStrategy.GIT,
+ },
+ });
+ }).not.toThrow();
+
+ bundlingSpy.mockRestore();
});
- const template = Template.fromStack(stack);
+ test('Create a function from basic_app only gets rebuild when source changes', async () => {
+ const createBasicAppStack = async (workspacePath: string) => {
+ const { app, stack } = await createStack();
+
+ new PythonFunction(stack, 'basic_app', {
+ rootDir: path.join(workspacePath, 'basic_app'),
+ index: 'handler.py',
+ handler: 'lambda_handler',
+ runtime: Runtime.PYTHON_3_12,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.GIT,
+ },
+ });
+
+ return { app, stack };
+ };
- template.hasResourceProperties('AWS::Lambda::Function', {
- Handler: 'app_handler.handle_event',
- Runtime: 'python3.10',
- Code: {
- S3Bucket: Match.anyValue(),
- S3Key: Match.anyValue(),
- },
+ const workspacePath = await copyWorkspaceToTemp('basic_app', { git: true });
+
+ let { app, stack } = await createBasicAppStack(workspacePath);
+ let template = Template.fromStack(stack);
+
+ const run1Functions = getFunctions(template);
+ const basicAppAssetPath = getAssetPath(run1Functions[0], app);
+ expect(run1Functions).toHaveLength(1);
+ const contents = await getAssetContent(run1Functions[0], app);
+ expect(contents).toContain('handler.py');
+ // change the contents of the folder so we can verify what is rebuilt
+ await setAssetContents(run1Functions[0], app, ['basic_app.text']);
+
+ // Need to create the stack again as synthesize is only called once otherwise
+ ({ app, stack } = await createBasicAppStack(workspacePath));
+ template = Template.fromStack(stack);
+ const run2Functions = getFunctions(template);
+ // validate that same folder is used because the hash is the same
+ expect(getAssetPath(run2Functions[0], app)).toEqual(basicAppAssetPath);
+ expect(await getAssetContent(run2Functions[0], app)).toEqual([
+ 'basic_app.text',
+ ]);
+
+ // Now modify the source folder and check that the function is rebuilt
+ await replaceStringInFile(
+ path.join(workspacePath, 'basic_app', 'handler.py'),
+ 'Hello',
+ 'Hi',
+ );
+
+ ({ app, stack } = await createBasicAppStack(workspacePath));
+ template = Template.fromStack(stack);
+ const run3Functions = getFunctions(template);
+ // validate that different folder is used because the hash is different
+ expect(getAssetPath(run3Functions[0], app)).not.toEqual(basicAppAssetPath);
+ expect(await getAssetContent(run3Functions[0], app)).toContain(
+ 'handler.py',
+ );
});
- const functions = Object.values(
- template.findResources('AWS::Lambda::Function'),
- );
- expect(functions).toHaveLength(1);
- const contents = await getFunctionAssetContents(functions[0], app);
- for (const entry of [
- 'app',
- 'common',
- 'pydantic',
- 'httpx',
- '_common.pth',
- 'app_handler.py',
- ]) {
- expect(contents).toContain(entry);
+ test('Create a function with workspaces_app', async () => {
+ const { app, stack } = await createStack('wstest');
+
+ new PythonFunction(stack, 'workspaces_app', {
+ rootDir: path.join(resourcesPath, 'workspaces_app'),
+ workspacePackage: 'app',
+ index: 'app_handler.py',
+ handler: 'handle_event',
+ runtime: Runtime.PYTHON_3_10,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.GIT,
+ },
+ });
+
+ const template = Template.fromStack(stack);
+
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'app_handler.handle_event',
+ Runtime: 'python3.10',
+ Code: {
+ S3Bucket: Match.anyValue(),
+ S3Key: Match.anyValue(),
+ },
+ });
+
+ const functions = getFunctions(template);
+ expect(functions).toHaveLength(1);
+ const contents = await getAssetContent(functions[0], app);
+ for (const entry of [
+ 'app',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(contents).toContain(entry);
+ }
+ });
+
+ const createWorkspaceAppStack = async (workspacePath: string) => {
+ const { app, stack } = await createStack();
+
+ new PythonFunction(stack, 'workspaces_app', {
+ rootDir: path.join(workspacePath, 'workspaces_app'),
+ workspacePackage: 'app',
+ index: 'app_handler.py',
+ handler: 'handle_event',
+ runtime: Runtime.PYTHON_3_10,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.GIT,
+ },
+ });
+
+ return { app, stack };
+ };
+
+ test('Create a function with workspaces_app gets rebuilt if app changes', async () => {
+ const workspacePath = await copyWorkspaceToTemp('workspaces_app', {
+ git: true,
+ });
+
+ let { app, stack } = await createWorkspaceAppStack(workspacePath);
+
+ let template = Template.fromStack(stack);
+
+ const run1Functions = getFunctions(template);
+ expect(run1Functions).toHaveLength(1);
+ const appAssetPath = getAssetPath(run1Functions[0], app);
+ const contents = await getAssetContent(run1Functions[0], app);
+ for (const entry of [
+ 'app',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(contents).toContain(entry);
+ }
+
+ // change the contents of the folder so we can verify what is rebuilt
+ await setAssetContents(run1Functions[0], app, ['app.text']);
+
+ // Need to create the stack again as synthesize is only called once otherwise
+ ({ app, stack } = await createWorkspaceAppStack(workspacePath));
+ template = Template.fromStack(stack);
+ const run2Functions = getFunctions(template);
+ // validate that same folder is used because the hash is the same
+ expect(getAssetPath(run2Functions[0], app)).toEqual(appAssetPath);
+ expect(await getAssetContent(run2Functions[0], app)).toEqual(['app.text']);
+
+ // Now modify the source folder and check that the function is rebuilt
+ await replaceStringInFile(
+ path.join(workspacePath, 'workspaces_app', 'app', 'app_handler.py'),
+ 'Corolla',
+ 'Auris',
+ );
+
+ ({ app, stack } = await createWorkspaceAppStack(workspacePath));
+ template = Template.fromStack(stack);
+ const run3Functions = getFunctions(template);
+ const run3AssetPath = getAssetPath(run3Functions[0], app);
+ // validate that different folder is used because the hash is different
+ expect(run3AssetPath).not.toEqual(appAssetPath);
+ expect(await getAssetContent(run3Functions[0], app)).toContain(
+ 'app_handler.py',
+ );
+ });
+
+ test('Create a function with workspaces_app gets rebuilt if dependency changes', async () => {
+ const workspacePath = await copyWorkspaceToTemp('workspaces_app', {
+ git: true,
+ });
+
+ let { app, stack } = await createWorkspaceAppStack(workspacePath);
+
+ let template = Template.fromStack(stack);
+
+ const run1Functions = getFunctions(template);
+ expect(run1Functions).toHaveLength(1);
+ const appAssetPath = getAssetPath(run1Functions[0], app);
+ const contents = await getAssetContent(run1Functions[0], app);
+ for (const entry of [
+ 'app',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(contents).toContain(entry);
+ }
+
+ // change the contents of the folder so we can verify what is rebuilt
+ await setAssetContents(run1Functions[0], app, ['app.text']);
+
+ // Now modify the dependency source folder and check that the function is rebuilt
+ await replaceStringInFile(
+ path.join(workspacePath, 'workspaces_app', 'common', 'README.md'),
+ 'Common',
+ 'Shared',
+ );
+
+ ({ app, stack } = await createWorkspaceAppStack(workspacePath));
+ template = Template.fromStack(stack);
+ const run3Functions = getFunctions(template);
+ const run3AssetPath = getAssetPath(run3Functions[0], app);
+ // validate that different folder is used because the hash is different
+ expect(run3AssetPath).not.toEqual(appAssetPath);
+ expect(await getAssetContent(run3Functions[0], app)).toContain(
+ 'app_handler.py',
+ );
+ });
+
+ test('Create a function with workspaces_app does NOT get rebuilt if unrelated code changes', async () => {
+ const workspacePath = await copyWorkspaceToTemp('workspaces_app', {
+ git: true,
+ });
+
+ let { app, stack } = await createWorkspaceAppStack(workspacePath);
+
+ let template = Template.fromStack(stack);
+
+ const run1Functions = getFunctions(template);
+ expect(run1Functions).toHaveLength(1);
+ const appAssetPath = getAssetPath(run1Functions[0], app);
+ const contents = await getAssetContent(run1Functions[0], app);
+ for (const entry of [
+ 'app',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(contents).toContain(entry);
+ }
+
+ // change the contents of the folder so we can verify what is rebuilt
+ await setAssetContents(run1Functions[0], app, ['app.text']);
+
+ // Now modify the unrelated app source and check that the function is not rebuilt
+ await replaceStringInFile(
+ path.join(
+ workspacePath,
+ 'workspaces_app',
+ 'backend',
+ 'backend_handler.py',
+ ),
+ 'Skyline',
+ 'Juke',
+ );
+
+ ({ app, stack } = await createWorkspaceAppStack(workspacePath));
+ template = Template.fromStack(stack);
+ const run3Functions = getFunctions(template);
+ const run3AssetPath = getAssetPath(run3Functions[0], app);
+ // validate that same folder is used because the hash is the same
+ expect(run3AssetPath).toEqual(appAssetPath);
+ expect(await getAssetContent(run3Functions[0], app)).toContain('app.text');
+ });
+
+ async function createMultipleFunctionStack(
+ workspacePath?: string,
+ ): Promise<{ app: App; stack: Stack }> {
+ const { app, stack } = await createStack('wstest');
+ const rootPath = workspacePath ?? resourcesPath;
+
+ new PythonFunction(stack, 'workspaces_app', {
+ rootDir: path.join(rootPath, 'workspaces_app'),
+ workspacePackage: 'app',
+ index: 'app_handler.py',
+ handler: 'handle_event',
+ runtime: Runtime.PYTHON_3_10,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.GIT,
+ },
+ });
+
+ new PythonFunction(stack, 'workspaces_backend', {
+ rootDir: path.join(rootPath, 'workspaces_app'),
+ workspacePackage: 'backend',
+ index: 'backend_handler.py',
+ handler: 'handle_event',
+ runtime: Runtime.PYTHON_3_10,
+ architecture: await getDockerHostArch(),
+ bundling: {
+ bundlingStrategy: BundlingStrategy.GIT,
+ },
+ });
+
+ return { app, stack };
}
+
+ test('Create multiple functions with workspaces_app', async () => {
+ const { app, stack } = await createMultipleFunctionStack();
+ const template = Template.fromStack(stack);
+
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'app_handler.handle_event',
+ });
+
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Handler: 'backend_handler.handle_event',
+ });
+
+ const functions = getFunctions(template);
+ expect(functions).toHaveLength(2);
+ const appContents = await getAssetContent(functions[0], app);
+ for (const entry of [
+ 'app',
+ 'backend',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(appContents).toContain(entry);
+ }
+ expect(appContents).not.toContain('backend_handler.py');
+ expect(appContents).not.toContain('pathlib');
+ const backendContents = await getAssetContent(functions[1], app);
+ for (const entry of [
+ 'app',
+ 'backend',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'backend_handler.py',
+ ]) {
+ expect(backendContents).toContain(entry);
+ }
+ expect(backendContents).not.toContain('app_handler.py');
+ expect(backendContents).not.toContain('flask');
+ });
+
+ test("Doesn't rebuild multiple functions if they haven't changed", async () => {
+ let { app, stack } = await createMultipleFunctionStack();
+ let template = Template.fromStack(stack);
+
+ const run1Functions = getFunctions(template);
+ const appAssetPath = getAssetPath(run1Functions[0], app);
+ const backendAssetPath = getAssetPath(run1Functions[1], app);
+ // change the contents of the folder so we can verify they are not rebuilt
+ await setAssetContents(run1Functions[0], app, ['app.text']);
+ await setAssetContents(run1Functions[1], app, ['backend.text']);
+
+ // Need to create the stack again as synthesize is only called once otherwise
+ ({ app, stack } = await createMultipleFunctionStack());
+
+ template = Template.fromStack(stack);
+ const run2Functions = getFunctions(template);
+ // validate that the same folder is being used for the functions
+ expect(getAssetPath(run2Functions[0], app)).toEqual(appAssetPath);
+ expect(getAssetPath(run2Functions[1], app)).toEqual(backendAssetPath);
+
+ // validate they contain the content we put in above, i.e. haven't been overwritten
+ expect(await getAssetContent(run2Functions[0], app)).toEqual(['app.text']);
+ expect(await getAssetContent(run2Functions[1], app)).toEqual([
+ 'backend.text',
+ ]);
+ });
+
+ test('Rebuild single function when its content changes', async () => {
+ // copy the workspace so we can edit it without affecting the original
+ const workspacePath = await copyWorkspaceToTemp('workspaces_app', {
+ git: true,
+ });
+ let { app, stack } = await createMultipleFunctionStack(workspacePath);
+ let template = Template.fromStack(stack);
+
+ const run1Functions = getFunctions(template);
+ const appAssetPath = getAssetPath(run1Functions[0], app);
+ const backendAssetPath = getAssetPath(run1Functions[1], app);
+ // change the contents of the folder so we can verify what is rebuilt
+ await setAssetContents(run1Functions[0], app, ['app.text']);
+ await setAssetContents(run1Functions[1], app, ['backend.text']);
+
+ // Now modify the source folder and check that the function is rebuilt
+ await replaceStringInFile(
+ path.join(workspacePath, 'workspaces_app', 'app', 'app_handler.py'),
+ 'Corolla',
+ 'Auris',
+ );
+ // Need to create the stack again as synthesize is only called once otherwise
+ ({ app, stack } = await createMultipleFunctionStack(workspacePath));
+
+ template = Template.fromStack(stack);
+ const run2Functions = getFunctions(template);
+ // validate that different folder for updated function but same one for unchanged
+ expect(getAssetPath(run2Functions[0], app)).not.toEqual(appAssetPath);
+ expect(getAssetPath(run2Functions[1], app)).toEqual(backendAssetPath);
+
+ // validate app has been rebuild but backend hasn't been touched
+ const appContents = await getAssetContent(run2Functions[0], app);
+ for (const entry of [
+ 'app',
+ 'backend',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(appContents).toContain(entry);
+ }
+ expect(await getAssetContent(run2Functions[1], app)).toEqual([
+ 'backend.text',
+ ]);
+ });
+
+ test('Rebuild any function when its dependency version changes', async () => {
+ // copy the workspace so we can edit it without affecting the original
+ const workspacePath = await copyWorkspaceToTemp('workspaces_app', {
+ git: true,
+ });
+ let { app, stack } = await createMultipleFunctionStack(workspacePath);
+ let template = Template.fromStack(stack);
+
+ const run1Functions = getFunctions(template);
+ const appAssetPath = getAssetPath(run1Functions[0], app);
+ const backendAssetPath = getAssetPath(run1Functions[1], app);
+ // change the contents of the folder so we can verify what is rebuilt
+ await setAssetContents(run1Functions[0], app, ['app.text']);
+ await setAssetContents(run1Functions[1], app, ['backend.text']);
+
+ // Now modify the dependency source folder and check that both functions are rebuilt
+ await replaceStringInFile(
+ path.join(workspacePath, 'workspaces_app', 'common', 'README.md'),
+ 'Common',
+ 'Shared',
+ );
+ // Need to create the stack again as synthesize is only called once otherwise
+ ({ app, stack } = await createMultipleFunctionStack(workspacePath));
+
+ template = Template.fromStack(stack);
+ const run2Functions = getFunctions(template);
+ // validate that different folder for both updated functions
+ expect(getAssetPath(run2Functions[0], app)).not.toEqual(appAssetPath);
+ expect(getAssetPath(run2Functions[1], app)).not.toEqual(backendAssetPath);
+
+ // validate app and backend have both been rebuilt
+ const appContents = await getAssetContent(run2Functions[0], app);
+ for (const entry of [
+ 'app',
+ 'backend',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'app_handler.py',
+ ]) {
+ expect(appContents).toContain(entry);
+ }
+ const backendContents = await getAssetContent(run2Functions[1], app);
+ for (const entry of [
+ 'app',
+ 'backend',
+ 'common',
+ 'pydantic',
+ 'httpx',
+ '_common.pth',
+ 'backend_handler.py',
+ ]) {
+ expect(backendContents).toContain(entry);
+ }
+ });
});
-// biome-ignore lint/suspicious/noExplicitAny:
-async function getFunctionAssetContents(functionResource: any, app: App) {
+/**
+ * Copy the workspace to a temporary directory and return the path to the temporary directory.
+ * The workspace is copied to ensure that the original workspace is not modified when we change it during tests.
+ *
+ * @param workspace
+ * @param git - if true, a git repository is initialized in the copied workspace
+ */
+async function copyWorkspaceToTemp(
+ workspace: string,
+ { git = false }: { git?: boolean } = { git: false },
+): Promise {
+ const tempDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'uv-python-lambda-workspace'),
+ );
+ await fsextra.copy(
+ path.join(resourcesPath, workspace),
+ path.join(tempDir, workspace),
+ );
+
+ if (git) {
+ const gitPath = path.join(tempDir, workspace);
+ await execAsync('git init', { cwd: gitPath });
+ await execAsync('git add .', { cwd: gitPath });
+ await execAsync('git config user.email "test@example.com"', {
+ cwd: gitPath,
+ });
+ await execAsync('git config user.name "Testy McTestface"', {
+ cwd: gitPath,
+ });
+
+ await execAsync('git commit -m "commit for test purposes"', {
+ cwd: gitPath,
+ });
+ }
+ return tempDir;
+}
+
+/**
+ * Replace all occurrences of a string in a file. Used to make modifications to workspace copies for testing purposes.
+ *
+ * @param filePath
+ * @param searchString
+ * @param replaceString
+ */
+async function replaceStringInFile(
+ filePath: string,
+ searchString: string,
+ replaceString: string,
+): Promise {
+ try {
+ // Read the file content
+ const content = await fs.readFile(filePath, 'utf-8');
+
+ // Perform the replacement
+ const updatedContent = content.replace(
+ new RegExp(searchString, 'g'),
+ replaceString,
+ );
+
+ // Write the modified content back to the file
+ await fs.writeFile(filePath, updatedContent, 'utf-8');
+ } catch (error) {
+ if (error instanceof Error) {
+ throw new Error(`Failed to replace string in file: ${error.message}`);
+ }
+ throw error;
+ }
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: any is what is returned from the CDK template.findResources method
+function getFunctions(template: Template): any[] {
+ return Object.values(template.findResources('AWS::Lambda::Function'));
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: any is what is returned from the CDK template.findResources method
+function getAssetPath(functionResource: any, app: App): string {
const [assetHash] = functionResource.Properties.Code.S3Key.split('.');
- const assetPath = path.join(app.outdir, `asset.${assetHash}`);
- const contents = await fs.readdir(assetPath);
- return contents;
+ return path.join(app.outdir, `asset.${assetHash}`);
+}
+
+// biome-ignore lint/suspicious/noExplicitAny: any is what is returned from the CDK template.findResources method
+async function getAssetContent(functionResource: any, app: App) {
+ return await fs.readdir(getAssetPath(functionResource, app));
+}
+
+async function setAssetContents(
+ // biome-ignore lint/suspicious/noExplicitAny: any is what is returned from the CDK template.findResources method
+ functionResource: any,
+ app: App,
+ contents: string[],
+) {
+ const assetPath = getAssetPath(functionResource, app);
+
+ // remove all existing contents and add the new ones as empty files
+ const existingContents = await fs.readdir(assetPath);
+ for (const entry of existingContents) {
+ await fs.rm(path.join(assetPath, entry), { force: true, recursive: true });
+ }
+ for (const entry of contents) {
+ await fs.writeFile(path.join(assetPath, entry), '');
+ }
}
diff --git a/test/resources/workspaces_app/app/app_handler.py b/test/resources/workspaces_app/app/app_handler.py
index 9f5af87..d9bdaae 100644
--- a/test/resources/workspaces_app/app/app_handler.py
+++ b/test/resources/workspaces_app/app/app_handler.py
@@ -1,10 +1,5 @@
-from pydantic import BaseModel
-
-class Car(BaseModel):
- brand: str
- model: str
- year: int
+from common.car import Car
def handle_event(event, context):
car = Car(brand="Toyota", model="Corolla", year=2020)
- return car.model_dump()
\ No newline at end of file
+ return car.model_dump()
diff --git a/test/resources/workspaces_app/app/pyproject.toml b/test/resources/workspaces_app/app/pyproject.toml
index 75a3019..47d74cb 100644
--- a/test/resources/workspaces_app/app/pyproject.toml
+++ b/test/resources/workspaces_app/app/pyproject.toml
@@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"common",
- "pydantic>=2.9.2",
+ "flask>=3.1.0",
]
[tool.uv.sources]
diff --git a/test/resources/workspaces_app/backend/.python-version b/test/resources/workspaces_app/backend/.python-version
new file mode 100644
index 0000000..c8cfe39
--- /dev/null
+++ b/test/resources/workspaces_app/backend/.python-version
@@ -0,0 +1 @@
+3.10
diff --git a/test/resources/workspaces_app/backend/README.md b/test/resources/workspaces_app/backend/README.md
new file mode 100644
index 0000000..e69de29
diff --git a/test/resources/workspaces_app/backend/backend_handler.py b/test/resources/workspaces_app/backend/backend_handler.py
new file mode 100644
index 0000000..6c3e595
--- /dev/null
+++ b/test/resources/workspaces_app/backend/backend_handler.py
@@ -0,0 +1,5 @@
+from common.car import Car
+
+def handle_event(event, context):
+ car = Car(brand="Nissan", model="Skyline", year=2022)
+ return car.model_dump()
diff --git a/test/resources/workspaces_app/backend/pyproject.toml b/test/resources/workspaces_app/backend/pyproject.toml
new file mode 100644
index 0000000..d84c3c1
--- /dev/null
+++ b/test/resources/workspaces_app/backend/pyproject.toml
@@ -0,0 +1,13 @@
+[project]
+name = "backend"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+ "common",
+ "pathlib>=1.0.1",
+]
+
+[tool.uv.sources]
+common = { workspace = true }
diff --git a/test/resources/workspaces_app/common/README.md b/test/resources/workspaces_app/common/README.md
index e69de29..418e2f2 100644
--- a/test/resources/workspaces_app/common/README.md
+++ b/test/resources/workspaces_app/common/README.md
@@ -0,0 +1 @@
+Common code for all workspaces apps
diff --git a/test/resources/workspaces_app/common/pyproject.toml b/test/resources/workspaces_app/common/pyproject.toml
index 25a22a3..7f1d0c1 100644
--- a/test/resources/workspaces_app/common/pyproject.toml
+++ b/test/resources/workspaces_app/common/pyproject.toml
@@ -6,6 +6,7 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"httpx>=0.27.2",
+ "pydantic>=2.9.2",
]
[project.scripts]
diff --git a/test/resources/workspaces_app/common/src/common/car.py b/test/resources/workspaces_app/common/src/common/car.py
new file mode 100644
index 0000000..f0f37a2
--- /dev/null
+++ b/test/resources/workspaces_app/common/src/common/car.py
@@ -0,0 +1,6 @@
+from pydantic import BaseModel
+
+class Car(BaseModel):
+ brand: str
+ model: str
+ year: int
diff --git a/test/resources/workspaces_app/pyproject.toml b/test/resources/workspaces_app/pyproject.toml
index 4af2b65..7c347fa 100644
--- a/test/resources/workspaces_app/pyproject.toml
+++ b/test/resources/workspaces_app/pyproject.toml
@@ -7,15 +7,16 @@ requires-python = ">=3.10"
dependencies = []
[tool.uv.workspace]
-members = ["common", "app"]
+members = ["common", "app", "backend"]
[tool.uv.sources]
common = { workspace = true }
app = { workspace = true }
+backend = { workspace = true }
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
-packages = ["app", "common"]
+packages = ["app", "backend", "common"]
diff --git a/test/resources/workspaces_app/uv.lock b/test/resources/workspaces_app/uv.lock
index deb98d5..c34f8ff 100644
--- a/test/resources/workspaces_app/uv.lock
+++ b/test/resources/workspaces_app/uv.lock
@@ -8,6 +8,7 @@ resolution-markers = [
[manifest]
members = [
"app",
+ "backend",
"common",
"workspaces-app",
]
@@ -42,13 +43,37 @@ version = "0.1.0"
source = { editable = "app" }
dependencies = [
{ name = "common" },
- { name = "pydantic" },
+ { name = "flask" },
]
[package.metadata]
requires-dist = [
{ name = "common", editable = "common" },
- { name = "pydantic", specifier = ">=2.9.2" },
+ { name = "flask", specifier = ">=3.1.0" },
+]
+
+[[package]]
+name = "backend"
+version = "0.1.0"
+source = { editable = "backend" }
+dependencies = [
+ { name = "common" },
+ { name = "pathlib" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "common", editable = "common" },
+ { name = "pathlib", specifier = ">=1.0.1" },
+]
+
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
]
[[package]]
@@ -60,16 +85,41 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
]
+[[package]]
+name = "click"
+version = "8.1.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "platform_system == 'Windows'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
[[package]]
name = "common"
version = "0.1.0"
source = { editable = "common" }
dependencies = [
{ name = "httpx" },
+ { name = "pydantic" },
]
[package.metadata]
-requires-dist = [{ name = "httpx", specifier = ">=0.27.2" }]
+requires-dist = [
+ { name = "httpx", specifier = ">=0.27.2" },
+ { name = "pydantic", specifier = ">=2.9.2" },
+]
[[package]]
name = "exceptiongroup"
@@ -80,6 +130,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
]
+[[package]]
+name = "flask"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blinker" },
+ { name = "click" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/50/dff6380f1c7f84135484e176e0cac8690af72fa90e932ad2a0a60e28c69b/flask-3.1.0.tar.gz", hash = "sha256:5f873c5184c897c8d9d1b05df1e3d01b14910ce69607a117bd3277098a5836ac", size = 680824 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/af/47/93213ee66ef8fae3b93b3e29206f6b251e65c97bd91d8e1c5596ef15af0a/flask-3.1.0-py3-none-any.whl", hash = "sha256:d667207822eb83f1c4b50949b1623c8fc8d51f2341d65f72e1a1815397551136", size = 102979 },
+]
+
[[package]]
name = "h11"
version = "0.14.0"
@@ -127,6 +193,94 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 },
+ { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 },
+ { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 },
+ { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 },
+ { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 },
+ { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 },
+ { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 },
+ { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 },
+ { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 },
+ { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 },
+ { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 },
+ { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 },
+ { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 },
+ { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 },
+ { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 },
+ { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 },
+ { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 },
+ { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 },
+ { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 },
+ { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 },
+ { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 },
+ { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 },
+ { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 },
+ { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 },
+ { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 },
+ { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 },
+ { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 },
+ { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 },
+ { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
+ { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
+ { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 },
+ { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 },
+ { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 },
+ { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 },
+ { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 },
+ { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 },
+ { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 },
+ { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 },
+ { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
+ { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
+ { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 },
+ { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 },
+ { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 },
+ { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 },
+ { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 },
+ { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 },
+ { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 },
+ { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
+ { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
+ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
+]
+
+[[package]]
+name = "pathlib"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/9b065a76b9af472437a0059f77e8f962fe350438b927cb80184c32f075eb/pathlib-1.0.1.tar.gz", hash = "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f", size = 49298 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/f9/690a8600b93c332de3ab4a344a4ac34f00c8f104917061f779db6a918ed6/pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147", size = 14363 },
+]
+
[[package]]
name = "pydantic"
version = "2.9.2"
@@ -226,6 +380,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
]
+[[package]]
+name = "werkzeug"
+version = "3.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 },
+]
+
[[package]]
name = "workspaces-app"
version = "0.1.0"
diff --git a/yarn.lock b/yarn.lock
index ad31660..ec49906 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -335,7 +335,7 @@
"@biomejs/cli-darwin-arm64@1.9.4":
version "1.9.4"
- resolved "https://registry.yarnpkg.com/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz#dfa376d23a54a2d8f17133c92f23c1bf2e62509f"
+ resolved "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-1.9.4.tgz"
integrity sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==
"@biomejs/cli-darwin-x64@1.9.4":
@@ -355,12 +355,12 @@
"@biomejs/cli-linux-x64-musl@1.9.4":
version "1.9.4"
- resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-1.9.4.tgz#f36982b966bd671a36671e1de4417963d7db15fb"
integrity sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==
"@biomejs/cli-linux-x64@1.9.4":
version "1.9.4"
- resolved "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz"
+ resolved "https://registry.yarnpkg.com/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz#a0a7f56680c76b8034ddc149dbf398bdd3a462e8"
integrity sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==
"@biomejs/cli-win32-arm64@1.9.4":
@@ -783,6 +783,14 @@
dependencies:
"@babel/types" "^7.20.7"
+"@types/fs-extra@^11.0.4":
+ version "11.0.4"
+ resolved "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz"
+ integrity sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==
+ dependencies:
+ "@types/jsonfile" "*"
+ "@types/node" "*"
+
"@types/graceful-fs@^4.1.3":
version "4.1.9"
resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz"
@@ -817,6 +825,13 @@
expect "^29.0.0"
pretty-format "^29.0.0"
+"@types/jsonfile@*":
+ version "6.1.4"
+ resolved "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz"
+ integrity sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==
+ dependencies:
+ "@types/node" "*"
+
"@types/minimist@^1.2.0":
version "1.2.5"
resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz"
@@ -1872,7 +1887,7 @@ fs.realpath@^1.0.0:
fsevents@^2.3.2:
version "2.3.3"
- resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+ resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
function-bind@^1.1.2: