diff --git a/gradle.properties b/gradle.properties
index fa33e6757d..9e301db7c1 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,7 +10,7 @@ pluginRepositoryUrl = https://github.com/unit-mesh/auto-dev
pluginVersion = 2.4.6
# MPP Unified Version (mpp-core, mpp-ui, mpp-server)
-mppVersion =3.0.0-alpha5
+mppVersion=1.0.2
# Supported IDEs: idea, pycharm
baseIDE=idea
diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/PEP723Parser.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/PEP723Parser.kt
new file mode 100644
index 0000000000..c77cac6d5d
--- /dev/null
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/artifact/PEP723Parser.kt
@@ -0,0 +1,181 @@
+package cc.unitmesh.agent.artifact
+
+/**
+ * PEP 723 Inline Script Metadata Parser & Generator.
+ *
+ * Parses and generates PEP 723 compliant inline metadata blocks in Python scripts.
+ *
+ * PEP 723 format example:
+ * ```python
+ * # /// script
+ * # requires-python = ">=3.11"
+ * # dependencies = [
+ * # "requests>=2.28.0",
+ * # "pandas>=1.5.0",
+ * # ]
+ * # ///
+ * ```
+ *
+ * @see PEP 723
+ */
+object PEP723Parser {
+
+ /**
+ * Parsed result from a PEP 723 metadata block.
+ */
+ data class PEP723Metadata(
+ /** Required Python version constraint, e.g. ">=3.11" */
+ val requiresPython: String? = null,
+ /** List of dependency specifiers, e.g. ["requests>=2.28.0", "pandas>=1.5.0"] */
+ val dependencies: List = emptyList(),
+ /** AutoDev Unit custom metadata embedded in [tool.autodev-unit] */
+ val autodevContext: Map = emptyMap(),
+ /** The raw text of the entire metadata block (including comment prefixes) */
+ val rawBlock: String? = null
+ )
+
+ // ---- Parsing ----
+
+ private val PEP723_BLOCK_PATTERN = Regex(
+ """(?s)#\s*///\s*script\s*\n(.*?)#\s*///"""
+ )
+
+ private val REQUIRES_PYTHON_PATTERN = Regex(
+ """requires-python\s*=\s*"([^"]*)""""
+ )
+
+ private val DEPENDENCIES_PATTERN = Regex(
+ """(?s)dependencies\s*=\s*\[(.*?)\]"""
+ )
+
+ private val DEP_ITEM_PATTERN = Regex("""["']([^"']+)["']""")
+
+ private val AUTODEV_SECTION_PATTERN = Regex(
+ """(?s)\[tool\.autodev-unit\]\s*\n(.*?)(?=#\s*///|\[tool\.|$)"""
+ )
+
+ private val AUTODEV_KV_PATTERN = Regex(
+ """#\s*(\S+)\s*=\s*"([^"]*)""""
+ )
+
+ /**
+ * Parse PEP 723 inline metadata from a Python script.
+ *
+ * @param pythonContent Full text of the Python script.
+ * @return Parsed metadata, or a default empty metadata if no block is found.
+ */
+ fun parse(pythonContent: String): PEP723Metadata {
+ val blockMatch = PEP723_BLOCK_PATTERN.find(pythonContent)
+ ?: return PEP723Metadata()
+
+ val metadataBlock = blockMatch.groupValues[1]
+ val rawBlock = blockMatch.value
+
+ // Parse requires-python
+ val requiresPython = REQUIRES_PYTHON_PATTERN.find(metadataBlock)?.groupValues?.get(1)
+
+ // Parse dependencies
+ val dependencies = parseDependencies(metadataBlock)
+
+ // Parse [tool.autodev-unit] section
+ val autodevContext = parseAutodevContext(metadataBlock)
+
+ return PEP723Metadata(
+ requiresPython = requiresPython,
+ dependencies = dependencies,
+ autodevContext = autodevContext,
+ rawBlock = rawBlock
+ )
+ }
+
+ /**
+ * Extract only the dependency list from a Python script (convenience method).
+ */
+ fun parseDependencies(pythonContent: String): List {
+ val depsMatch = DEPENDENCIES_PATTERN.find(pythonContent) ?: return emptyList()
+ val depsContent = depsMatch.groupValues[1]
+
+ return DEP_ITEM_PATTERN.findAll(depsContent)
+ .map { it.groupValues[1] }
+ .toList()
+ }
+
+ private fun parseAutodevContext(metadataBlock: String): Map {
+ val sectionMatch = AUTODEV_SECTION_PATTERN.find(metadataBlock)
+ ?: return emptyMap()
+
+ val sectionContent = sectionMatch.groupValues[1]
+ return AUTODEV_KV_PATTERN.findAll(sectionContent)
+ .associate { it.groupValues[1] to it.groupValues[2] }
+ }
+
+ // ---- Generation ----
+
+ /**
+ * Generate a PEP 723 metadata header block.
+ *
+ * @param dependencies List of dependency specifiers (e.g. "requests>=2.28.0").
+ * @param requiresPython Python version constraint (default ">=3.11").
+ * @param autodevContext Optional AutoDev Unit context key-value pairs to embed.
+ * @return The generated metadata block as a string, ready to prepend to a script.
+ */
+ fun generate(
+ dependencies: List = emptyList(),
+ requiresPython: String = ">=3.11",
+ autodevContext: Map = emptyMap()
+ ): String = buildString {
+ appendLine("# /// script")
+ appendLine("# requires-python = \"$requiresPython\"")
+
+ if (dependencies.isNotEmpty()) {
+ appendLine("# dependencies = [")
+ dependencies.forEach { dep ->
+ appendLine("# \"$dep\",")
+ }
+ appendLine("# ]")
+ }
+
+ if (autodevContext.isNotEmpty()) {
+ appendLine("# [tool.autodev-unit]")
+ autodevContext.forEach { (key, value) ->
+ appendLine("# $key = \"$value\"")
+ }
+ }
+
+ appendLine("# ///")
+ }
+
+ /**
+ * Inject or replace a PEP 723 metadata block in a Python script.
+ *
+ * If the script already contains a PEP 723 block, it is replaced.
+ * Otherwise the new block is prepended.
+ *
+ * @param pythonContent The original script content.
+ * @param dependencies Dependency list.
+ * @param requiresPython Python version constraint.
+ * @param autodevContext Optional AutoDev context map.
+ * @return The script with the metadata block injected/replaced.
+ */
+ fun injectMetadata(
+ pythonContent: String,
+ dependencies: List = emptyList(),
+ requiresPython: String = ">=3.11",
+ autodevContext: Map = emptyMap()
+ ): String {
+ val newBlock = generate(dependencies, requiresPython, autodevContext)
+
+ return if (PEP723_BLOCK_PATTERN.containsMatchIn(pythonContent)) {
+ PEP723_BLOCK_PATTERN.replace(pythonContent, newBlock.trimEnd())
+ } else {
+ newBlock + "\n" + pythonContent
+ }
+ }
+
+ /**
+ * Strip the PEP 723 metadata block from a Python script, returning only the code body.
+ */
+ fun stripMetadata(pythonContent: String): String {
+ return PEP723_BLOCK_PATTERN.replace(pythonContent, "").trimStart('\r', '\n')
+ }
+}
diff --git a/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/subagent/PythonArtifactAgent.kt b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/subagent/PythonArtifactAgent.kt
new file mode 100644
index 0000000000..aac4d86519
--- /dev/null
+++ b/mpp-core/src/commonMain/kotlin/cc/unitmesh/agent/subagent/PythonArtifactAgent.kt
@@ -0,0 +1,211 @@
+package cc.unitmesh.agent.subagent
+
+import cc.unitmesh.agent.artifact.ArtifactContext
+import cc.unitmesh.agent.artifact.ConversationMessage
+import cc.unitmesh.agent.artifact.ModelInfo
+import cc.unitmesh.agent.artifact.PEP723Parser
+import cc.unitmesh.agent.core.SubAgent
+import cc.unitmesh.agent.model.AgentDefinition
+import cc.unitmesh.agent.model.PromptConfig
+import cc.unitmesh.agent.model.RunConfig
+import cc.unitmesh.agent.tool.ToolResult
+import cc.unitmesh.llm.LLMService
+import cc.unitmesh.devins.llm.Message
+import cc.unitmesh.devins.llm.MessageRole
+import cc.unitmesh.llm.ModelConfig
+import kotlinx.serialization.Serializable
+
+/**
+ * PythonArtifactAgent – Sub-agent responsible for generating
+ * complete, self-contained Python scripts with PEP 723 inline metadata.
+ *
+ * The generated scripts follow the AutoDev Artifact convention and include
+ * dependency declarations so that they can be executed with `uv run` or
+ * after a simple `pip install`.
+ *
+ * @see Issue #526
+ */
+class PythonArtifactAgent(
+ private val llmService: LLMService
+) : SubAgent(
+ AgentDefinition(
+ name = "PythonArtifactAgent",
+ displayName = "Python Artifact Agent",
+ description = "Generates self-contained Python scripts with PEP 723 metadata for the AutoDev Unit system",
+ promptConfig = PromptConfig(
+ systemPrompt = SYSTEM_PROMPT,
+ queryTemplate = null,
+ initialMessages = emptyList()
+ ),
+ modelConfig = ModelConfig.default(),
+ runConfig = RunConfig(
+ maxTurns = 1,
+ maxTimeMinutes = 5,
+ terminateOnError = true
+ )
+ )
+) {
+
+ override fun validateInput(input: Map): PythonArtifactInput {
+ val prompt = input["prompt"] as? String
+ ?: throw IllegalArgumentException("'prompt' is required")
+ val dependencies = (input["dependencies"] as? List<*>)
+ ?.filterIsInstance()
+ ?: emptyList()
+
+ return PythonArtifactInput(
+ prompt = prompt,
+ dependencies = dependencies,
+ requiresPython = input["requiresPython"] as? String ?: ">=3.11"
+ )
+ }
+
+ override suspend fun execute(
+ input: PythonArtifactInput,
+ onProgress: (String) -> Unit
+ ): ToolResult.AgentResult {
+ onProgress("[Python] Generating script...")
+
+ val responseBuilder = StringBuilder()
+
+ val historyMessages = listOf(
+ Message(role = MessageRole.SYSTEM, content = SYSTEM_PROMPT)
+ )
+
+ return try {
+ llmService.streamPrompt(
+ userPrompt = buildUserPrompt(input),
+ historyMessages = historyMessages,
+ compileDevIns = false
+ ).collect { chunk ->
+ responseBuilder.append(chunk)
+ onProgress(chunk)
+ }
+
+ val rawResponse = responseBuilder.toString()
+ val scriptContent = extractPythonCode(rawResponse)
+
+ if (scriptContent.isNullOrBlank()) {
+ return ToolResult.AgentResult(
+ success = false,
+ content = "Failed to extract Python code from LLM response."
+ )
+ }
+
+ // Validate PEP 723 metadata is present; inject if missing
+ val meta = PEP723Parser.parse(scriptContent)
+ val finalScript = if (meta.rawBlock == null) {
+ PEP723Parser.injectMetadata(
+ pythonContent = scriptContent,
+ dependencies = input.dependencies,
+ requiresPython = input.requiresPython
+ )
+ } else {
+ scriptContent
+ }
+
+ onProgress("\n[OK] Python script generated successfully.")
+
+ ToolResult.AgentResult(
+ success = true,
+ content = finalScript,
+ metadata = mapOf(
+ "type" to "python",
+ "dependencies" to PEP723Parser.parseDependencies(finalScript).joinToString(","),
+ "requiresPython" to (PEP723Parser.parse(finalScript).requiresPython ?: ">=3.11")
+ )
+ )
+ } catch (e: Exception) {
+ ToolResult.AgentResult(
+ success = false,
+ content = "Generation failed: ${e.message}"
+ )
+ }
+ }
+
+ override fun formatOutput(output: ToolResult.AgentResult): String = output.content
+
+ // ---- helpers ----
+
+ private fun buildUserPrompt(input: PythonArtifactInput): String = buildString {
+ appendLine(input.prompt)
+ if (input.dependencies.isNotEmpty()) {
+ appendLine()
+ appendLine("Required dependencies: ${input.dependencies.joinToString(", ")}")
+ }
+ }
+
+ /**
+ * Extract the Python code block from an LLM response.
+ * Supports fenced code blocks (```python ... ```) and raw artifact XML.
+ */
+ private fun extractPythonCode(response: String): String? {
+ // Try autodev-artifact XML tag first
+ val artifactPattern = Regex(
+ """(?s)]*type="application/autodev\.artifacts\.python"[^>]*>(.*?)"""
+ )
+ artifactPattern.find(response)?.let { return it.groupValues[1].trim() }
+
+ // Try fenced python code block
+ val fencedPattern = Regex(
+ """(?s)```python\s*\n(.*?)```"""
+ )
+ fencedPattern.find(response)?.let { return it.groupValues[1].trim() }
+
+ // Fallback: if the whole response looks like Python code
+ if (response.trimStart().startsWith("#") || response.trimStart().startsWith("import ") || response.trimStart().startsWith("from ")) {
+ return response.trim()
+ }
+
+ return null
+ }
+
+ companion object {
+ /**
+ * System prompt guiding the LLM to generate PEP 723 compliant Python scripts.
+ */
+ const val SYSTEM_PROMPT = """You are an expert Python developer specializing in creating self-contained, executable Python scripts.
+
+## Rules
+
+1. **PEP 723 Metadata** – Every script MUST begin with an inline metadata block:
+```python
+# /// script
+# requires-python = ">=3.11"
+# dependencies = [
+# "some-package>=1.0",
+# ]
+# ///
+```
+
+2. **Self-Contained** – The script must run independently. All logic resides in a single file.
+
+3. **Main Guard** – Always include:
+```python
+if __name__ == "__main__":
+ main()
+```
+
+4. **Clear Output** – Use `print()` to provide meaningful output to stdout.
+
+5. **Error Handling** – Include basic try/except blocks for I/O, network, or file operations.
+
+6. **No External Config** – Avoid reading from external config files. Use environment variables via `os.environ.get()` when necessary.
+
+7. **Output Format** – Wrap the script in `` tags.
+"""
+ }
+}
+
+/**
+ * Input for PythonArtifactAgent
+ */
+@Serializable
+data class PythonArtifactInput(
+ /** Natural-language description of what the script should do */
+ val prompt: String,
+ /** Pre-declared dependencies (may be empty) */
+ val dependencies: List = emptyList(),
+ /** Python version constraint */
+ val requiresPython: String = ">=3.11"
+)
diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/PythonArtifactPackager.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/PythonArtifactPackager.kt
new file mode 100644
index 0000000000..dda4d11bbc
--- /dev/null
+++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/PythonArtifactPackager.kt
@@ -0,0 +1,223 @@
+package cc.unitmesh.agent.artifact
+
+import cc.unitmesh.agent.logging.AutoDevLogger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+
+/**
+ * Packages a Python artifact into a self-contained executable or ready-to-run bundle.
+ *
+ * Supports two strategies:
+ * 1. **UV (preferred)** – uses `uv run` for zero-config execution with PEP 723 metadata.
+ * 2. **pip fallback** – generates a `requirements.txt` and calls `pip install`.
+ *
+ * The packager can also embed AutoDev Unit context inside the PEP 723 metadata block
+ * so that the script remains fully reversible (load-back support).
+ */
+class PythonArtifactPackager {
+
+ private val logger = AutoDevLogger
+
+ /**
+ * Result of a packaging operation.
+ */
+ sealed class PackageResult {
+ data class Success(
+ val scriptFile: File,
+ val strategy: Strategy,
+ val message: String
+ ) : PackageResult()
+
+ data class Error(
+ val message: String,
+ val cause: Exception? = null
+ ) : PackageResult()
+ }
+
+ enum class Strategy { UV, PIP }
+
+ /**
+ * Package a Python script with dependencies resolved.
+ *
+ * 1. Injects AutoDev context into the PEP 723 header.
+ * 2. Writes the enriched script to [outputDir].
+ * 3. Attempts to install dependencies via `uv` or falls back to `pip`.
+ *
+ * @param scriptContent Raw Python script (may or may not contain PEP 723 block).
+ * @param context AutoDev artifact context to embed.
+ * @param outputDir Directory where the enriched script and artifacts are written.
+ * @param onOutput Optional callback for streaming console output.
+ * @return [PackageResult] indicating success or error.
+ */
+ suspend fun packageScript(
+ scriptContent: String,
+ context: ArtifactContext,
+ outputDir: File,
+ onOutput: ((String) -> Unit)? = null
+ ): PackageResult = withContext(Dispatchers.IO) {
+ try {
+ // 1. Parse existing metadata (if any)
+ val existingMeta = PEP723Parser.parse(scriptContent)
+ val dependencies = existingMeta.dependencies
+
+ // 2. Build AutoDev context map
+ val autodevMap = buildMap {
+ put("version", "1.0")
+ put("fingerprint", context.fingerprint)
+ context.model?.let { put("model", it.name) }
+ }
+
+ // 3. Inject / replace PEP 723 header
+ val enrichedScript = PEP723Parser.injectMetadata(
+ pythonContent = scriptContent,
+ dependencies = dependencies,
+ requiresPython = existingMeta.requiresPython ?: ">=3.11",
+ autodevContext = autodevMap
+ )
+
+ // 4. Write enriched script
+ outputDir.mkdirs()
+ val scriptFile = File(outputDir, "index.py")
+ scriptFile.writeText(enrichedScript)
+ logger.info("PythonArtifactPackager") { "📝 Enriched script written to ${scriptFile.absolutePath}" }
+
+ // 5. Write requirements.txt (for pip fallback)
+ if (dependencies.isNotEmpty()) {
+ val reqFile = File(outputDir, "requirements.txt")
+ reqFile.writeText(dependencies.joinToString("\n"))
+ }
+
+ // 6. Resolve dependencies
+ val strategy = resolveDependencies(outputDir, dependencies, onOutput)
+
+ PackageResult.Success(
+ scriptFile = scriptFile,
+ strategy = strategy,
+ message = "Python script packaged successfully with $strategy strategy."
+ )
+ } catch (e: Exception) {
+ logger.error("PythonArtifactPackager") { "❌ Packaging failed: ${e.message}" }
+ PackageResult.Error("Packaging failed: ${e.message}", e)
+ }
+ }
+
+ // ---- internals ----
+
+ /**
+ * Try to resolve dependencies: prefer `uv`, fall back to `pip`.
+ */
+ private suspend fun resolveDependencies(
+ workDir: File,
+ dependencies: List,
+ onOutput: ((String) -> Unit)?
+ ): Strategy = withContext(Dispatchers.IO) {
+ if (dependencies.isEmpty()) {
+ onOutput?.invoke("No dependencies to install.\n")
+ return@withContext Strategy.UV // doesn't matter
+ }
+
+ // Try uv first
+ if (isCommandAvailable("uv")) {
+ onOutput?.invoke("📦 Installing dependencies via uv...\n")
+ val args = listOf("uv", "pip", "install") + dependencies
+ val result = executeCommandArray(args, workDir.absolutePath, onOutput)
+ if (result.exitCode == 0) {
+ onOutput?.invoke("✅ Dependencies installed via uv.\n")
+ return@withContext Strategy.UV
+ }
+ onOutput?.invoke("⚠️ uv install failed, falling back to pip.\n")
+ }
+
+ // Fallback: pip
+ onOutput?.invoke("📦 Installing dependencies via pip...\n")
+ val pipResult = executeCommand("pip install -r requirements.txt", workDir.absolutePath, onOutput)
+ if (pipResult.exitCode != 0) {
+ onOutput?.invoke("⚠️ pip install failed (exit code ${pipResult.exitCode}). Continuing anyway.\n")
+ } else {
+ onOutput?.invoke("✅ Dependencies installed via pip.\n")
+ }
+ Strategy.PIP
+ }
+
+ private suspend fun isCommandAvailable(cmd: String): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val isWindows = System.getProperty("os.name").lowercase().contains("win")
+ val checkCmd = if (isWindows) listOf("where", cmd) else listOf("which", cmd)
+ val process = ProcessBuilder(checkCmd)
+ .redirectErrorStream(true)
+ .start()
+ process.waitFor() == 0
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ private suspend fun executeCommand(
+ command: String,
+ workingDirectory: String,
+ onOutput: ((String) -> Unit)? = null
+ ): CommandResult = withContext(Dispatchers.IO) {
+ try {
+ val processBuilder = ProcessBuilder()
+ .command("sh", "-c", command)
+ .directory(File(workingDirectory))
+ .redirectErrorStream(true)
+
+ val process = processBuilder.start()
+ val outputBuilder = StringBuilder()
+
+ coroutineScope {
+ val outputJob = launch(Dispatchers.IO) {
+ process.inputStream.bufferedReader().use { reader ->
+ reader.lineSequence().forEach { line ->
+ outputBuilder.appendLine(line)
+ onOutput?.invoke("$line\n")
+ }
+ }
+ }
+ val exitCode = process.waitFor()
+ outputJob.join()
+ CommandResult(exitCode, outputBuilder.toString(), "")
+ }
+ } catch (e: Exception) {
+ CommandResult(-1, "", "Error: ${e.message}")
+ }
+ }
+
+ private suspend fun executeCommandArray(
+ command: List,
+ workingDirectory: String,
+ onOutput: ((String) -> Unit)? = null
+ ): CommandResult = withContext(Dispatchers.IO) {
+ try {
+ val processBuilder = ProcessBuilder()
+ .command(command)
+ .directory(File(workingDirectory))
+ .redirectErrorStream(true)
+
+ val process = processBuilder.start()
+ val outputBuilder = StringBuilder()
+
+ coroutineScope {
+ val outputJob = launch(Dispatchers.IO) {
+ process.inputStream.bufferedReader().use { reader ->
+ reader.lineSequence().forEach { line ->
+ outputBuilder.appendLine(line)
+ onOutput?.invoke("$line\n")
+ }
+ }
+ }
+ val exitCode = process.waitFor()
+ outputJob.join()
+ CommandResult(exitCode, outputBuilder.toString(), "")
+ }
+ } catch (e: Exception) {
+ CommandResult(-1, "", "Error: ${e.message}")
+ }
+ }
+
+ private data class CommandResult(val exitCode: Int, val stdout: String, val stderr: String)
+}
diff --git a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/PythonArtifactExecutor.kt b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/PythonArtifactExecutor.kt
index 6046f95b95..338eb40538 100644
--- a/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/PythonArtifactExecutor.kt
+++ b/mpp-core/src/jvmMain/kotlin/cc/unitmesh/agent/artifact/executor/PythonArtifactExecutor.kt
@@ -1,6 +1,7 @@
package cc.unitmesh.agent.artifact.executor
import cc.unitmesh.agent.artifact.ArtifactType
+import cc.unitmesh.agent.artifact.PEP723Parser
import cc.unitmesh.agent.logging.AutoDevLogger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@@ -121,43 +122,11 @@ class PythonArtifactExecutor : ArtifactExecutor {
}
/**
- * Parse PEP 723 inline metadata from Python script
- *
- * PEP 723 format:
- * ```python
- * # /// script
- * # requires-python = ">=3.11"
- * # dependencies = [
- * # "requests>=2.28.0",
- * # "pandas>=1.5.0",
- * # ]
- * # ///
- * ```
+ * Parse PEP 723 inline metadata from Python script.
+ * Delegates to the shared [PEP723Parser].
*/
private fun parsePep723Dependencies(pythonContent: String): List {
- val dependencies = mutableListOf()
-
- // Look for PEP 723 metadata block
- val pep723Pattern = Regex(
- """#\s*///\s*script\s*\n(.*?)#\s*///""",
- RegexOption.DOT_MATCHES_ALL
- )
-
- val match = pep723Pattern.find(pythonContent) ?: return emptyList()
- val metadataBlock = match.groupValues[1]
-
- // Parse dependencies
- val depsPattern = Regex("""dependencies\s*=\s*\[(.*?)\]""", RegexOption.DOT_MATCHES_ALL)
- val depsMatch = depsPattern.find(metadataBlock) ?: return emptyList()
- val depsContent = depsMatch.groupValues[1]
-
- // Extract individual dependencies
- val depPattern = Regex("""["']([^"']+)["']""")
- depPattern.findAll(depsContent).forEach { depMatch ->
- dependencies.add(depMatch.groupValues[1])
- }
-
- return dependencies
+ return PEP723Parser.parseDependencies(pythonContent)
}
private suspend fun executeCommand(
diff --git a/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/PEP723ParserTest.kt b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/PEP723ParserTest.kt
new file mode 100644
index 0000000000..febfb08316
--- /dev/null
+++ b/mpp-core/src/jvmTest/kotlin/cc/unitmesh/agent/artifact/PEP723ParserTest.kt
@@ -0,0 +1,215 @@
+package cc.unitmesh.agent.artifact
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class PEP723ParserTest {
+
+ // ===== parse() tests =====
+
+ @Test
+ fun `parse should extract requires-python`() {
+ val script = """
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "requests>=2.28.0",
+ # ]
+ # ///
+
+ import requests
+ print("hello")
+ """.trimIndent()
+
+ val meta = PEP723Parser.parse(script)
+
+ assertEquals(">=3.11", meta.requiresPython)
+ }
+
+ @Test
+ fun `parse should extract dependencies list`() {
+ val script = """
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "requests>=2.28.0",
+ # "pandas>=2.0.0",
+ # "numpy",
+ # ]
+ # ///
+
+ import requests
+ """.trimIndent()
+
+ val meta = PEP723Parser.parse(script)
+
+ assertEquals(3, meta.dependencies.size)
+ assertEquals("requests>=2.28.0", meta.dependencies[0])
+ assertEquals("pandas>=2.0.0", meta.dependencies[1])
+ assertEquals("numpy", meta.dependencies[2])
+ }
+
+ @Test
+ fun `parse should extract autodev-unit context`() {
+ val script = """
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "requests>=2.28.0",
+ # ]
+ # [tool.autodev-unit]
+ # version = "1.0"
+ # session-id = "abc123"
+ # ///
+
+ import requests
+ """.trimIndent()
+
+ val meta = PEP723Parser.parse(script)
+
+ assertEquals("1.0", meta.autodevContext["version"])
+ assertEquals("abc123", meta.autodevContext["session-id"])
+ }
+
+ @Test
+ fun `parse should return empty metadata when no block exists`() {
+ val script = """
+ import os
+ print("hello")
+ """.trimIndent()
+
+ val meta = PEP723Parser.parse(script)
+
+ assertNull(meta.requiresPython)
+ assertTrue(meta.dependencies.isEmpty())
+ assertNull(meta.rawBlock)
+ }
+
+ @Test
+ fun `parse should capture rawBlock`() {
+ val script = """
+ # /// script
+ # requires-python = ">=3.11"
+ # ///
+
+ print("test")
+ """.trimIndent()
+
+ val meta = PEP723Parser.parse(script)
+ assertNotNull(meta.rawBlock)
+ assertTrue(meta.rawBlock!!.contains("requires-python"))
+ }
+
+ // ===== parseDependencies() tests =====
+
+ @Test
+ fun `parseDependencies should return empty list for no deps block`() {
+ val script = """
+ # /// script
+ # requires-python = ">=3.11"
+ # ///
+ """.trimIndent()
+
+ val deps = PEP723Parser.parseDependencies(script)
+ assertTrue(deps.isEmpty())
+ }
+
+ // ===== generate() tests =====
+
+ @Test
+ fun `generate should produce valid PEP 723 block`() {
+ val block = PEP723Parser.generate(
+ dependencies = listOf("requests>=2.28.0", "pandas>=2.0.0"),
+ requiresPython = ">=3.12"
+ )
+
+ assertTrue(block.contains("# /// script"))
+ assertTrue(block.contains("# ///"))
+ assertTrue(block.contains("""requires-python = ">=3.12""""))
+ assertTrue(block.contains(""""requests>=2.28.0""""))
+ assertTrue(block.contains(""""pandas>=2.0.0""""))
+ }
+
+ @Test
+ fun `generate should include autodev context`() {
+ val block = PEP723Parser.generate(
+ dependencies = listOf("flask"),
+ autodevContext = mapOf("version" to "1.0", "session-id" to "xyz")
+ )
+
+ assertTrue(block.contains("[tool.autodev-unit]"))
+ assertTrue(block.contains("""version = "1.0""""))
+ assertTrue(block.contains("""session-id = "xyz""""))
+ }
+
+ // ===== injectMetadata() tests =====
+
+ @Test
+ fun `injectMetadata should prepend block when none exists`() {
+ val script = """
+ import os
+ print("hello")
+ """.trimIndent()
+
+ val result = PEP723Parser.injectMetadata(
+ pythonContent = script,
+ dependencies = listOf("requests"),
+ requiresPython = ">=3.11"
+ )
+
+ assertTrue(result.startsWith("# /// script"))
+ assertTrue(result.contains("import os"))
+ assertTrue(result.contains(""""requests""""))
+ }
+
+ @Test
+ fun `injectMetadata should replace existing block`() {
+ val script = """
+ # /// script
+ # requires-python = ">=3.10"
+ # dependencies = [
+ # "old-package",
+ # ]
+ # ///
+
+ import os
+ """.trimIndent()
+
+ val result = PEP723Parser.injectMetadata(
+ pythonContent = script,
+ dependencies = listOf("new-package>=1.0"),
+ requiresPython = ">=3.12"
+ )
+
+ assertTrue(result.contains(""""new-package>=1.0""""))
+ assertTrue(result.contains("""requires-python = ">=3.12""""))
+ // old package should be gone
+ assertTrue(!result.contains("old-package"))
+ }
+
+ // ===== stripMetadata() tests =====
+
+ @Test
+ fun `stripMetadata should remove PEP 723 block`() {
+ val script = """
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "requests",
+ # ]
+ # ///
+
+ import requests
+ print("done")
+ """.trimIndent()
+
+ val stripped = PEP723Parser.stripMetadata(script)
+
+ assertTrue(!stripped.contains("# /// script"))
+ assertTrue(!stripped.contains("# ///"))
+ assertTrue(stripped.contains("import requests"))
+ }
+}