All Downloads are FREE. Search and download functionalities are using the official Maven repository.

scala.cli.commands.run.Run.scala Maven / Gradle / Ivy

The newest version!
package scala.cli.commands.run

import ai.kien.python.Python
import caseapp.*
import caseapp.core.help.HelpFormat

import java.io.File
import java.util.Locale
import java.util.concurrent.CompletableFuture
import java.util.concurrent.atomic.AtomicReference

import scala.build.EitherCps.{either, value}
import scala.build.*
import scala.build.errors.BuildException
import scala.build.input.{Inputs, ScalaCliInvokeData, SubCommand}
import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig}
import scala.build.internals.ConsoleUtils.ScalaCliConsole
import scala.build.internals.EnvVar
import scala.build.options.{BuildOptions, JavaOpt, PackageType, Platform, ScalacOpt}
import scala.cli.CurrentParams
import scala.cli.commands.package0.Package
import scala.cli.commands.publish.ConfigUtil.*
import scala.cli.commands.run.RunMode
import scala.cli.commands.setupide.SetupIde
import scala.cli.commands.shared.{HelpCommandGroup, HelpGroup, SharedOptions}
import scala.cli.commands.update.Update
import scala.cli.commands.util.{BuildCommandHelpers, RunHadoop, RunSpark}
import scala.cli.commands.{CommandUtils, ScalaCommand, SpecificationLevel, WatchUtil}
import scala.cli.config.{ConfigDb, Keys}
import scala.cli.internal.ProcUtil
import scala.cli.packaging.Library.fullClassPathMaybeAsJar
import scala.cli.util.ArgHelpers.*
import scala.cli.util.ConfigDbUtils
import scala.util.{Properties, Try}

object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers {
  override def group: String                               = HelpCommandGroup.Main.toString
  override def scalaSpecificationLevel: SpecificationLevel = SpecificationLevel.MUST

  val primaryHelpGroups: Seq[HelpGroup] = Seq(HelpGroup.Run, HelpGroup.Entrypoint, HelpGroup.Watch)
  override def helpFormat: HelpFormat   = super.helpFormat.withPrimaryGroups(primaryHelpGroups)
  override def sharedOptions(options: RunOptions): Option[SharedOptions] = Some(options.shared)

  private def runMode(options: RunOptions): RunMode =
    if (
      options.sharedRun.standaloneSpark.getOrElse(false) &&
      !options.sharedRun.sparkSubmit.contains(false)
    )
      RunMode.StandaloneSparkSubmit(options.sharedRun.submitArgument)
    else if (options.sharedRun.sparkSubmit.getOrElse(false))
      RunMode.SparkSubmit(options.sharedRun.submitArgument)
    else if (options.sharedRun.hadoopJar)
      RunMode.HadoopJar
    else
      RunMode.Default

  private def scratchDirOpt(options: RunOptions): Option[os.Path] =
    options.sharedRun.scratchDir
      .filter(_.trim.nonEmpty)
      .map(os.Path(_, os.pwd))

  override def runCommand(options: RunOptions, args: RemainingArgs, logger: Logger): Unit =
    runCommand(
      options,
      args.remaining,
      args.unparsed,
      () => Inputs.default(),
      logger,
      invokeData
    )

  override def buildOptions(options: RunOptions): Some[BuildOptions] = Some {
    import options.*
    import options.sharedRun.*
    val logger      = options.shared.logger
    val baseOptions = shared.buildOptions().orExit(logger)
    baseOptions.copy(
      mainClass = mainClass.mainClass,
      javaOptions = baseOptions.javaOptions.copy(
        javaOpts =
          baseOptions.javaOptions.javaOpts ++
            sharedJava.allJavaOpts.map(JavaOpt(_)).map(Positioned.commandLine),
        jvmIdOpt = baseOptions.javaOptions.jvmIdOpt.orElse {
          runMode(options) match {
            case _: RunMode.Spark | RunMode.HadoopJar =>
              Some(Positioned.none("8"))
            case RunMode.Default => None
          }
        }
      ),
      internal = baseOptions.internal.copy(
        keepResolution = baseOptions.internal.keepResolution || {
          runMode(options) match {
            case _: RunMode.Spark | RunMode.HadoopJar => true
            case RunMode.Default                      => false
          }
        }
      ),
      notForBloopOptions = baseOptions.notForBloopOptions.copy(
        runWithManifest = options.sharedRun.useManifest,
        addRunnerDependencyOpt = baseOptions.notForBloopOptions.addRunnerDependencyOpt.orElse {
          runMode(options) match {
            case _: RunMode.Spark | RunMode.HadoopJar =>
              Some(false)
            case RunMode.Default => None
          }
        }
      )
    )
  }

  def runCommand(
    options0: RunOptions,
    inputArgs: Seq[String],
    programArgs: Seq[String],
    defaultInputs: () => Option[Inputs],
    logger: Logger,
    invokeData: ScalaCliInvokeData
  ): Unit = {
    val shouldDefaultServerFalse =
      inputArgs.isEmpty && options0.shared.compilationServer.server.isEmpty &&
      !options0.shared.hasSnippets
    val options = if (shouldDefaultServerFalse) options0.copy(shared =
      options0.shared.copy(compilationServer =
        options0.shared.compilationServer.copy(server = Some(false))
      )
    )
    else options0
    val initialBuildOptions = {
      val buildOptions = buildOptionsOrExit(options)
      if (invokeData.subCommand == SubCommand.Shebang) {
        val suppressDepUpdateOptions = buildOptions.suppressWarningOptions.copy(
          suppressOutdatedDependencyWarning = Some(true)
        )

        buildOptions.copy(
          suppressWarningOptions = suppressDepUpdateOptions
        )
      }
      else buildOptions
    }

    val inputs = options.shared.inputs(
      inputArgs,
      defaultInputs
    )(
      using invokeData
    ).orExit(logger)
    CurrentParams.workspaceOpt = Some(inputs.workspace)
    val threads = BuildThreads.create()

    val compilerMaker = options.shared.compilerMaker(threads)

    def maybeRun(
      build: Build.Successful,
      allowTerminate: Boolean,
      runMode: RunMode,
      showCommand: Boolean,
      scratchDirOpt: Option[os.Path]
    ): Either[BuildException, Option[(Process, CompletableFuture[_])]] = either {
      val potentialMainClasses = build.foundMainClasses()
      if (options.sharedRun.mainClass.mainClassLs.contains(true))
        value {
          options.sharedRun.mainClass
            .maybePrintMainClasses(potentialMainClasses, shouldExit = allowTerminate)
            .map(_ => None)
        }
      else {
        val processOrCommand = value {
          maybeRunOnce(
            build,
            programArgs,
            logger,
            allowExecve = allowTerminate,
            jvmRunner = build.artifacts.hasJvmRunner,
            potentialMainClasses,
            runMode,
            showCommand,
            scratchDirOpt,
            asJar = options.shared.asJar
          )
        }

        processOrCommand match {
          case Right((process, onExitOpt)) =>
            val onExitProcess = process.onExit().thenApply { p1 =>
              val retCode = p1.exitValue()
              onExitOpt.foreach(_())
              (retCode, allowTerminate) match {
                case (0, true) =>
                case (0, false) =>
                  val gray  = ScalaCliConsole.GRAY
                  val reset = Console.RESET
                  System.err.println(s"${gray}Program exited with return code $retCode.$reset")
                case (_, true) =>
                  sys.exit(retCode)
                case (_, false) =>
                  val red      = Console.RED
                  val lightRed = "\u001b[91m"
                  val reset    = Console.RESET
                  System.err.println(
                    s"${red}Program exited with return code $lightRed$retCode$red.$reset"
                  )
              }
            }

            Some((process, onExitProcess))

          case Left(command) =>
            for (arg <- command)
              println(arg)
            None
        }
      }
    }

    val cross = options.sharedRun.compileCross.cross.getOrElse(false)
    SetupIde.runSafe(
      options.shared,
      inputs,
      logger,
      initialBuildOptions,
      Some(name),
      inputArgs
    )
    if CommandUtils.shouldCheckUpdate then Update.checkUpdateSafe(logger)

    val configDb = ConfigDbUtils.configDb.orExit(logger)
    val actionableDiagnostics =
      options.shared.logging.verbosityOptions.actions.orElse(
        configDb.get(Keys.actions).getOrElse(None)
      )

    if options.sharedRun.watch.watchMode then {

      /** A handle to the Runner process, used to kill the process if it's still alive when a change
        * occured and restarts are allowed or to wait for it if restarts are not allowed
        */
      val processOpt = AtomicReference(Option.empty[(Process, CompletableFuture[_])])

      /** shouldReadInput controls whether [[WatchUtil.waitForCtrlC]](that's keeping the main thread
        * alive) should try to read StdIn or just call wait()
        */
      val shouldReadInput = AtomicReference(false)

      /** A handle to the main thread to interrupt its operations when:
        *   - it's blocked on reading StdIn, and it's no longer required
        *   - it's waiting and should start reading StdIn
        */
      val mainThreadOpt = AtomicReference(Option.empty[Thread])

      val watcher = Build.watch(
        inputs = inputs,
        options = initialBuildOptions,
        compilerMaker = compilerMaker,
        docCompilerMakerOpt = None,
        logger = logger,
        crossBuilds = cross,
        buildTests = false,
        partial = None,
        actionableDiagnostics = actionableDiagnostics,
        postAction = () =>
          if processOpt.get().exists(_._1.isAlive()) then WatchUtil.printWatchWhileRunningMessage()
          else WatchUtil.printWatchMessage()
      ) { res =>
        for ((process, onExitProcess) <- processOpt.get()) {
          onExitProcess.cancel(true)
          ProcUtil.interruptProcess(process, logger)
        }
        res.orReport(logger).map(_.main).foreach {
          case s: Build.Successful =>
            for ((proc, _) <- processOpt.get() if proc.isAlive)
              // If the process doesn't exit, send SIGKILL
              ProcUtil.forceKillProcess(proc, logger)
            shouldReadInput.set(false)
            mainThreadOpt.get().foreach(_.interrupt())
            val maybeProcess = maybeRun(
              s,
              allowTerminate = false,
              runMode = runMode(options),
              showCommand = options.sharedRun.command,
              scratchDirOpt = scratchDirOpt(options)
            )
              .orReport(logger)
              .flatten
              .map {
                case (proc, onExit) =>
                  if (options.sharedRun.watch.restart)
                    onExit.thenApply { _ =>
                      shouldReadInput.set(true)
                      mainThreadOpt.get().foreach(_.interrupt())
                    }
                  (proc, onExit)
              }
            s.copyOutput(options.shared)
            if options.sharedRun.watch.restart then processOpt.set(maybeProcess)
            else {
              for ((proc, onExit) <- maybeProcess)
                ProcUtil.waitForProcess(proc, onExit)
              shouldReadInput.set(true)
              mainThreadOpt.get().foreach(_.interrupt())
            }
          case _: Build.Failed =>
            System.err.println("Compilation failed")
        }
      }
      mainThreadOpt.set(Some(Thread.currentThread()))

      try
        WatchUtil.waitForCtrlC(
          { () =>
            watcher.schedule()
            shouldReadInput.set(false)
          },
          () => shouldReadInput.get()
        )
      finally {
        mainThreadOpt.set(None)
        watcher.dispose()
      }
    }
    else {
      val builds =
        Build.build(
          inputs,
          initialBuildOptions,
          compilerMaker,
          None,
          logger,
          crossBuilds = cross,
          buildTests = false,
          partial = None,
          actionableDiagnostics = actionableDiagnostics
        )
          .orExit(logger)
      builds.main match {
        case s: Build.Successful =>
          s.copyOutput(options.shared)
          val res = maybeRun(
            s,
            allowTerminate = true,
            runMode = runMode(options),
            showCommand = options.sharedRun.command,
            scratchDirOpt = scratchDirOpt(options)
          )
            .orExit(logger)
          for ((process, onExit) <- res)
            ProcUtil.waitForProcess(process, onExit)
        case _: Build.Failed =>
          System.err.println("Compilation failed")
          sys.exit(1)
      }
    }
  }

  private def maybeRunOnce(
    build: Build.Successful,
    args: Seq[String],
    logger: Logger,
    allowExecve: Boolean,
    jvmRunner: Boolean,
    potentialMainClasses: Seq[String],
    runMode: RunMode,
    showCommand: Boolean,
    scratchDirOpt: Option[os.Path],
    asJar: Boolean
  ): Either[BuildException, Either[Seq[String], (Process, Option[() => Unit])]] = either {

    val mainClassOpt = build.options.mainClass.filter(_.nonEmpty) // trim it too?
      .orElse {
        if build.options.jmhOptions.enableJmh.contains(true) && !build.options.jmhOptions.canRunJmh
        then Some("org.openjdk.jmh.Main")
        else None
      }
    val mainClass = mainClassOpt match {
      case Some(cls) => cls
      case None      => value(build.retainedMainClass(logger, mainClasses = potentialMainClasses))
    }
    val verbosity = build.options.internal.verbosity.getOrElse(0).toString

    val (finalMainClass, finalArgs) =
      if (jvmRunner) (Constants.runnerMainClass, mainClass +: verbosity +: args)
      else (mainClass, args)
    val res = runOnce(
      build,
      finalMainClass,
      finalArgs,
      logger,
      allowExecve,
      runMode,
      showCommand,
      scratchDirOpt,
      asJar
    )
    value(res)
  }

  def pythonPathEnv(dirs: os.Path*): Map[String, String] = {
    val onlySafePaths = sys.env.exists {
      case (k, v) =>
        k.toLowerCase(Locale.ROOT) == "pythonsafepath" && v.nonEmpty
    }
    // Don't add unsafe directories to PYTHONPATH if PYTHONSAFEPATH is set,
    // see https://docs.python.org/3/using/cmdline.html#envvar-PYTHONSAFEPATH
    // and https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1336017760
    // for more details.
    if (onlySafePaths) Map.empty[String, String]
    else {
      val (pythonPathEnvVarName, currentPythonPath) = sys.env
        .find(_._1.toLowerCase(Locale.ROOT) == "pythonpath")
        .getOrElse(("PYTHONPATH", ""))
      val updatedPythonPath = (currentPythonPath +: dirs.map(_.toString))
        .filter(_.nonEmpty)
        .mkString(File.pathSeparator)
      Map(pythonPathEnvVarName -> updatedPythonPath)
    }
  }

  private def runOnce(
    build: Build.Successful,
    mainClass: String,
    args: Seq[String],
    logger: Logger,
    allowExecve: Boolean,
    runMode: RunMode,
    showCommand: Boolean,
    scratchDirOpt: Option[os.Path],
    asJar: Boolean
  ): Either[BuildException, Either[Seq[String], (Process, Option[() => Unit])]] = either {

    build.options.platform.value match {
      case Platform.JS =>
        val esModule =
          build.options.scalaJsOptions.moduleKindStr.exists(m => m == "es" || m == "esmodule")

        val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger)
        val jsDest = {
          val delete = scratchDirOpt.isEmpty
          scratchDirOpt.foreach(os.makeDir.all(_))
          os.temp(
            dir = scratchDirOpt.orNull,
            prefix = "main",
            suffix = if (esModule) ".mjs" else ".js",
            deleteOnExit = delete
          )
        }
        val res =
          Package.linkJs(
            build,
            jsDest,
            Some(mainClass),
            addTestInitializer = false,
            linkerConfig,
            value(build.options.scalaJsOptions.fullOpt),
            build.options.scalaJsOptions.noOpt.getOrElse(false),
            logger,
            scratchDirOpt
          ).map { outputPath =>
            val jsDom = build.options.scalaJsOptions.dom.getOrElse(false)
            if (showCommand)
              Left(Runner.jsCommand(outputPath.toIO, args, jsDom = jsDom))
            else {
              val process = value {
                Runner.runJs(
                  outputPath.toIO,
                  args,
                  logger,
                  allowExecve = allowExecve,
                  jsDom = jsDom,
                  sourceMap = build.options.scalaJsOptions.emitSourceMaps,
                  esModule = esModule
                )
              }
              process.onExit().thenApply(_ => if (os.exists(jsDest)) os.remove(jsDest))
              Right((process, None))
            }
          }
        value(res)
      case Platform.Native =>
        val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false)
        val (pythonExecutable, pythonLibraryPaths, pythonExtraEnv) =
          if (setupPython) {
            val (exec, libPaths) = value {
              val python = Python()
              val pythonPropertiesOrError = for {
                paths      <- python.nativeLibraryPaths
                executable <- python.executable
              } yield (Some(executable), paths)
              logger.debug(s"Python executable and native library paths: $pythonPropertiesOrError")
              pythonPropertiesOrError.orPythonDetectionError
            }
            // Putting the workspace in PYTHONPATH, see
            // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174
            // for context.
            (exec, libPaths, pythonPathEnv(build.inputs.workspace))
          }
          else
            (None, Nil, Map())
        // seems conda doesn't add the lib directory to LD_LIBRARY_PATH (see conda/conda#308),
        // which prevents apps from finding libpython for example, so we update it manually here
        val libraryPathsEnv =
          if (pythonLibraryPaths.isEmpty) Map.empty
          else {
            val prependTo =
              if (Properties.isWin) EnvVar.Misc.path.name
              else if (Properties.isMac) EnvVar.Misc.dyldLibraryPath.name
              else EnvVar.Misc.ldLibraryPath.name
            val currentOpt = Option(System.getenv(prependTo))
            val currentEntries = currentOpt
              .map(_.split(File.pathSeparator).toSet)
              .getOrElse(Set.empty)
            val additionalEntries = pythonLibraryPaths.filter(!currentEntries.contains(_))
            if (additionalEntries.isEmpty)
              Map.empty
            else {
              val newValue =
                (additionalEntries.iterator ++ currentOpt.iterator).mkString(File.pathSeparator)
              Map(prependTo -> newValue)
            }
          }
        val programNameEnv =
          pythonExecutable.fold(Map.empty)(py => Map("SCALAPY_PYTHON_PROGRAMNAME" -> py))
        val extraEnv = libraryPathsEnv ++ programNameEnv ++ pythonExtraEnv
        val maybeResult = withNativeLauncher(
          build,
          mainClass,
          logger
        ) { launcher =>
          if (showCommand)
            Left(
              extraEnv.toVector.sorted.map { case (k, v) => s"$k=$v" } ++
                Seq(launcher.toString) ++
                args
            )
          else {
            val proc = Runner.runNative(
              launcher.toIO,
              args,
              logger,
              allowExecve = allowExecve,
              extraEnv = extraEnv
            )
            Right((proc, None))
          }
        }
        value(maybeResult)
      case Platform.JVM =>
        runMode match {
          case RunMode.Default =>
            val baseJavaProps = build.options.javaOptions.javaOpts.toSeq.map(_.value.value)
            val setupPython   = build.options.notForBloopOptions.doSetupPython.getOrElse(false)
            val (pythonJavaProps, pythonExtraEnv) =
              if (setupPython) {
                val scalapyProps = value {
                  val python       = Python()
                  val propsOrError = python.scalapyProperties
                  logger.debug(s"Python Java properties: $propsOrError")
                  propsOrError.orPythonDetectionError
                }
                val props = scalapyProps.toVector.sorted.map {
                  case (k, v) => s"-D$k=$v"
                }
                // Putting the workspace in PYTHONPATH, see
                // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174
                // for context.
                (props, pythonPathEnv(build.inputs.workspace))
              }
              else
                (Nil, Map.empty[String, String])
            val allJavaOpts = pythonJavaProps ++ baseJavaProps
            if (showCommand) {
              val command = Runner.jvmCommand(
                build.options.javaHome().value.javaCommand,
                allJavaOpts,
                build.fullClassPathMaybeAsJar(asJar),
                mainClass,
                args,
                extraEnv = pythonExtraEnv,
                useManifest = build.options.notForBloopOptions.runWithManifest,
                scratchDirOpt = scratchDirOpt
              )
              Left(command)
            }
            else {
              val proc = Runner.runJvm(
                build.options.javaHome().value.javaCommand,
                allJavaOpts,
                build.fullClassPathMaybeAsJar(asJar),
                mainClass,
                args,
                logger,
                allowExecve = allowExecve,
                extraEnv = pythonExtraEnv,
                useManifest = build.options.notForBloopOptions.runWithManifest,
                scratchDirOpt = scratchDirOpt
              )
              Right((proc, None))
            }
          case mode: RunMode.SparkSubmit =>
            value {
              RunSpark.run(
                build,
                mainClass,
                args,
                mode.submitArgs,
                logger,
                allowExecve,
                showCommand,
                scratchDirOpt
              )
            }
          case mode: RunMode.StandaloneSparkSubmit =>
            value {
              RunSpark.runStandalone(
                build,
                mainClass,
                args,
                mode.submitArgs,
                logger,
                allowExecve,
                showCommand,
                scratchDirOpt
              )
            }
          case RunMode.HadoopJar =>
            value {
              RunHadoop.run(
                build,
                mainClass,
                args,
                logger,
                allowExecve,
                showCommand,
                scratchDirOpt
              )
            }
        }
    }
  }

  def withLinkedJs[T](
    build: Build.Successful,
    mainClassOpt: Option[String],
    addTestInitializer: Boolean,
    config: ScalaJsLinkerConfig,
    fullOpt: Boolean,
    noOpt: Boolean,
    logger: Logger,
    esModule: Boolean
  )(f: os.Path => T): Either[BuildException, T] = {
    val dest = os.temp(prefix = "main", suffix = if (esModule) ".mjs" else ".js")
    try Package.linkJs(
        build,
        dest,
        mainClassOpt,
        addTestInitializer,
        config,
        fullOpt,
        noOpt,
        logger
      ).map { outputPath =>
        f(outputPath)
      }
    finally if (os.exists(dest)) os.remove(dest)
  }

  def withNativeLauncher[T](
    build: Build.Successful,
    mainClass: String,
    logger: Logger
  )(f: os.Path => T): Either[BuildException, T] =
    Package.buildNative(
      build = build,
      mainClass = Some(mainClass),
      targetType = PackageType.Native.Application,
      destPath = None,
      logger = logger
    ).map(f)

  final class PythonDetectionError(cause: Throwable) extends BuildException(
        s"Error detecting Python environment: ${cause.getMessage}",
        cause = cause
      )

  extension [T](t: Try[T])
    def orPythonDetectionError: Either[PythonDetectionError, T] =
      t.toEither.left.map(new PythonDetectionError(_))
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy