Add Support for Project Templates (#1902)

Add an ability to create a project from a template
This commit is contained in:
Dmitry Bushev 2021-07-27 19:13:14 +03:00 committed by GitHub
parent bc96f0e05c
commit 8336a97931
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 340 additions and 47 deletions

View File

@ -5,6 +5,11 @@
- Added support for documenting modules directly
([#1900](https://github.com/enso-org/enso/pull/1900)).
## Tooling
- Added support for creating projects from a template
([#1902](https://github.com/enso-org/enso/pull/1902)).
# Enso 0.2.16 (2021-07-23)
## Interpreter/Runtime

View File

@ -336,6 +336,9 @@ interface ProjectCreateRequest {
/** Name of the project to create. */
name: String;
/** The name of the project template to create. */
projectTemplate?: String;
/**
* Enso Engine version to use for the project.
*

View File

@ -72,6 +72,7 @@ case class Launcher(cliOptions: GlobalCLIOptions) {
*/
def newProject(
name: String,
projectTemplate: Option[String],
path: Option[Path],
versionOverride: Option[SemVer],
useSystemJVM: Boolean,
@ -90,6 +91,7 @@ case class Launcher(cliOptions: GlobalCLIOptions) {
path = actualPath,
name = name,
engineVersion = version,
projectTemplate = projectTemplate,
authorName = globalConfig.authorName,
authorEmail = globalConfig.authorEmail,
additionalArguments = additionalArguments

View File

@ -57,11 +57,19 @@ object LauncherApplication {
"PATH specifies where to create the project. If it is not specified, " +
"a directory called PROJECT-NAME is created in the current directory."
)
def projectTemplate = {
Opts.optionalParameter[String](
"new-project-template",
"TEMPLATE-NAME",
"Specifies a project template when creating a project."
)
}
val additionalArgs = Opts.additionalArguments()
(
nameOpt,
pathOpt,
projectTemplate,
versionOverride,
systemJVMOverride,
jvmOpts,
@ -70,6 +78,7 @@ object LauncherApplication {
(
name,
path,
template,
versionOverride,
systemJVMOverride,
jvmOpts,
@ -78,6 +87,7 @@ object LauncherApplication {
Launcher(config).newProject(
name = name,
path = path,
projectTemplate = template,
versionOverride = versionOverride,
useSystemJVM = systemJVMOverride,
jvmOpts = jvmOpts,

View File

@ -124,6 +124,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest {
path = projectPath,
name = "ProjectName",
engineVersion = defaultEngineVersion,
projectTemplate = None,
authorName = Some(authorName),
authorEmail = Some(authorEmail),
additionalArguments = Seq(additionalArgument)
@ -151,6 +152,7 @@ class LauncherRunnerSpec extends RuntimeVersionManagerTest {
path = projectPath,
name = "ProjectName2",
engineVersion = nightlyVersion,
projectTemplate = None,
authorName = None,
authorEmail = None,
additionalArguments = Seq()

View File

@ -8,13 +8,13 @@ import org.enso.editions.DefaultEdition
import org.enso.languageserver.boot
import org.enso.languageserver.boot.LanguageServerConfig
import org.enso.loggingservice.LogLevel
import org.enso.pkg.{Contact, PackageManager}
import org.enso.pkg.{Contact, PackageManager, Template}
import org.enso.polyglot.{LanguageInfo, Module, PolyglotContext}
import org.enso.version.VersionDescription
import org.graalvm.polyglot.PolyglotException
import java.io.File
import java.util.UUID
import scala.Console.err
import scala.jdk.CollectionConverters._
import scala.util.Try
@ -26,6 +26,7 @@ object Main {
private val HELP_OPTION = "help"
private val NEW_OPTION = "new"
private val PROJECT_NAME_OPTION = "new-project-name"
private val PROJECT_TEMPLATE_OPTION = "new-project-template"
private val PROJECT_AUTHOR_NAME_OPTION = "new-project-author-name"
private val PROJECT_AUTHOR_EMAIL_OPTION = "new-project-author-email"
private val REPL_OPTION = "repl"
@ -87,6 +88,15 @@ object Main {
s"Specifies a project name when creating a project using --$NEW_OPTION."
)
.build
val newProjectTemplateOpt = CliOption.builder
.hasArg(true)
.numberOfArgs(1)
.argName("name")
.longOpt(PROJECT_TEMPLATE_OPTION)
.desc(
s"Specifies a project template when creating a project using --$NEW_OPTION."
)
.build
val newProjectAuthorNameOpt = CliOption.builder
.hasArg(true)
.numberOfArgs(1)
@ -202,6 +212,7 @@ object Main {
.addOption(docs)
.addOption(newOpt)
.addOption(newProjectNameOpt)
.addOption(newProjectTemplateOpt)
.addOption(newProjectAuthorNameOpt)
.addOption(newProjectAuthorEmailOpt)
.addOption(lsOption)
@ -248,12 +259,14 @@ object Main {
*
* @param path root path of the newly created project
* @param nameOption specifies the name of the created project
* @param templateOption specifies the template of the created project
* @param authorName if set, sets the name of the author and maintainer
* @param authorEmail if set, sets the email of the author and maintainer
*/
private def createNew(
path: String,
nameOption: Option[String],
templateOption: Option[String],
authorName: Option[String],
authorEmail: Option[String]
): Unit = {
@ -270,12 +283,22 @@ object Main {
s"Creating a new project $name based on edition [$baseEdition]."
)
}
val template = templateOption
.map { name =>
Template.fromString(name).getOrElse {
logger.error(s"Unknown project template name: '$name'.")
exitFail()
}
}
PackageManager.Default.create(
root = root,
name = name,
edition = Some(edition),
authors = authors,
maintainers = authors
maintainers = authors,
template = template.getOrElse(Template.Default)
)
exitSuccess()
}
@ -629,10 +652,11 @@ object Main {
if (line.hasOption(NEW_OPTION)) {
createNew(
path = line.getOptionValue(NEW_OPTION),
nameOption = Option(line.getOptionValue(PROJECT_NAME_OPTION)),
authorName = Option(line.getOptionValue(PROJECT_AUTHOR_NAME_OPTION)),
authorEmail = Option(line.getOptionValue(PROJECT_AUTHOR_EMAIL_OPTION))
path = line.getOptionValue(NEW_OPTION),
nameOption = Option(line.getOptionValue(PROJECT_NAME_OPTION)),
authorName = Option(line.getOptionValue(PROJECT_AUTHOR_NAME_OPTION)),
authorEmail = Option(line.getOptionValue(PROJECT_AUTHOR_EMAIL_OPTION)),
templateOption = Option(line.getOptionValue(PROJECT_TEMPLATE_OPTION))
)
}

View File

@ -3,9 +3,7 @@ package org.enso.interpreter.runtime.util;
import com.oracle.truffle.api.TruffleFile;
import org.enso.filesystem.FileSystem;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.*;
import java.nio.file.FileVisitResult;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
@ -53,6 +51,16 @@ public class TruffleFileSystem implements FileSystem<TruffleFile> {
return file.getName();
}
@Override
public InputStream newInputStream(TruffleFile file) throws IOException {
return file.newInputStream();
}
@Override
public OutputStream newOutputStream(TruffleFile file) throws IOException {
return file.newOutputStream();
}
@Override
public BufferedWriter newBufferedWriter(TruffleFile file) throws IOException {
return file.newBufferedWriter();

View File

@ -0,0 +1 @@
Hello World!

View File

@ -0,0 +1,5 @@
from Standard.Base import all
main =
operator1 = Enso_Project.root / "hello.txt"
File.read operator1

View File

@ -1,7 +1,14 @@
package org.enso.filesystem
import scala.jdk.CollectionConverters._
import java.io.{BufferedReader, BufferedWriter, File, IOException}
import java.io.{
BufferedReader,
BufferedWriter,
File,
IOException,
InputStream,
OutputStream
}
import java.nio.file.Files
import java.nio.file.attribute.{BasicFileAttributes, FileTime}
import java.util.stream
@ -64,6 +71,22 @@ trait FileSystem[F] {
*/
def getName(file: F): String
/** Creates a new input stream for the given file.
*
* @param file the file to open.
* @return an input stream for `file`.
*/
@throws[IOException]
def newInputStream(file: F): InputStream
/** Creates a new output stream for the given file.
*
* @param file the file to open.
* @return an output stream for `file`.
*/
@throws[IOException]
def newOutputStream(file: F): OutputStream
/** Creates a new buffered writer for the given file.
*
* @param file the file to open.
@ -140,6 +163,10 @@ object FileSystem {
def getName: String = fs.getName(file)
def newInputStream: InputStream = fs.newInputStream(file)
def newOutputStream: OutputStream = fs.newOutputStream(file)
def newBufferedWriter: BufferedWriter = fs.newBufferedWriter(file)
def newBufferedReader: BufferedReader = fs.newBufferedReader(file)
@ -175,6 +202,12 @@ object FileSystem {
override def getName(file: File): String = file.getName
override def newInputStream(file: File): InputStream =
Files.newInputStream(file.toPath)
override def newOutputStream(file: File): OutputStream =
Files.newOutputStream(file.toPath)
override def newBufferedWriter(file: File): BufferedWriter =
Files.newBufferedWriter(file.toPath)

View File

@ -4,9 +4,9 @@ import cats.Show
import org.enso.editions.{Editions, LibraryName}
import org.enso.filesystem.FileSystem
import org.enso.pkg.validation.NameValidation
import java.io.{File, InputStream, OutputStream}
import java.net.URI
import java.io.File
import scala.io.Source
import scala.jdk.CollectionConverters._
import scala.util.{Failure, Try, Using}
@ -48,13 +48,14 @@ case class Package[F](
/** Stores the package metadata on the hard drive. If the package does not exist,
* creates the required directory structure.
*/
def save(): Try[Unit] = for {
_ <- Try {
if (!root.exists) createDirectories()
if (!sourceDir.exists) createSourceDir()
}
_ <- saveConfig()
} yield ()
def save(): Try[Unit] =
for {
_ <- Try {
if (!root.exists) createDirectories()
if (!sourceDir.exists) createSourceDir()
}
_ <- saveConfig()
} yield ()
/** Creates the package directory structure.
*/
@ -82,19 +83,14 @@ case class Package[F](
*/
def updateConfig(update: Config => Config): Package[F] = {
val newPkg = copy(config = update(config))
newPkg.save()
newPkg.saveConfig()
newPkg
}
/** Creates the sources directory and populates it with a dummy Main file.
/** Creates the sources directory.
*/
def createSourceDir(): Unit = {
Try(sourceDir.createDirectories()).getOrElse(throw CouldNotCreateDirectory)
val mainCodeSrc = Source.fromResource(Package.mainFileName)
val writer = sourceDir.getChild(Package.mainFileName).newBufferedWriter
writer.write(mainCodeSrc.mkString)
writer.close()
mainCodeSrc.close()
}
/** Saves the config metadata into the package configuration file.
@ -185,10 +181,12 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
*/
def create(
root: F,
config: Config
config: Config,
template: Template
): Package[F] = {
val pkg = Package(root, config, fileSystem)
pkg.save()
copyResources(pkg, template)
pkg
}
@ -197,6 +195,7 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
* @param root the root location of the package.
* @param name the name for the new package.
* @param version version of the newly-created package.
* @param template the template for the new package.
* @param edition the edition to use for the project; if not specified, it
* will not specify any, meaning that the current default one
* will be used
@ -207,6 +206,7 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
name: String,
namespace: String = "local",
version: String = "0.0.1",
template: Template = Template.Default,
edition: Option[Editions.RawEdition] = None,
authors: List[Contact] = List(),
maintainers: List[Contact] = List(),
@ -222,7 +222,7 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
preferLocalLibraries = true,
maintainers = maintainers
)
create(root, config)
create(root, config, template)
}
/** Tries to parse package structure from a given root location.
@ -302,6 +302,51 @@ class PackageManager[F](implicit val fileSystem: FileSystem[F]) {
val dirname = file.getName
NameValidation.normalizeName(dirname)
}
/** Copy the template resources to the package.
*
* @param template the template to copy the resources from
* @param pkg the package to copy the resources to
*/
private def copyResources(pkg: Package[F], template: Template): Unit =
template match {
case Template.Default =>
val mainEnsoPath = new URI(s"/default/src/${Package.mainFileName}")
copyResource(
mainEnsoPath,
pkg.sourceDir.getChild(Package.mainFileName)
)
case Template.Example =>
val helloTxtPath = new URI("/example/hello.txt")
val mainEnsoPath = new URI(s"/example/src/${Package.mainFileName}")
copyResource(
helloTxtPath,
pkg.root.getChild("hello.txt")
)
copyResource(
mainEnsoPath,
pkg.sourceDir.getChild(Package.mainFileName)
)
}
/** Copy the resource to provided resource.
*
* @param from the source
* @param to the destination
*/
private def copyResource(from: URI, to: F): Unit = {
val fromStream = getClass.getResourceAsStream(from.toString)
val toStream = to.newOutputStream
try PackageManager.copyStream(fromStream, toStream)
finally {
fromStream.close()
toStream.close()
}
}
}
object PackageManager {
@ -326,6 +371,16 @@ object PackageManager {
/** The error indicating that the project name is invalid. */
case class InvalidNameException(message: String)
extends RuntimeException(message)
private def copyStream(in: InputStream, out: OutputStream): Unit = {
val buffer = Array.ofDim[Byte](4096)
var length = in.read(buffer)
while (length != -1) {
out.write(buffer, 0, length)
length = in.read(buffer)
}
}
}
/** A companion object for static methods on the [[Package]] class.

View File

@ -0,0 +1,30 @@
package org.enso.pkg
/** Base trait for the project templates. */
sealed trait Template {
/** The template name. */
def name: String
}
object Template {
/** Create a template from string.
*
* @param template the template name
* @return the template for the provided name
*/
def fromString(template: String): Option[Template] =
allTemplates.find(_.name == template.toLowerCase)
/** The default project template. */
case object Default extends Template {
override val name = "default"
}
/** The example project template. */
case object Example extends Template {
override val name = "example"
}
val allTemplates = Seq(Default, Example)
}

View File

@ -1,6 +1,7 @@
package org.enso.projectmanager.protocol
import java.util.UUID
import io.circe.Json
import io.circe.syntax._
import nl.gn0s1s.bump.SemVer
@ -24,6 +25,7 @@ object ProjectManagementApi {
case class Params(
name: String,
version: Option[EnsoVersion],
projectTemplate: Option[String],
missingComponentAction: Option[MissingComponentAction]
)

View File

@ -56,6 +56,7 @@ class ProjectCreateHandler[F[+_, +_]: Exec: CovariantFlatMap: ErrorChannel](
progressTracker = self,
name = params.name,
engineVersion = actualVersion,
projectTemplate = params.projectTemplate,
missingComponentAction = missingComponentAction
)
} yield ProjectCreate.Result(projectId)

View File

@ -14,7 +14,6 @@ import org.enso.projectmanager.service.versionmanagement.RuntimeVersionManagerFa
import org.enso.projectmanager.versionmanagement.DistributionConfiguration
import org.enso.runtimeversionmanager.config.GlobalConfigurationManager
import org.enso.runtimeversionmanager.runner.Runner
import java.nio.file.Path
/** A service for creating new project structures using the runner of the
@ -35,6 +34,7 @@ class ProjectCreationService[
path: Path,
name: String,
engineVersion: SemVer,
projectTemplate: Option[String],
missingComponentAction: MissingComponentAction
): F[ProjectServiceFailure, Unit] = Sync[F]
.blockingOp {
@ -57,7 +57,17 @@ class ProjectCreationService[
)
val settings =
runner.newProject(path, name, engineVersion, None, None, Seq()).get
runner
.newProject(
path,
name,
engineVersion,
projectTemplate,
None,
None,
Seq()
)
.get
val jvmSettings = distributionConfiguration.defaultJVMSettings
runner.withCommand(settings, jvmSettings) { command =>
logger.trace(

View File

@ -18,6 +18,7 @@ trait ProjectCreationServiceApi[F[+_, +_]] {
* @param path path at which to create the project
* @param name name of the project
* @param engineVersion version of the engine this project is meant for
* @param projectTemplate the name of the project template
* @param missingComponentAction specifies how to handle missing components
*/
def createProject(
@ -25,6 +26,7 @@ trait ProjectCreationServiceApi[F[+_, +_]] {
path: Path,
name: String,
engineVersion: SemVer,
projectTemplate: Option[String],
missingComponentAction: MissingComponentAction
): F[ProjectServiceFailure, Unit]
}

View File

@ -47,7 +47,6 @@ import org.enso.projectmanager.service.config.GlobalConfigServiceApi
import org.enso.projectmanager.service.versionmanagement.RuntimeVersionManagerErrorRecoverySyntax._
import org.enso.projectmanager.service.versionmanagement.RuntimeVersionManagerFactory
import org.enso.projectmanager.versionmanagement.DistributionConfiguration
import java.util.UUID
/** Implementation of business logic for project management.
@ -82,12 +81,19 @@ class ProjectService[
/** @inheritdoc */
override def createUserProject(
progressTracker: ActorRef,
name: String,
projectName: String,
engineVersion: SemVer,
projectTemplate: Option[String],
missingComponentAction: MissingComponentAction
): F[ProjectServiceFailure, UUID] = for {
projectId <- gen.randomUUID()
_ <- log.debug("Creating project [{}, {}].", name, projectId)
projectId <- gen.randomUUID()
_ <- log.debug(
"Creating project [{}, {}, {}].",
projectName,
projectId,
projectTemplate
)
name <- getNameForNewProject(projectName, projectTemplate)
_ <- validateName(name)
_ <- checkIfNameExists(name)
creationTime <- clock.nowInUtc()
@ -111,6 +117,7 @@ class ProjectService[
path,
name,
engineVersion,
projectTemplate,
missingComponentAction
)
_ <- log.debug(
@ -481,4 +488,35 @@ class ProjectService[
)
}
private def getNameForNewProject(
projectName: String,
projectTemplate: Option[String]
): F[ProjectServiceFailure, String] = {
def mkName(name: String, suffix: Int): String =
s"${name}_${suffix}"
def findAvailableName(
projectName: String,
suffix: Int
): F[ProjectRepositoryFailure, String] = {
val newName = mkName(projectName, suffix)
CovariantFlatMap[F].ifM(repo.exists(newName))(
ifTrue = findAvailableName(projectName, suffix + 1),
ifFalse = CovariantFlatMap[F].pure(newName)
)
}
projectTemplate match {
case Some(_) =>
CovariantFlatMap[F]
.ifM(repo.exists(projectName))(
ifTrue = findAvailableName(projectName, 1),
ifFalse = CovariantFlatMap[F].pure(projectName)
)
.mapError(toServiceFailure)
case None =>
CovariantFlatMap[F].pure(projectName)
}
}
}

View File

@ -19,8 +19,9 @@ trait ProjectServiceApi[F[+_, +_]] {
/** Creates a user project.
*
* @param progressTracker the actor to send progress updates to
* @param name the name of th project
* @param name the name of the project
* @param engineVersion Enso version to use for the new project
* @param projectTemplate the name of the project template
* @param missingComponentAction specifies how to handle missing components
* @return projectId
*/
@ -28,6 +29,7 @@ trait ProjectServiceApi[F[+_, +_]] {
progressTracker: ActorRef,
name: String,
engineVersion: SemVer,
projectTemplate: Option[String],
missingComponentAction: MissingComponentAction
): F[ProjectServiceFailure, UUID]

View File

@ -17,12 +17,17 @@ trait ProjectManagementOps { this: BaseServerSpec =>
def createProject(
name: String,
projectTemplate: Option[String] = None,
missingComponentAction: Option[MissingComponentAction] = None
)(implicit client: WsTestClient): UUID = {
val fields = Seq("name" -> name.asJson) ++
missingComponentAction
.map(a => "missingComponentAction" -> a.asJson)
.toSeq ++
projectTemplate
.map(t => "projectTemplate" -> t.asJson)
.toSeq
val params = Json.obj(fields: _*)
val request = json"""
{ "jsonrpc": "2.0",

View File

@ -6,10 +6,10 @@ import org.apache.commons.io.FileUtils
import org.enso.editions.SemVerJson._
import org.enso.projectmanager.{BaseServerSpec, ProjectManagementOps}
import org.enso.testkit.{FlakySpec, RetrySpec}
import java.io.File
import java.nio.file.Paths
import java.nio.file.{Files, Paths}
import java.util.UUID
import scala.io.Source
class ProjectManagementApiSpec
@ -171,6 +171,56 @@ class ProjectManagementApiSpec
meta shouldBe Symbol("file")
}
"create project from default template" in {
val projectName = "Foo"
implicit val client = new WsTestClient(address)
createProject(projectName, projectTemplate = Some("default"))
val projectDir = new File(userProjectDir, projectName)
val packageFile = new File(projectDir, "package.yaml")
val mainEnso = Paths.get(projectDir.toString, "src", "Main.enso").toFile
val meta = Paths.get(projectDir.toString, ".enso", "project.json").toFile
packageFile shouldBe Symbol("file")
mainEnso shouldBe Symbol("file")
meta shouldBe Symbol("file")
}
"create project from example template" in {
val projectName = "Foo"
implicit val client = new WsTestClient(address)
createProject(projectName, projectTemplate = Some("example"))
val projectDir = new File(userProjectDir, projectName)
val packageFile = new File(projectDir, "package.yaml")
val mainEnso = Paths.get(projectDir.toString, "src", "Main.enso").toFile
val helloTxt = Paths.get(projectDir.toString, "hello.txt").toFile
val meta = Paths.get(projectDir.toString, ".enso", "project.json").toFile
packageFile shouldBe Symbol("file")
mainEnso shouldBe Symbol("file")
helloTxt shouldBe Symbol("file")
meta shouldBe Symbol("file")
}
"find a name when project is created from template" in {
val projectName = "Foo"
implicit val client = new WsTestClient(address)
createProject(projectName, projectTemplate = Some("default"))
createProject(projectName, projectTemplate = Some("default"))
val projectDir = new File(userProjectDir, "Foo_1")
val packageFile = new File(projectDir, "package.yaml")
Files.readAllLines(packageFile.toPath) contains "name: Foo_1"
}
"create project with specific version" in {
implicit val client = new WsTestClient(address)
client.send(json"""

View File

@ -27,18 +27,20 @@ abstract class ProjectOpenSpecBase
val blackhole = system.actorOf(blackholeProps)
val ordinaryAction = projectService.createUserProject(
blackhole,
"Proj_1",
defaultVersion,
MissingComponentAction.Fail
progressTracker = blackhole,
projectName = "Proj_1",
projectTemplate = None,
engineVersion = defaultVersion,
missingComponentAction = MissingComponentAction.Fail
)
ordinaryProject = Runtime.default.unsafeRun(ordinaryAction)
val brokenName = "Projbroken"
val brokenAction = projectService.createUserProject(
blackhole,
brokenName,
defaultVersion,
MissingComponentAction.Fail
progressTracker = blackhole,
projectName = brokenName,
projectTemplate = None,
engineVersion = defaultVersion,
missingComponentAction = MissingComponentAction.Fail
)
brokenProject = Runtime.default.unsafeRun(brokenAction)

View File

@ -44,6 +44,7 @@ class Runner(
path: Path,
name: String,
engineVersion: SemVer,
projectTemplate: Option[String],
authorName: Option[String],
authorEmail: Option[String],
additionalArguments: Seq[String]
@ -53,13 +54,15 @@ class Runner(
authorName.map(Seq("--new-project-author-name", _)).getOrElse(Seq())
val authorEmailOption =
authorEmail.map(Seq("--new-project-author-email", _)).getOrElse(Seq())
val templateOption =
projectTemplate.map(Seq("--new-project-template", _)).getOrElse(Seq())
val arguments =
Seq(
"--new",
path.toAbsolutePath.normalize.toString,
"--new-project-name",
name
) ++ authorNameOption ++ authorEmailOption ++ additionalArguments
) ++ templateOption ++ authorNameOption ++ authorEmailOption ++ additionalArguments
// TODO [RW] reporting warnings to the IDE (#1710)
if (Engine.isNightly(engineVersion)) {
Logger[Runner].warn(