Skip to content

Commit 92278a2

Browse files
committed
refactor(satellite): extract Python utilities for deployment handling
1 parent 067313f commit 92278a2

File tree

2 files changed

+298
-182
lines changed

2 files changed

+298
-182
lines changed

services/satellite/src/process/github-deployment.ts

Lines changed: 25 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ import { validateBuildScripts } from '../config/security-validation';
1313
import { TmpfsManager } from '../lib/tmpfs-manager';
1414
import { githubDeploymentBaseDir, nsjailConfig } from '../config/nsjail';
1515
import { selectBestPythonForDeployment } from '../utils/runtime-validator';
16+
import {
17+
isPyprojectSimpleScript,
18+
parsePyprojectDependencies,
19+
resolvePythonEntryPoint
20+
} from '../utils/python-helpers';
1621

1722
/**
1823
* Parsed GitHub repository information
@@ -623,65 +628,6 @@ export class GitHubDeploymentHandler {
623628
}
624629
}
625630

626-
/**
627-
* Detect if pyproject.toml is a simple script (not installable package)
628-
*
629-
* A simple script is one that:
630-
* 1. Has no package structure (no src/ dir, no matching package dir), OR
631-
* 2. Has [build-system] but the build will fail due to missing structure
632-
*
633-
* We detect this by checking if common Python script files exist at root level
634-
* (server.py, main.py, app.py, __main__.py) - indicating it's meant to run directly
635-
*/
636-
async isPyprojectSimpleScript(tempDir: string): Promise<boolean> {
637-
const pyprojectPath = path.join(tempDir, 'pyproject.toml');
638-
try {
639-
const content = await fs.promises.readFile(pyprojectPath, 'utf8');
640-
641-
// Check if pyproject.toml has [build-system] section
642-
const hasBuildSystem = content.includes('[build-system]');
643-
644-
// If no build-system, it's definitely a simple script (just dependency management)
645-
if (!hasBuildSystem) {
646-
return true;
647-
}
648-
649-
// Has build-system - check if package structure exists
650-
// Look for src/ directory or package directory matching project name
651-
const hasSrcDir = await fileExists(path.join(tempDir, 'src'));
652-
653-
// Extract package name from pyproject.toml
654-
const nameMatch = content.match(/^name\s*=\s*"([^"]+)"/m);
655-
const packageName = nameMatch ? nameMatch[1].replace(/-/g, '_') : null;
656-
const hasPackageDir = packageName ? await fileExists(path.join(tempDir, packageName)) : false;
657-
658-
// If it has proper package structure (src/ or package dir), it's installable
659-
if (hasSrcDir || hasPackageDir) {
660-
return false;
661-
}
662-
663-
// No package structure - check if there are standalone script files at root
664-
const scriptFiles = ['server.py', 'main.py', 'app.py', '__main__.py'];
665-
for (const scriptFile of scriptFiles) {
666-
if (await fileExists(path.join(tempDir, scriptFile))) {
667-
// Found a standalone script - treat as simple script
668-
this.logger.debug({
669-
operation: 'python_pattern_detected',
670-
pattern: 'simple_script',
671-
reason: `Found ${scriptFile} at root without package structure`,
672-
temp_dir: tempDir
673-
}, `Detected simple Python script pattern (${scriptFile} without package structure)`);
674-
return true;
675-
}
676-
}
677-
678-
// Has build-system, has scripts, but no package structure and no standalone scripts
679-
// This will likely fail to build - treat as simple script to avoid build errors
680-
return true;
681-
} catch {
682-
return false;
683-
}
684-
}
685631

686632
/**
687633
* Install Python dependencies using uv or pip
@@ -721,8 +667,8 @@ export class GitHubDeploymentHandler {
721667
let args: string[];
722668
let installMethod: string;
723669

724-
// Determine installation method
725-
if (hasPyproject && !(await this.isPyprojectSimpleScript(tempDir))) {
670+
// Determine installation method using pattern detection from python-helpers
671+
if (hasPyproject && !(await isPyprojectSimpleScript(tempDir))) {
726672
// Pattern 1: Installable package with pyproject.toml
727673
// Use uv sync to create venv and install package as editable
728674
command = 'uv';
@@ -867,39 +813,19 @@ export class GitHubDeploymentHandler {
867813
let pipArgs: string[];
868814

869815
if (hasPyproject) {
870-
// Parse dependencies from pyproject.toml and install them directly
816+
// Parse dependencies from pyproject.toml using extracted helper
871817
const pyprojectPath = path.join(tempDir, 'pyproject.toml');
872-
const pyprojectContent = await fs.promises.readFile(pyprojectPath, 'utf8');
873-
874-
// Extract dependencies array from [project] section
875-
const depsMatch = pyprojectContent.match(/dependencies\s*=\s*\[([\s\S]*?)\]/);
876-
if (depsMatch) {
877-
const depsContent = depsMatch[1];
878-
// Parse individual dependencies (handle both "pkg" and 'pkg' quotes)
879-
const deps = depsContent
880-
.split(',')
881-
.map(d => d.trim())
882-
.filter(d => d.length > 0)
883-
.map(d => d.replace(/^["']|["']$/g, '')); // Remove quotes
818+
const deps = await parsePyprojectDependencies(pyprojectPath);
884819

885-
if (deps.length > 0) {
886-
pipArgs = ['pip', 'install', ...deps];
887-
} else {
888-
// No dependencies found, skip install
889-
this.logger.info({
890-
operation: 'python_deps_skip',
891-
temp_dir: tempDir,
892-
reason: 'no_dependencies_in_pyproject'
893-
}, 'No dependencies found in pyproject.toml, skipping install');
894-
pipArgs = [];
895-
}
820+
if (deps.length > 0) {
821+
pipArgs = ['pip', 'install', ...deps];
896822
} else {
897-
// No dependencies section found
823+
// No dependencies found, skip install
898824
this.logger.info({
899825
operation: 'python_deps_skip',
900826
temp_dir: tempDir,
901-
reason: 'no_dependencies_section'
902-
}, 'No [project.dependencies] section in pyproject.toml, skipping install');
827+
reason: 'no_dependencies_in_pyproject'
828+
}, 'No dependencies found in pyproject.toml, skipping install');
903829
pipArgs = [];
904830
}
905831
} else {
@@ -1104,111 +1030,28 @@ export class GitHubDeploymentHandler {
11041030

11051031
/**
11061032
* Resolve Python package entry point from pyproject.toml or __main__.py
1033+
* Delegates to resolvePythonEntryPoint() from python-helpers.ts
11071034
*/
11081035
async resolvePythonPackageEntry(tempDir: string): Promise<{ command: string; entryPoint: string }> {
11091036
this.logger.debug({
11101037
operation: 'python_entry_resolve_start',
11111038
temp_dir: tempDir
11121039
}, 'Resolving Python package entry point');
11131040

1114-
// Try pyproject.toml first
1115-
const pyprojectPath = path.join(tempDir, 'pyproject.toml');
1116-
if (await fileExists(pyprojectPath)) {
1117-
const content = await fs.promises.readFile(pyprojectPath, 'utf8');
1118-
1119-
// Look for [project.scripts] section
1120-
const scriptsMatch = content.match(/\[project\.scripts\]\s*\n([^[]+)/);
1121-
if (scriptsMatch) {
1122-
const firstScriptMatch = scriptsMatch[1].match(/^(\w+)\s*=/m);
1123-
if (firstScriptMatch) {
1124-
const scriptName = firstScriptMatch[1];
1125-
// Entry point is installed in .venv/bin/ after uv sync
1126-
const entryPoint = path.join(tempDir, '.venv', 'bin', scriptName);
1127-
1128-
this.logger.info({
1129-
operation: 'python_entry_resolved',
1130-
temp_dir: tempDir,
1131-
script_name: scriptName,
1132-
entry_point: entryPoint
1133-
}, `Resolved Python entry point: ${scriptName}`);
1134-
1135-
return { command: entryPoint, entryPoint };
1136-
}
1137-
}
1138-
1139-
// Look for [project.gui-scripts] as fallback
1140-
const guiMatch = content.match(/\[project\.gui-scripts\]\s*\n([^[]+)/);
1141-
if (guiMatch) {
1142-
const firstScriptMatch = guiMatch[1].match(/^(\w+)\s*=/m);
1143-
if (firstScriptMatch) {
1144-
const scriptName = firstScriptMatch[1];
1145-
const entryPoint = path.join(tempDir, '.venv', 'bin', scriptName);
1041+
const result = await resolvePythonEntryPoint(tempDir);
11461042

1147-
this.logger.info({
1148-
operation: 'python_entry_resolved',
1149-
temp_dir: tempDir,
1150-
script_name: scriptName,
1151-
entry_point: entryPoint
1152-
}, `Resolved Python GUI entry point: ${scriptName}`);
1153-
1154-
return { command: entryPoint, entryPoint };
1155-
}
1156-
}
1043+
if (!result) {
1044+
throw new Error('Cannot resolve Python entry point: no scripts in pyproject.toml, no __main__.py, and no common script files (server.py, main.py, app.py, run.py) found');
11571045
}
11581046

1159-
// Fallback: look for __main__.py
1160-
const mainPath = path.join(tempDir, '__main__.py');
1161-
if (await fileExists(mainPath)) {
1162-
this.logger.info({
1163-
operation: 'python_entry_resolved_main',
1164-
temp_dir: tempDir,
1165-
entry_point: mainPath
1166-
}, 'Using __main__.py as entry point');
1167-
1168-
// Use venv Python if available
1169-
const venvPython = path.join(tempDir, '.venv', 'bin', 'python');
1170-
const command = await fileExists(venvPython) ? venvPython : 'python3';
1171-
1172-
return { command, entryPoint: mainPath };
1173-
}
1174-
1175-
// Try src/__main__.py (common pattern)
1176-
const srcMainPath = path.join(tempDir, 'src', '__main__.py');
1177-
if (await fileExists(srcMainPath)) {
1178-
this.logger.info({
1179-
operation: 'python_entry_resolved_src_main',
1180-
temp_dir: tempDir,
1181-
entry_point: srcMainPath
1182-
}, 'Using src/__main__.py as entry point');
1183-
1184-
// Use venv Python if available
1185-
const venvPython = path.join(tempDir, '.venv', 'bin', 'python');
1186-
const command = await fileExists(venvPython) ? venvPython : 'python3';
1187-
1188-
return { command, entryPoint: srcMainPath };
1189-
}
1190-
1191-
// Try common script names (server.py, main.py, app.py)
1192-
const commonScriptNames = ['server.py', 'main.py', 'app.py', 'run.py'];
1193-
for (const scriptName of commonScriptNames) {
1194-
const scriptPath = path.join(tempDir, scriptName);
1195-
if (await fileExists(scriptPath)) {
1196-
this.logger.info({
1197-
operation: 'python_entry_resolved_script',
1198-
temp_dir: tempDir,
1199-
script_name: scriptName,
1200-
entry_point: scriptPath
1201-
}, `Using ${scriptName} as entry point`);
1202-
1203-
// Use venv Python if available
1204-
const venvPython = path.join(tempDir, '.venv', 'bin', 'python');
1205-
const command = await fileExists(venvPython) ? venvPython : 'python3';
1206-
1207-
return { command, entryPoint: scriptPath };
1208-
}
1209-
}
1047+
this.logger.info({
1048+
operation: 'python_entry_resolved',
1049+
temp_dir: tempDir,
1050+
command: result.command,
1051+
entry_point: result.entryPoint
1052+
}, `Resolved Python entry point: ${result.command}`);
12101053

1211-
throw new Error('Cannot resolve Python entry point: no scripts in pyproject.toml, no __main__.py, and no common script files (server.py, main.py, app.py) found');
1054+
return result;
12121055
}
12131056

12141057
/**

0 commit comments

Comments
 (0)