diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt index 4fad811..3bc4d19 100644 --- a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/CLI.kt @@ -101,11 +101,35 @@ object CLI { data class LabelName(val name: String): ILabelIdentification data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification - sealed interface IData - data class DirectData(val data: List) : IData - data class FileData(val file: InputStream, val parse: (String) -> T) : IData { + sealed interface IData { + abstract fun toList(): List + } + data class DirectData(val data: List) : IData { + override fun toList(): List = data + } + data class FileData(val file: InputStream, val parse: (String) -> T?) : IData { + override fun toList(): List { + val raw = file.bufferedReader().use { it.readText() }.split(',') + val parsed = ArrayList(raw.size) + val errors = mutableListOf() + raw.forEach { + val use = it.trim() + if(use.isEmpty()) return@forEach + + parse(use)?.let { p -> parsed += p } ?: run { + errors += use + } + } + + if(errors.isNotEmpty()) { + System.err.println("Failed to parse these entries: ${errors.map { "'$it'" }}") + exitProcess(-1) + } + return parsed + } + companion object { - fun floatFile(file: InputStream) = FileData(file) { it.toFloat() } + fun floatFile(file: InputStream) = FileData(file) { it.toFloatOrNull() } } } @@ -120,7 +144,7 @@ object CLI { val project by findOrSetObject { this } - override fun run() {} + override fun run() { project /* force initialization */ } @Suppress("unused") class ProjectList : CliktCommand(name = "list") { @@ -172,7 +196,7 @@ object CLI { option("--ns", "--nano", "--nanosec", help = "Set the time unit to nanoseconds").flag().convert { TimeUnit.NANOS }, option("--us", "--micro", "--μs", "--microsec", help = "Set the time unit to microseconds").flag().convert { TimeUnit.MICROS }, option("--ms", "--milli", "--millis", "--millisec", help = "Set the time unit to milliseconds").flag().convert { TimeUnit.MILLIS }, - option("--s", "--sec", "--second", help = "Set the time unit to seconds").flag().convert{ TimeUnit.SECONDS }, + option("--s", "--sec", "--second", help = "Set the time unit to seconds").flag().convert{ TimeUnit.SECONDS }, option("--min", "--m", "--minutes", help = "Set the time unit to minutes").flag().convert { TimeUnit.MINUTES }, option("--h", "--hour", help = "Set the time unit to hours").flag().convert { TimeUnit.HOURS } ).single() diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt index 00e1bda..84b42ba 100644 --- a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/ProjectHandlers.kt @@ -1,9 +1,35 @@ package com.jaytux.phoebench.clients.cli +import com.github.ajalt.clikt.parameters.options.convert +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.jaytux.phoebench.clients.cli.CLI.Commands.Project.ProjectName +import com.jaytux.phoebench.common.ApiRoute +import com.jaytux.phoebench.common.Either import com.jaytux.phoebench.common.EmptyRequest +import com.jaytux.phoebench.common.EntryRequest +import com.jaytux.phoebench.common.ErrorResponse +import com.jaytux.phoebench.common.LabelRequest +import com.jaytux.phoebench.common.LabelResponse +import com.jaytux.phoebench.common.ProjectRequest import com.jaytux.phoebench.common.Routes import com.jaytux.phoebench.common.TimeUnit +import com.jaytux.phoebench.common.bind +import com.jaytux.phoebench.common.error import com.jaytux.phoebench.common.fold +import com.jaytux.phoebench.common.map +import com.jaytux.phoebench.common.value +import kotlinx.coroutines.runBlocking +import kotlinx.datetime.toLocalDateTime +import java.nio.file.OpenOption +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.isReadable +import kotlin.io.path.isRegularFile +import kotlin.system.exitProcess +import kotlin.time.Clock +import kotlin.uuid.Uuid object ProjectHandlers { fun list() { @@ -19,13 +45,94 @@ object ProjectHandlers { } } + fun ensureProjectIdentification(got: CLI.Commands.Project.IProjectIdentification?): CLI.Commands.Project.IProjectIdentification = + got.maybePrompt("project name (as [user]/[project])") { + val split = it.split('/') + if (split.size != 2) { + System.err.println("Invalid format (expected [user]/[project])") + exitProcess(-1) + } + ProjectName(split[0], split[1]) + } + + suspend fun CLI.Commands.Project.IProjectIdentification.toId(): Either = + when(this) { + is CLI.Commands.Project.ID -> id.value() + is ProjectName -> Client.callRoute(Routes.home, EmptyRequest()).bind { + (it.ownProjects + it.publicProjects).firstOrNull { pr -> + pr.owner.name == user && pr.name == project + }?.id?.value() ?: ErrorResponse("Project ${user}/${project} not found").error() + } + } + fun details(find: CLI.Commands.Project.IProjectIdentification?) { - // + val id = ensureProjectIdentification(find) + tryAuthenticated { + id.toId().bind { + Client.callRoute(Routes.Project.get, it) + } + }.fold({ + System.err.println("Failed to get project details: ${it.msg}") + }) { + println("Project ${it.owner.name}/${it.name} [${it.id}]:") + println("${it.usedLabels.size} labels:") + it.usedLabels.forEach { l -> + println(" - [${l.id}] ${l.name} (with color ${l.color})") + } + val map = it.usedLabels.associateBy { l -> l.id } + + println("\n${it.entries.size} entries:") + it.entries.forEach { e -> + println(" - [${e.id}] Entry labeled ${map[e.labelId]?.name ?: ""} at ${e.timestamp.fmt()} " + + "(${e.warmups.size} warmup data points, ${e.measurements.size} measurement data points; in ${e.unit.disp})") + } + } } - fun create(name: String?, isPublic: Boolean) {} + fun create(name: String?, isPublic: Boolean) { + val useName = name.maybePrompt("project name") { it } + tryAuthenticated { + Client.callRoute(Routes.Project.new, ProjectRequest(useName, isPublic)) + }.fold({ + System.err.println("Failed to create project '$useName': ${it.msg}") + }) { + println("Project ${it.owner.name}/${it.name} [${it.id}] created.") + } + } - fun newLabel(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?) {} + fun newLabel(name: String?, color: String?, project: CLI.Commands.Project.IProjectIdentification?) { + val id = ensureProjectIdentification(project) + val useName = name.maybePrompt("label name") { it } + val useColor = color.maybePrompt("color") { + val check = it.length == 7 && it[0] == '#' && it.substring(1, it.length).all { c -> c.isDigit() || c in "ABCDEF" } + if(check) it + else { + System.err.println("Invalid color format: '$it' (expected RGB-hex-format (#ABCDEF))") + exitProcess(-1) + } + } + + tryAuthenticated { + id.toId().bind { projectId -> + Client.callRoute(Routes.Label.new, LabelRequest(useName, useColor, projectId)) + } + }.fold({ + System.err.println("Could not create label: ${it.msg}") + }) { + println("Label ${it.name} (${it.color}) [${it.id}] created.") + } + } + + private fun CLI.Commands.Project.IData?.ensure(x: String): CLI.Commands.Project.IData { + return maybePrompt("$x data file") { + val path = Path(it) + if(!path.exists() || !path.isRegularFile() || !path.isReadable()) { + System.err.println("Path $it is not readable") + exitProcess(-1) + } + CLI.Commands.Project.FileData.floatFile(path.inputStream()) + } + } fun newData( project: CLI.Commands.Project.IProjectIdentification?, @@ -33,5 +140,43 @@ object ProjectHandlers { warmup: CLI.Commands.Project.IData?, measurement: CLI.Commands.Project.IData?, unit: TimeUnit? - ) {} + ) { + val projectId = ensureProjectIdentification(project) + val labelId = label.maybePrompt("label name") { + CLI.Commands.Project.LabelName(it) + } + val warmupData = warmup.ensure("warmup").toList() + val measureData = measurement.ensure("measurements").toList() + val timeUnit = unit.maybePrompt("time unit for data") { + when(it) { + in setOf("ns", "nano", "nanosec") -> TimeUnit.NANOS + in setOf("us", "micro", "μs", "microsec") -> TimeUnit.MICROS + in setOf("ms", "milli", "millis", "millisec") -> TimeUnit.MILLIS + in setOf("s", "sec", "second") -> TimeUnit.SECONDS + in setOf("min", "m", "minutes") -> TimeUnit.MINUTES + in setOf("h", "hour") -> TimeUnit.HOURS + else -> { + System.err.println("Unknown time unit $it (see help message)") + exitProcess(-1) + } + } + } + + tryAuthenticated { + projectId.toId().bind { pId -> + when(labelId) { + is CLI.Commands.Project.ID -> labelId.id.value() + is CLI.Commands.Project.LabelName -> Client.callRoute(Routes.Project.get, pId).bind { + it.usedLabels.firstOrNull { l -> l.name == labelId.name }?.id?.value() + ?: ErrorResponse("Label ${labelId.name} does not exist in this project.").error() + } + }.map { pId to it } + }.bind { (pId, lId) -> + Client.callRoute(Routes.Entry.new, EntryRequest( + lId, Clock.System.now(), pId, + warmupData, measureData, timeUnit + )) + } + } + } } \ No newline at end of file diff --git a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Util.kt b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Util.kt index bb3be23..72438ca 100644 --- a/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Util.kt +++ b/clients/cli/src/main/kotlin/com/jaytux/phoebench/clients/cli/Util.kt @@ -3,7 +3,15 @@ package com.jaytux.phoebench.clients.cli import com.jaytux.phoebench.clients.cli.CLI.Root import com.jaytux.phoebench.common.* import kotlinx.coroutines.runBlocking +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.MonthNames +import kotlinx.datetime.format.Padding +import kotlinx.datetime.format.char +import kotlinx.datetime.toLocalDateTime import java.util.Locale.getDefault +import kotlin.time.Instant fun T.ignore(): Unit {} @@ -38,4 +46,14 @@ inline fun tryAuthenticated(crossinline body: suspend () -> Either= user.projects.count())) + if(user.projectLimit != -1 && (user.projects.count() >= user.projectLimit)) throw RouteError("You have reached your project limit.", HttpStatusCode.Conflict) val proj = Project.new {