Add Python/Ruby code pieces and fix template loading performance

- Add Python code execution piece with subprocess-based runner
- Add Ruby code execution piece with subprocess-based runner
- Fix template loading: fetch individual templates from cloud for community edition
- Add piece name aliasing for renamed pieces (piece-text-ai → piece-ai, etc.)
- Add dev pieces caching to avoid disk reads on every request (60s TTL)
- Add Python and Ruby logos to Django static files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-20 00:18:42 -05:00
parent 3aa7199503
commit f3e1b8f8bf
19 changed files with 557 additions and 6 deletions

View File

@@ -0,0 +1,5 @@
{
"name": "@activepieces/piece-python-code",
"version": "0.0.1",
"dependencies": {}
}

View File

@@ -0,0 +1,60 @@
{
"name": "pieces-python-code",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/pieces/community/python-code/src",
"projectType": "library",
"release": {
"version": {
"currentVersionResolver": "git-tag",
"preserveLocalDependencyProtocols": false,
"manifestRootsToUpdate": [
"dist/{projectRoot}"
]
}
},
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": [
"{options.outputPath}"
],
"options": {
"outputPath": "dist/packages/pieces/community/python-code",
"tsConfig": "packages/pieces/community/python-code/tsconfig.lib.json",
"packageJson": "packages/pieces/community/python-code/package.json",
"main": "packages/pieces/community/python-code/src/index.ts",
"assets": [
"packages/pieces/community/python-code/*.md"
],
"buildableProjectDepsInPackageJsonType": "dependencies",
"updateBuildableProjectDepsInPackageJson": true
},
"dependsOn": [
"^build",
"prebuild"
]
},
"nx-release-publish": {
"options": {
"packageRoot": "dist/{projectRoot}"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": [
"{options.outputFile}"
]
},
"prebuild": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/pieces/community/python-code",
"command": "bun install --no-save --silent"
},
"dependsOn": [
"^build"
]
}
}
}

View File

@@ -0,0 +1,18 @@
import { createPiece, PieceAuth } from '@activepieces/pieces-framework';
import { PieceCategory } from '@activepieces/shared';
import { runPythonCode } from './lib/run-python-code';
// Python logo - hosted on Django backend
const PYTHON_LOGO = 'http://lvh.me:8000/static/images/python-logo.svg';
export const pythonCode = createPiece({
displayName: 'Python Code',
description: 'Execute Python code in your automations',
auth: PieceAuth.None(),
minimumSupportedRelease: '0.36.1',
logoUrl: PYTHON_LOGO,
categories: [PieceCategory.CORE, PieceCategory.DEVELOPER_TOOLS],
authors: ['smoothschedule'],
actions: [runPythonCode],
triggers: [],
});

View File

@@ -0,0 +1,112 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const execAsync = promisify(exec);
export const runPythonCode = createAction({
name: 'run_python_code',
displayName: 'Run Python Code',
description: 'Execute Python code and return the output. Use print() to output results.',
props: {
code: Property.LongText({
displayName: 'Python Code',
description: 'The Python code to execute. Use print() to output results that will be captured.',
required: true,
defaultValue: `# Example: Process input data
import json
# Access inputs via the 'inputs' variable (parsed from JSON)
# Example: name = inputs.get('name', 'World')
# Your code here
result = "Hello from Python!"
# Print your output (will be captured as the action result)
print(result)`,
}),
inputs: Property.Object({
displayName: 'Inputs',
description: 'Input data to pass to the Python code. Available as the `inputs` variable (dict).',
required: false,
defaultValue: {},
}),
timeout: Property.Number({
displayName: 'Timeout (seconds)',
description: 'Maximum execution time in seconds',
required: false,
defaultValue: 30,
}),
},
async run(context) {
const { code, inputs, timeout } = context.propsValue;
const timeoutMs = (timeout || 30) * 1000;
// Create a temporary file for the Python code
const tmpDir = os.tmpdir();
const scriptPath = path.join(tmpDir, `ap_python_${Date.now()}.py`);
const inputPath = path.join(tmpDir, `ap_python_input_${Date.now()}.json`);
try {
// Write inputs to a JSON file
await fs.promises.writeFile(inputPath, JSON.stringify(inputs || {}));
// Wrap the user code to load inputs
const wrappedCode = `
import json
import sys
# Load inputs from JSON file
with open('${inputPath.replace(/\\/g, '\\\\')}', 'r') as f:
inputs = json.load(f)
# User code starts here
${code}
`;
// Write the script to a temp file
await fs.promises.writeFile(scriptPath, wrappedCode);
// Execute Python
const { stdout, stderr } = await execAsync(`python3 "${scriptPath}"`, {
timeout: timeoutMs,
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
});
// Clean up temp files
await fs.promises.unlink(scriptPath).catch(() => {});
await fs.promises.unlink(inputPath).catch(() => {});
// Try to parse output as JSON, otherwise return as string
const output = stdout.trim();
let result: unknown;
try {
result = JSON.parse(output);
} catch {
result = output;
}
return {
success: true,
output: result,
stdout: stdout,
stderr: stderr || null,
};
} catch (error: unknown) {
// Clean up temp files on error
await fs.promises.unlink(scriptPath).catch(() => {});
await fs.promises.unlink(inputPath).catch(() => {});
const execError = error as { stderr?: string; message?: string; killed?: boolean };
if (execError.killed) {
throw new Error(`Python script timed out after ${timeout} seconds`);
}
throw new Error(`Python execution failed: ${execError.stderr || execError.message}`);
}
},
});

View File

@@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,5 @@
{
"name": "@activepieces/piece-ruby-code",
"version": "0.0.1",
"dependencies": {}
}

View File

@@ -0,0 +1,60 @@
{
"name": "pieces-ruby-code",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/pieces/community/ruby-code/src",
"projectType": "library",
"release": {
"version": {
"currentVersionResolver": "git-tag",
"preserveLocalDependencyProtocols": false,
"manifestRootsToUpdate": [
"dist/{projectRoot}"
]
}
},
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": [
"{options.outputPath}"
],
"options": {
"outputPath": "dist/packages/pieces/community/ruby-code",
"tsConfig": "packages/pieces/community/ruby-code/tsconfig.lib.json",
"packageJson": "packages/pieces/community/ruby-code/package.json",
"main": "packages/pieces/community/ruby-code/src/index.ts",
"assets": [
"packages/pieces/community/ruby-code/*.md"
],
"buildableProjectDepsInPackageJsonType": "dependencies",
"updateBuildableProjectDepsInPackageJson": true
},
"dependsOn": [
"^build",
"prebuild"
]
},
"nx-release-publish": {
"options": {
"packageRoot": "dist/{projectRoot}"
}
},
"lint": {
"executor": "@nx/eslint:lint",
"outputs": [
"{options.outputFile}"
]
},
"prebuild": {
"executor": "nx:run-commands",
"options": {
"cwd": "packages/pieces/community/ruby-code",
"command": "bun install --no-save --silent"
},
"dependsOn": [
"^build"
]
}
}
}

View File

@@ -0,0 +1,18 @@
import { createPiece, PieceAuth } from '@activepieces/pieces-framework';
import { PieceCategory } from '@activepieces/shared';
import { runRubyCode } from './lib/run-ruby-code';
// Ruby logo - hosted on Django backend
const RUBY_LOGO = 'http://lvh.me:8000/static/images/ruby-logo.svg';
export const rubyCode = createPiece({
displayName: 'Ruby Code',
description: 'Execute Ruby code in your automations',
auth: PieceAuth.None(),
minimumSupportedRelease: '0.36.1',
logoUrl: RUBY_LOGO,
categories: [PieceCategory.CORE, PieceCategory.DEVELOPER_TOOLS],
authors: ['smoothschedule'],
actions: [runRubyCode],
triggers: [],
});

View File

@@ -0,0 +1,113 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
const execAsync = promisify(exec);
export const runRubyCode = createAction({
name: 'run_ruby_code',
displayName: 'Run Ruby Code',
description: 'Execute Ruby code and return the output. Use puts or print to output results.',
props: {
code: Property.LongText({
displayName: 'Ruby Code',
description: 'The Ruby code to execute. Use puts or print to output results that will be captured.',
required: true,
defaultValue: `# Example: Process input data
require 'json'
# Access inputs via the 'inputs' variable (parsed from JSON)
# Example: name = inputs['name'] || 'World'
# Your code here
result = "Hello from Ruby!"
# Print your output (will be captured as the action result)
puts result`,
}),
inputs: Property.Object({
displayName: 'Inputs',
description: 'Input data to pass to the Ruby code. Available as the `inputs` variable (Hash).',
required: false,
defaultValue: {},
}),
timeout: Property.Number({
displayName: 'Timeout (seconds)',
description: 'Maximum execution time in seconds',
required: false,
defaultValue: 30,
}),
},
async run(context) {
const { code, inputs, timeout } = context.propsValue;
const timeoutMs = (timeout || 30) * 1000;
// Create a temporary file for the Ruby code
const tmpDir = os.tmpdir();
const scriptPath = path.join(tmpDir, `ap_ruby_${Date.now()}.rb`);
const inputPath = path.join(tmpDir, `ap_ruby_input_${Date.now()}.json`);
try {
// Write inputs to a JSON file
await fs.promises.writeFile(inputPath, JSON.stringify(inputs || {}));
// Escape the input path for Ruby
const escapedInputPath = inputPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
// Wrap the user code to load inputs
const wrappedCode = `
require 'json'
# Load inputs from JSON file
inputs = JSON.parse(File.read('${escapedInputPath}'))
# User code starts here
${code}
`;
// Write the script to a temp file
await fs.promises.writeFile(scriptPath, wrappedCode);
// Execute Ruby
const { stdout, stderr } = await execAsync(`ruby "${scriptPath}"`, {
timeout: timeoutMs,
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
});
// Clean up temp files
await fs.promises.unlink(scriptPath).catch(() => {});
await fs.promises.unlink(inputPath).catch(() => {});
// Try to parse output as JSON, otherwise return as string
const output = stdout.trim();
let result: unknown;
try {
result = JSON.parse(output);
} catch {
result = output;
}
return {
success: true,
output: result,
stdout: stdout,
stderr: stderr || null,
};
} catch (error: unknown) {
// Clean up temp files on error
await fs.promises.unlink(scriptPath).catch(() => {});
await fs.promises.unlink(inputPath).catch(() => {});
const execError = error as { stderr?: string; message?: string; killed?: boolean };
if (execError.killed) {
throw new Error(`Ruby script timed out after ${timeout} seconds`);
}
throw new Error(`Ruby execution failed: ${execError.stderr || execError.message}`);
}
},
});

View File

@@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

View File

@@ -37,6 +37,19 @@ import { pieceListUtils } from './utils'
export const pieceRepos = repoFactory(PieceMetadataEntity)
// Map of old/renamed piece names to their current names
// This allows templates with old piece references to still work
const PIECE_NAME_ALIASES: Record<string, string> = {
'@activepieces/piece-text-ai': '@activepieces/piece-ai',
'@activepieces/piece-utility-ai': '@activepieces/piece-ai',
'@activepieces/piece-image-ai': '@activepieces/piece-ai',
}
// Cache for dev pieces to avoid reading from disk on every request
let devPiecesCache: PieceMetadataSchema[] | null = null
let devPiecesCacheTime: number = 0
const DEV_PIECES_CACHE_TTL_MS = 60000 // 1 minute cache
export const pieceMetadataService = (log: FastifyBaseLogger) => {
return {
async setup(): Promise<void> {
@@ -89,13 +102,35 @@ export const pieceMetadataService = (log: FastifyBaseLogger) => {
release: undefined,
log,
})
const piece = originalPieces.find((piece) => {
let piece = originalPieces.find((piece) => {
const strictlyLessThan = (isNil(versionToSearch) || (
semVer.compare(piece.version, versionToSearch.nextExcludedVersion) < 0
&& semVer.compare(piece.version, versionToSearch.baseVersion) >= 0
))
return piece.name === name && strictlyLessThan
})
// Fall back to latest version if specific version not found
// This allows templates with old piece versions to still work
if (isNil(piece) && !isNil(version)) {
piece = originalPieces.find((p) => p.name === name)
if (!isNil(piece)) {
log.info(`Piece ${name} version ${version} not found, falling back to latest version ${piece.version}`)
}
}
// Try piece name alias if piece still not found
// This handles renamed pieces (e.g., piece-text-ai -> piece-ai)
if (isNil(piece)) {
const aliasedName = PIECE_NAME_ALIASES[name]
if (!isNil(aliasedName)) {
piece = originalPieces.find((p) => p.name === aliasedName)
if (!isNil(piece)) {
log.info(`Piece ${name} not found, using alias ${aliasedName} (version ${piece.version})`)
}
}
}
const isFiltered = !isNil(piece) && await enterpriseFilteringUtils.isFiltered({
piece,
projectId,
@@ -287,10 +322,20 @@ const loadDevPiecesIfEnabled = async (log: FastifyBaseLogger): Promise<PieceMeta
if (isNil(devPiecesConfig) || isEmpty(devPiecesConfig)) {
return []
}
// Check if cache is still valid
const now = Date.now()
if (!isNil(devPiecesCache) && (now - devPiecesCacheTime) < DEV_PIECES_CACHE_TTL_MS) {
log.debug(`Using cached dev pieces (${devPiecesCache.length} pieces, age: ${now - devPiecesCacheTime}ms)`)
return devPiecesCache
}
// Cache expired or doesn't exist, load from disk
log.info('Loading dev pieces from disk (cache expired or empty)')
const piecesNames = devPiecesConfig.split(',')
const pieces = await filePiecesUtils(log).loadDistPiecesMetadata(piecesNames)
return pieces.map((p): PieceMetadataSchema => ({
const result = pieces.map((p): PieceMetadataSchema => ({
id: apId(),
...p,
projectUsage: 0,
@@ -299,6 +344,13 @@ const loadDevPiecesIfEnabled = async (log: FastifyBaseLogger): Promise<PieceMeta
created: new Date().toISOString(),
updated: new Date().toISOString(),
}))
// Update cache
devPiecesCache = result
devPiecesCacheTime = now
log.info(`Cached ${result.length} dev pieces`)
return result
}
const findOldestCreatedDate = async ({ name, platformId }: { name: string, platformId?: string }): Promise<string> => {

View File

@@ -25,6 +25,29 @@ export const communityTemplates = {
const templates = await response.json()
return templates
},
getById: async (id: string): Promise<Template | null> => {
const templateSource = system.get(AppSystemProp.TEMPLATES_SOURCE_URL)
if (isNil(templateSource)) {
return null
}
// Fetch the template by ID from the cloud templates endpoint
const url = `${templateSource}/${id}`
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
return null
}
return await response.json()
}
catch {
return null
}
},
}

View File

@@ -27,6 +27,14 @@ const edition = system.getEdition()
export const templateController: FastifyPluginAsyncTypebox = async (app) => {
app.get('/:id', GetParams, async (request) => {
// For community edition, try to fetch from cloud templates first
if (edition !== ApEdition.CLOUD) {
const cloudTemplate = await communityTemplates.getById(request.params.id)
if (!isNil(cloudTemplate)) {
return cloudTemplate
}
}
// Fall back to local database
return templateService().getOneOrThrow({ id: request.params.id })
})