CLI client

This commit is contained in:
2026-08-04 20:37:53 +02:00
parent a10423b015
commit cd8bbe5687
4 changed files with 199 additions and 12 deletions
@@ -101,11 +101,35 @@ object CLI {
data class LabelName(val name: String): ILabelIdentification
data class ID(val id: Uuid) : IProjectIdentification, ILabelIdentification
sealed interface IData<T>
data class DirectData<T>(val data: List<T>) : IData<T>
data class FileData<T>(val file: InputStream, val parse: (String) -> T) : IData<T> {
sealed interface IData<T> {
abstract fun toList(): List<T>
}
data class DirectData<T>(val data: List<T>) : IData<T> {
override fun toList(): List<T> = data
}
data class FileData<T>(val file: InputStream, val parse: (String) -> T?) : IData<T> {
override fun toList(): List<T> {
val raw = file.bufferedReader().use { it.readText() }.split(',')
val parsed = ArrayList<T>(raw.size)
val errors = mutableListOf<String>()
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<Float>(file) { it.toFloat() }
fun floatFile(file: InputStream) = FileData<Float>(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()
@@ -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<ErrorResponse, Uuid> =
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 ?: "<invalid label>"} 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<Float>?.ensure(x: String): CLI.Commands.Project.IData<Float> {
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<Float>?,
measurement: CLI.Commands.Project.IData<Float>?,
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
))
}
}
}
}
@@ -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> T.ignore(): Unit {}
@@ -38,4 +46,14 @@ inline fun <reified V> tryAuthenticated(crossinline body: suspend () -> Either<E
}) {
it.value()
}
}
}
val systemTz = TimeZone.currentSystemDefault()
val formatter = LocalDateTime.Format {
val noPad = Padding.NONE
day(noPad); char(' '); monthName(MonthNames.ENGLISH_ABBREVIATED); char(' '); year(noPad); char(' ')
hour(); char(':'); minute(); char(':'); second()
}
fun Instant.fmt(): String = this.toLocalDateTime(systemTz).format(formatter)
@@ -65,7 +65,7 @@ object ProjectHandler {
}
fun createProject(user: User, req: ProjectRequest) = transaction {
if(user.projectLimit != -1 && (user.projectLimit >= 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 {