Skip to content

Commit 59964cf

Browse files
Put workspace in PYTHONPATH in 'scala-cli run --python'
1 parent 3d399b5 commit 59964cf

File tree

2 files changed

+98
-8
lines changed

2 files changed

+98
-8
lines changed

modules/cli/src/main/scala/scala/cli/commands/run/Run.scala

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ai.kien.python.Python
44
import caseapp.*
55

66
import java.io.File
7+
import java.util.Locale
78
import java.util.concurrent.CompletableFuture
89

910
import scala.build.EitherCps.{either, value}
@@ -338,6 +339,27 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
338339
value(res)
339340
}
340341

342+
def pythonPathEnv(dirs: os.Path*): Map[String, String] = {
343+
val onlySafePaths = sys.env.exists {
344+
case (k, v) =>
345+
k.toLowerCase(Locale.ROOT) == "pythonsafepath" && v.nonEmpty
346+
}
347+
// Don't add unsafe directories to PYTHONPATH if PYTHONSAFEPATH is set,
348+
// see https://docs.python.org/3/using/cmdline.html#envvar-PYTHONSAFEPATH
349+
// and https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1336017760
350+
// for more details.
351+
if (onlySafePaths) Map.empty[String, String]
352+
else {
353+
val (pythonPathEnvVarName, currentPythonPath) = sys.env
354+
.find(_._1.toLowerCase(Locale.ROOT) == "pythonpath")
355+
.getOrElse(("PYTHONPATH", ""))
356+
val updatedPythonPath = (currentPythonPath +: dirs.map(_.toString))
357+
.filter(_.nonEmpty)
358+
.mkString(File.pathSeparator)
359+
Map(pythonPathEnvVarName -> updatedPythonPath)
360+
}
361+
}
362+
341363
private def runOnce(
342364
build: Build.Successful,
343365
mainClass: String,
@@ -397,9 +419,9 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
397419
value(res)
398420
case Platform.Native =>
399421
val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false)
400-
val (pythonExecutable, pythonLibraryPaths) =
401-
if (setupPython)
402-
value {
422+
val (pythonExecutable, pythonLibraryPaths, pythonExtraEnv) =
423+
if (setupPython) {
424+
val (exec, libPaths) = value {
403425
val python = Python()
404426
val pythonPropertiesOrError = for {
405427
paths <- python.nativeLibraryPaths
@@ -408,8 +430,13 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
408430
logger.debug(s"Python executable and native library paths: $pythonPropertiesOrError")
409431
pythonPropertiesOrError.orPythonDetectionError
410432
}
433+
// Putting the workspace in PYTHONPATH, see
434+
// https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174
435+
// for context.
436+
(exec, libPaths, pythonPathEnv(build.inputs.workspace))
437+
}
411438
else
412-
(None, Nil)
439+
(None, Nil, Map())
413440
// seems conda doesn't add the lib directory to LD_LIBRARY_PATH (see conda/conda#308),
414441
// which prevents apps from finding libpython for example, so we update it manually here
415442
val libraryPathsEnv =
@@ -434,7 +461,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
434461
}
435462
val programNameEnv =
436463
pythonExecutable.fold(Map.empty)(py => Map("SCALAPY_PYTHON_PROGRAMNAME" -> py))
437-
val extraEnv = libraryPathsEnv ++ programNameEnv
464+
val extraEnv = libraryPathsEnv ++ programNameEnv ++ pythonExtraEnv
438465
val maybeResult = withNativeLauncher(
439466
build,
440467
mainClass,
@@ -463,20 +490,24 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
463490
case RunMode.Default =>
464491
val baseJavaProps = build.options.javaOptions.javaOpts.toSeq.map(_.value.value)
465492
val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false)
466-
val pythonJavaProps =
493+
val (pythonJavaProps, pythonExtraEnv) =
467494
if (setupPython) {
468495
val scalapyProps = value {
469496
val python = Python()
470497
val propsOrError = python.scalapyProperties
471498
logger.debug(s"Python Java properties: $propsOrError")
472499
propsOrError.orPythonDetectionError
473500
}
474-
scalapyProps.toVector.sorted.map {
501+
val props = scalapyProps.toVector.sorted.map {
475502
case (k, v) => s"-D$k=$v"
476503
}
504+
// Putting the workspace in PYTHONPATH, see
505+
// https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174
506+
// for context.
507+
(props, pythonPathEnv(build.inputs.workspace))
477508
}
478509
else
479-
Nil
510+
(Nil, Map.empty[String, String])
480511
val allJavaOpts = pythonJavaProps ++ baseJavaProps
481512
if (showCommand) {
482513
val command = Runner.jvmCommand(
@@ -485,6 +516,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
485516
build.fullClassPath,
486517
mainClass,
487518
args,
519+
extraEnv = pythonExtraEnv,
488520
useManifest = build.options.notForBloopOptions.runWithManifest,
489521
scratchDirOpt = scratchDirOpt
490522
)
@@ -499,6 +531,7 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
499531
args,
500532
logger,
501533
allowExecve = allowExecve,
534+
extraEnv = pythonExtraEnv,
502535
useManifest = build.options.notForBloopOptions.runWithManifest,
503536
scratchDirOpt = scratchDirOpt
504537
)

modules/integration/src/test/scala/scala/cli/integration/RunScalaPyTestDefinitions.scala

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,61 @@ trait RunScalaPyTestDefinitions { _: RunTestDefinitions =>
7878
test("scalapy native") {
7979
scalapyNativeTest()
8080
}
81+
82+
def pythonAndScalaSourcesTest(native: Boolean): Unit = {
83+
val tq = "\"\"\""
84+
val inputs = TestInputs(
85+
os.rel / "src" / "helpers.py" ->
86+
s"""class Helper:
87+
| ${tq}Helper class$tq
88+
|
89+
| def message(self):
90+
| return 'Hello from Python'
91+
|""".stripMargin,
92+
os.rel / "src" / "Hello.scala" ->
93+
s"""//> using python
94+
|$maybeScalapyPrefix
95+
|object Hello {
96+
| def main(args: Array[String]): Unit =
97+
| py.local {
98+
| val helpers = py.module("helpers")
99+
| println(helpers.Helper().message())
100+
| }
101+
|}
102+
|""".stripMargin
103+
)
104+
val nativeOpt = if (native) Seq("--native") else Nil
105+
inputs.fromRoot { root =>
106+
107+
// Script dir shouldn't be added to PYTHONPATH if PYTHONSAFEPATH is non-empty
108+
val errorRes = os.proc(TestUtil.cli, "run", extraOptions, nativeOpt, "src")
109+
.call(
110+
cwd = root,
111+
env = Map("PYTHONSAFEPATH" -> "foo"),
112+
mergeErrIntoOut = true,
113+
check = false
114+
)
115+
expect(errorRes.exitCode != 0)
116+
val errorOutput = errorRes.out.text()
117+
expect(errorOutput.contains("No module named 'helpers'"))
118+
119+
val res = os.proc(TestUtil.cli, "run", extraOptions, nativeOpt, "src")
120+
.call(cwd = root)
121+
val output = res.out.trim()
122+
if (native)
123+
expect(output.linesIterator.toVector.endsWith(Seq("Hello from Python")))
124+
else
125+
expect(output == "Hello from Python")
126+
}
127+
}
128+
129+
test("Python and Scala sources") {
130+
pythonAndScalaSourcesTest(native = false)
131+
}
132+
// disabled on Windows for now, for context, see
133+
// https://github.com/VirtusLab/scala-cli/pull/1270#issuecomment-1237904394
134+
if (!Properties.isWin)
135+
test("Python and Scala sources (native)") {
136+
pythonAndScalaSourcesTest(native = true)
137+
}
81138
}

0 commit comments

Comments
 (0)