Skip to content

Commit d2418f0

Browse files
committed
Dev agent: a debug-only channel from a notebook or hot reload into the running interpreter (#233)
When the launcher sets PYTHON_MULTIPLATFORM_DEV_AGENT (port, token, optional overlay), Python3.initialize() loads the embedded stdlib-only agent and it listens on 127.0.0.1. After a hello with the token: execute in __main__ with streamed output, the last value and errors; complete; push .py files into an overlay first on sys.path and re-import the loaded ones through typedpython.importgate when present, keeping the old module on failure. The Python file is the source of truth; generateDevAgentSource embeds it as a Kotlin constant. SPEC L-12.
1 parent 8acb347 commit d2418f0

8 files changed

Lines changed: 866 additions & 1 deletion

File tree

‎.github/workflows/desktop.yml‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ jobs:
4242
restore-keys: |
4343
${{ runner.os }}-konan-
4444
45+
# The dev agent's protocol (SPEC L-12, #233): stdlib-only, so the runner's own python3 runs it.
46+
- name: Dev agent protocol tests
47+
working-directory: python-multiplatform/src/test/python
48+
run: python3 -m unittest -v test_devagent
49+
4550
- name: Run Desktop Tests
4651
# Using --console=plain so logs are readable in GitHub Actions
4752
run: ./gradlew :python-multiplatform:desktopTest --console=plain

‎docs/SPEC.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,20 @@ fail, then implement.
135135
source directories, file selection, a staged fake tree); `PM/iosSimulatorArm64Test/.../env/IosPythonHomeTest.kt`
136136
(resolution order; the simulator test task's `PYTHONHOME` still wins). The installed-app run on the
137137
simulator is a manual check, `docs/platforms/ios-app-bundle.md`.
138+
- **L-12** The dev agent (#233; toolchain `docs/design/live-connection.md`, option B): when the launcher sets
139+
`PYTHON_MULTIPLATFORM_DEV_AGENT` to `port=<n>;token=<t>[;overlay=<dir>]` (a debug launch; a release build
140+
never does), `Python3.initialize()` loads the embedded agent (`src/commonMain/python/
141+
python_multiplatform_devagent.py`, generated into Kotlin by `generateDevAgentSource`) as the module
142+
`python_multiplatform_devagent` and it listens on 127.0.0.1 only. A connection's first message must be
143+
`hello` with the token. Then, one JSON object per line: `execute` runs code in `__main__`'s persistent
144+
namespace, streams stdout and stderr, returns the last expression's value and reports errors with their
145+
traceback; `complete` completes from that namespace; `push` writes `.py` files into a writable overlay first
146+
on `sys.path` and re-imports the loaded ones, children first, through `typedpython.importgate` when the app
147+
has it (N-7b), keeping the old module when a re-import is refused or fails. A malformed request is printed
148+
and does not stop the app. `Status: partial`: desktop
149+
(`PM/desktopTest/.../env/PythonDevAgentTest.kt`: the embedded agent answers over loopback, and a child JVM started with the variable has an agent on that port while one started without it has none; the protocol, `python-multiplatform/src/test/python/
150+
test_devagent.py`, CI `Dev agent protocol tests`); nothing sets the variable on Android or iOS yet
151+
(toolchain's M2, M3); no interrupt; `execute` runs on the agent's thread (no main-thread dispatch yet).
138152

139153
## 2. Low-level C API (downcall surface)
140154

‎python-multiplatform/build.gradle.kts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,41 @@ val generatePythonProperties by tasks.registering {
719719
}
720720
}
721721

722+
/**
723+
* The dev agent (SPEC L-12, #233) is Python, kept as a real file -- `src/commonMain/python/
724+
* python_multiplatform_devagent.py`, which `src/test/python/test_devagent.py` tests with plain CPython -- and
725+
* embedded here as a Kotlin string constant, the way the runtime's other helper sources are, so every platform
726+
* carries it with no resource or packaging step.
727+
*/
728+
val generateDevAgentSource by tasks.registering {
729+
val source = layout.projectDirectory.file("src/commonMain/python/python_multiplatform_devagent.py")
730+
val outputDir = layout.buildDirectory.dir("generated/devagent/kotlin")
731+
inputs.file(source)
732+
outputs.dir(outputDir)
733+
doLast {
734+
val escaped = buildString {
735+
source.asFile.readText().forEach { c ->
736+
when (c) {
737+
'\\' -> append("\\\\")
738+
'"' -> append("\\\"")
739+
'$' -> append("\\$")
740+
'\n' -> append("\\n")
741+
'\r' -> {}
742+
else -> append(c)
743+
}
744+
}
745+
}
746+
val file = outputDir.get().asFile.resolve("python/multiplatform/env/DevAgentSource.kt")
747+
file.parentFile.mkdirs()
748+
file.writeText(
749+
"// Generated by :python-multiplatform:generateDevAgentSource from\n" +
750+
"// src/commonMain/python/python_multiplatform_devagent.py. Do not edit.\n" +
751+
"package python.multiplatform.env\n\n" +
752+
"internal const val DEV_AGENT_SOURCE: String = \"" + escaped + "\"\n",
753+
)
754+
}
755+
}
756+
722757
val androidBuildDir = "$projectDir/build/android"
723758

724759
// =================================================================================================
@@ -1303,7 +1338,9 @@ kotlin {
13031338

13041339
sourceSets {
13051340
val jvmMain by creating
1306-
val commonMain by getting
1341+
val commonMain by getting {
1342+
kotlin.srcDir(generateDevAgentSource)
1343+
}
13071344
val commonTest by getting {
13081345
dependencies {
13091346
implementation(libs.kotlin.test)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package python.multiplatform.env
2+
3+
import python.multiplatform.ffi.Python3
4+
import python.native.ffi.PyImport_ExecCodeModule
5+
import python.native.ffi.Py_CompileString
6+
import python.native.ffi.Py_DecRef
7+
import python.multiplatform.ffi.pyErrorOrGeneric
8+
9+
/**
10+
* The dev agent (SPEC L-12, #233): a debug-only channel from a notebook or a hot reload into this
11+
* interpreter, designed in toolchain's `docs/design/live-connection.md` (option B).
12+
*
13+
* [Python3.initialize] calls [startIfRequested]. It does nothing unless the launcher set
14+
* [ENVIRONMENT_VARIABLE] (`port=<n>;token=<t>[;overlay=<dir>]`), which a debug launch does and a release
15+
* build never does; then the agent's Python ([DEV_AGENT_SOURCE], embedded from
16+
* `src/commonMain/python/python_multiplatform_devagent.py`) is loaded as the module [MODULE] and listens on
17+
* 127.0.0.1 with that token. A malformed request is reported on stderr and does not stop the app.
18+
*/
19+
internal object PythonDevAgent {
20+
const val ENVIRONMENT_VARIABLE = "PYTHON_MULTIPLATFORM_DEV_AGENT"
21+
const val MODULE = "python_multiplatform_devagent"
22+
23+
private const val PY_FILE_INPUT: Int = 257
24+
private var loaded = false
25+
private var requested = false
26+
27+
fun startIfRequested() {
28+
if (requested || readEnvVar(ENVIRONMENT_VARIABLE).isNullOrBlank()) return
29+
requested = true
30+
try {
31+
load()
32+
Python3.exec("__import__(\"$MODULE\").start_from_env()")
33+
} catch (e: Exception) {
34+
println("WARNING: python-multiplatform dev agent not started: ${e.message}")
35+
}
36+
}
37+
38+
/** Loads [DEV_AGENT_SOURCE] as the module [MODULE] (once). */
39+
fun load() {
40+
if (loaded) return
41+
Python3.withPython {
42+
val code = Py_CompileString(DEV_AGENT_SOURCE, "<python-multiplatform:devagent>", PY_FILE_INPUT)
43+
?: throw pyErrorOrGeneric("Failed to compile the dev agent")
44+
try {
45+
val module = PyImport_ExecCodeModule(MODULE, code)
46+
?: throw pyErrorOrGeneric("Failed to load the dev agent")
47+
Py_DecRef(module)
48+
} finally {
49+
Py_DecRef(code)
50+
}
51+
}
52+
loaded = true
53+
}
54+
}

‎python-multiplatform/src/commonMain/kotlin/python/multiplatform/ffi/Python3.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package python.multiplatform.ffi
22

33
import python.multiplatform.BuildConfig
44
import python.multiplatform.env.PythonHomeCheck
5+
import python.multiplatform.env.PythonDevAgent
56
import python.multiplatform.env.PythonPayload
67
import python.multiplatform.env.applyPackagedPythonHome
78
import python.multiplatform.ffi.exceptions.PyException
@@ -51,6 +52,7 @@ object Python3 {
5152
// does its work at most once per process, so arriving here repeatedly is free.
5253
PythonPayload.installStagedRootsOnStartup()
5354
installBuiltinCompiledOnce()
55+
PythonDevAgent.startIfRequested()
5456
return
5557
}
5658
// Runs once per process, before the call whose own failure mode is either an uncatchable
@@ -94,6 +96,9 @@ object Python3 {
9496
// is a separate mechanism entirely -- see PythonPayload for the whole reasoning, and for
9597
// the `autoInstall` switch that turns this off.
9698
PythonPayload.installStagedRootsOnStartup()
99+
// The dev agent (SPEC L-12): only when a debug launch asked for it, after the payload is on
100+
// sys.path so its overlay can go in front of it.
101+
PythonDevAgent.startIfRequested()
97102
}
98103

99104
private var builtinCompiledInstalled = false

0 commit comments

Comments
 (0)