diff --git a/plugins/Python-Tools/.gitignore b/plugins/Python-Tools/.gitignore index 30f6e1a1..ce96be66 100644 --- a/plugins/Python-Tools/.gitignore +++ b/plugins/Python-Tools/.gitignore @@ -17,3 +17,4 @@ local.properties # macOS .DS_Store +.cxx/ diff --git a/plugins/Python-Tools/README.md b/plugins/Python-Tools/README.md index e8531d08..a0ef6938 100644 --- a/plugins/Python-Tools/README.md +++ b/plugins/Python-Tools/README.md @@ -11,7 +11,18 @@ Two Python project templates: - **Python Flask App** — a Flask web app with routes, HTML templates, static files, and error handling. - **Python Starter** — a minimal Python project with a single entry point. -It also installs Python on-device (via Termux) the first time it is needed, and provides built-in run, install-dependencies, and test actions for Python projects. +It also installs Python on-device the first time it is needed, and provides built-in run, install-dependencies, and test actions for Python projects. + +`.py` files get syntax highlighting from a bundled tree-sitter Python grammar, and code completion, diagnostics (pyflakes), and formatting (yapf) from `pylsp`, which runs on the device. Both are contributed through the plugin API's `LanguageExtension`, so the plugin needs Code on the Go 26.41 or later. + +## Offline bundle + +Python 3.12 and pip ship in Code on the Go's Termux bootstrap, so the plugin only bundles what used to need a network connection: + +- **Wheelhouse** - python-lsp-server with pyflakes and yapf, Flask, gunicorn, and everything they depend on. On first use the plugin installs them with `pip install --no-index` from the bundled files. `ujson` and `markupsafe` have no Android wheels, so they ship as source and are compiled on the device by the bootstrap's `clang` (with `setuptools`, `setuptools-scm`, and `wheel` bundled for the build). +- **Project requirements** are installed automatically when a Python project opens, looking in the wheelhouse first, so the Flask template (Flask and gunicorn) needs no network. Other packages in a project's `requirements.txt` still download from PyPI. + +`python-bundle.lock` pins every file by URL and SHA-256. `downloadPythonBundle` fetches them at build time (resuming interrupted transfers) and refuses any file whose checksum does not match. To update, regenerate the lock from a `pip install --dry-run --report` run on a device. ## In-app help diff --git a/plugins/Python-Tools/build.gradle.kts b/plugins/Python-Tools/build.gradle.kts index bd85463f..41234fb4 100644 --- a/plugins/Python-Tools/build.gradle.kts +++ b/plugins/Python-Tools/build.gradle.kts @@ -1,3 +1,10 @@ +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URI +import java.security.MessageDigest +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { @@ -9,6 +16,164 @@ pluginBuilder { pluginName = "python-tools" } +data class GrammarSource( + val name: String, + val version: String, + val sha256: String, +) { + val url = "https://registry.npmjs.org/tree-sitter-$name/-/tree-sitter-$name-$version.tgz" +} + +val grammarSource = GrammarSource("python", "0.23.6", "aab5b860d93dbf84b37fed532f7dacc0468ede5387d0c74911d68f964287662a") +val grammarDir = layout.buildDirectory.dir("tree-sitter/${grammarSource.name}") + +val DOWNLOAD_ATTEMPTS = 40 + +fun sha256Of(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { stream -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = stream.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +fun fetchVerified( + url: String, + sha256: String, + target: File, +) { + if (target.exists() && sha256Of(target) == sha256) return + target.parentFile.mkdirs() + target.delete() + logger.lifecycle("Downloading $url") + var attempt = 0 + while (true) { + attempt++ + val offset = target.length() + val connection = URI(url).toURL().openConnection() as HttpURLConnection + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + if (offset > 0) connection.setRequestProperty("Range", "bytes=$offset-") + val interrupted = + try { + val append = offset > 0 && connection.responseCode == HttpURLConnection.HTTP_PARTIAL + connection.inputStream.use { input -> + FileOutputStream(target, append).use { output -> input.copyTo(output) } + } + null + } catch (e: IOException) { + e + } finally { + connection.disconnect() + } + if (interrupted == null && connection.contentLengthLong in listOf(-1L, target.length() - offset, target.length())) break + if (attempt >= DOWNLOAD_ATTEMPTS) { + target.delete() + throw GradleException("Failed to download $url after $attempt attempts: ${interrupted?.message}", interrupted) + } + logger.lifecycle("Resuming $url at ${target.length()} bytes (attempt ${attempt + 1})") + } + val actual = sha256Of(target) + if (actual != sha256) { + target.delete() + throw GradleException("Checksum mismatch for $url (expected $sha256, got $actual)") + } +} + +data class BundleEntry( + val directory: String, + val fileName: String, + val url: String, + val sha256: String, +) + +val pythonBundleLock = layout.projectDirectory.file("python-bundle.lock") +val pythonBundleCache = layout.buildDirectory.dir("python-bundle-cache") +val pythonBundleDir = layout.buildDirectory.dir("generated/pythonBundle") + +val downloadPythonBundle = + tasks.register("downloadPythonBundle") { + group = "setup" + description = "Download and verify the Python wheels bundled into the plugin" + + val lock = pythonBundleLock + val cacheDir = pythonBundleCache + val outputDir = pythonBundleDir + inputs.file(lock) + outputs.dir(outputDir) + + doLast { + val entries = + lock.asFile.readLines().filter { it.isNotBlank() }.map { line -> + val fields = line.split('\t') + if (fields.size != 4) throw GradleException("Malformed python-bundle.lock line: $line") + BundleEntry(fields[0], fields[1], fields[2], fields[3]) + } + val cache = cacheDir.get().asFile + entries.forEach { fetchVerified(it.url, it.sha256, File(cache, "${it.directory}/${it.fileName}")) } + + val zipFile = File(outputDir.get().asFile, "python/wheelhouse.zip") + zipFile.parentFile.deleteRecursively() + zipFile.parentFile.mkdirs() + ZipOutputStream(zipFile.outputStream()).use { zip -> + entries.filter { it.directory == "wheelhouse" }.sortedBy { it.fileName }.forEach { entry -> + zip.putNextEntry(ZipEntry(entry.fileName).apply { time = 0L }) + File(cache, "${entry.directory}/${entry.fileName}").inputStream().use { it.copyTo(zip) } + zip.closeEntry() + } + } + } + } + +val downloadGrammar = + tasks.register("downloadTreeSitterGrammar") { + group = "setup" + description = "Download, verify, and unpack the tree-sitter grammar compiled into the plugin" + + val source = grammarSource + val outputDir = grammarDir + val archive = layout.buildDirectory.file("tree-sitter/${source.name}-${source.version}.tgz") + inputs.property("grammarVersion", source.version) + inputs.property("grammarUrl", source.url) + inputs.property("grammarSha256", source.sha256) + outputs.dir(outputDir) + + doLast { + val archiveFile = archive.get().asFile + fetchVerified(source.url, source.sha256, archiveFile) + val target = outputDir.get().asFile + target.deleteRecursively() + project.copy { + from(project.tarTree(project.resources.gzip(archiveFile))) + include("package/src/**") + eachFile { path = path.removePrefix("package/") } + includeEmptyDirs = false + into(target) + } + } + } + +tasks.named("preBuild") { + dependsOn(downloadGrammar, downloadPythonBundle) +} + +tasks.matching { it.name.startsWith("configureCMake") || it.name.startsWith("buildCMake") }.configureEach { + dependsOn(downloadGrammar) +} + +downloadGrammar.configure { + mustRunAfter(tasks.named("clean")) +} + +downloadPythonBundle.configure { + mustRunAfter(tasks.named("clean")) +} + android { namespace = "com.appdevforall.python.plugin" compileSdk = 36 @@ -17,8 +182,38 @@ android { applicationId = "com.appdevforall.python.plugin" minSdk = 26 targetSdk = 36 - versionCode = 1 - versionName = "1.0.0" + versionCode = 3 + versionName = "1.2.0" + + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a") + } + + externalNativeBuild { + cmake { + arguments += "-DTREE_SITTER_GRAMMAR_NAME=${grammarSource.name}" + arguments += "-DTREE_SITTER_GRAMMAR_DIR=${grammarDir.get().asFile.absolutePath}" + arguments += "-DCMAKE_BUILD_TYPE=Release" + arguments += "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + } + } + } + + androidResources { + noCompress += listOf("zip") + } + + sourceSets { + named("main") { + assets.srcDir(pythonBundleDir.get().asFile) + } + } + + externalNativeBuild { + cmake { + path("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } } buildTypes { diff --git a/plugins/Python-Tools/python-bundle.lock b/plugins/Python-Tools/python-bundle.lock new file mode 100644 index 00000000..05ff7673 --- /dev/null +++ b/plugins/Python-Tools/python-bundle.lock @@ -0,0 +1,31 @@ +wheelhouse black-26.5.1-py3-none-any.whl https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl 4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2 +wheelhouse blinker-1.9.0-py3-none-any.whl https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc +wheelhouse click-8.5.0-py3-none-any.whl https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl 255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 +wheelhouse docstring_to_markdown-0.17-py3-none-any.whl https://files.pythonhosted.org/packages/56/7b/af3d0da15bed3a8665419bb3a630585756920f4ad67abfdfef26240ebcc0/docstring_to_markdown-0.17-py3-none-any.whl fd7d5094aa83943bf5f9e1a13701866b7c452eac19765380dead666e36d3711c +wheelhouse flask-3.1.3-py3-none-any.whl https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c +wheelhouse gunicorn-26.2.0-py3-none-any.whl https://files.pythonhosted.org/packages/fe/85/7522a52e5e2f42faf1a129113ab63e548c42e103e9af395b7bfe65e403e2/gunicorn-26.2.0-py3-none-any.whl bd249d0b3f7972f7432f0a6b6ff3b3ee2d129f70cd1ff6c09a9dd9e29a2b88e3 +wheelhouse importlib_metadata-9.0.1-py3-none-any.whl https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0 +wheelhouse itsdangerous-2.2.0-py3-none-any.whl https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef +wheelhouse jedi-0.20.0-py2.py3-none-any.whl https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl 7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67 +wheelhouse jinja2-3.1.6-py3-none-any.whl https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl 85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 +wheelhouse markupsafe-3.0.3.tar.gz https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz 722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 +wheelhouse mypy_extensions-1.1.0-py3-none-any.whl https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl 1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 +wheelhouse packaging-26.3-py3-none-any.whl https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c +wheelhouse parso-0.8.7-py2.py3-none-any.whl https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c +wheelhouse pathspec-1.1.1-py3-none-any.whl https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 +wheelhouse platformdirs-4.12.2-py3-none-any.whl https://files.pythonhosted.org/packages/d0/89/446044f33aba0348d35e433f56d12206d010a5281a1df54054d4cfb82388/platformdirs-4.12.2-py3-none-any.whl 29dbf06d96c500bc6bdbce75fb0a14d63279c93b1842f97e72a135b33e856983 +wheelhouse pluggy-1.6.0-py3-none-any.whl https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +wheelhouse pyflakes-3.2.0-py2.py3-none-any.whl https://files.pythonhosted.org/packages/d4/d7/f1b7db88d8e4417c5d47adad627a93547f44bdc9028372dbd2313f34a855/pyflakes-3.2.0-py2.py3-none-any.whl 84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a +wheelhouse python_lsp_jsonrpc-1.1.2-py3-none-any.whl https://files.pythonhosted.org/packages/cb/d9/656659d5b5d5f402b2b174cd0ba9bc827e07ce3c0bf88da65424baf64af8/python_lsp_jsonrpc-1.1.2-py3-none-any.whl 7339c2e9630ae98903fdaea1ace8c47fba0484983794d6aafd0bd8989be2b03c +wheelhouse python_lsp_server-1.15.0-py3-none-any.whl https://files.pythonhosted.org/packages/c2/4a/b4abc04dcc65c985fa66d6486393ad3051ff485ccabd9727aea43a242f88/python_lsp_server-1.15.0-py3-none-any.whl d6ac11b467021310498f2dffb2edf09629bbcfe0d3073156db2337334a60463f +wheelhouse pytokens-0.4.1-py3-none-any.whl https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl 26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de +wheelhouse setuptools-84.0.0-py3-none-any.whl https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl 51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670 +wheelhouse setuptools_scm-10.3.4-py3-none-any.whl https://files.pythonhosted.org/packages/da/f5/54538a1f17ea753c42b0928dc2e62986d463f8c393340b9a1e602f2d4dd9/setuptools_scm-10.3.4-py3-none-any.whl 82f34c3e3084fc2b57d397200637cc13f2338004052d2fee47baa5f0e902464e +wheelhouse typing_extensions-4.16.0-py3-none-any.whl https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl 481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 +wheelhouse ujson-6.0.0.tar.gz https://files.pythonhosted.org/packages/64/7c/e1fa3fb70b53192436d751b5cb671f0ee960baa188b8351a7fec735223d3/ujson-6.0.0.tar.gz 80e23393feb707582e0ad495c397a4477b646d08094d2df64f7316f9fafd8aae +wheelhouse vcs_versioning-2.5.0-py3-none-any.whl https://files.pythonhosted.org/packages/e4/e6/b4dedd1efea8a1e432123575896328dec83c26327164f7098db415d13f6f/vcs_versioning-2.5.0-py3-none-any.whl dbf44f6303dc817e5792cb7d0e9cc90e3046ee1e32eaa357b20ad368811313d0 +wheelhouse werkzeug-3.1.9-py3-none-any.whl https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl 6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab +wheelhouse whatthepatch-1.0.7-py3-none-any.whl https://files.pythonhosted.org/packages/8e/93/af1d6ccb69ab6b5a00e03fa0cefa563f9862412667776ea15dd4eece3a90/whatthepatch-1.0.7-py3-none-any.whl 1b6f655fd31091c001c209529dfaabbabdbad438f5de14e3951266ea0fc6e7ed +wheelhouse wheel-0.48.0-py3-none-any.whl https://files.pythonhosted.org/packages/2e/29/69cfbb602cd91690c55d38ba9fe53e6a7e76a6fa647bf38f19c138d25449/wheel-0.48.0-py3-none-any.whl 3217dcc807155e45db462d7ef2431f5ddda0d7273b700d05a67b271ceb1287ab +wheelhouse yapf-0.43.0-py3-none-any.whl https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl 224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca +wheelhouse zipp-4.1.0-py3-none-any.whl https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl 25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f diff --git a/plugins/Python-Tools/python-tools.html b/plugins/Python-Tools/python-tools.html index 8f2e3691..79288eec 100644 --- a/plugins/Python-Tools/python-tools.html +++ b/plugins/Python-Tools/python-tools.html @@ -231,6 +231,12 @@

Hiding the Gradle actions

override fun toolbarActionsToHide(): Set<String> =
     if (isPythonProjectOpen()) ToolbarActionIds.BUILD_HIDEABLE else emptySet()
+

+ Quick Run is not in that set. The plugin hides it through + UIExtension, which accepts any toolbar action id: +

+
override fun getHiddenToolbarActionIds(): Set<String> =
+    if (isPythonProjectOpen()) setOf("ide.editor.build.quickBuild") else emptySet()

Process lifecycle

diff --git a/plugins/Python-Tools/src/main/AndroidManifest.xml b/plugins/Python-Tools/src/main/AndroidManifest.xml index a6cbf79c..fbfb343f 100644 --- a/plugins/Python-Tools/src/main/AndroidManifest.xml +++ b/plugins/Python-Tools/src/main/AndroidManifest.xml @@ -27,11 +27,11 @@ + android:value="26.41" /> + android:value="filesystem.read,filesystem.write,system.commands,project.structure,native.code" /> " + "+=" + "<" + "<<" + "<<=" + "<=" + "<>" + "=" + ":=" + "==" + ">" + ">=" + ">>" + ">>=" + "|" + "|=" + "~" + "@=" + "and" + "in" + "is" + "not" + "or" + "is not" + "not in" +] @operator + +[ + "as" + "assert" + "async" + "await" + "break" + "class" + "continue" + "def" + "del" + "elif" + "else" + "except" + "exec" + "finally" + "for" + "from" + "global" + "if" + "import" + "lambda" + "nonlocal" + "pass" + "print" + "raise" + "return" + "try" + "while" + "with" + "yield" + "match" + "case" +] @keyword + +; Identifier naming conventions + +((identifier) @constant + (#match? @constant "^[A-Z][A-Z_]*$")) + +((identifier) @constructor + (#match? @constructor "^[A-Z]")) + +(identifier) @variable diff --git a/plugins/Python-Tools/src/main/cpp/CMakeLists.txt b/plugins/Python-Tools/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..8c93e2e8 --- /dev/null +++ b/plugins/Python-Tools/src/main/cpp/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.22.1) +project(tree_sitter_grammar C) + +set(GRAMMAR_TARGET tree-sitter-${TREE_SITTER_GRAMMAR_NAME}) +set(GRAMMAR_SOURCE_DIR ${TREE_SITTER_GRAMMAR_DIR}/src) +file(GLOB GRAMMAR_SOURCES ${GRAMMAR_SOURCE_DIR}/parser.c ${GRAMMAR_SOURCE_DIR}/scanner.c) + +add_library(${GRAMMAR_TARGET} SHARED ${GRAMMAR_SOURCES}) +target_include_directories(${GRAMMAR_TARGET} PRIVATE ${GRAMMAR_SOURCE_DIR}) +set_target_properties(${GRAMMAR_TARGET} PROPERTIES C_STANDARD 11) +target_link_options(${GRAMMAR_TARGET} PRIVATE -Wl,-z,max-page-size=16384) diff --git a/plugins/Python-Tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt b/plugins/Python-Tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt index 3b401d48..e734d626 100644 --- a/plugins/Python-Tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt +++ b/plugins/Python-Tools/src/main/kotlin/com/appdevforall/python/plugin/PythonToolsPlugin.kt @@ -12,9 +12,14 @@ import com.itsaky.androidide.plugins.extensions.BuildActionExtension import com.itsaky.androidide.plugins.extensions.CommandResult import com.itsaky.androidide.plugins.extensions.CommandSpec import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.LanguageDefinition +import com.itsaky.androidide.plugins.extensions.LanguageExtension +import com.itsaky.androidide.plugins.extensions.LanguageServerDefinition import com.itsaky.androidide.plugins.extensions.PluginBuildAction import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry import com.itsaky.androidide.plugins.extensions.ToolbarActionIds +import com.itsaky.androidide.plugins.extensions.TreeSitterGrammar +import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.services.IdeCommandService import com.itsaky.androidide.plugins.services.IdeEditorService import com.itsaky.androidide.plugins.services.IdeProjectService @@ -28,8 +33,11 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.io.File +import java.io.IOException import java.lang.ref.WeakReference import java.util.concurrent.atomic.AtomicBoolean +import java.util.zip.ZipInputStream +import java.util.concurrent.atomic.AtomicReference /** * Python Tools plugin. @@ -44,7 +52,7 @@ import java.util.concurrent.atomic.AtomicBoolean * plugin must not steal the toolbar from Java/Kotlin/Android projects, and it must be usable on a * fresh device without Python pre-installed. */ -class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension { +class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension, LanguageExtension, UIExtension { private var pluginContext: PluginContext? = null private var templateService: IdeTemplateService? = null @@ -58,6 +66,7 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension private var installJob: Job? = null private var toolbarContainer: WeakReference? = null private val tooltipBindingScheduled = AtomicBoolean(false) + private val requirementsSyncedRoot = AtomicReference(null) override fun initialize(context: PluginContext): Boolean { pluginContext = context @@ -74,7 +83,7 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension override fun activate(): Boolean { installPyHooks() registerTemplates() - installJob = scope.launch { ensurePython() } + installJob = scope.launch { if (ensurePython()) ensureBundledPackages() } Log.i(TAG, "Python Tools activated") return true } @@ -89,6 +98,18 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension return true } + override fun getLanguages(): List = listOf( + LanguageDefinition( + languageId = LANGUAGE_ID, + fileExtensions = setOf("py"), + grammar = TreeSitterGrammar(name = LANGUAGE_ID, queriesAssetPath = "treesitter/python"), + server = LanguageServerDefinition( + command = listOf(LANGUAGE_SERVER_COMMAND), + environment = mapOf("PYTHONUNBUFFERED" to "1"), + ), + ), + ) + override fun dispose() { scope.cancel() pluginContext = null @@ -104,10 +125,14 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension override fun toolbarActionsToHide(): Set { if (!isPythonProjectOpen()) return emptySet() + syncRequirementsOnce() scheduleTooltipBinding() return ToolbarActionIds.BUILD_HIDEABLE } + override fun getHiddenToolbarActionIds(): Set = + if (isPythonProjectOpen()) setOf(QUICK_BUILD_ACTION_ID) else emptySet() + override fun getBuildActions(): List { if (!isPythonProjectOpen()) return emptyList() scheduleTooltipBinding() @@ -154,7 +179,7 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension description = "Install dependencies from requirements.txt", icon = R.drawable.ic_sync_deps, category = BuildActionCategory.BUILD, - command = shell("pip install -r requirements.txt"), + command = shell(requirementsInstallScript()), timeoutMs = 300_000, ), ) @@ -217,12 +242,7 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension } notify("Missing dependencies — installing from requirements.txt…") scope.launch { - val result = try { - cmd.executeCommand(shell("pip install -r requirements.txt"), timeoutMs = 300_000L).await() - } catch (t: Throwable) { - notify("Could not start dependency install.") - return@launch - } + val result = pipInstallRequirements(cmd) ?: return@launch when (result) { is CommandResult.Success -> notify("Dependencies installed. Tap Run again.") is CommandResult.Failure -> notify("Dependency install failed (exit ${result.exitCode}).") @@ -231,6 +251,41 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension } } + private fun syncRequirementsOnce() { + val cmd = commandService ?: return + val root = projectService?.getCurrentProject()?.rootDir ?: return + val path = root.absolutePath + if (requirementsSyncedRoot.getAndSet(path) == path) return + scope.launch { + installJob?.join() + if (!File(root, "requirements.txt").exists()) return@launch + val result = pipInstallRequirements(cmd) ?: return@launch + when (result) { + is CommandResult.Success -> + if (result.stdout.contains("Successfully installed")) notify("Project dependencies installed.") + is CommandResult.Failure -> { + Log.e(TAG, "pip install -r requirements.txt failed: ${result.stderr.take(2000)}") + notify( + "Could not install project dependencies (exit ${result.exitCode}). It needs a network " + + "connection; tap ${actionLabel(ACTION_SYNC_DEPS)} to try again.", + ) + } + is CommandResult.Cancelled -> Log.i(TAG, "pip install -r requirements.txt cancelled") + } + } + } + + private suspend fun pipInstallRequirements(cmd: IdeCommandService): CommandResult? = try { + cmd.executeCommand( + shell(requirementsInstallScript()), + timeoutMs = 300_000L, + ).await() + } catch (t: Throwable) { + Log.e(TAG, "Could not start pip", t) + notify("Could not start dependency install.") + null + } + private fun isPythonProjectOpen(): Boolean = PythonDomain.isPythonProject(projectService?.getCurrentProject()?.rootDir) @@ -355,39 +410,115 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension // region Python interpreter bootstrap - private suspend fun ensurePython() { + private suspend fun ensurePython(): Boolean { val cmd = commandService ?: run { Log.e(TAG, "IdeCommandService unavailable; cannot manage Python") - return + return false } if (pythonAvailable(cmd)) { Log.i(TAG, "Python is already installed") - return + return true } notify("Python not found. Installing via Termux…") - val result = try { - cmd.executeCommand(shell("pkg install python -y"), timeoutMs = 15 * 60_000L).await() - } catch (t: Throwable) { - Log.e(TAG, "Could not start the package manager", t) - notify("Could not start the package manager (pkg). Install Python manually.") + return runInstall(cmd, "Python", "pkg install python -y", INSTALL_TIMEOUT_MS) && pythonAvailable(cmd) + } + + private suspend fun ensureBundledPackages() { + val cmd = commandService ?: return + if (!extractWheelhouse()) { + notify("Could not unpack the bundled Python packages.") + return + } + if (probe(cmd, "command -v $LANGUAGE_SERVER_COMMAND >/dev/null && python -c 'import flask, gunicorn'")) { + Log.i(TAG, "Bundled Python packages are already installed") return } - when (result) { - is CommandResult.Success -> - if (pythonAvailable(cmd)) notify("Python installed successfully.") - else notify("Install finished but Python is still not on PATH.") + notify("Installing the Python language server, Flask, and gunicorn. This runs once.") + val wheelhouse = quote(wheelhouseDir().absolutePath) + runInstall( + cmd, + "the Python language server, Flask, and gunicorn", + "python -m pip install --disable-pip-version-check --no-index --find-links $wheelhouse " + + "\"$LANGUAGE_SERVER_PACKAGE\" $BUNDLED_PACKAGES", + INSTALL_TIMEOUT_MS, + ) + } + + private suspend fun runInstall( + cmd: IdeCommandService, + label: String, + script: String, + timeoutMs: Long, + ): Boolean { + val result = try { + cmd.executeCommand(shell(script), timeoutMs = timeoutMs).await() + } catch (t: Throwable) { + Log.e(TAG, "Could not start the $label install", t) + notify("Could not start the $label install.") + return false + } + return when (result) { + is CommandResult.Success -> { + notify("Installed $label.") + true + } is CommandResult.Failure -> { val detail = result.error ?: result.stderr.takeIf { it.isNotBlank() } ?: "exit ${result.exitCode}" - Log.e(TAG, "pkg install python failed: $detail") - notify("Failed to install Python: $detail") + Log.e(TAG, "$label install failed: $detail") + notify("Failed to install $label: ${detail.lineSequence().last { it.isNotBlank() }}") + false + } + is CommandResult.Cancelled -> { + Log.i(TAG, "$label install cancelled") + false + } + } + } + + private suspend fun probe(cmd: IdeCommandService, script: String): Boolean = try { + val result = cmd.executeCommand(shell(script), timeoutMs = PROBE_TIMEOUT_MS).await() + result is CommandResult.Success && result.exitCode == 0 + } catch (t: Throwable) { + false + } + + private fun extractWheelhouse(): Boolean { + val assets = pluginContext?.androidContext?.assets ?: return false + val target = wheelhouseDir() + return runCatching { + target.mkdirs() + ZipInputStream(assets.open(WHEELHOUSE_ASSET)).use { zip -> + generateSequence { zip.nextEntry }.forEach { entry -> + val out = File(target, entry.name) + if (!out.canonicalPath.startsWith(target.canonicalPath + File.separator)) { + throw SecurityException("Wheelhouse entry escapes its directory: ${entry.name}") + } + if (!out.exists()) { + val partial = File(target, "${entry.name}.partial") + partial.outputStream().use { zip.copyTo(it) } + if (!partial.renameTo(out)) throw IOException("Could not move ${partial.name} into place") + } + } } - is CommandResult.Cancelled -> Log.i(TAG, "Python installation cancelled") + true + }.getOrElse { + Log.e(TAG, "Failed to extract the bundled wheelhouse", it) + false } } + private fun pluginDir(): File = pluginContext!!.resources.getPluginDirectory() + + private fun wheelhouseDir(): File = File(pluginDir(), "wheelhouse") + + private fun quote(path: String): String = "\"" + path + "\"" + + private fun requirementsInstallScript(): String = + "python -m pip install --disable-pip-version-check --find-links ${quote(wheelhouseDir().absolutePath)} -r requirements.txt" + private suspend fun pythonAvailable(cmd: IdeCommandService): Boolean = try { val result = cmd.executeCommand(shell("python --version"), timeoutMs = 20_000L).await() result is CommandResult.Success && result.exitCode == 0 @@ -407,6 +538,14 @@ class PythonToolsPlugin : IPlugin, BuildActionExtension, DocumentationExtension companion object { private const val TAG = "PythonToolsPlugin" + private const val LANGUAGE_ID = "python" + private const val LANGUAGE_SERVER_COMMAND = "pylsp" + private const val LANGUAGE_SERVER_PACKAGE = "python-lsp-server[pyflakes,yapf]" + private const val PROBE_TIMEOUT_MS = 20_000L + private const val INSTALL_TIMEOUT_MS = 20 * 60_000L + private const val WHEELHOUSE_ASSET = "python/wheelhouse.zip" + private const val BUNDLED_PACKAGES = "flask gunicorn" + private const val QUICK_BUILD_ACTION_ID = "ide.editor.build.quickBuild" internal const val PLUGIN_ID = "com.appdevforall.python.plugin" internal const val ACTION_RUN_APP = "python.run.app"