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")) + } +}