diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json index a16067e6fd..f479b00387 100644 --- a/packages/js-sdk/package.json +++ b/packages/js-sdk/package.json @@ -90,8 +90,12 @@ "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "compare-versions": "^6.1.0", + "dockerfile-ast": "^0.7.1", + "glob": "^11.0.3", "openapi-fetch": "^0.9.7", - "platform": "^1.3.6" + "platform": "^1.3.6", + "strip-ansi": "^7.1.0", + "tar": "^7.4.3" }, "engines": { "node": ">=18" diff --git a/packages/js-sdk/src/api/index.ts b/packages/js-sdk/src/api/index.ts index 8a3ecd9428..75ce9ab258 100644 --- a/packages/js-sdk/src/api/index.ts +++ b/packages/js-sdk/src/api/index.ts @@ -7,12 +7,23 @@ import { AuthenticationError, RateLimitError, SandboxError } from '../errors' import { createApiLogger } from '../logs' export function handleApiError( - response: FetchResponse + response: FetchResponse, + errorClass: new (message: string) => Error = SandboxError ): Error | undefined { if (!response.error) { return } + if (response.response.status === 401) { + const message = 'Unauthorized, please check your credentials.' + const content = response.error?.message ?? response.error + + if (content) { + return new AuthenticationError(`${message} - ${content}`) + } + return new AuthenticationError(message) + } + if (response.response.status === 429) { const message = 'Rate limit exceeded, please try again later' const content = response.error?.message ?? response.error @@ -24,7 +35,7 @@ export function handleApiError( } const message = response.error?.message ?? response.error - return new SandboxError(`${response.response.status}: ${message}`) + return new errorClass(`${response.response.status}: ${message}`) } /** diff --git a/packages/js-sdk/src/api/metadata.ts b/packages/js-sdk/src/api/metadata.ts index 9dbc71296c..3128631261 100644 --- a/packages/js-sdk/src/api/metadata.ts +++ b/packages/js-sdk/src/api/metadata.ts @@ -1,55 +1,10 @@ import platform from 'platform' import { version } from '../../package.json' +import { runtime, runtimeVersion } from '../utils' export { version } -declare let window: any - -type Runtime = - | 'node' - | 'browser' - | 'deno' - | 'bun' - | 'vercel-edge' - | 'cloudflare-worker' - | 'unknown' - -function getRuntime(): { runtime: Runtime; version: string } { - // @ts-ignore - if ((globalThis as any).Bun) { - // @ts-ignore - return { runtime: 'bun', version: globalThis.Bun.version } - } - - // @ts-ignore - if ((globalThis as any).Deno) { - // @ts-ignore - return { runtime: 'deno', version: globalThis.Deno.version.deno } - } - - if ((globalThis as any).process?.release?.name === 'node') { - return { runtime: 'node', version: platform.version || 'unknown' } - } - - // @ts-ignore - if (typeof EdgeRuntime === 'string') { - return { runtime: 'vercel-edge', version: 'unknown' } - } - - if ((globalThis as any).navigator?.userAgent === 'Cloudflare-Workers') { - return { runtime: 'cloudflare-worker', version: 'unknown' } - } - - if (typeof window !== 'undefined') { - return { runtime: 'browser', version: platform.version || 'unknown' } - } - - return { runtime: 'unknown', version: 'unknown' } -} - -export const { runtime, version: runtimeVersion } = getRuntime() - export const defaultHeaders = { browser: (typeof window !== 'undefined' && platform.name) || 'unknown', lang: 'js', diff --git a/packages/js-sdk/src/envd/rpc.ts b/packages/js-sdk/src/envd/rpc.ts index e338680ebc..38d5110443 100644 --- a/packages/js-sdk/src/envd/rpc.ts +++ b/packages/js-sdk/src/envd/rpc.ts @@ -1,5 +1,5 @@ import { Code, ConnectError } from '@connectrpc/connect' -import { runtime } from '../api/metadata' +import { runtime } from '../utils' import { defaultUsername } from '../connectionConfig' import { diff --git a/packages/js-sdk/src/errors.ts b/packages/js-sdk/src/errors.ts index c4d472f5b7..019e476811 100644 --- a/packages/js-sdk/src/errors.ts +++ b/packages/js-sdk/src/errors.ts @@ -68,7 +68,7 @@ export class NotFoundError extends SandboxError { /** * Thrown when authentication fails. */ -export class AuthenticationError extends SandboxError { +export class AuthenticationError extends Error { constructor(message: any) { super(message) this.name = 'AuthenticationError' @@ -94,3 +94,17 @@ export class RateLimitError extends SandboxError { this.name = 'RateLimitError' } } + +export class BuildError extends Error { + constructor(message: string) { + super(message) + this.name = 'BuildError' + } +} + +export class FileUploadError extends BuildError { + constructor(message: string) { + super(message) + this.name = 'FileUploadError' + } +} diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index 942d2a4370..217e1a7583 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -12,6 +12,8 @@ export { TemplateError, TimeoutError, RateLimitError, + BuildError, + FileUploadError, } from './errors' export type { Logger } from './logs' @@ -58,3 +60,5 @@ export type { export { Sandbox } import { Sandbox } from './sandbox' export default Sandbox + +export { Template, type TemplateClass, type TemplateBase } from './template' diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts new file mode 100644 index 0000000000..859ea7c62b --- /dev/null +++ b/packages/js-sdk/src/template/buildApi.ts @@ -0,0 +1,223 @@ +import { ApiClient, paths, handleApiError } from '../api' +import { BuildError, FileUploadError } from '../errors' +import { LogEntry } from './types' +import { tarFileStreamUpload } from './utils' +import stripAnsi from 'strip-ansi' + +type RequestBuildInput = { + alias: string + cpuCount: number + memoryMB: number +} + +type GetFileUploadLinkInput = { + templateID: string + filesHash: string +} + +type TriggerBuildInput = { + templateID: string + buildID: string + template: TriggerBuildTemplate +} + +type GetBuildStatusInput = { + templateID: string + buildID: string + logsOffset: number +} + +export type GetBuildStatusResponse = + paths['/templates/{templateID}/builds/{buildID}/status']['get']['responses']['200']['content']['application/json'] + +export type TriggerBuildTemplate = + paths['/v2/templates/{templateID}/builds/{buildID}']['post']['requestBody']['content']['application/json'] + +export async function requestBuild( + client: ApiClient, + { alias, cpuCount, memoryMB }: RequestBuildInput +) { + const requestBuildRes = await client.api.POST('/v2/templates', { + body: { + alias, + cpuCount, + memoryMB, + }, + }) + + const error = handleApiError(requestBuildRes, BuildError) + if (error) { + throw error + } + + if (!requestBuildRes.data) { + throw new BuildError('Failed to request build') + } + + return requestBuildRes.data +} + +export async function getFileUploadLink( + client: ApiClient, + { templateID, filesHash }: GetFileUploadLinkInput +) { + const fileUploadLinkRes = await client.api.GET( + '/templates/{templateID}/files/{hash}', + { + params: { + path: { + templateID, + hash: filesHash, + }, + }, + } + ) + + const error = handleApiError(fileUploadLinkRes, FileUploadError) + if (error) { + throw error + } + + if (!fileUploadLinkRes.data) { + throw new FileUploadError('Failed to get file upload link') + } + + return fileUploadLinkRes.data +} + +export async function uploadFile(options: { + fileName: string + fileContextPath: string + url: string +}) { + const { fileName, url, fileContextPath } = options + const { contentLength, uploadStream } = await tarFileStreamUpload( + fileName, + fileContextPath + ) + + // The compiler assumes this is Web fetch API, but it's actually Node.js fetch API + const res = await fetch(url, { + method: 'PUT', + // @ts-expect-error + body: uploadStream, + headers: { + 'Content-Length': contentLength.toString(), + }, + duplex: 'half', + }) + + if (!res.ok) { + throw new FileUploadError( + `Failed to upload file: ${res.statusText} ${res.status}` + ) + } +} + +export async function triggerBuild( + client: ApiClient, + { templateID, buildID, template }: TriggerBuildInput +) { + const triggerBuildRes = await client.api.POST( + '/v2/templates/{templateID}/builds/{buildID}', + { + params: { + path: { + templateID, + buildID, + }, + }, + body: template, + } + ) + + const error = handleApiError(triggerBuildRes, BuildError) + if (error) { + throw error + } +} + +export async function getBuildStatus( + client: ApiClient, + { templateID, buildID, logsOffset }: GetBuildStatusInput +) { + const buildStatusRes = await client.api.GET( + '/templates/{templateID}/builds/{buildID}/status', + { + params: { + path: { + templateID, + buildID, + }, + query: { + logsOffset, + }, + }, + } + ) + + const error = handleApiError(buildStatusRes, BuildError) + if (error) { + throw error + } + + if (!buildStatusRes.data) { + throw new BuildError('Failed to get build status') + } + + return buildStatusRes.data +} + +export async function waitForBuildFinish( + client: ApiClient, + { + templateID, + buildID, + onBuildLogs, + logsRefreshFrequency, + }: { + templateID: string + buildID: string + onBuildLogs?: (logEntry: InstanceType) => void + logsRefreshFrequency: number + } +): Promise { + let logsOffset = 0 + let status: GetBuildStatusResponse['status'] = 'building' + + while (status === 'building') { + const buildStatus = await getBuildStatus(client, { + templateID, + buildID, + logsOffset, + }) + + logsOffset += buildStatus.logEntries.length + + buildStatus.logEntries.forEach( + (logEntry: GetBuildStatusResponse['logEntries'][number]) => + onBuildLogs?.( + new LogEntry( + new Date(logEntry.timestamp), + logEntry.level, + stripAnsi(logEntry.message) + ) + ) + ) + + status = buildStatus.status + switch (status) { + case 'ready': { + return + } + case 'error': { + throw new BuildError(buildStatus?.reason?.message ?? 'Unknown error') + } + } + + // Wait for a short period before checking the status again + await new Promise((resolve) => setTimeout(resolve, logsRefreshFrequency)) + } + + throw new BuildError('Unknown build error occurred.') +} diff --git a/packages/js-sdk/src/template/dockerfileParser.ts b/packages/js-sdk/src/template/dockerfileParser.ts new file mode 100644 index 0000000000..f195aaa82d --- /dev/null +++ b/packages/js-sdk/src/template/dockerfileParser.ts @@ -0,0 +1,267 @@ +import { Instruction } from './types' +import { + DockerfileParser, + Instruction as DockerfileInstruction, + Argument, +} from 'dockerfile-ast' +import fs from 'node:fs' + +export interface DockerfileParseResult { + baseImage: string + instructions: Instruction[] +} + +export interface DockerfileParserFinalInterface {} + +export interface DockerfileParserInterface { + setWorkdir(workdir: string): DockerfileParserInterface + setUser(user: string): DockerfileParserInterface + setEnvs(envs: Record): DockerfileParserInterface + runCmd(command: string): DockerfileParserInterface + copy(src: string, dest: string): DockerfileParserInterface + setStartCmd( + startCommand: string, + readyCommand: string + ): DockerfileParserFinalInterface +} + +/** + * Parse a Dockerfile and convert it to Template SDK format + * + * @param dockerfileContentOrPath Either the Dockerfile content as a string, + * or a path to a Dockerfile file + * @param templateBuilder Interface providing template builder methods + * @returns Parsed Dockerfile result with base image and instructions + */ +export function parseDockerfile( + dockerfileContentOrPath: string, + templateBuilder: DockerfileParserInterface +): DockerfileParseResult { + // Check if input is a file path that exists + let dockerfileContent: string + try { + if ( + fs.existsSync(dockerfileContentOrPath) && + fs.statSync(dockerfileContentOrPath).isFile() + ) { + // Read the file content + dockerfileContent = fs.readFileSync(dockerfileContentOrPath, 'utf-8') + } else { + // Treat as content directly + dockerfileContent = dockerfileContentOrPath + } + } catch { + // If there's any error checking the file, treat as content + dockerfileContent = dockerfileContentOrPath + } + + const dockerfile = DockerfileParser.parse(dockerfileContent) + const instructions = dockerfile.getInstructions() + + // Check for multi-stage builds + const fromInstructions = instructions.filter( + (instruction) => instruction.getKeyword() === 'FROM' + ) + + if (fromInstructions.length > 1) { + throw new Error('Multi-stage Dockerfiles are not supported') + } + + if (fromInstructions.length === 0) { + throw new Error('Dockerfile must contain a FROM instruction') + } + + // Set the base image from the first FROM instruction + const fromInstruction = fromInstructions[0] + const argumentsData = fromInstruction.getArguments() + let baseImage = 'e2bdev/base' // default fallback + if (argumentsData && argumentsData.length > 0) { + baseImage = argumentsData[0].getValue() + } + + const resultInstructions: Instruction[] = [] + + // Process all other instructions + for (const instruction of instructions) { + const keyword = instruction.getKeyword() + + switch (keyword) { + case 'FROM': + // Already handled above + break + + case 'RUN': + handleRunInstruction(instruction, templateBuilder) + break + + case 'COPY': + case 'ADD': + handleCopyInstruction(instruction, templateBuilder) + break + + case 'WORKDIR': + handleWorkdirInstruction(instruction, templateBuilder) + break + + case 'USER': + handleUserInstruction(instruction, templateBuilder) + break + + case 'ENV': + case 'ARG': + handleEnvInstruction(instruction, templateBuilder) + break + + case 'EXPOSE': + // EXPOSE is not directly supported in our SDK, so we'll skip it + break + + case 'VOLUME': + // VOLUME is not directly supported in our SDK, so we'll skip it + break + + case 'CMD': + case 'ENTRYPOINT': + handleCmdEntrypointInstruction(instruction, templateBuilder) + break + + default: + console.warn(`Unsupported instruction: ${keyword}`) + break + } + } + + return { + baseImage, + instructions: resultInstructions, + } +} + +function handleRunInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + const command = argumentsData + .map((arg: Argument) => arg.getValue()) + .join(' ') + templateBuilder.runCmd(command) + } +} + +function handleCopyInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length >= 2) { + const src = argumentsData[0].getValue() + const dest = argumentsData[argumentsData.length - 1].getValue() + templateBuilder.copy(src, dest) + } +} + +function handleWorkdirInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + const workdir = argumentsData[0].getValue() + templateBuilder.setWorkdir(workdir) + } +} + +function handleUserInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + const user = argumentsData[0].getValue() + templateBuilder.setUser(user) + } +} + +function handleEnvInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + const keyword = instruction.getKeyword() + + if (argumentsData && argumentsData.length >= 1) { + if (argumentsData.length === 2) { + // ENV key value format OR multiple key=value pairs (from line continuation) + const firstArg = argumentsData[0].getValue() + const secondArg = argumentsData[1].getValue() + + // Check if both arguments contain '=' (multiple key=value pairs) + if (firstArg.includes('=') && secondArg.includes('=')) { + // Both are key=value pairs (line continuation) + for (const arg of argumentsData) { + const envString = arg.getValue() + const equalIndex = envString.indexOf('=') + if (equalIndex > 0) { + const key = envString.substring(0, equalIndex) + const value = envString.substring(equalIndex + 1) + templateBuilder.setEnvs({ [key]: value }) + } + } + } else { + // Traditional ENV key value format + templateBuilder.setEnvs({ [firstArg]: secondArg }) + } + } else if (argumentsData.length === 1) { + // ENV/ARG key=value format (single argument) or ARG key (without default) + const envString = argumentsData[0].getValue() + + // Check if it's a simple key=value or just a key (for ARG without default) + const equalIndex = envString.indexOf('=') + if (equalIndex > 0) { + const key = envString.substring(0, equalIndex) + const value = envString.substring(equalIndex + 1) + templateBuilder.setEnvs({ [key]: value }) + } else if (keyword === 'ARG' && envString.trim()) { + // ARG without default value - set as empty ENV + const key = envString.trim() + templateBuilder.setEnvs({ [key]: '' }) + } + } else { + // Multiple arguments (from line continuation with backslashes) + for (const arg of argumentsData) { + const envString = arg.getValue() + const equalIndex = envString.indexOf('=') + if (equalIndex > 0) { + const key = envString.substring(0, equalIndex) + const value = envString.substring(equalIndex + 1) + templateBuilder.setEnvs({ [key]: value }) + } else if (keyword === 'ARG') { + // ARG without default value + const key = envString + templateBuilder.setEnvs({ [key]: '' }) + } + } + } + } +} + +function handleCmdEntrypointInstruction( + instruction: DockerfileInstruction, + templateBuilder: DockerfileParserInterface +): void { + const argumentsData = instruction.getArguments() + if (argumentsData && argumentsData.length > 0) { + const command = argumentsData + .map((arg: Argument) => arg.getValue()) + .join(' ') + // Import waitForTimeout locally to avoid circular dependency + const waitForTimeout = (timeout: number) => { + // convert to seconds, but ensure minimum of 1 second + const seconds = Math.max(1, Math.floor(timeout / 1000)) + return `sleep ${seconds}` + } + templateBuilder.setStartCmd(command, waitForTimeout(20_000)) + } +} diff --git a/packages/js-sdk/src/template/index.ts b/packages/js-sdk/src/template/index.ts new file mode 100644 index 0000000000..32aa8629c3 --- /dev/null +++ b/packages/js-sdk/src/template/index.ts @@ -0,0 +1,608 @@ +import { ApiClient } from '../api' +import { runtime } from '../utils' +import { + getFileUploadLink, + requestBuild, + triggerBuild, + TriggerBuildTemplate, + uploadFile, + waitForBuildFinish, +} from './buildApi' +import { parseDockerfile } from './dockerfileParser' +import { Instruction, Step, CopyItem, TemplateType } from './types' +import { + calculateFilesHash, + getCallerDirectory, + padOctal, + readDockerignore, +} from './utils' +import { ConnectionConfig } from '../connectionConfig' +import { LogEntry } from './types' + +type TemplateOptions = { + fileContextPath?: string + ignoreFilePaths?: string[] +} + +type BasicBuildOptions = { + alias: string + cpuCount?: number + memoryMB?: number + skipCache?: boolean + onBuildLogs?: (logEntry: InstanceType) => void +} + +export type BuildOptions = BasicBuildOptions & { + apiKey?: string + domain?: string +} + +export class TemplateBuilder { + constructor(private template: TemplateBase) {} + + getTemplateBase(): TemplateBase { + return this.template + } + + copy( + src: string, + dest: string, + options?: { forceUpload?: true; user?: string; mode?: number } + ): TemplateBuilder + copy( + items: CopyItem[], + options?: { forceUpload?: true; user?: string; mode?: number } + ): TemplateBuilder + copy( + srcOrItems: string | CopyItem[], + destOrOptions?: + | string + | { forceUpload?: true; user?: string; mode?: number }, + options?: { forceUpload?: true; user?: string; mode?: number } + ): TemplateBuilder { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for copy') + } + + const items = Array.isArray(srcOrItems) + ? srcOrItems + : [ + { + src: srcOrItems, + dest: destOrOptions as string, + mode: options?.mode, + user: options?.user, + forceUpload: options?.forceUpload, + }, + ] + + for (const item of items) { + const args = [ + item.src, + item.dest, + item.user ?? '', + item.mode ? padOctal(item.mode) : '', + ] + + const instruction: Instruction = { + type: 'COPY', + args, + force: item.forceUpload ?? this.template.forceNextLayer, + forceUpload: item.forceUpload, + } + this.template.instructions.push(instruction) + } + return this + } + + remove( + path: string, + options?: { force?: boolean; recursive?: boolean } + ): TemplateBuilder { + const args = ['rm', path] + if (options?.recursive) { + args.push('-r') + } + if (options?.force) { + args.push('-f') + } + this.runCmd(args.join(' ')) + return this + } + + rename( + src: string, + dest: string, + options?: { force?: boolean } + ): TemplateBuilder { + const args = ['mv', src, dest] + if (options?.force) { + args.push('-f') + } + this.runCmd(args.join(' ')) + return this + } + + makeDir( + paths: string | string[], + options?: { mode?: number } + ): TemplateBuilder { + const pathList = Array.isArray(paths) ? paths : [paths] + const args = ['mkdir', '-p', ...pathList] + if (options?.mode) { + args.push(`-m ${padOctal(options.mode)}`) + } + this.runCmd(args.join(' ')) + return this + } + + makeSymlink(src: string, dest: string): TemplateBuilder { + const args = ['ln', '-s', src, dest] + this.runCmd(args.join(' ')) + return this + } + + runCmd(command: string, options?: { user?: string }): TemplateBuilder + runCmd(commands: string[], options?: { user?: string }): TemplateBuilder + runCmd( + commandOrCommands: string | string[], + options?: { user?: string } + ): TemplateBuilder { + const cmds = Array.isArray(commandOrCommands) + ? commandOrCommands + : [commandOrCommands] + + const args = [cmds.join(' && ')] + if (options?.user) { + args.push(options.user) + } + + this.template.instructions.push({ + type: 'RUN', + args, + force: this.template.forceNextLayer, + }) + return this + } + + setWorkdir(workdir: string): TemplateBuilder { + const instruction: Instruction = { + type: 'WORKDIR', + args: [workdir], + force: this.template.forceNextLayer, + } + this.template.instructions.push(instruction) + return this + } + + setUser(user: string): TemplateBuilder { + const instruction: Instruction = { + type: 'USER', + args: [user], + force: this.template.forceNextLayer, + } + this.template.instructions.push(instruction) + return this + } + + pipInstall(packages?: string | string[]): TemplateBuilder { + const args = ['pip', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (packageList) { + args.push(...packageList) + } else { + args.push('.') + } + return this.runCmd(args) + } + + npmInstall(packages?: string | string[], g?: boolean): TemplateBuilder { + const args = ['npm', 'install'] + const packageList = packages + ? Array.isArray(packages) + ? packages + : [packages] + : undefined + if (packageList) { + args.push(...packageList) + } + if (g) { + args.push('-g') + } + return this.runCmd(args) + } + + aptInstall(packages: string | string[]): TemplateBuilder { + const packageList = Array.isArray(packages) ? packages : [packages] + + return this.runCmd( + [ + 'apt-get update', + `DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends ${packageList.join( + ' ' + )}`, + ], + { user: 'root' } + ) + } + + gitClone( + url: string, + path?: string, + options?: { branch?: string; depth?: number } + ): TemplateBuilder { + const args = ['git', 'clone', url, path] + if (options?.branch) { + args.push(`--branch ${options.branch}`) + args.push('--single-branch') + } + if (options?.depth) { + args.push(`--depth ${options.depth}`) + } + this.runCmd(args.join(' ')) + return this + } + + setEnvs(envs: Record): TemplateBuilder { + if (Object.keys(envs).length === 0) { + return this + } + + const instruction: Instruction = { + type: 'ENV', + args: Object.entries(envs).flatMap(([key, value]) => [key, value]), + force: this.template.forceNextLayer, + } + this.template.instructions.push(instruction) + return this + } + + skipCache(): TemplateBuilder { + this.template.forceNextLayer = true + return this + } + + setStartCmd(startCmd: string, readyCmd: string): TemplateFinal { + this.template.startCmd = startCmd + this.template.readyCmd = readyCmd + return new TemplateFinal(this.template) + } + + setReadyCmd(readyCmd: string): TemplateFinal { + this.template.readyCmd = readyCmd + return new TemplateFinal(this.template) + } +} + +export class TemplateFinal { + constructor(private template: TemplateBase) {} + + getTemplateBase(): TemplateBase { + return this.template + } +} + +export class TemplateBase { + // Public instance fields + public startCmd: string | undefined = undefined + public readyCmd: string | undefined = undefined + public forceNextLayer: boolean = false + public instructions: Instruction[] = [] + + // Private instance fields + private defaultBaseImage: string = 'e2bdev/base' + private baseImage: string | undefined = this.defaultBaseImage + private baseTemplate: string | undefined = undefined + // Force the whole template to be rebuilt + private force: boolean = false + private fileContextPath: string = + runtime === 'browser' ? '.' : getCallerDirectory() ?? '.' + private ignoreFilePaths: string[] = [] + private logsRefreshFrequency: number = 200 + + constructor(options?: TemplateOptions) { + this.fileContextPath = options?.fileContextPath ?? this.fileContextPath + this.ignoreFilePaths = options?.ignoreFilePaths ?? this.ignoreFilePaths + } + + static async toJSON(template: TemplateClass): Promise { + const templateBase = template.getTemplateBase() + return JSON.stringify( + templateBase.serialize(await templateBase.calculateFilesHashes()), + undefined, + 2 + ) + } + + static toDockerfile(template: TemplateClass): string { + const templateBase = template.getTemplateBase() + if (templateBase.baseTemplate !== undefined) { + throw new Error( + 'Cannot convert template built from another template to Dockerfile. ' + + 'Templates based on other templates can only be built using the E2B API.' + ) + } + + if (templateBase.baseImage === undefined) { + throw new Error('No base image specified for template') + } + + let dockerfile = `FROM ${templateBase.baseImage}\n` + for (const instruction of templateBase.instructions) { + dockerfile += `${instruction.type} ${instruction.args.join(' ')}\n` + } + if (templateBase.startCmd) { + dockerfile += `ENTRYPOINT ${templateBase.startCmd}\n` + } + return dockerfile + } + + static async build( + template: TemplateClass, + options: BuildOptions + ): Promise { + const config = new ConnectionConfig({ + domain: options.domain, + apiKey: options.apiKey, + }) + const client = new ApiClient(config) + const templateBase = template.getTemplateBase() + + if (options.skipCache) { + templateBase.force = true + } + + // Create template + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Requesting build for template: ${options.alias}` + ) + ) + + const { templateID, buildID } = await requestBuild(client, { + alias: options.alias, + cpuCount: options.cpuCount ?? 1, + memoryMB: options.memoryMB ?? 1024, + }) + + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Template created with ID: ${templateID}, Build ID: ${buildID}` + ) + ) + + const instructionsWithHashes = await templateBase.calculateFilesHashes() + + // Prepare file uploads + const fileUploads = instructionsWithHashes + .filter((instruction) => instruction.type === 'COPY') + .map((instruction) => ({ + src: instruction.args[0], + dest: instruction.args[1], + filesHash: instruction.filesHash, + forceUpload: instruction.forceUpload, + })) + + // Upload files in parallel + const uploadPromises = fileUploads.map(async (file) => { + const { present, url } = await getFileUploadLink(client, { + templateID, + filesHash: file.filesHash!, + }) + + if ( + (file.forceUpload && url != null) || + (present === false && url != null) + ) { + await uploadFile({ + fileName: file.src, + fileContextPath: templateBase.fileContextPath, + url, + }) + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', `Uploaded '${file.src}'`) + ) + } else { + options.onBuildLogs?.( + new LogEntry( + new Date(), + 'info', + `Skipping upload of '${file.src}', already cached` + ) + ) + } + }) + + await Promise.all(uploadPromises) + + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'All file uploads completed') + ) + + // Start build + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'Starting building...') + ) + + await triggerBuild(client, { + templateID, + buildID, + template: templateBase.serialize(instructionsWithHashes), + }) + + options.onBuildLogs?.( + new LogEntry(new Date(), 'info', 'Waiting for logs...') + ) + + await waitForBuildFinish(client, { + templateID, + buildID, + onBuildLogs: options.onBuildLogs, + logsRefreshFrequency: templateBase.logsRefreshFrequency, + }) + } + + // Public instance methods + skipCache(): TemplateBase { + this.forceNextLayer = true + return this + } + + // Built-in image mixins + fromDebianImage(variant: string = 'slim'): TemplateBuilder { + return this.fromImage(`debian:${variant}`) + } + + fromUbuntuImage(variant: string = 'lts'): TemplateBuilder { + return this.fromImage(`ubuntu:${variant}`) + } + + fromPythonImage(version: string = '3.13'): TemplateBuilder { + return this.fromImage(`python:${version}`) + } + + fromNodeImage(variant: string = 'lts'): TemplateBuilder { + return this.fromImage(`node:${variant}`) + } + + fromBaseImage(): TemplateBuilder { + return this.fromImage(this.defaultBaseImage) + } + + fromImage(baseImage: string): TemplateBuilder { + this.baseImage = baseImage + this.baseTemplate = undefined + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + return new TemplateBuilder(this) + } + + fromTemplate(template: string): TemplateBuilder { + this.baseTemplate = template + this.baseImage = undefined + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + return new TemplateBuilder(this) + } + + fromDockerfile(dockerfileContentOrPath: string): TemplateBuilder { + const templateBuilder = new TemplateBuilder(this) + const { baseImage } = parseDockerfile( + dockerfileContentOrPath, + templateBuilder + ) + this.baseImage = baseImage + this.baseTemplate = undefined + + // If we should force the next layer and it's a FROM command, invalidate whole template + if (this.forceNextLayer) { + this.force = true + } + + return templateBuilder + } + + public async calculateFilesHashes(): Promise { + const steps: Step[] = [] + + for (const instruction of this.instructions) { + const step: Step = { + type: instruction.type, + args: instruction.args, + force: instruction.force, + forceUpload: instruction.forceUpload, + } + + if (instruction.type === 'COPY') { + step.filesHash = await calculateFilesHash( + instruction.args[0], + instruction.args[1], + this.fileContextPath, + [ + ...this.ignoreFilePaths, + ...(runtime === 'browser' + ? [] + : readDockerignore(this.fileContextPath)), + ] + ) + } + + steps.push(step) + } + + return steps + } + + public serialize(steps: Step[]): TriggerBuildTemplate { + const templateData: TemplateType = { + steps, + force: this.force, + } + + if (this.baseImage !== undefined) { + templateData.fromImage = this.baseImage + } + + if (this.baseTemplate !== undefined) { + templateData.fromTemplate = this.baseTemplate + } + + if (this.startCmd !== undefined) { + templateData.startCmd = this.startCmd + } + + if (this.readyCmd !== undefined) { + templateData.readyCmd = this.readyCmd + } + + return templateData as TriggerBuildTemplate + } +} + +// Factory function to create Template instances without 'new' +export function Template(options?: TemplateOptions): TemplateBase { + return new TemplateBase(options) +} + +Template.build = TemplateBase.build +Template.toJSON = TemplateBase.toJSON +Template.toDockerfile = TemplateBase.toDockerfile +Template.waitForPort = function (port: number) { + return `ss -tuln | grep :${port}` +} + +Template.waitForURL = function (url: string, statusCode: number = 200) { + return `curl -s -o /dev/null -w "%{http_code}" ${url} | grep -q "${statusCode}"` +} + +Template.waitForProcess = function (processName: string) { + return `pgrep ${processName} > /dev/null` +} + +Template.waitForFile = function (filename: string) { + return `[ -f ${filename} ]` +} + +Template.waitForTimeout = function (timeout: number) { + // convert to seconds, but ensure minimum of 1 second + const seconds = Math.max(1, Math.floor(timeout / 1000)) + return `sleep ${seconds}` +} + +export type TemplateClass = TemplateBuilder | TemplateFinal diff --git a/packages/js-sdk/src/template/types.ts b/packages/js-sdk/src/template/types.ts new file mode 100644 index 0000000000..e9466e7902 --- /dev/null +++ b/packages/js-sdk/src/template/types.ts @@ -0,0 +1,43 @@ +import stripAnsi from 'strip-ansi' + +export type Instruction = { + type: 'COPY' | 'ENV' | 'RUN' | 'WORKDIR' | 'USER' + args: string[] + force: boolean + forceUpload?: boolean +} + +export type Step = Instruction & { + filesHash?: string +} + +export type CopyItem = { + src: string + dest: string + forceUpload?: boolean + user?: string + mode?: number +} + +export type TemplateType = { + steps: Step[] + force: boolean + fromImage?: string + fromTemplate?: string + startCmd?: string + readyCmd?: string +} + +export class LogEntry { + constructor( + public readonly timestamp: Date, + public readonly level: 'debug' | 'info' | 'warn' | 'error', + public readonly message: string + ) {} + + toString() { + return `[${this.timestamp.toISOString()}] [${this.level}] ${stripAnsi( + this.message + )}` + } +} diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts new file mode 100644 index 0000000000..7f35a89b9f --- /dev/null +++ b/packages/js-sdk/src/template/utils.ts @@ -0,0 +1,99 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { dynamicGlob, dynamicTar } from '../utils' + +export function readDockerignore(contextPath: string): string[] { + const dockerignorePath = path.join(contextPath, '.dockerignore') + if (!fs.existsSync(dockerignorePath)) { + return [] + } + + const content = fs.readFileSync(dockerignorePath, 'utf-8') + return content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) +} + +export async function calculateFilesHash( + src: string, + dest: string, + contextPath: string, + ignorePatterns?: string[] +): Promise { + const { globSync } = await dynamicGlob() + const srcPath = path.join(contextPath, src) + const hash = crypto.createHash('sha256') + const content = `COPY ${src} ${dest}` + + hash.update(content) + + const files = globSync(srcPath, { + ignore: ignorePatterns, + }) + + if (files.length === 0) { + throw new Error(`No files found in ${srcPath}`) + } + + for (const file of files) { + const content = fs.readFileSync(file) + hash.update(new Uint8Array(content)) + } + + return hash.digest('hex') +} + +export function getCallerDirectory(): string | undefined { + const stackTrace = new Error().stack + if (!stackTrace) { + return + } + + const lines = stackTrace.split('\n') + const caller = lines[4] + + const match = caller.match(/at ([^:]+):\d+:\d+/) + if (match) { + const filePath = match[1] + return path.dirname(filePath) + } + + return +} + +export function padOctal(mode: number): string { + return mode.toString(8).padStart(4, '0') +} + +export async function tarFileStream(fileName: string, fileContextPath: string) { + const { globSync } = await dynamicGlob() + const { create } = await dynamicTar() + const files = globSync(fileName, { cwd: fileContextPath, nodir: false }) + + return create( + { + gzip: true, + cwd: fileContextPath, + }, + files + ) +} + +export async function tarFileStreamUpload( + fileName: string, + fileContextPath: string +) { + // First pass: calculate the compressed size without buffering + const sizeCalculationStream = await tarFileStream(fileName, fileContextPath) + let contentLength = 0 + for await (const chunk of sizeCalculationStream as unknown as AsyncIterable) { + contentLength += chunk.length + } + + return { + contentLength, + uploadStream: await tarFileStream(fileName, fileContextPath), + } +} diff --git a/packages/js-sdk/src/utils.ts b/packages/js-sdk/src/utils.ts index c7adb75c7f..fc56db1250 100644 --- a/packages/js-sdk/src/utils.ts +++ b/packages/js-sdk/src/utils.ts @@ -1,3 +1,51 @@ +import platform from 'platform' + +declare let window: any + +type Runtime = + | 'node' + | 'browser' + | 'deno' + | 'bun' + | 'vercel-edge' + | 'cloudflare-worker' + | 'unknown' + +function getRuntime(): { runtime: Runtime; version: string } { + // @ts-ignore + if ((globalThis as any).Bun) { + // @ts-ignore + return { runtime: 'bun', version: globalThis.Bun.version } + } + + // @ts-ignore + if ((globalThis as any).Deno) { + // @ts-ignore + return { runtime: 'deno', version: globalThis.Deno.version.deno } + } + + if ((globalThis as any).process?.release?.name === 'node') { + return { runtime: 'node', version: platform.version || 'unknown' } + } + + // @ts-ignore + if (typeof EdgeRuntime === 'string') { + return { runtime: 'vercel-edge', version: 'unknown' } + } + + if ((globalThis as any).navigator?.userAgent === 'Cloudflare-Workers') { + return { runtime: 'cloudflare-worker', version: 'unknown' } + } + + if (typeof window !== 'undefined') { + return { runtime: 'browser', version: platform.version || 'unknown' } + } + + return { runtime: 'unknown', version: 'unknown' } +} + +export const { runtime, version: runtimeVersion } = getRuntime() + export async function sha256(data: string): Promise { // Use WebCrypto API if available if (typeof crypto !== 'undefined') { @@ -18,3 +66,21 @@ export async function sha256(data: string): Promise { export function timeoutToSeconds(timeout: number): number { return Math.ceil(timeout / 1000) } + +export async function dynamicGlob(): Promise { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for glob') + } + + // @ts-ignore + return await import('glob') +} + +export async function dynamicTar(): Promise { + if (runtime === 'browser') { + throw new Error('Browser runtime is not supported for tar') + } + + // @ts-ignore + return await import('tar') +} diff --git a/packages/js-sdk/tests/template/build.test.ts b/packages/js-sdk/tests/template/build.test.ts new file mode 100644 index 0000000000..f86f539e57 --- /dev/null +++ b/packages/js-sdk/tests/template/build.test.ts @@ -0,0 +1,30 @@ +import { test } from 'vitest' +import { Template } from '../../src' +import { randomUUID } from 'node:crypto' +import path from 'node:path' +import fs from 'node:fs' + +test('build template', { timeout: 180000 }, async () => { + const folderPath = path.join(__dirname, 'folder') + fs.mkdirSync(folderPath, { recursive: true }) + fs.writeFileSync(path.join(folderPath, 'test.txt'), 'This is a test file.') + + const template = Template() + .fromImage('ubuntu:22.04') + .copy('folder/*.txt', 'folder', { forceUpload: true }) + .setEnvs({ + ENV_1: 'value1', + ENV_2: 'value2', + }) + .runCmd('cat folder/test.txt') + .setWorkdir('/app') + .setStartCmd('echo "Hello, world!"', Template.waitForTimeout(10_000)) + + await Template.build(template, { + alias: randomUUID(), + cpuCount: 1, + memoryMB: 1024, + }) + + fs.rmSync(folderPath, { recursive: true }) +}) diff --git a/packages/python-sdk/Makefile b/packages/python-sdk/Makefile index 18895ce496..987fb695a8 100644 --- a/packages/python-sdk/Makefile +++ b/packages/python-sdk/Makefile @@ -1,7 +1,7 @@ ROOT_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) generate-api: - python $(ROOT_DIR)/spec/remove_extra_tags.py sandboxes + python $(ROOT_DIR)/spec/remove_extra_tags.py sandboxes templates openapi-python-client generate --output-path $(ROOT_DIR)/packages/python-sdk/e2b/api/api --overwrite --path $(ROOT_DIR)/spec/openapi_generated.yml rm -rf e2b/api/client mv e2b/api/api/e2b_api_client e2b/api/client diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 082b9d3717..e409b5d115 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -41,6 +41,8 @@ InvalidArgumentException, NotEnoughSpaceException, TemplateException, + BuildException, + FileUploadException, ) from .sandbox.sandbox_api import SandboxInfo, SandboxQuery, SandboxState, SandboxMetrics from .sandbox.commands.main import ProcessInfo @@ -69,6 +71,8 @@ from .sandbox_async.commands.command_handle import AsyncCommandHandle from .sandbox_sync.paginator import SandboxPaginator +from .template import Template, AsyncTemplate, TemplateBase, TemplateClass + __all__ = [ # API "ApiClient", @@ -84,6 +88,8 @@ "InvalidArgumentException", "NotEnoughSpaceException", "TemplateException", + "BuildException", + "FileUploadException", # Sandbox API "SandboxInfo", "SandboxMetrics", @@ -115,4 +121,9 @@ "AsyncSandbox", "AsyncWatchHandle", "AsyncCommandHandle", + # Template + "Template", + "AsyncTemplate", + "TemplateBase", + "TemplateClass", ] diff --git a/packages/python-sdk/e2b/api/__init__.py b/packages/python-sdk/e2b/api/__init__.py index e011179f16..f7c01d4df0 100644 --- a/packages/python-sdk/e2b/api/__init__.py +++ b/packages/python-sdk/e2b/api/__init__.py @@ -26,22 +26,29 @@ class SandboxCreateResponse: envd_access_token: str -def handle_api_exception(e: Response): +def handle_api_exception( + e: Response, default_exception_class: type[Exception] = SandboxException +): try: body = json.loads(e.content) if e.content else {} except json.JSONDecodeError: body = {} - if e.status_code == 429: - message = f"{e.status_code}: Rate limit exceeded, please try again later" + if e.status_code == 401: + message = f"{e.status_code}: Unauthorized, please check your credentials." if body.get("message"): message += f" - {body['message']}" + return AuthenticationException(message) + if e.status_code == 429: + message = f"{e.status_code}: Rate limit exceeded, please try again later." + if body.get("message"): + message += f" - {body['message']}" return RateLimitException(message) if "message" in body: - return SandboxException(f"{e.status_code}: {body['message']}") - return SandboxException(f"{e.status_code}: {e.content}") + return default_exception_class(f"{e.status_code}: {body['message']}") + return default_exception_class(f"{e.status_code}: {e.content}") class ApiClient(AuthenticatedClient): @@ -74,7 +81,7 @@ def __init__( raise AuthenticationException( "API key is required, please visit the Team tab at https://e2b.dev/dashboard to get your API key. " "You can either set the environment variable `E2B_API_KEY` " - 'or you can pass it directly to the sandbox like Sandbox(api_key="e2b_...")', + 'or you can pass it directly to the method like api_key="e2b_..."', ) token = config.api_key diff --git a/packages/python-sdk/e2b/api/client/api/templates/__init__.py b/packages/python-sdk/e2b/api/client/api/templates/__init__.py new file mode 100644 index 0000000000..2d7c0b23da --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/__init__.py @@ -0,0 +1 @@ +"""Contains endpoint functions for accessing the API""" diff --git a/packages/python-sdk/e2b/api/client/api/templates/delete_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/delete_templates_template_id.py new file mode 100644 index 0000000000..d73839c73b --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/delete_templates_template_id.py @@ -0,0 +1,157 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + template_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "delete", + "url": f"/templates/{template_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 204: + response_204 = cast(Any, None) + return response_204 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Delete a template + + Args: + template_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates.py new file mode 100644 index 0000000000..a25db12f81 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template import Template +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + *, + team_id: Union[Unset, str] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["teamID"] = team_id + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": "/templates", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, list["Template"]]]: + if response.status_code == 200: + response_200 = [] + _response_200 = response.json() + for response_200_item_data in _response_200: + response_200_item = Template.from_dict(response_200_item_data) + + response_200.append(response_200_item) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, list["Template"]]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['Template']]] + """ + + kwargs = _get_kwargs( + team_id=team_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['Template']] + """ + + return sync_detailed( + client=client, + team_id=team_id, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Response[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, list['Template']]] + """ + + kwargs = _get_kwargs( + team_id=team_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + team_id: Union[Unset, str] = UNSET, +) -> Optional[Union[Error, list["Template"]]]: + """List all templates + + Args: + team_id (Union[Unset, str]): Identifier of the team + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, list['Template']] + """ + + return ( + await asyncio_detailed( + client=client, + team_id=team_id, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_status.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_status.py new file mode 100644 index 0000000000..b233e75610 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_builds_build_id_status.py @@ -0,0 +1,217 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.log_level import LogLevel +from ...models.template_build import TemplateBuild +from ...types import UNSET, Response, Unset + + +def _get_kwargs( + template_id: str, + build_id: str, + *, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> dict[str, Any]: + params: dict[str, Any] = {} + + params["logsOffset"] = logs_offset + + json_level: Union[Unset, str] = UNSET + if not isinstance(level, Unset): + json_level = level.value + + params["level"] = json_level + + params = {k: v for k, v in params.items() if v is not UNSET and v is not None} + + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/builds/{build_id}/status", + "params": params, + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateBuild]]: + if response.status_code == 200: + response_200 = TemplateBuild.from_dict(response.json()) + + return response_200 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateBuild]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Response[Union[Error, TemplateBuild]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuild]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + logs_offset=logs_offset, + level=level, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Optional[Union[Error, TemplateBuild]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuild] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + level=level, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Response[Union[Error, TemplateBuild]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuild]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + logs_offset=logs_offset, + level=level, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + logs_offset: Union[Unset, int] = 0, + level: Union[Unset, LogLevel] = UNSET, +) -> Optional[Union[Error, TemplateBuild]]: + """Get template build info + + Args: + template_id (str): + build_id (str): + logs_offset (Union[Unset, int]): Default: 0. + level (Union[Unset, LogLevel]): State of the sandbox + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuild] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + level=level, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py new file mode 100644 index 0000000000..0f6a1e4cac --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates_template_id_files_hash.py @@ -0,0 +1,180 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_file_upload import TemplateBuildFileUpload +from ...types import Response + + +def _get_kwargs( + template_id: str, + hash_: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "get", + "url": f"/templates/{template_id}/files/{hash_}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + if response.status_code == 201: + response_201 = TemplateBuildFileUpload.from_dict(response.json()) + + return response_201 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 404: + response_404 = Error.from_dict(response.json()) + + return response_404 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, TemplateBuildFileUpload]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildFileUpload]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + hash_=hash_, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildFileUpload] + """ + + return sync_detailed( + template_id=template_id, + hash_=hash_, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, TemplateBuildFileUpload]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + hash_=hash_, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + hash_: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Error, TemplateBuildFileUpload]]: + """Get an upload link for a tar file containing build layer files + + Args: + template_id (str): + hash_ (str): Hash of the files + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, TemplateBuildFileUpload] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + hash_=hash_, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/patch_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/patch_templates_template_id.py new file mode 100644 index 0000000000..cf19391f40 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/patch_templates_template_id.py @@ -0,0 +1,183 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_update_request import TemplateUpdateRequest +from ...types import Response + + +def _get_kwargs( + template_id: str, + *, + body: TemplateUpdateRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "patch", + "url": f"/templates/{template_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 200: + response_200 = cast(Any, None) + return response_200 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Response[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateUpdateRequest, +) -> Optional[Union[Any, Error]]: + """Update template + + Args: + template_id (str): + body (TemplateUpdateRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_templates.py b/packages/python-sdk/e2b/api/client/api/templates/post_templates.py new file mode 100644 index 0000000000..dfd9d064a4 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template import Template +from ...models.template_build_request import TemplateBuildRequest +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Template]]: + if response.status_code == 202: + response_202 = Template.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Template]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Template]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Template] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Template]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Template] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id.py b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id.py new file mode 100644 index 0000000000..a478bb8368 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id.py @@ -0,0 +1,181 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template import Template +from ...models.template_build_request import TemplateBuildRequest +from ...types import Response + + +def _get_kwargs( + template_id: str, + *, + body: TemplateBuildRequest, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/templates/{template_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Template]]: + if response.status_code == 202: + response_202 = Template.from_dict(response.json()) + + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Template]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, Template]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Template]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, Template]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Template] + """ + + return sync_detailed( + template_id=template_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Response[Union[Error, Template]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Template]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildRequest, +) -> Optional[Union[Error, Template]]: + """Rebuild an template + + Args: + template_id (str): + body (TemplateBuildRequest): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Template] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id_builds_build_id.py b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id_builds_build_id.py new file mode 100644 index 0000000000..d2709ffd44 --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_templates_template_id_builds_build_id.py @@ -0,0 +1,170 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...types import Response + + +def _get_kwargs( + template_id: str, + build_id: str, +) -> dict[str, Any]: + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/templates/{template_id}/builds/{build_id}", + } + + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 202: + response_202 = cast(Any, None) + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_v2_templates.py b/packages/python-sdk/e2b/api/client/api/templates/post_v2_templates.py new file mode 100644 index 0000000000..1a261efd0e --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_v2_templates.py @@ -0,0 +1,172 @@ +from http import HTTPStatus +from typing import Any, Optional, Union + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template import Template +from ...models.template_build_request_v2 import TemplateBuildRequestV2 +from ...types import Response + + +def _get_kwargs( + *, + body: TemplateBuildRequestV2, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": "/v2/templates", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Error, Template]]: + if response.status_code == 202: + response_202 = Template.from_dict(response.json()) + + return response_202 + if response.status_code == 400: + response_400 = Error.from_dict(response.json()) + + return response_400 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Error, Template]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Response[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Template]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Optional[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Template] + """ + + return sync_detailed( + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Response[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Error, Template]] + """ + + kwargs = _get_kwargs( + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + *, + client: AuthenticatedClient, + body: TemplateBuildRequestV2, +) -> Optional[Union[Error, Template]]: + """Create a new template + + Args: + body (TemplateBuildRequestV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Error, Template] + """ + + return ( + await asyncio_detailed( + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py b/packages/python-sdk/e2b/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py new file mode 100644 index 0000000000..0d3da1fb5e --- /dev/null +++ b/packages/python-sdk/e2b/api/client/api/templates/post_v_2_templates_template_id_builds_build_id.py @@ -0,0 +1,192 @@ +from http import HTTPStatus +from typing import Any, Optional, Union, cast + +import httpx + +from ... import errors +from ...client import AuthenticatedClient, Client +from ...models.error import Error +from ...models.template_build_start_v2 import TemplateBuildStartV2 +from ...types import Response + + +def _get_kwargs( + template_id: str, + build_id: str, + *, + body: TemplateBuildStartV2, +) -> dict[str, Any]: + headers: dict[str, Any] = {} + + _kwargs: dict[str, Any] = { + "method": "post", + "url": f"/v2/templates/{template_id}/builds/{build_id}", + } + + _kwargs["json"] = body.to_dict() + + headers["Content-Type"] = "application/json" + + _kwargs["headers"] = headers + return _kwargs + + +def _parse_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Optional[Union[Any, Error]]: + if response.status_code == 202: + response_202 = cast(Any, None) + return response_202 + if response.status_code == 401: + response_401 = Error.from_dict(response.json()) + + return response_401 + if response.status_code == 500: + response_500 = Error.from_dict(response.json()) + + return response_500 + if client.raise_on_unexpected_status: + raise errors.UnexpectedStatus(response.status_code, response.content) + else: + return None + + +def _build_response( + *, client: Union[AuthenticatedClient, Client], response: httpx.Response +) -> Response[Union[Any, Error]]: + return Response( + status_code=HTTPStatus(response.status_code), + content=response.content, + headers=response.headers, + parsed=_parse_response(client=client, response=response), + ) + + +def sync_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + body=body, + ) + + response = client.get_httpx_client().request( + **kwargs, + ) + + return _build_response(client=client, response=response) + + +def sync( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=body, + ).parsed + + +async def asyncio_detailed( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Response[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Response[Union[Any, Error]] + """ + + kwargs = _get_kwargs( + template_id=template_id, + build_id=build_id, + body=body, + ) + + response = await client.get_async_httpx_client().request(**kwargs) + + return _build_response(client=client, response=response) + + +async def asyncio( + template_id: str, + build_id: str, + *, + client: AuthenticatedClient, + body: TemplateBuildStartV2, +) -> Optional[Union[Any, Error]]: + """Start the build + + Args: + template_id (str): + build_id (str): + body (TemplateBuildStartV2): + + Raises: + errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. + httpx.TimeoutException: If the request takes longer than Client.timeout. + + Returns: + Union[Any, Error] + """ + + return ( + await asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=body, + ) + ).parsed diff --git a/packages/python-sdk/e2b/exceptions.py b/packages/python-sdk/e2b/exceptions.py index ad162048ee..965c6f4296 100644 --- a/packages/python-sdk/e2b/exceptions.py +++ b/packages/python-sdk/e2b/exceptions.py @@ -63,7 +63,7 @@ class NotFoundException(SandboxException): pass -class AuthenticationException(SandboxException): +class AuthenticationException(Exception): """ Raised when authentication fails. """ @@ -81,3 +81,15 @@ class RateLimitException(SandboxException): """ Raised when the API rate limit is exceeded. """ + + +class BuildException(Exception): + """ + Raised when a build fails. + """ + + +class FileUploadException(BuildException): + """ + Raised when a file upload fails. + """ diff --git a/packages/python-sdk/e2b/template/__init__.py b/packages/python-sdk/e2b/template/__init__.py new file mode 100644 index 0000000000..784f5d5a34 --- /dev/null +++ b/packages/python-sdk/e2b/template/__init__.py @@ -0,0 +1,10 @@ +from e2b.template.main import TemplateBase, TemplateClass +from e2b.template_sync import Template +from e2b.template_async import AsyncTemplate + +__all__ = [ + "Template", + "AsyncTemplate", + "TemplateBase", + "TemplateClass", +] diff --git a/packages/python-sdk/e2b/template/dockerfile_parser.py b/packages/python-sdk/e2b/template/dockerfile_parser.py new file mode 100644 index 0000000000..1c91961298 --- /dev/null +++ b/packages/python-sdk/e2b/template/dockerfile_parser.py @@ -0,0 +1,251 @@ +import os +import re +import tempfile +from typing import Dict, List, Optional, Protocol, Union + +from dockerfile_parse import DockerfileParser +from e2b.template.types import CopyItem + + +class DockerfileParserFinalInterface(Protocol): + """Protocol defining the interface for Dockerfile parsing final callbacks.""" + + +class DockerfileParserInterface(Protocol): + """Protocol defining the interface for Dockerfile parsing callbacks.""" + + def run_cmd( + self, command: Union[str, List[str]], user: Optional[str] = None + ) -> "DockerfileParserInterface": + """Handle RUN instruction.""" + ... + + def copy( + self, + src: Union[str, List[CopyItem]], + dest: Optional[str] = None, + force_upload: Optional[bool] = None, + ) -> "DockerfileParserInterface": + """Handle COPY instruction.""" + ... + + def set_workdir(self, workdir: str) -> "DockerfileParserInterface": + """Handle WORKDIR instruction.""" + ... + + def set_user(self, user: str) -> "DockerfileParserInterface": + """Handle USER instruction.""" + ... + + def set_envs(self, envs: Dict[str, str]) -> "DockerfileParserInterface": + """Handle ENV instruction.""" + ... + + def set_start_cmd( + self, start_cmd: str, ready_cmd: str + ) -> "DockerfileParserFinalInterface": + """Handle CMD/ENTRYPOINT instruction.""" + ... + + +def parse_dockerfile( + dockerfile_content_or_path: str, template_builder: "DockerfileParserInterface" +) -> str: + """Parse a Dockerfile and convert it to Template SDK format. + + Args: + dockerfile_content_or_path: Either the Dockerfile content as a string, + or a path to a Dockerfile file + template_builder: Interface providing template builder methods + + Returns: + The base image from the Dockerfile + + Raises: + ValueError: If the Dockerfile is invalid or unsupported + """ + # Check if input is a file path that exists + if os.path.isfile(dockerfile_content_or_path): + # Read the file content + with open(dockerfile_content_or_path, "r", encoding="utf-8") as f: + dockerfile_content = f.read() + else: + # Treat as content directly + dockerfile_content = dockerfile_content_or_path + + # Use a temporary directory to avoid creating files in the current directory + with tempfile.TemporaryDirectory() as temp_dir: + # Create a temporary Dockerfile + dockerfile_path = os.path.join(temp_dir, "Dockerfile") + with open(dockerfile_path, "w") as f: + f.write(dockerfile_content) + + dfp = DockerfileParser(path=temp_dir) + + # Check for multi-stage builds + from_instructions = [ + instruction + for instruction in dfp.structure + if instruction["instruction"] == "FROM" + ] + + if len(from_instructions) > 1: + raise ValueError("Multi-stage Dockerfiles are not supported") + + if len(from_instructions) == 0: + raise ValueError("Dockerfile must contain a FROM instruction") + + # Set the base image from the first FROM instruction + base_image = from_instructions[0]["value"] + # Remove AS alias if present (e.g., "node:18 AS builder" -> "node:18") + if " as " in base_image.lower(): + base_image = base_image.split(" as ")[0].strip() + + # Process all other instructions + for instruction_data in dfp.structure: + instruction = instruction_data["instruction"] + value = instruction_data["value"] + + if instruction == "FROM": + # Already handled above + continue + elif instruction == "RUN": + _handle_run_instruction(value, template_builder) + elif instruction in ["COPY", "ADD"]: + _handle_copy_instruction(value, template_builder) + elif instruction == "WORKDIR": + _handle_workdir_instruction(value, template_builder) + elif instruction == "USER": + _handle_user_instruction(value, template_builder) + elif instruction in ["ENV", "ARG"]: + _handle_env_instruction(value, instruction, template_builder) + elif instruction in ["CMD", "ENTRYPOINT"]: + _handle_cmd_entrypoint_instruction(value, template_builder) + else: + print(f"Unsupported instruction: {instruction}") + continue + + return base_image + + +def _handle_run_instruction( + value: str, template_builder: "DockerfileParserInterface" +) -> None: + """Handle RUN instruction""" + if not value.strip(): + return + # Remove line continuations and normalize whitespace + command = re.sub(r"\\\s*\n\s*", " ", value).strip() + template_builder.run_cmd(command) + + +def _handle_copy_instruction( + value: str, template_builder: "DockerfileParserInterface" +) -> None: + """Handle COPY/ADD instruction""" + if not value.strip(): + return + # Parse source and destination from COPY/ADD command + # Handle both quoted and unquoted paths + parts = [] + current_part = "" + in_quotes = False + quote_char = None + + i = 0 + while i < len(value): + char = value[i] + if char in ['"', "'"] and (i == 0 or value[i - 1] != "\\"): + if not in_quotes: + in_quotes = True + quote_char = char + elif char == quote_char: + in_quotes = False + quote_char = None + else: + current_part += char + elif char == " " and not in_quotes: + if current_part: + parts.append(current_part) + current_part = "" + else: + current_part += char + i += 1 + + if current_part: + parts.append(current_part) + + if len(parts) >= 2: + src = parts[0] + dest = parts[-1] # Last part is destination + template_builder.copy(src, dest) + + +def _handle_workdir_instruction( + value: str, template_builder: "DockerfileParserInterface" +) -> None: + """Handle WORKDIR instruction""" + if not value.strip(): + return + workdir = value.strip() + template_builder.set_workdir(workdir) + + +def _handle_user_instruction( + value: str, template_builder: "DockerfileParserInterface" +) -> None: + """Handle USER instruction""" + if not value.strip(): + return + user = value.strip() + template_builder.set_user(user) + + +def _handle_env_instruction( + value: str, instruction_type: str, template_builder: "DockerfileParserInterface" +) -> None: + """Handle ENV/ARG instruction""" + if not value.strip(): + return + + # Parse environment variables from the value + # Handle both "KEY=value" and "KEY value" formats + env_vars = {} + + # First try to split on = for KEY=value format + if "=" in value: + # Handle multiple KEY=value pairs on one line + pairs = re.findall(r"(\w+)=([^\s]*(?:\s+(?!\w+=)[^\s]*)*)", value) + for key, val in pairs: + env_vars[key] = val.strip("\"'") + else: + # Handle "KEY value" format + parts = value.split(None, 1) + if len(parts) == 2: + key, val = parts + env_vars[key] = val.strip("\"'") + elif len(parts) == 1 and instruction_type == "ARG": + # ARG without default value + key = parts[0] + env_vars[key] = "" + + # Add each environment variable + if env_vars: + template_builder.set_envs(env_vars) + + +def _handle_cmd_entrypoint_instruction( + value: str, template_builder: "DockerfileParserInterface" +) -> None: + """Handle CMD/ENTRYPOINT instruction - convert to setStartCmd with 20s timeout""" + if not value.strip(): + return + command = value.strip() + + # Import wait_for_timeout locally to avoid circular dependency + def wait_for_timeout(timeout: int) -> str: + # convert to seconds, but ensure minimum of 1 second + seconds = max(1, timeout // 1000) + return f"sleep {seconds}" + + template_builder.set_start_cmd(command, wait_for_timeout(20_000)) diff --git a/packages/python-sdk/e2b/template/main.py b/packages/python-sdk/e2b/template/main.py new file mode 100644 index 0000000000..82ec22f505 --- /dev/null +++ b/packages/python-sdk/e2b/template/main.py @@ -0,0 +1,431 @@ +import json +from typing import Dict, List, Optional, Union +from httpx import Limits + +from e2b.template.dockerfile_parser import parse_dockerfile +from e2b.template.types import CopyItem, Instruction, Step, TemplateType +from e2b.template.utils import ( + calculate_files_hash, + get_caller_directory, + pad_octal, + read_dockerignore, +) + + +class TemplateBuilder: + def __init__(self, template: "TemplateBase"): + self.__template = template + + def get_template_base(self) -> "TemplateBase": + return self.__template + + def copy( + self, + src: Union[str, List[CopyItem]], + dest: Optional[str] = None, + force_upload: Optional[bool] = None, + user: Optional[str] = None, + mode: Optional[int] = None, + ) -> "TemplateBuilder": + if isinstance(src, str): + # Single copy operation + if dest is None: + raise ValueError("dest parameter is required when src is a string") + copy_items: List[CopyItem] = [ + { + "src": src, + "dest": dest, + "forceUpload": force_upload, + "user": user, + "mode": mode, + } + ] + else: + # Multiple copy operations + copy_items = src + + for copy_item in copy_items: + args = [ + copy_item["src"], + copy_item["dest"], + user or "", + pad_octal(mode) if mode else "", + ] + + instruction: Instruction = Instruction( + type="COPY", + args=args, + force=force_upload or self.__template._force_next_layer, + forceUpload=force_upload, + ) + self.__template._instructions.append(instruction) + return self + + def remove( + self, path: str, force: bool = False, recursive: bool = False + ) -> "TemplateBuilder": + args = ["rm", path] + if recursive: + args.append("-r") + if force: + args.append("-f") + + self.run_cmd(" ".join(args)) + return self + + def rename(self, src: str, dest: str, force: bool = False) -> "TemplateBuilder": + args = ["mv", src, dest] + if force: + args.append("-f") + + self.run_cmd(" ".join(args)) + return self + + def make_dir( + self, paths: Union[str, List[str]], mode: Optional[int] = None + ) -> "TemplateBuilder": + if isinstance(paths, str): + paths = [paths] + + args = ["mkdir", "-p", *paths] + if mode: + args.append(f"-m {pad_octal(mode)}") + + self.run_cmd(" ".join(args)) + return self + + def make_symlink(self, src: str, dest: str) -> "TemplateBuilder": + args = ["ln", "-s", src, dest] + self.run_cmd(" ".join(args)) + return self + + def run_cmd( + self, command: Union[str, List[str]], user: Optional[str] = None + ) -> "TemplateBuilder": + commands = [command] if isinstance(command, str) else command + args = [" && ".join(commands)] + + if user: + args.append(user) + + instruction: Instruction = Instruction( + type="RUN", + args=args, + force=self.__template._force_next_layer, + forceUpload=None, + ) + self.__template._instructions.append(instruction) + return self + + def set_workdir(self, workdir: str) -> "TemplateBuilder": + instruction: Instruction = Instruction( + type="WORKDIR", + args=[workdir], + force=self.__template._force_next_layer, + forceUpload=None, + ) + self.__template._instructions.append(instruction) + return self + + def set_user(self, user: str) -> "TemplateBuilder": + instruction: Instruction = Instruction( + type="USER", + args=[user], + force=self.__template._force_next_layer, + forceUpload=None, + ) + self.__template._instructions.append(instruction) + return self + + def pip_install( + self, packages: Optional[Union[str, List[str]]] = None + ) -> "TemplateBuilder": + if isinstance(packages, str): + packages = [packages] + + args = ["pip", "install"] + if packages: + args.extend(packages) + else: + args.append(".") + + return self.run_cmd(" ".join(args)) + + def npm_install( + self, + packages: Optional[Union[str, List[str]]] = None, + g: Optional[bool] = False, + ) -> "TemplateBuilder": + if isinstance(packages, str): + packages = [packages] + + args = ["npm", "install"] + if packages: + args.extend(packages) + if g: + args.append("-g") + + return self.run_cmd(" ".join(args)) + + def apt_install(self, packages: Union[str, List[str]]) -> "TemplateBuilder": + if isinstance(packages, str): + packages = [packages] + + return self.run_cmd( + [ + "apt-get update", + f"DEBIAN_FRONTEND=noninteractive DEBCONF_NOWARNINGS=yes apt-get install -y --no-install-recommends {' '.join(packages)}", + ], + user="root", + ) + + def git_clone( + self, + url: str, + path: Optional[str] = None, + branch: Optional[str] = None, + depth: Optional[int] = None, + ) -> "TemplateBuilder": + args = ["git", "clone", url, path] + if branch: + args.append(f"--branch {branch}") + args.append("--single-branch") + if depth: + args.append(f"--depth {depth}") + return self.run_cmd(" ".join(args)) + + def set_envs(self, envs: Dict[str, str]) -> "TemplateBuilder": + if len(envs) == 0: + return self + + instruction: Instruction = Instruction( + type="ENV", + args=[item for key, value in envs.items() for item in [key, value]], + force=self.__template._force_next_layer, + forceUpload=None, + ) + self.__template._instructions.append(instruction) + return self + + def skip_cache(self) -> "TemplateBuilder": + self.__template._force_next_layer = True + return self + + def set_start_cmd(self, start_cmd: str, ready_cmd: str) -> "TemplateFinal": + self.__template._start_cmd = start_cmd + self.__template._ready_cmd = ready_cmd + return TemplateFinal(self.__template) + + def set_ready_cmd(self, ready_cmd: str) -> "TemplateFinal": + self.__template._ready_cmd = ready_cmd + return TemplateFinal(self.__template) + + +class TemplateFinal: + def __init__(self, template: "TemplateBase"): + self.__template = template + + def get_template_base(self) -> "TemplateBase": + return self.__template + + +class TemplateBase: + _limits = Limits( + max_keepalive_connections=40, + max_connections=40, + keepalive_expiry=300, + ) + _logs_refresh_frequency = 0.2 + + def __init__( + self, + file_context_path: Optional[str] = None, + ignore_file_paths: Optional[List[str]] = None, + ): + self._default_base_image: str = "e2bdev/base" + self._base_image: Optional[str] = self._default_base_image + self._base_template: Optional[str] = None + self._start_cmd: Optional[str] = None + self._ready_cmd: Optional[str] = None + # Force the whole template to be rebuilt + self._force: bool = False + # Force the next layer to be rebuilt + self._force_next_layer: bool = False + self._instructions: List[Instruction] = [] + # If no file_context_path is provided, use the caller's directory + self._file_context_path: str = ( + file_context_path or get_caller_directory() or "." + ) + self._ignore_file_paths: List[str] = ignore_file_paths or [] + + def skip_cache(self) -> "TemplateBase": + """Skip cache for the next instruction (before from instruction)""" + self._force_next_layer = True + return self + + # Built-in image mixins + def from_debian_image(self, variant: str = "slim") -> TemplateBuilder: + return self.from_image(f"debian:{variant}") + + def from_ubuntu_image(self, variant: str = "lts") -> TemplateBuilder: + return self.from_image(f"ubuntu:{variant}") + + def from_python_image(self, version: str = "3.13") -> TemplateBuilder: + return self.from_image(f"python:{version}") + + def from_node_image(self, variant: str = "lts") -> TemplateBuilder: + return self.from_image(f"node:{variant}") + + def from_base_image(self) -> TemplateBuilder: + return self.from_image(self._default_base_image) + + def from_image(self, base_image: str) -> TemplateBuilder: + self._base_image = base_image + self._base_template = None + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + return TemplateBuilder(self) + + def from_template(self, template: str) -> TemplateBuilder: + self._base_template = template + self._base_image = None + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + return TemplateBuilder(self) + + def from_dockerfile(self, dockerfile_content_or_path: str) -> TemplateBuilder: + """Parse a Dockerfile and convert it to Template SDK format + + Args: + dockerfile_content_or_path: Either the Dockerfile content as a string, + or a path to a Dockerfile file + """ + # Create a TemplateBuilder first to use its methods + builder = TemplateBuilder(self) + + # Parse the dockerfile using the builder as the interface + base_image = parse_dockerfile(dockerfile_content_or_path, builder) + self._base_image = base_image + + # If we should force the next layer and it's a FROM command, invalidate whole template + if self._force_next_layer: + self._force = True + + return builder + + @staticmethod + def to_json(template: "TemplateClass") -> str: + template_base = template.get_template_base() + return json.dumps( + template_base._serialize(template_base._calculate_hashes()), + indent=2, + ) + + @staticmethod + def to_dockerfile(template: "TemplateClass") -> str: + template_base = template.get_template_base() + + if template_base._base_template is not None: + raise ValueError( + "Cannot convert template built from another template to Dockerfile. " + "Templates based on other templates can only be built using the E2B API." + ) + + if template_base._base_image is None: + raise ValueError("No base image specified for template") + + dockerfile = f"FROM {template_base._base_image}\n" + + for instruction in template_base._instructions: + dockerfile += f"{instruction['type']} {' '.join(instruction['args'])}\n" + + if template_base._start_cmd: + dockerfile += f"ENTRYPOINT {template_base._start_cmd}\n" + + return dockerfile + + def _calculate_hashes( + self, + ) -> List[Step]: + steps: List[Step] = [] + + for instruction in self._instructions: + step: Step = Step( + type=instruction["type"], + args=instruction["args"], + force=instruction["force"], + forceUpload=instruction.get("forceUpload"), + ) + + if instruction["type"] == "COPY": + step["filesHash"] = calculate_files_hash( + instruction["args"][0], + instruction["args"][1], + self._file_context_path, + [ + *self._ignore_file_paths, + *read_dockerignore(self._file_context_path), + ], + ) + + steps.append(step) + + return steps + + def _serialize(self, steps: List[Step]) -> TemplateType: + template_data: TemplateType = { + "steps": steps, + "force": self._force, + } + + if self._base_image is not None: + template_data["fromImage"] = self._base_image + + if self._base_template is not None: + template_data["fromTemplate"] = self._base_template + + if self._start_cmd is not None: + template_data["startCmd"] = self._start_cmd + + if self._ready_cmd is not None: + template_data["readyCmd"] = self._ready_cmd + + return template_data + + @classmethod + def wait_for_port(cls, port: int) -> str: + """Generate a command to wait for a port to be available.""" + return f"ss -tuln | grep :{port}" + + @classmethod + def wait_for_url(cls, url: str, status_code: int = 200) -> str: + """Generate a command to wait for a URL to return a specific status code.""" + return ( + f'curl -s -o /dev/null -w "%{{http_code}}" {url} | grep -q "{status_code}"' + ) + + @classmethod + def wait_for_process(cls, process_name: str) -> str: + """Generate a command to wait for a process to be running.""" + return f"pgrep {process_name} > /dev/null" + + @classmethod + def wait_for_file(cls, filename: str) -> str: + """Generate a command to wait for a file to exist.""" + return f"[ -f {filename} ]" + + @classmethod + def wait_for_timeout(cls, timeout: int) -> str: + """Generate a command to wait for a specified duration.""" + # convert to seconds, but ensure minimum of 1 second + seconds = max(1, timeout // 1000) + return f"sleep {seconds}" + + +TemplateClass = Union[TemplateFinal, TemplateBuilder] diff --git a/packages/python-sdk/e2b/template/types.py b/packages/python-sdk/e2b/template/types.py new file mode 100644 index 0000000000..894553bc9b --- /dev/null +++ b/packages/python-sdk/e2b/template/types.py @@ -0,0 +1,48 @@ +from typing import List, Optional, TypedDict +from typing_extensions import NotRequired +from dataclasses import dataclass +from datetime import datetime +from typing import Literal +from e2b.template.utils import strip_ansi_escape_codes + + +class CopyItem(TypedDict): + src: str + dest: str + forceUpload: NotRequired[Optional[bool]] + user: NotRequired[Optional[str]] + mode: NotRequired[Optional[int]] + + +class Instruction(TypedDict): + type: str + args: List[str] + force: bool + forceUpload: Optional[bool] + + +class Step(Instruction): + filesHash: NotRequired[str] + + +class TemplateType(TypedDict): + fromImage: NotRequired[str] + fromTemplate: NotRequired[str] + startCmd: NotRequired[str] + readyCmd: NotRequired[str] + readyCmdTimeoutMs: NotRequired[int] + steps: List[Step] + force: bool + + +@dataclass +class LogEntry: + timestamp: datetime + level: Literal["debug", "info", "warn", "error"] + message: str + + def __post_init__(self): + self.message = strip_ansi_escape_codes(self.message) + + def __str__(self) -> str: + return f"[{self.timestamp.isoformat()}] [{self.level}] {self.message}" diff --git a/packages/python-sdk/e2b/template/utils.py b/packages/python-sdk/e2b/template/utils.py new file mode 100644 index 0000000000..e8c331dded --- /dev/null +++ b/packages/python-sdk/e2b/template/utils.py @@ -0,0 +1,88 @@ +import hashlib +import os +from glob import glob +import fnmatch +import re +import inspect +from typing import List, Optional + + +def read_dockerignore(context_path: str) -> List[str]: + dockerignore_path = os.path.join(context_path, ".dockerignore") + if not os.path.exists(dockerignore_path): + return [] + + with open(dockerignore_path, "r", encoding="utf-8") as f: + content = f.read() + + return [ + line.strip() + for line in content.split("\n") + if line.strip() and not line.strip().startswith("#") + ] + + +def calculate_files_hash( + src: str, + dest: str, + context_path: str, + ignore_patterns: Optional[List[str]] = None, +) -> str: + src_path = os.path.join(context_path, src) + hash_obj = hashlib.sha256() + content = f"COPY {src} {dest}" + + hash_obj.update(content.encode()) + + files_glob = glob(src_path, recursive=True) + + files = [] + for file in files_glob: + if ignore_patterns and any( + fnmatch.fnmatch(file, pattern) for pattern in ignore_patterns + ): + continue + files.append(file) + + if len(files) == 0: + raise ValueError(f"No files found in {src_path}") + + for file in files: + with open(file, "rb") as f: + hash_obj.update(f.read()) + + return hash_obj.hexdigest() + + +def strip_ansi_escape_codes(text: str) -> str: + """Strip ANSI escape codes from a string. Source: https://github.com/chalk/ansi-regex/blob/main/index.js""" + # Valid string terminator sequences are BEL, ESC\, and 0x9c + st = r"(?:\u0007|\u001B\u005C|\u009C)" + pattern = [ + rf"[\u001B\u009B][\[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?{st})", + r"(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))", + ] + ansi_escape = re.compile("|".join(pattern), re.UNICODE) + return ansi_escape.sub("", text) + + +def get_caller_directory() -> Optional[str]: + """Get the directory of the file that called this function.""" + try: + # Get the stack trace + stack = inspect.stack() + if len(stack) < 3: + return None + + # Get the caller frame (skip this function and the immediate caller) + caller_frame = stack[2] + caller_file = caller_frame.filename + + # Return the directory of the caller file + return os.path.dirname(os.path.abspath(caller_file)) + except Exception: + return None + + +def pad_octal(mode: int) -> str: + return f"{mode:04o}" diff --git a/packages/python-sdk/e2b/template_async/__init__.py b/packages/python-sdk/e2b/template_async/__init__.py new file mode 100644 index 0000000000..f81153ec35 --- /dev/null +++ b/packages/python-sdk/e2b/template_async/__init__.py @@ -0,0 +1,3 @@ +from e2b.template_async.main import AsyncTemplate + +__all__ = ["AsyncTemplate"] diff --git a/packages/python-sdk/e2b/template_async/build_api.py b/packages/python-sdk/e2b/template_async/build_api.py new file mode 100644 index 0000000000..97fde804e5 --- /dev/null +++ b/packages/python-sdk/e2b/template_async/build_api.py @@ -0,0 +1,194 @@ +import io +import os +from glob import glob +import tarfile +import asyncio +from typing import Callable, Literal, Optional + +import httpx + +from e2b.api.client.types import UNSET +from e2b.template.types import TemplateType, LogEntry +from e2b.api.client.client import AuthenticatedClient +from e2b.api.client.api.templates import ( + post_v2_templates, + get_templates_template_id_files_hash, + post_v_2_templates_template_id_builds_build_id, + get_templates_template_id_builds_build_id_status, +) +from e2b.api.client.models import ( + TemplateBuildRequestV2, + TemplateBuildStartV2, + TemplateBuildFileUpload, + TemplateBuild, + TemplateStep, + Error, +) +from e2b.api import handle_api_exception +from e2b.exceptions import BuildException, FileUploadException + + +async def request_build( + client: AuthenticatedClient, name: str, cpu_count: int, memory_mb: int +): + res = await post_v2_templates.asyncio_detailed( + client=client, + body=TemplateBuildRequestV2( + alias=name, + cpu_count=cpu_count, + memory_mb=memory_mb, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to request build") + + return res.parsed + + +async def get_file_upload_link( + client: AuthenticatedClient, template_id: str, files_hash: str +) -> TemplateBuildFileUpload: + res = await get_templates_template_id_files_hash.asyncio_detailed( + template_id=template_id, + hash_=files_hash, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, FileUploadException) + + if isinstance(res.parsed, Error): + raise FileUploadException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise FileUploadException("Failed to get file upload link") + + return res.parsed + + +async def upload_file(file_name: str, context_path: str, url: str): + tar_buffer = io.BytesIO() + + with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: + src_path = os.path.join(context_path, file_name) + files = glob(src_path, recursive=True) + for file in files: + arcname = os.path.relpath(file, context_path) + tar.add(file, arcname=arcname) + + async with httpx.AsyncClient() as client: + response = await client.put(url, content=tar_buffer.getvalue()) + response.raise_for_status() + + +async def trigger_build( + client: AuthenticatedClient, + template_id: str, + build_id: str, + template: TemplateType, +) -> None: + # Convert template dict to TemplateBuildStartV2 model + template_steps = [] + for step in template.get("steps", []): + template_step = TemplateStep( + type_=step["type"], + args=step.get("args", []), + force=step.get("force", False), + ) + if "filesHash" in step: + template_step.files_hash = step["filesHash"] + template_steps.append(template_step) + + # Create the appropriate template data type based on fromImage or fromTemplate + template_data = TemplateBuildStartV2( + from_image=template.get("fromImage", UNSET), + from_template=template.get("fromTemplate", UNSET), + force=template.get("force", False), + steps=template_steps, + start_cmd=template.get("startCmd", UNSET), + ready_cmd=template.get("readyCmd", UNSET), + ) + + # Validate that either fromImage or fromTemplate is specified + if template_data.from_image is UNSET and template_data.from_template is UNSET: + raise BuildException("Template must specify either fromImage or fromTemplate") + + res = await post_v_2_templates_template_id_builds_build_id.asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=template_data, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + +async def get_build_status( + client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int +) -> TemplateBuild: + res = await get_templates_template_id_builds_build_id_status.asyncio_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to get build status") + + return res.parsed + + +async def wait_for_build_finish( + client: AuthenticatedClient, + template_id: str, + build_id: str, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + logs_refresh_frequency: float = 0.2, +): + logs_offset = 0 + status: Literal["building", "waiting", "ready", "error"] = "building" + + while status == "building": + build_status = await get_build_status( + client, template_id, build_id, logs_offset + ) + + logs_offset += len(build_status.log_entries) + + for log_entry in build_status.log_entries: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=log_entry.timestamp, + level=log_entry.level.value, + message=log_entry.message, + ) + ) + + status = build_status.status.value + + if status == "ready": + return + + elif status == "error": + raise BuildException(build_status.reason or "Build failed") + + # Wait for a short period before checking the status again + await asyncio.sleep(logs_refresh_frequency) + + raise BuildException("Unknown build error occurred.") diff --git a/packages/python-sdk/e2b/template_async/main.py b/packages/python-sdk/e2b/template_async/main.py new file mode 100644 index 0000000000..004221368e --- /dev/null +++ b/packages/python-sdk/e2b/template_async/main.py @@ -0,0 +1,165 @@ +from typing import Callable, Optional + +from e2b.template import TemplateBase, TemplateClass + +import os +from datetime import datetime + +from e2b.connection_config import ConnectionConfig +from e2b.api import AsyncApiClient +from e2b.template.types import LogEntry +from .build_api import ( + get_file_upload_link, + request_build, + trigger_build, + upload_file, + wait_for_build_finish, +) + + +class AsyncTemplate(TemplateBase): + @staticmethod + async def build( + template: TemplateClass, + alias: str, + cpu_count: int, + memory_mb: int, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ) -> None: + domain = domain or os.environ.get("E2B_DOMAIN", "e2b.dev") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("E2B_API_KEY") + ) + client = AsyncApiClient( + config, + require_api_key=True, + require_access_token=False, + limits=TemplateBase._limits, + ) + + template_base = template.get_template_base() + + if skip_cache: + template_base._force = True + + with client as api_client: + # Create template + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Requesting build for template: {alias}", + ) + ) + + response = await request_build( + api_client, + name=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + ) + + template_id = response.template_id + build_id = response.build_id + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Template created with ID: {template_id}, Build ID: {build_id}", + ) + ) + + instructions_with_hashes = template_base._calculate_hashes() + + # Prepare file uploads + file_uploads = [ + { + "src": instruction["args"][0], + "dest": instruction["args"][1], + "filesHash": instruction.get("filesHash"), + "forceUpload": instruction.get("forceUpload"), + } + for instruction in instructions_with_hashes + if instruction["type"] == "COPY" + ] + + # Upload files + for file_upload in file_uploads: + file_info = await get_file_upload_link( + api_client, template_id, file_upload["filesHash"] + ) + + if (file_upload["forceUpload"] and file_info.url) or ( + file_info.present is False and file_info.url + ): + await upload_file( + file_upload["src"], + template_base._file_context_path, + file_info.url, + ) + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Uploaded '{file_upload['src']}'", + ) + ) + else: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Skipping upload of '{file_upload['src']}', already cached", + ) + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="All file uploads completed", + ) + ) + + # Start build + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Starting building...", + ) + ) + + await trigger_build( + api_client, + template_id, + build_id, + template_base._serialize(instructions_with_hashes), + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Waiting for logs...", + ) + ) + + await wait_for_build_finish( + api_client, + template_id, + build_id, + on_build_logs, + logs_refresh_frequency=TemplateBase._logs_refresh_frequency, + ) diff --git a/packages/python-sdk/e2b/template_sync/__init__.py b/packages/python-sdk/e2b/template_sync/__init__.py new file mode 100644 index 0000000000..d639762ce1 --- /dev/null +++ b/packages/python-sdk/e2b/template_sync/__init__.py @@ -0,0 +1,3 @@ +from e2b.template_sync.main import Template + +__all__ = ["Template"] diff --git a/packages/python-sdk/e2b/template_sync/build_api.py b/packages/python-sdk/e2b/template_sync/build_api.py new file mode 100644 index 0000000000..adc33dd143 --- /dev/null +++ b/packages/python-sdk/e2b/template_sync/build_api.py @@ -0,0 +1,192 @@ +import io +import os +from glob import glob +import tarfile +import time +from typing import Callable, Literal, Optional + +import httpx + +from e2b.api.client.types import UNSET +from e2b.template.types import TemplateType, LogEntry +from e2b.api.client.client import AuthenticatedClient +from e2b.api.client.api.templates import ( + post_v2_templates, + get_templates_template_id_files_hash, + post_v_2_templates_template_id_builds_build_id, + get_templates_template_id_builds_build_id_status, +) +from e2b.api.client.models import ( + TemplateBuildRequestV2, + TemplateBuildStartV2, + TemplateBuildFileUpload, + TemplateBuild, + TemplateStep, + Error, +) +from e2b.api import handle_api_exception +from e2b.exceptions import BuildException, FileUploadException + + +def request_build( + client: AuthenticatedClient, name: str, cpu_count: int, memory_mb: int +): + res = post_v2_templates.sync_detailed( + client=client, + body=TemplateBuildRequestV2( + alias=name, + cpu_count=cpu_count, + memory_mb=memory_mb, + ), + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to request build") + + return res.parsed + + +def get_file_upload_link( + client: AuthenticatedClient, template_id: str, files_hash: str +) -> TemplateBuildFileUpload: + res = get_templates_template_id_files_hash.sync_detailed( + template_id=template_id, + hash_=files_hash, + client=client, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, FileUploadException) + + if isinstance(res.parsed, Error): + raise FileUploadException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise FileUploadException("Failed to get file upload link") + + return res.parsed + + +def upload_file(file_name: str, context_path: str, url: str): + tar_buffer = io.BytesIO() + + with tarfile.open(fileobj=tar_buffer, mode="w:gz") as tar: + src_path = os.path.join(context_path, file_name) + files = glob(src_path, recursive=True) + for file in files: + arcname = os.path.relpath(file, context_path) + tar.add(file, arcname=arcname) + + with httpx.Client() as client: + response = client.put(url, content=tar_buffer.getvalue()) + response.raise_for_status() + + +def trigger_build( + client: AuthenticatedClient, + template_id: str, + build_id: str, + template: TemplateType, +) -> None: + # Convert template dict to TemplateBuildStartV2 model + template_steps = [] + for step in template.get("steps", []): + template_step = TemplateStep( + type_=step["type"], + args=step.get("args", []), + force=step.get("force", False), + ) + if "filesHash" in step: + template_step.files_hash = step["filesHash"] + template_steps.append(template_step) + + # Create the appropriate template data type based on fromImage or fromTemplate + template_data = TemplateBuildStartV2( + from_image=template.get("fromImage", UNSET), + from_template=template.get("fromTemplate", UNSET), + force=template.get("force", False), + steps=template_steps, + start_cmd=template.get("startCmd", UNSET), + ready_cmd=template.get("readyCmd", UNSET), + ) + + # Validate that either fromImage or fromTemplate is specified + if template_data.from_image is UNSET and template_data.from_template is UNSET: + raise BuildException("Template must specify either fromImage or fromTemplate") + + res = post_v_2_templates_template_id_builds_build_id.sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + body=template_data, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + +def get_build_status( + client: AuthenticatedClient, template_id: str, build_id: str, logs_offset: int +) -> TemplateBuild: + res = get_templates_template_id_builds_build_id_status.sync_detailed( + template_id=template_id, + build_id=build_id, + client=client, + logs_offset=logs_offset, + ) + + if res.status_code >= 300: + raise handle_api_exception(res, BuildException) + + if isinstance(res.parsed, Error): + raise BuildException(f"API error: {res.parsed.message}") + + if res.parsed is None: + raise BuildException("Failed to get build status") + + return res.parsed + + +def wait_for_build_finish( + client: AuthenticatedClient, + template_id: str, + build_id: str, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + logs_refresh_frequency: float = 0.2, +): + logs_offset = 0 + status: Literal["building", "waiting", "ready", "error"] = "building" + + while status == "building": + build_status = get_build_status(client, template_id, build_id, logs_offset) + + logs_offset += len(build_status.log_entries) + + for log_entry in build_status.log_entries: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=log_entry.timestamp, + level=log_entry.level.value, + message=log_entry.message, + ) + ) + + status = build_status.status.value + + if status == "ready": + return + + elif status == "error": + raise BuildException(build_status.reason or "Build failed") + + # Wait for a short period before checking the status again + time.sleep(logs_refresh_frequency) + + raise BuildException("Unknown build error occurred.") diff --git a/packages/python-sdk/e2b/template_sync/main.py b/packages/python-sdk/e2b/template_sync/main.py new file mode 100644 index 0000000000..92198cb4aa --- /dev/null +++ b/packages/python-sdk/e2b/template_sync/main.py @@ -0,0 +1,165 @@ +from typing import Callable, Optional + +from e2b.template import TemplateBase, TemplateClass +from e2b.template.types import LogEntry + +import os +from datetime import datetime + +from e2b.api import ApiClient +from e2b.connection_config import ConnectionConfig +from e2b.template_sync.build_api import ( + get_file_upload_link, + request_build, + trigger_build, + upload_file, + wait_for_build_finish, +) + + +class Template(TemplateBase): + @staticmethod + def build( + template: TemplateClass, + alias: str, + cpu_count: int, + memory_mb: int, + skip_cache: bool = False, + on_build_logs: Optional[Callable[[LogEntry], None]] = None, + api_key: Optional[str] = None, + domain: Optional[str] = None, + ) -> None: + domain = domain or os.environ.get("E2B_DOMAIN", "e2b.dev") + config = ConnectionConfig( + domain=domain, api_key=api_key or os.environ.get("E2B_API_KEY") + ) + client = ApiClient( + config, + require_api_key=True, + require_access_token=False, + limits=TemplateBase._limits, + ) + + template_base = template.get_template_base() + + if skip_cache: + template_base._force = True + + with client as api_client: + # Create template + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Requesting build for template: {alias}", + ) + ) + + response = request_build( + api_client, + name=alias, + cpu_count=cpu_count, + memory_mb=memory_mb, + ) + + template_id = response.template_id + build_id = response.build_id + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Template created with ID: {template_id}, Build ID: {build_id}", + ) + ) + + instructions_with_hashes = template_base._calculate_hashes() + + # Prepare file uploads + file_uploads = [ + { + "src": instruction["args"][0], + "dest": instruction["args"][1], + "filesHash": instruction.get("filesHash"), + "forceUpload": instruction.get("forceUpload"), + } + for instruction in instructions_with_hashes + if instruction["type"] == "COPY" + ] + + # Upload files + for file_upload in file_uploads: + file_info = get_file_upload_link( + api_client, template_id, file_upload["filesHash"] + ) + + if (file_upload["forceUpload"] and file_info.url) or ( + file_info.present is False and file_info.url + ): + upload_file( + file_upload["src"], + template_base._file_context_path, + file_info.url, + ) + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Uploaded '{file_upload['src']}'", + ) + ) + else: + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message=f"Skipping upload of '{file_upload['src']}', already cached", + ) + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="All file uploads completed", + ) + ) + + # Start build + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Starting building...", + ) + ) + + trigger_build( + api_client, + template_id, + build_id, + template_base._serialize(instructions_with_hashes), + ) + + if on_build_logs: + on_build_logs( + LogEntry( + timestamp=datetime.now(), + level="info", + message="Waiting for logs...", + ) + ) + + wait_for_build_finish( + api_client, + template_id, + build_id, + on_build_logs, + logs_refresh_frequency=TemplateBase._logs_refresh_frequency, + ) diff --git a/packages/python-sdk/poetry.lock b/packages/python-sdk/poetry.lock index ce95aa380b..59bf8b8669 100644 --- a/packages/python-sdk/poetry.lock +++ b/packages/python-sdk/poetry.lock @@ -313,6 +313,18 @@ wrapt = ">=1.10,<2" [package.extras] dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +description = "Python library for Dockerfile manipulation" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc"}, + {file = "dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6"}, +] + [[package]] name = "docspec" version = "2.2.1" @@ -971,15 +983,15 @@ files = [ [[package]] name = "setuptools" -version = "78.1.1" +version = "80.9.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.9" groups = ["dev"] markers = "python_version < \"3.10\"" files = [ - {file = "setuptools-78.1.1-py3-none-any.whl", hash = "sha256:c3a9c4211ff4c309edb8b8c4f1cbfa7ae324c4ba9f91ff254e3d305b9fd54561"}, - {file = "setuptools-78.1.1.tar.gz", hash = "sha256:fcc17fd9cd898242f6b4adfaca46137a9edef687f43e6f78469692a5e70d851d"}, + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] [package.extras] @@ -1252,4 +1264,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = "^3.9" -content-hash = "634f4c6d2241e87ff2c82aeda992ceb4497795b7aac4aadf3741a735a175dfd7" +content-hash = "523d843a32fd2e83f97f7fd4d4a764a3c75760c09f4e8e3f833791e7c5fdba31" diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml index e68868f684..577c88d352 100644 --- a/packages/python-sdk/pyproject.toml +++ b/packages/python-sdk/pyproject.toml @@ -19,6 +19,7 @@ httpx = ">=0.27.0, <1.0.0" attrs = ">=23.2.0" packaging = ">=24.1" typing-extensions = ">=4.1.0" +dockerfile-parse = "^2.0.1" [tool.poetry.group.dev.dependencies] pytest = "^7.4.0" diff --git a/packages/python-sdk/tests/async/template_async/test_build.py b/packages/python-sdk/tests/async/template_async/test_build.py new file mode 100644 index 0000000000..f0ede56955 --- /dev/null +++ b/packages/python-sdk/tests/async/template_async/test_build.py @@ -0,0 +1,40 @@ +import pytest +import os +from uuid import uuid4 +import shutil + +from e2b import AsyncTemplate + + +@pytest.mark.skip_debug() +async def test_build(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + folder_path = os.path.join(test_dir, "folder") + + os.makedirs(folder_path, exist_ok=True) + with open(os.path.join(folder_path, "test.txt"), "w") as f: + f.write("test") + + template = ( + AsyncTemplate() + .from_image("ubuntu:22.04") + .copy("folder/*.txt", "folder", force_upload=True) + .set_envs( + { + "ENV_1": "value1", + "ENV_2": "value2", + } + ) + .run_cmd("cat folder/test.txt") + .set_workdir("/app") + .set_start_cmd("echo 'Hello, world!'", AsyncTemplate.wait_for_timeout(10_000)) + ) + + await AsyncTemplate.build( + template, + alias=str(uuid4()), + cpu_count=1, + memory_mb=1024, + ) + + shutil.rmtree(folder_path) diff --git a/packages/python-sdk/tests/sync/template_sync/test_build.py b/packages/python-sdk/tests/sync/template_sync/test_build.py new file mode 100644 index 0000000000..a3c0d7af91 --- /dev/null +++ b/packages/python-sdk/tests/sync/template_sync/test_build.py @@ -0,0 +1,40 @@ +import pytest +import os +from uuid import uuid4 +import shutil + +from e2b import Template + + +@pytest.mark.skip_debug() +def test_build(): + test_dir = os.path.dirname(os.path.abspath(__file__)) + folder_path = os.path.join(test_dir, "folder") + + os.makedirs(folder_path, exist_ok=True) + with open(os.path.join(folder_path, "test.txt"), "w") as f: + f.write("test") + + template = ( + Template() + .from_image("ubuntu:22.04") + .copy("folder/*.txt", "folder", force_upload=True) + .set_envs( + { + "ENV_1": "value1", + "ENV_2": "value2", + } + ) + .run_cmd("cat folder/test.txt") + .set_workdir("/app") + .set_start_cmd("echo 'Hello, world!'", Template.wait_for_timeout(10_000)) + ) + + Template.build( + template, + alias=str(uuid4()), + cpu_count=1, + memory_mb=1024, + ) + + shutil.rmtree(folder_path) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c87227638b..66513ecbc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -335,12 +335,24 @@ importers: compare-versions: specifier: ^6.1.0 version: 6.1.1 + dockerfile-ast: + specifier: ^0.7.1 + version: 0.7.1 + glob: + specifier: ^11.0.3 + version: 11.0.3 openapi-fetch: specifier: ^0.9.7 version: 0.9.8 platform: specifier: ^1.3.6 version: 1.3.6 + strip-ansi: + specifier: ^7.1.0 + version: 7.1.0 + tar: + specifier: ^7.4.3 + version: 7.4.3 devDependencies: '@testing-library/react': specifier: ^16.2.0 @@ -930,10 +942,22 @@ packages: '@types/node': optional: true + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.8': resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -3380,6 +3404,10 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -3758,6 +3786,9 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -4216,6 +4247,10 @@ packages: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data-encoder@1.7.2: resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} @@ -4366,6 +4401,11 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -4944,6 +4984,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} @@ -5145,6 +5189,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5421,6 +5469,10 @@ packages: resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -5486,6 +5538,10 @@ packages: resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} engines: {node: '>= 8'} + minizlib@3.0.2: + resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} + engines: {node: '>= 18'} + mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} @@ -5494,6 +5550,11 @@ packages: engines: {node: '>=10'} hasBin: true + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} @@ -5916,14 +5977,14 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - path-scurry@1.10.1: - resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} - engines: {node: '>=16 || 14 >=14.17'} - path-scurry@1.11.1: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -6883,6 +6944,10 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + term-size@2.2.1: resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} engines: {node: '>=8'} @@ -7420,6 +7485,12 @@ packages: jsdom: optional: true + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + vscode-oniguruma@1.7.0: resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} @@ -7587,6 +7658,10 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} @@ -8234,6 +8309,12 @@ snapshots: '@types/node': 18.18.6 optional: true + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -8243,6 +8324,10 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + '@jridgewell/gen-mapping@0.3.8': dependencies: '@jridgewell/set-array': 1.2.1 @@ -11107,7 +11192,7 @@ snapshots: dependencies: '@npmcli/fs': 3.1.0 fs-minipass: 3.0.3 - glob: 10.3.10 + glob: 10.4.5 lru-cache: 7.18.3 minipass: 7.1.2 minipass-collect: 1.0.2 @@ -11235,6 +11320,8 @@ snapshots: chownr@2.0.0: {} + chownr@3.0.0: {} + chrome-trace-event@1.0.4: {} ci-info@3.8.0: {} @@ -11581,6 +11668,11 @@ snapshots: dlv@1.1.3: {} + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -12275,6 +12367,11 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data-encoder@1.7.2: {} form-data-encoder@2.1.4: {} @@ -12439,7 +12536,7 @@ snapshots: jackspeak: 2.3.6 minimatch: 9.0.5 minipass: 7.1.2 - path-scurry: 1.10.1 + path-scurry: 1.11.1 glob@10.4.5: dependencies: @@ -12450,6 +12547,15 @@ snapshots: package-json-from-dist: 1.0.0 path-scurry: 1.11.1 + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.0.3 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -13043,6 +13149,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + jest-worker@27.5.1: dependencies: '@types/node': 18.18.6 @@ -13267,6 +13377,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.1.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -13812,6 +13924,10 @@ snapshots: mimic-response@4.0.0: {} + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -13878,10 +13994,16 @@ snapshots: minipass: 3.3.6 yallist: 4.0.0 + minizlib@3.0.2: + dependencies: + minipass: 7.1.2 + mkdirp-classic@0.5.3: {} mkdirp@1.0.4: {} + mkdirp@3.0.1: {} + module-details-from-path@1.0.3: {} mri@1.2.0: {} @@ -14432,14 +14554,14 @@ snapshots: path-parse@1.0.7: {} - path-scurry@1.10.1: + path-scurry@1.11.1: dependencies: lru-cache: 10.4.3 minipass: 7.1.2 - path-scurry@1.11.1: + path-scurry@2.0.0: dependencies: - lru-cache: 10.4.3 + lru-cache: 11.1.0 minipass: 7.1.2 path-to-regexp@6.3.0: @@ -14760,7 +14882,7 @@ snapshots: read-package-json@6.0.4: dependencies: - glob: 10.3.10 + glob: 10.4.5 json-parse-even-better-errors: 3.0.0 normalize-package-data: 5.0.0 npm-normalize-package-bin: 3.0.1 @@ -14937,7 +15059,7 @@ snapshots: rimraf@5.0.5: dependencies: - glob: 10.3.10 + glob: 10.4.5 robust-predicates@3.0.2: {} @@ -15530,6 +15652,15 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.2 + mkdirp: 3.0.1 + yallist: 5.0.0 + term-size@2.2.1: {} terser-webpack-plugin@5.3.14(webpack@5.94.0): @@ -16144,6 +16275,10 @@ snapshots: - tsx - yaml + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + vscode-oniguruma@1.7.0: {} vscode-textmate@6.0.0: {} @@ -16371,6 +16506,8 @@ snapshots: yallist@4.0.0: {} + yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} yaml@2.5.1: {}