From 3aa74a433fe3ca8d731c192136d93f581b599bac Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 26 Feb 2025 17:25:44 -0500 Subject: [PATCH 001/111] Super important ascii art --- main.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/main.go b/main.go index 721284c..8e5657e 100644 --- a/main.go +++ b/main.go @@ -7,10 +7,43 @@ import ( "github.com/S1ro1/popcorn-cli/src/cmd" ) +func displayAsciiArt() { + art := ` + _ __ _ ______ _ +| | / / | | | ___ \ | | +| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ +| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| +| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ +\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| + + POPCORN CLI - GPU MODE + + ┌───────────────────────────────────────┐ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ + │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ + │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ + │ └─────┘ └─────┘ └─────┘ │████████│ │▒ + │ │████████│ │▒ + │ ┌────────────────────────┐ │████████│ │▒ + │ │ │ │████████│ │▒ + │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ + │ │ │ │▒ + │ └────────────────────────┘ │▒ + │ │▒ + └───────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +` + fmt.Println(art) +} + func main() { + if os.Getenv("POPCORN_API_URL") == "" { fmt.Println("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API.") os.Exit(1) } + displayAsciiArt() cmd.Execute() } From 7c235a7cc2385475d2e4379dca8c3de7fc644a2a Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 16 Mar 2025 02:40:14 +0100 Subject: [PATCH 002/111] Feat: works without popcorn directives --- src/cmd/popcorn-cli.go | 30 +++++++++++----------------- src/service/api.go | 9 ++++----- src/utils/utils.go | 45 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 24 deletions(-) create mode 100644 src/utils/utils.go diff --git a/src/cmd/popcorn-cli.go b/src/cmd/popcorn-cli.go index 96cfe17..685d0de 100644 --- a/src/cmd/popcorn-cli.go +++ b/src/cmd/popcorn-cli.go @@ -11,6 +11,8 @@ import ( "github.com/S1ro1/popcorn-cli/src/models" "github.com/S1ro1/popcorn-cli/src/service" + // "github.com/S1ro1/popcorn-cli/src/utils" + tea "github.com/charmbracelet/bubbletea" ) @@ -35,8 +37,6 @@ type model struct { filepath string leaderboardsList list.Model selectedLeaderboard string - runnersList list.Model - selectedRunner string gpusList list.Model selectedGpu string submissionModeList list.Model @@ -72,22 +72,16 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case models.ModelStateLeaderboardSelection: if i := m.leaderboardsList.SelectedItem(); i != nil { m.selectedLeaderboard = i.(models.LeaderboardItem).TitleText - m.modalState = models.ModelStateRunnerSelection - m.runnersList.SetSize(m.width-2, m.height-2) - } - case models.ModelStateRunnerSelection: - if i := m.runnersList.SelectedItem(); i != nil { - m.selectedRunner = i.(models.RunnerItem).Value m.modalState = models.ModelStateGpuSelection gpus, err := service.GetListItems(func() ([]models.GpuItem, error) { - return service.FetchAvailableGpus(m.selectedLeaderboard, m.selectedRunner) + return service.FetchAvailableGpus(m.selectedLeaderboard) }) if err != nil { m.SetError(fmt.Sprintf("Error fetching GPUs: %s", err)) return m, tea.Quit } if len(gpus) == 0 { - m.SetError("No GPUs available for this runner and leaderboard.") + m.SetError("No GPUs available for this leaderboard.") return m, tea.Quit } m.gpusList = list.New(gpus, list.NewDefaultDelegate(), m.width-2, m.height-2) @@ -119,8 +113,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.modalState { case models.ModelStateLeaderboardSelection: m.leaderboardsList.SetSize(listWidth, listHeight) - case models.ModelStateRunnerSelection: - m.runnersList.SetSize(listWidth, listHeight) case models.ModelStateGpuSelection: m.gpusList.SetSize(listWidth, listHeight) case models.ModelStateSubmissionModeSelection: @@ -131,8 +123,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.modalState { case models.ModelStateLeaderboardSelection: m.leaderboardsList, cmd = m.leaderboardsList.Update(msg) - case models.ModelStateRunnerSelection: - m.runnersList, cmd = m.runnersList.Update(msg) case models.ModelStateGpuSelection: m.gpusList, cmd = m.gpusList.Update(msg) case models.ModelStateSubmissionModeSelection: @@ -159,8 +149,6 @@ func (m model) View() string { switch m.modalState { case models.ModelStateLeaderboardSelection: content = m.leaderboardsList.View() - case models.ModelStateRunnerSelection: - content = m.runnersList.View() case models.ModelStateGpuSelection: content = m.gpusList.View() case models.ModelStateSubmissionModeSelection: @@ -187,7 +175,7 @@ func (m model) Submit() tea.Cmd { return } - prettyResult, err := service.SubmitSolution(m.selectedLeaderboard, m.selectedRunner, m.selectedGpu, m.selectedSubmissionMode, m.filepath, fileContent) + prettyResult, err := service.SubmitSolution(m.selectedLeaderboard, m.selectedGpu, m.selectedSubmissionMode, m.filepath, fileContent) if err != nil { p.Send(models.ErrorMsg{Err: fmt.Errorf("error submitting solution: %s", err)}) m.SetError(fmt.Sprintf("Error submitting solution: %s", err)) @@ -215,6 +203,12 @@ func Execute() { return } + // popcornDirectives, err := utils.GetPopcornDirectives(filepath) + // if err != nil { + // fmt.Println("Error fetching popcorn directives:", err) + // return + // } + leaderboardItems, err := service.GetListItems(service.FetchLeaderboards) if err != nil { fmt.Println("Error fetching leaderboards:", err) @@ -228,7 +222,6 @@ func Execute() { m := model{ filepath: filepath, leaderboardsList: list.New(leaderboardItems, list.NewDefaultDelegate(), 0, 0), - runnersList: list.New(runnerItems, list.NewDefaultDelegate(), 0, 0), submissionModeList: list.New(submissionModeItems, list.NewDefaultDelegate(), 0, 0), spinner: s, modalState: models.ModelStateLeaderboardSelection, @@ -236,7 +229,6 @@ func Execute() { finalStatus: "", } m.leaderboardsList.Title = "Leaderboards" - m.runnersList.Title = "Runners" p = tea.NewProgram(m) finalModel, err := p.Run() diff --git a/src/service/api.go b/src/service/api.go index 9fde6fa..f45e137 100644 --- a/src/service/api.go +++ b/src/service/api.go @@ -53,8 +53,8 @@ func FetchLeaderboards() ([]models.LeaderboardItem, error) { return leaderboardNames, nil } -func FetchAvailableGpus(leaderboard string, runner string) ([]models.GpuItem, error) { - resp, err := http.Get(BASE_URL + "/" + leaderboard + "/" + runner + "/gpus") +func FetchAvailableGpus(leaderboard string) ([]models.GpuItem, error) { + resp, err := http.Get(BASE_URL + "/gpus/" + leaderboard) if err != nil { return nil, err } @@ -83,7 +83,7 @@ func FetchAvailableGpus(leaderboard string, runner string) ([]models.GpuItem, er return gpuItems, nil } -func SubmitSolution(leaderboard string, runner string, gpu string, submissionMode string, filename string, fileContent []byte) (string, error) { +func SubmitSolution(leaderboard string, gpu string, submissionMode string, filename string, fileContent []byte) (string, error) { body := &bytes.Buffer{} writer := multipart.NewWriter(body) @@ -100,10 +100,9 @@ func SubmitSolution(leaderboard string, runner string, gpu string, submissionMod return "", fmt.Errorf("error closing form: %s", err) } - url := fmt.Sprintf("%s/%s/%s/%s/%s", + url := fmt.Sprintf("%s/%s/%s/%s", BASE_URL, strings.ToLower(leaderboard), - strings.ToLower(runner), strings.ToLower(gpu), strings.ToLower(submissionMode)) diff --git a/src/utils/utils.go b/src/utils/utils.go new file mode 100644 index 0000000..b268798 --- /dev/null +++ b/src/utils/utils.go @@ -0,0 +1,45 @@ +package utils + +import ( + "os" + "strings" +) + +type PopcornDirectives struct { + LeaderboardName string + Gpus []string +} + +func GetPopcornDirectives(filepath string) (*PopcornDirectives, error) { + content, err := os.ReadFile(filepath) + + var gpus []string = []string{} + var leaderboard_name string = "" + + if err != nil { + return nil, err + } + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if !strings.HasPrefix(line, "//") && !strings.HasPrefix(line, "#") { + continue + } + + parts := strings.Split(line, " ") + if parts[0] == "//!POPCORN" || parts[0] == "#!POPCORN" { + arg := strings.ToLower(parts[1]) + if arg == "gpu" || arg == "gpus" { + gpus = parts[2:] + } else if arg == "leaderboard" { + leaderboard_name = parts[2] + } + } + } + + return &PopcornDirectives{ + LeaderboardName: leaderboard_name, + Gpus: gpus, + }, nil +} + From 37759d65675b0f2e3b742d58e04b801414b2b8ed Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 16 Mar 2025 03:30:05 +0100 Subject: [PATCH 003/111] Feat: works --- main.go | 35 +-------------- src/cmd/popcorn-cli.go | 98 +++++++++++++++++++++++++++++------------- src/models/types.go | 2 +- src/utils/utils.go | 39 ++++++++++++++++- 4 files changed, 108 insertions(+), 66 deletions(-) diff --git a/main.go b/main.go index f1166d8..62de05d 100644 --- a/main.go +++ b/main.go @@ -7,43 +7,12 @@ import ( "github.com/S1ro1/popcorn-cli/src/cmd" ) -func displayAsciiArt() { - art := ` - _ __ _ ______ _ -| | / / | | | ___ \ | | -| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ -| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| -| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ -\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| - - POPCORN CLI - GPU MODE - - ┌───────────────────────────────────────┐ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ - │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ - │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ - │ └─────┘ └─────┘ └─────┘ │████████│ │▒ - │ │████████│ │▒ - │ ┌────────────────────────┐ │████████│ │▒ - │ │ │ │████████│ │▒ - │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ - │ │ │ │▒ - │ └────────────────────────┘ │▒ - │ │▒ - └───────────────────────────────────────┘▒ - ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ -` - fmt.Println(art) -} func main() { - _, ok := os.LookupEnv("POPCORN_API_URL") - if !ok { + + if os.Getenv("POPCORN_API_URL") == "" { fmt.Println("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API.") os.Exit(1) } - displayAsciiArt() cmd.Execute() } diff --git a/src/cmd/popcorn-cli.go b/src/cmd/popcorn-cli.go index 685d0de..df50f71 100644 --- a/src/cmd/popcorn-cli.go +++ b/src/cmd/popcorn-cli.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/spinner" @@ -11,16 +12,11 @@ import ( "github.com/S1ro1/popcorn-cli/src/models" "github.com/S1ro1/popcorn-cli/src/service" - // "github.com/S1ro1/popcorn-cli/src/utils" + "github.com/S1ro1/popcorn-cli/src/utils" tea "github.com/charmbracelet/bubbletea" ) -var runnerItems = []list.Item{ - models.RunnerItem{TitleText: "Modal", DescriptionText: "Submit a solution to be evaluated on Modal runners.", Value: "modal"}, - models.RunnerItem{TitleText: "Github", DescriptionText: "Submit a solution to be evaluated on Github runners. This can take a little longer to spin up.", Value: "github"}, -} - var submissionModeItems = []list.Item{ models.SubmissionModeItem{TitleText: "Test", DescriptionText: "Test the solution and give detailed results about passed/failed tests.", Value: "test"}, models.SubmissionModeItem{TitleText: "Benchmark", DescriptionText: "Benchmark the solution, this also runs the tests and afterwards runs the benchmark, returning detailed timing results", Value: "benchmark"}, @@ -58,6 +54,17 @@ func (m model) Init() tea.Cmd { func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd + if len(m.gpusList.Items()) == 0 && m.modalState == models.ModelStateGpuSelection { + gpus, err := service.GetListItems(func() ([]models.GpuItem, error) { + return service.FetchAvailableGpus(m.selectedLeaderboard) + }) + if err != nil { + m.SetError(fmt.Sprintf("Error fetching GPUs: %s", err)) + return m, tea.Quit + } + m.gpusList = list.New(gpus, list.NewDefaultDelegate(), m.width-2, m.height-2) + m.gpusList.SetSize(m.width-2, m.height-2) + } if !m.finishedOkay { return m, tea.Quit } @@ -72,19 +79,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case models.ModelStateLeaderboardSelection: if i := m.leaderboardsList.SelectedItem(); i != nil { m.selectedLeaderboard = i.(models.LeaderboardItem).TitleText - m.modalState = models.ModelStateGpuSelection - gpus, err := service.GetListItems(func() ([]models.GpuItem, error) { - return service.FetchAvailableGpus(m.selectedLeaderboard) - }) - if err != nil { - m.SetError(fmt.Sprintf("Error fetching GPUs: %s", err)) - return m, tea.Quit - } - if len(gpus) == 0 { - m.SetError("No GPUs available for this leaderboard.") - return m, tea.Quit + // No gpu selected in popcorn directives, fetch gpus and move to gpu selection + if m.selectedGpu == "" { + gpus, err := service.GetListItems(func() ([]models.GpuItem, error) { + return service.FetchAvailableGpus(m.selectedLeaderboard) + }) + if err != nil { + m.SetError(fmt.Sprintf("Error fetching GPUs: %s", err)) + return m, tea.Quit + } + if len(gpus) == 0 { + m.SetError("No GPUs available for this leaderboard.") + return m, tea.Quit + } + m.gpusList = list.New(gpus, list.NewDefaultDelegate(), m.width-2, m.height-2) + m.gpusList.SetSize(m.width-2, m.height-2) + m.modalState = models.ModelStateGpuSelection + } else { + m.modalState = models.ModelStateSubmissionModeSelection + m.submissionModeList.SetSize(m.width-2, m.height-2) } - m.gpusList = list.New(gpus, list.NewDefaultDelegate(), m.width-2, m.height-2) } case models.ModelStateGpuSelection: if i := m.gpusList.SelectedItem(); i != nil { @@ -203,16 +217,34 @@ func Execute() { return } - // popcornDirectives, err := utils.GetPopcornDirectives(filepath) - // if err != nil { - // fmt.Println("Error fetching popcorn directives:", err) - // return - // } + popcornDirectives, err := utils.GetPopcornDirectives(filepath) + if err != nil { + fmt.Println("Error:", err) + var input string + fmt.Scanln(&input) + if strings.ToLower(input) != "y" { + return + } + } + + var modalState models.ModelState + if popcornDirectives.LeaderboardName != "" && len(popcornDirectives.Gpus) > 0 { + modalState = models.ModelStateSubmissionModeSelection + } else if popcornDirectives.LeaderboardName != "" { + modalState = models.ModelStateGpuSelection + } else { + modalState = models.ModelStateLeaderboardSelection + } + + var selectedGpu string + if len(popcornDirectives.Gpus) > 0 { + selectedGpu = popcornDirectives.Gpus[0] + } leaderboardItems, err := service.GetListItems(service.FetchLeaderboards) if err != nil { fmt.Println("Error fetching leaderboards:", err) - return + } s := spinner.New() @@ -220,13 +252,16 @@ func Execute() { s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) m := model{ - filepath: filepath, - leaderboardsList: list.New(leaderboardItems, list.NewDefaultDelegate(), 0, 0), - submissionModeList: list.New(submissionModeItems, list.NewDefaultDelegate(), 0, 0), - spinner: s, - modalState: models.ModelStateLeaderboardSelection, - finishedOkay: true, - finalStatus: "", + filepath: filepath, + leaderboardsList: list.New(leaderboardItems, list.NewDefaultDelegate(), 0, 0), + submissionModeList: list.New(submissionModeItems, list.NewDefaultDelegate(), 0, 0), + gpusList: list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0), + spinner: s, + modalState: modalState, + finishedOkay: true, + finalStatus: "", + selectedLeaderboard: popcornDirectives.LeaderboardName, + selectedGpu: selectedGpu, } m.leaderboardsList.Title = "Leaderboards" @@ -238,6 +273,7 @@ func Execute() { } m, ok := finalModel.(model) + utils.DisplayAsciiArt() if ok && m.finishedOkay { fmt.Printf("\nResult:\n\n%s\n", m.finalStatus) } else if ok && !m.finishedOkay { diff --git a/src/models/types.go b/src/models/types.go index bcf9d60..83d6469 100644 --- a/src/models/types.go +++ b/src/models/types.go @@ -1,7 +1,7 @@ package models type LeaderboardItem struct { - TitleText string + TitleText string TaskDescription string } diff --git a/src/utils/utils.go b/src/utils/utils.go index b268798..0ebcf42 100644 --- a/src/utils/utils.go +++ b/src/utils/utils.go @@ -1,6 +1,7 @@ package utils import ( + "fmt" "os" "strings" ) @@ -11,6 +12,7 @@ type PopcornDirectives struct { } func GetPopcornDirectives(filepath string) (*PopcornDirectives, error) { + var err error = nil content, err := os.ReadFile(filepath) var gpus []string = []string{} @@ -37,9 +39,44 @@ func GetPopcornDirectives(filepath string) (*PopcornDirectives, error) { } } + if len(gpus) > 1 { + err = fmt.Errorf("multiple GPUs are not yet supported, continue with the first gpu? (%s) [y/N]", gpus[0]) + gpus = []string{gpus[0]} + } + return &PopcornDirectives{ LeaderboardName: leaderboard_name, Gpus: gpus, - }, nil + }, err } +func DisplayAsciiArt() { + art := ` + _ __ _ ______ _ +| | / / | | | ___ \ | | +| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ +| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| +| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ +\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| + + POPCORN CLI - GPU MODE + + ┌───────────────────────────────────────┐ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ + │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ + │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ + │ └─────┘ └─────┘ └─────┘ │████████│ │▒ + │ │████████│ │▒ + │ ┌────────────────────────┐ │████████│ │▒ + │ │ │ │████████│ │▒ + │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ + │ │ │ │▒ + │ └────────────────────────┘ │▒ + │ │▒ + └───────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +` + fmt.Println(art) +} From 224a1de844173fdedc1479b4556dd5ed5f6e9f6d Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 9 Apr 2025 01:39:44 +0300 Subject: [PATCH 004/111] WIP rust port --- rust/.gitignore | 18 ++ rust/Cargo.toml | 16 ++ rust/README.md | 56 ++++++ rust/build.sh | 8 + rust/src/cmd/mod.rs | 408 ++++++++++++++++++++++++++++++++++++++++ rust/src/main.rs | 20 ++ rust/src/models/mod.rs | 75 ++++++++ rust/src/service/mod.rs | 115 +++++++++++ rust/src/utils/mod.rs | 80 ++++++++ 9 files changed, 796 insertions(+) create mode 100644 rust/.gitignore create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100755 rust/build.sh create mode 100644 rust/src/cmd/mod.rs create mode 100644 rust/src/main.rs create mode 100644 rust/src/models/mod.rs create mode 100644 rust/src/service/mod.rs create mode 100644 rust/src/utils/mod.rs diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..8a6bea3 --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1,18 @@ +# Generated by Cargo +/target/ + +# Backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# IDEs and editors +/.idea/ +/.vscode/ +*.swp +*.swo \ No newline at end of file diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..93e7771 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "popcorn-cli" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +clap = "4.5.3" +reqwest = { version = "0.11", features = ["json", "multipart"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +ratatui = "0.26.1" +crossterm = "0.27.0" +anyhow = "1.0" \ No newline at end of file diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..0074ec4 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,56 @@ +# Popcorn CLI (Rust Version) + +A Rust implementation of the Popcorn CLI tool for interacting with the Popcorn GPU service. + +## Features + +- Submit code to Popcorn GPU service +- Select from available leaderboards +- Choose GPU configurations +- Multiple submission modes (test, benchmark, leaderboard) + +## Requirements + +- Rust 1.56.0 or later +- A valid Popcorn API URL + +## Setup + +```bash +# Clone the repository +git clone https://github.com/your-username/popcorn-cli +cd popcorn-cli/rust + +# Build the project +cargo build --release + +# Set the API URL +export POPCORN_API_URL=https://your-popcorn-api-url +``` + +## Usage + +```bash +# Run the CLI tool +cargo run --release -- /path/to/your/file.py +``` + +### Popcorn Directives + +You can add directives to your code files to pre-select leaderboards and GPUs: + +```python +#!POPCORN leaderboard matrix_multiplication +#!POPCORN gpu A100 +``` + +Or in other languages: + +```cpp +//!POPCORN leaderboard matrix_multiplication +//!POPCORN gpu A100 +``` + +## License + +[Same as original project license] \ No newline at end of file diff --git a/rust/build.sh b/rust/build.sh new file mode 100755 index 0000000..b088e49 --- /dev/null +++ b/rust/build.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "Building Popcorn CLI (Rust version)..." +cargo build --release + +echo "Build complete! Binary is available at: target/release/popcorn-cli" +echo "Run with: ./target/release/popcorn-cli " \ No newline at end of file diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs new file mode 100644 index 0000000..892dccb --- /dev/null +++ b/rust/src/cmd/mod.rs @@ -0,0 +1,408 @@ +use std::io::{self, Write}; +use std::fs; +use std::path::Path; + +use anyhow::{Result, anyhow}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use ratatui::prelude::*; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::style::{Color, Style}; + +use crate::models::{LeaderboardItem, GpuItem, SubmissionModeItem, ModelState}; +use crate::service; +use crate::utils; + +pub struct App { + pub filepath: String, + pub leaderboards: Vec, + pub leaderboards_state: ListState, + pub selected_leaderboard: Option, + pub gpus: Vec, + pub gpus_state: ListState, + pub selected_gpu: Option, + pub submission_modes: Vec, + pub submission_modes_state: ListState, + pub selected_submission_mode: Option, + pub modal_state: ModelState, + pub final_status: Option, + pub is_loading: bool, + pub should_quit: bool, +} + +impl App { + pub fn new>(filepath: P) -> Self { + let submission_modes = vec![ + SubmissionModeItem::new( + "Test".to_string(), + "Test the solution and give detailed results about passed/failed tests.".to_string(), + "test".to_string(), + ), + SubmissionModeItem::new( + "Benchmark".to_string(), + "Benchmark the solution, this also runs the tests and afterwards runs the benchmark, returning detailed timing results".to_string(), + "benchmark".to_string(), + ), + SubmissionModeItem::new( + "Leaderboard".to_string(), + "Submit to the leaderboard, this first runs public tests and then private tests. If both pass, the submission is evaluated and submit to the leaderboard.".to_string(), + "leaderboard".to_string(), + ), + SubmissionModeItem::new( + "Private".to_string(), + "TODO".to_string(), + "private".to_string(), + ), + SubmissionModeItem::new( + "Script".to_string(), + "TODO".to_string(), + "script".to_string(), + ), + SubmissionModeItem::new( + "Profile".to_string(), + "TODO".to_string(), + "profile".to_string(), + ), + ]; + + let mut app = Self { + filepath: filepath.as_ref().to_string_lossy().to_string(), + leaderboards: Vec::new(), + leaderboards_state: ListState::default(), + selected_leaderboard: None, + gpus: Vec::new(), + gpus_state: ListState::default(), + selected_gpu: None, + submission_modes, + submission_modes_state: ListState::default(), + selected_submission_mode: None, + modal_state: ModelState::LeaderboardSelection, + final_status: None, + is_loading: false, + should_quit: false, + }; + + // Initialize list states + app.leaderboards_state.select(Some(0)); + app.gpus_state.select(Some(0)); + app.submission_modes_state.select(Some(0)); + + app + } + + pub fn initialize_with_directives(&mut self, popcorn_directives: utils::PopcornDirectives) { + if !popcorn_directives.leaderboard_name.is_empty() { + self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); + + if !popcorn_directives.gpus.is_empty() { + self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::GpuSelection; + } + } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { + match key.code { + KeyCode::Char('q') | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + return Ok(true); + } + KeyCode::Enter => { + match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len() { + self.selected_leaderboard = Some(self.leaderboards[idx].title_text.clone()); + + if self.selected_gpu.is_none() { + self.modal_state = ModelState::GpuSelection; + } else { + self.modal_state = ModelState::SubmissionModeSelection; + } + return Ok(true); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len() { + self.selected_gpu = Some(self.gpus[idx].title_text.clone()); + self.modal_state = ModelState::SubmissionModeSelection; + return Ok(true); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx < self.submission_modes.len() { + self.selected_submission_mode = Some(self.submission_modes[idx].value.clone()); + self.modal_state = ModelState::WaitingForResult; + self.is_loading = true; + return Ok(true); + } + } + } + _ => {} + } + } + KeyCode::Up => { + self.move_selection_up(); + return Ok(true); + } + KeyCode::Down => { + self.move_selection_down(); + return Ok(true); + } + _ => {} + } + + Ok(false) + } + + fn move_selection_up(&mut self) { + match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx > 0 { + self.leaderboards_state.select(Some(idx - 1)); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx > 0 { + self.gpus_state.select(Some(idx - 1)); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx > 0 { + self.submission_modes_state.select(Some(idx - 1)); + } + } + } + _ => {} + } + } + + fn move_selection_down(&mut self) { + match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len() - 1 { + self.leaderboards_state.select(Some(idx + 1)); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len() - 1 { + self.gpus_state.select(Some(idx + 1)); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx < self.submission_modes.len() - 1 { + self.submission_modes_state.select(Some(idx + 1)); + } + } + } + _ => {} + } + } + + pub async fn load_leaderboards(&mut self) -> Result<()> { + self.leaderboards = service::fetch_leaderboards().await?; + Ok(()) + } + + pub async fn load_gpus(&mut self) -> Result<()> { + if let Some(leaderboard) = &self.selected_leaderboard { + self.gpus = service::fetch_available_gpus(leaderboard).await?; + if self.gpus.is_empty() { + return Err(anyhow!("No GPUs available for this leaderboard.")); + } + } + Ok(()) + } + + pub async fn submit_solution(&mut self) -> Result<()> { + let leaderboard = self.selected_leaderboard.as_ref() + .ok_or_else(|| anyhow!("No leaderboard selected"))?; + + let gpu = self.selected_gpu.as_ref() + .ok_or_else(|| anyhow!("No GPU selected"))?; + + let submission_mode = self.selected_submission_mode.as_ref() + .ok_or_else(|| anyhow!("No submission mode selected"))?; + + let file_content = fs::read(&self.filepath)?; + + let result = service::submit_solution( + leaderboard, + gpu, + submission_mode, + &self.filepath, + &file_content + ).await?; + + self.final_status = Some(result); + self.should_quit = true; + + Ok(()) + } +} + +pub fn ui(app: &App, frame: &mut Frame) { + let chunks = Layout::default() + .margin(1) + .constraints([Constraint::Min(0)].as_ref()) + .split(frame.size()); + + match app.modal_state { + ModelState::LeaderboardSelection => { + let items: Vec = app.leaderboards + .iter() + .map(|item| { + ListItem::new(format!("{}\n{}", item.title(), item.description())) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("Leaderboards").borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); + + frame.render_stateful_widget(list, chunks[0], &mut app.leaderboards_state.clone()); + } + ModelState::GpuSelection => { + let items: Vec = app.gpus + .iter() + .map(|item| { + ListItem::new(item.title()) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("GPUs").borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); + + frame.render_stateful_widget(list, chunks[0], &mut app.gpus_state.clone()); + } + ModelState::SubmissionModeSelection => { + let items: Vec = app.submission_modes + .iter() + .map(|item| { + ListItem::new(format!("{}\n{}", item.title(), item.description())) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().title("Submission Mode").borders(Borders::ALL)) + .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); + + frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); + } + ModelState::WaitingForResult => { + let text = "Submitting solution... press Ctrl+C to quit"; + + let paragraph = Paragraph::new(text) + .block(Block::default().title("Status").borders(Borders::ALL)) + .alignment(Alignment::Center); + + frame.render_widget(paragraph, chunks[0]); + } + } +} + +pub async fn execute() -> Result<()> { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + println!("Usage: popcorn "); + return Ok(()); + } + + let filepath = &args[1]; + let path = Path::new(filepath); + + if !path.exists() { + println!("File does not exist: {}", filepath); + return Ok(()); + } + + let (popcorn_directives, error) = utils::get_popcorn_directives(filepath)?; + + if let Some(error_msg) = error { + println!("Error: {}", error_msg); + print!("Continue? [y/N] "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim().to_lowercase() != "y" { + return Ok(()); + } + } + + // Initialize app + let mut app = App::new(filepath); + app.initialize_with_directives(popcorn_directives); + + // Initialize terminal + enable_raw_mode()?; + let stdout = io::stdout(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + // Load initial data + if app.modal_state == ModelState::LeaderboardSelection { + app.load_leaderboards().await?; + } + + if app.modal_state == ModelState::GpuSelection { + app.load_gpus().await?; + } + + // Main event loop + loop { + terminal.draw(|frame| ui(&app, frame))?; + + if app.is_loading && app.modal_state == ModelState::WaitingForResult { + if let Err(e) = app.submit_solution().await { + app.final_status = Some(format!("Error: {}", e)); + app.should_quit = true; + } + app.is_loading = false; + } + + if app.should_quit { + break; + } + + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + app.handle_key_event(key)?; + } + } + } + + // Restore terminal + disable_raw_mode()?; + terminal.clear()?; + terminal.show_cursor()?; + + // Display results + utils::display_ascii_art(); + + if let Some(status) = app.final_status { + println!("\nResult:\n\n{}\n", status); + } + + Ok(()) +} \ No newline at end of file diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..7c3446e --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,20 @@ +mod cmd; +mod models; +mod service; +mod utils; + +use std::env; +use std::process; + +#[tokio::main] +async fn main() { + if env::var("POPCORN_API_URL").is_err() { + eprintln!("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API."); + process::exit(1); + } + + if let Err(e) = cmd::execute().await { + eprintln!("Application error: {}", e); + process::exit(1); + } +} \ No newline at end of file diff --git a/rust/src/models/mod.rs b/rust/src/models/mod.rs new file mode 100644 index 0000000..b9ce252 --- /dev/null +++ b/rust/src/models/mod.rs @@ -0,0 +1,75 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug)] +pub struct LeaderboardItem { + pub title_text: String, + pub task_description: String, +} + +impl LeaderboardItem { + pub fn new(title_text: String, task_description: String) -> Self { + Self { + title_text, + task_description, + } + } + + pub fn title(&self) -> &str { + &self.title_text + } + + pub fn description(&self) -> &str { + &self.task_description + } +} + +#[derive(Clone, Debug)] +pub struct GpuItem { + pub title_text: String, +} + +impl GpuItem { + pub fn new(title_text: String) -> Self { + Self { title_text } + } + + pub fn title(&self) -> &str { + &self.title_text + } +} + +#[derive(Clone, Debug)] +pub struct SubmissionModeItem { + pub title_text: String, + pub description_text: String, + pub value: String, +} + +impl SubmissionModeItem { + pub fn new(title_text: String, description_text: String, value: String) -> Self { + Self { + title_text, + description_text, + value, + } + } + + pub fn title(&self) -> &str { + &self.title_text + } + + pub fn description(&self) -> &str { + &self.description_text + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ModelState { + LeaderboardSelection, + GpuSelection, + SubmissionModeSelection, + WaitingForResult, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SubmissionResultMsg(pub String); \ No newline at end of file diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs new file mode 100644 index 0000000..6c5c967 --- /dev/null +++ b/rust/src/service/mod.rs @@ -0,0 +1,115 @@ +use anyhow::{Result, anyhow}; +use reqwest::multipart::{Form, Part}; +use reqwest::Client; +use serde_json::Value; +use std::env; +use std::path::Path; +use std::time::Duration; + +use crate::models::{LeaderboardItem, GpuItem}; + +pub async fn fetch_leaderboards() -> Result> { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let client = Client::new(); + let resp = client + .get(format!("{}/leaderboards", base_url)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + return Err(anyhow!("Failed to fetch leaderboards: {}", status)); + } + + let leaderboards: Vec = resp.json().await?; + + let mut leaderboard_items = Vec::new(); + for lb in leaderboards { + let task = lb["task"].as_object().ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let name = lb["name"].as_str().ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let description = task["description"].as_str().ok_or_else(|| anyhow!("Invalid JSON structure"))?; + + leaderboard_items.push(LeaderboardItem::new( + name.to_string(), + description.to_string(), + )); + } + + Ok(leaderboard_items) +} + +pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let client = Client::new(); + let resp = client + .get(format!("{}/gpus/{}", base_url, leaderboard)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + return Err(anyhow!("Failed to fetch GPUs: {}", status)); + } + + let gpus: Vec = resp.json().await?; + + let gpu_items = gpus.into_iter() + .map(|gpu| GpuItem::new(gpu)) + .collect(); + + Ok(gpu_items) +} + +pub async fn submit_solution>( + leaderboard: &str, + gpu: &str, + submission_mode: &str, + filename: P, + file_content: &[u8], +) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let filename = filename.as_ref() + .file_name() + .ok_or_else(|| anyhow!("Invalid filename"))? + .to_string_lossy(); + + let part = Part::bytes(file_content.to_vec()) + .file_name(filename.to_string()); + + let form = Form::new().part("file", part); + + let url = format!("{}/{}/{}/{}", + base_url, + leaderboard.to_lowercase(), + gpu.to_lowercase(), + submission_mode.to_lowercase() + ); + + let client = Client::new(); + let resp = client + .post(&url) + .multipart(form) + .timeout(Duration::from_secs(60)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); + } + + let result: Value = resp.json().await?; + + let pretty_result = match result.get("result") { + Some(result_obj) => serde_json::to_string_pretty(result_obj)?, + None => return Err(anyhow!("Invalid response structure")), + }; + + Ok(pretty_result) +} \ No newline at end of file diff --git a/rust/src/utils/mod.rs b/rust/src/utils/mod.rs new file mode 100644 index 0000000..47fcfca --- /dev/null +++ b/rust/src/utils/mod.rs @@ -0,0 +1,80 @@ +use std::fs; +use std::path::Path; +use anyhow::Result; + +pub struct PopcornDirectives { + pub leaderboard_name: String, + pub gpus: Vec, +} + +pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, Option)> { + let content = fs::read_to_string(filepath)?; + + let mut gpus: Vec = Vec::new(); + let mut leaderboard_name = String::new(); + let mut error = None; + + for line in content.lines() { + if !line.starts_with("//") && !line.starts_with("#") { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + + if parts[0] == "//!POPCORN" || parts[0] == "#!POPCORN" { + let arg = parts[1].to_lowercase(); + if arg == "gpu" || arg == "gpus" { + gpus = parts[2..].iter().map(|s| s.to_string()).collect(); + } else if arg == "leaderboard" && parts.len() > 2 { + leaderboard_name = parts[2].to_string(); + } + } + } + + if gpus.len() > 1 { + error = Some(format!("multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", gpus[0])); + gpus = vec![gpus[0].clone()]; + } + + Ok(( + PopcornDirectives { + leaderboard_name, + gpus, + }, + error + )) +} + +pub fn display_ascii_art() { + let art = r#" + _ __ _ ______ _ +| | / / | | | ___ \ | | +| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ +| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| +| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ +\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| + + POPCORN CLI - GPU MODE + + ┌───────────────────────────────────────┐ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ + │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ + │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ + │ └─────┘ └─────┘ └─────┘ │████████│ │▒ + │ │████████│ │▒ + │ ┌────────────────────────┐ │████████│ │▒ + │ │ │ │████████│ │▒ + │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ + │ │ │ │▒ + │ └────────────────────────┘ │▒ + │ │▒ + └───────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ +"#; + println!("{}", art); +} \ No newline at end of file From 98fbbe866991fb3eea6c8730b4da978ca3195ad6 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 9 Apr 2025 01:43:45 +0300 Subject: [PATCH 005/111] Update README.md --- rust/README.md | 55 +------------------------------------------------- 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/rust/README.md b/rust/README.md index 0074ec4..81e3225 100644 --- a/rust/README.md +++ b/rust/README.md @@ -1,56 +1,3 @@ # Popcorn CLI (Rust Version) -A Rust implementation of the Popcorn CLI tool for interacting with the Popcorn GPU service. - -## Features - -- Submit code to Popcorn GPU service -- Select from available leaderboards -- Choose GPU configurations -- Multiple submission modes (test, benchmark, leaderboard) - -## Requirements - -- Rust 1.56.0 or later -- A valid Popcorn API URL - -## Setup - -```bash -# Clone the repository -git clone https://github.com/your-username/popcorn-cli -cd popcorn-cli/rust - -# Build the project -cargo build --release - -# Set the API URL -export POPCORN_API_URL=https://your-popcorn-api-url -``` - -## Usage - -```bash -# Run the CLI tool -cargo run --release -- /path/to/your/file.py -``` - -### Popcorn Directives - -You can add directives to your code files to pre-select leaderboards and GPUs: - -```python -#!POPCORN leaderboard matrix_multiplication -#!POPCORN gpu A100 -``` - -Or in other languages: - -```cpp -//!POPCORN leaderboard matrix_multiplication -//!POPCORN gpu A100 -``` - -## License - -[Same as original project license] \ No newline at end of file +Run `./build.sh` and then ` POPCORN_API_URL="http://127.0.0.1:8000" target/release/popcorn-cli ../../discord/discord-cluster-manager/reference-kernels/problems/pmpp/grayscale_py/submission.py` From a04ac6149a5b1df93ae4961fdc9dd0b4a379caa6 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 15:19:47 +0200 Subject: [PATCH 006/111] Feat: screen fix --- rust/src/cmd/mod.rs | 292 ++++++++++++++++++++++++---------------- rust/src/service/mod.rs | 5 +- rust/src/utils/mod.rs | 10 +- 3 files changed, 182 insertions(+), 125 deletions(-) diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 892dccb..02cd0cf 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -1,15 +1,15 @@ -use std::io::{self, Write}; use std::fs; +use std::io::{self, Write}; use std::path::Path; -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen}; use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::style::{Color, Style}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; -use crate::models::{LeaderboardItem, GpuItem, SubmissionModeItem, ModelState}; +use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; use crate::service; use crate::utils; @@ -64,7 +64,7 @@ impl App { "profile".to_string(), ), ]; - + let mut app = Self { filepath: filepath.as_ref().to_string_lossy().to_string(), leaderboards: Vec::new(), @@ -81,19 +81,19 @@ impl App { is_loading: false, should_quit: false, }; - + // Initialize list states app.leaderboards_state.select(Some(0)); app.gpus_state.select(Some(0)); app.submission_modes_state.select(Some(0)); - + app } - + pub fn initialize_with_directives(&mut self, popcorn_directives: utils::PopcornDirectives) { if !popcorn_directives.leaderboard_name.is_empty() { self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); - + if !popcorn_directives.gpus.is_empty() { self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); self.modal_state = ModelState::SubmissionModeSelection; @@ -102,51 +102,53 @@ impl App { } } } - + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { match key.code { - KeyCode::Char('q') | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Char('q') | KeyCode::Char('c') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { self.should_quit = true; return Ok(true); } - KeyCode::Enter => { - match self.modal_state { - ModelState::LeaderboardSelection => { - if let Some(idx) = self.leaderboards_state.selected() { - if idx < self.leaderboards.len() { - self.selected_leaderboard = Some(self.leaderboards[idx].title_text.clone()); - - if self.selected_gpu.is_none() { - self.modal_state = ModelState::GpuSelection; - } else { - self.modal_state = ModelState::SubmissionModeSelection; - } - return Ok(true); + KeyCode::Enter => match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len() { + self.selected_leaderboard = + Some(self.leaderboards[idx].title_text.clone()); + + if self.selected_gpu.is_none() { + self.modal_state = ModelState::GpuSelection; + } else { + self.modal_state = ModelState::SubmissionModeSelection; } + return Ok(true); } } - ModelState::GpuSelection => { - if let Some(idx) = self.gpus_state.selected() { - if idx < self.gpus.len() { - self.selected_gpu = Some(self.gpus[idx].title_text.clone()); - self.modal_state = ModelState::SubmissionModeSelection; - return Ok(true); - } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len() { + self.selected_gpu = Some(self.gpus[idx].title_text.clone()); + self.modal_state = ModelState::SubmissionModeSelection; + return Ok(true); } } - ModelState::SubmissionModeSelection => { - if let Some(idx) = self.submission_modes_state.selected() { - if idx < self.submission_modes.len() { - self.selected_submission_mode = Some(self.submission_modes[idx].value.clone()); - self.modal_state = ModelState::WaitingForResult; - self.is_loading = true; - return Ok(true); - } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx < self.submission_modes.len() { + self.selected_submission_mode = + Some(self.submission_modes[idx].value.clone()); + self.modal_state = ModelState::WaitingForResult; + self.is_loading = true; + return Ok(true); } } - _ => {} } - } + _ => {} + }, KeyCode::Up => { self.move_selection_up(); return Ok(true); @@ -157,10 +159,10 @@ impl App { } _ => {} } - + Ok(false) } - + fn move_selection_up(&mut self) { match self.modal_state { ModelState::LeaderboardSelection => { @@ -187,7 +189,7 @@ impl App { _ => {} } } - + fn move_selection_down(&mut self) { match self.modal_state { ModelState::LeaderboardSelection => { @@ -214,45 +216,75 @@ impl App { _ => {} } } - + pub async fn load_leaderboards(&mut self) -> Result<()> { - self.leaderboards = service::fetch_leaderboards().await?; + match service::fetch_leaderboards().await { + Ok(leaderboards) => { + self.leaderboards = leaderboards; + } + Err(e) => { + return Err(e); + } + } Ok(()) } - pub async fn load_gpus(&mut self) -> Result<()> { if let Some(leaderboard) = &self.selected_leaderboard { - self.gpus = service::fetch_available_gpus(leaderboard).await?; - if self.gpus.is_empty() { - return Err(anyhow!("No GPUs available for this leaderboard.")); + match service::fetch_available_gpus(leaderboard).await { + Ok(gpus) => { + self.gpus = gpus; + if self.gpus.is_empty() { + return Err(anyhow!("No GPUs available for this leaderboard.")); + } + } + Err(e) => { + if e.to_string().contains("Invalid leaderboard name") { + return Err(anyhow!("Invalid leaderboard name: '{}'. Please check if the leaderboard exists.", leaderboard)); + } + return Err(e); + } } } Ok(()) } - + pub async fn submit_solution(&mut self) -> Result<()> { - let leaderboard = self.selected_leaderboard.as_ref() + let leaderboard = self + .selected_leaderboard + .as_ref() .ok_or_else(|| anyhow!("No leaderboard selected"))?; - - let gpu = self.selected_gpu.as_ref() + + let gpu = self + .selected_gpu + .as_ref() .ok_or_else(|| anyhow!("No GPU selected"))?; - - let submission_mode = self.selected_submission_mode.as_ref() + + let submission_mode = self + .selected_submission_mode + .as_ref() .ok_or_else(|| anyhow!("No submission mode selected"))?; - + let file_content = fs::read(&self.filepath)?; - + let result = service::submit_solution( leaderboard, gpu, submission_mode, &self.filepath, - &file_content - ).await?; - - self.final_status = Some(result); - self.should_quit = true; - + &file_content, + ) + .await; + + match result { + Ok(result) => { + self.final_status = Some(result); + self.should_quit = true; + } + Err(e) => { + return Err(e); + } + } + Ok(()) } } @@ -262,57 +294,58 @@ pub fn ui(app: &App, frame: &mut Frame) { .margin(1) .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); - + match app.modal_state { ModelState::LeaderboardSelection => { - let items: Vec = app.leaderboards + let items: Vec = app + .leaderboards .iter() - .map(|item| { - ListItem::new(format!("{}\n{}", item.title(), item.description())) - }) + .map(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) .collect(); - + let list = List::new(items) .block(Block::default().title("Leaderboards").borders(Borders::ALL)) .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - + frame.render_stateful_widget(list, chunks[0], &mut app.leaderboards_state.clone()); } ModelState::GpuSelection => { - let items: Vec = app.gpus + let items: Vec = app + .gpus .iter() - .map(|item| { - ListItem::new(item.title()) - }) + .map(|item| ListItem::new(item.title())) .collect(); - + let list = List::new(items) .block(Block::default().title("GPUs").borders(Borders::ALL)) .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - + frame.render_stateful_widget(list, chunks[0], &mut app.gpus_state.clone()); } ModelState::SubmissionModeSelection => { - let items: Vec = app.submission_modes + let items: Vec = app + .submission_modes .iter() - .map(|item| { - ListItem::new(format!("{}\n{}", item.title(), item.description())) - }) + .map(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) .collect(); - + let list = List::new(items) - .block(Block::default().title("Submission Mode").borders(Borders::ALL)) + .block( + Block::default() + .title("Submission Mode") + .borders(Borders::ALL), + ) .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - + frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); } ModelState::WaitingForResult => { let text = "Submitting solution... press Ctrl+C to quit"; - + let paragraph = Paragraph::new(text) .block(Block::default().title("Status").borders(Borders::ALL)) .alignment(Alignment::Center); - + frame.render_widget(paragraph, chunks[0]); } } @@ -320,59 +353,71 @@ pub fn ui(app: &App, frame: &mut Frame) { pub async fn execute() -> Result<()> { let args: Vec = std::env::args().collect(); - + if args.len() < 2 { println!("Usage: popcorn "); return Ok(()); } - + let filepath = &args[1]; let path = Path::new(filepath); - + if !path.exists() { println!("File does not exist: {}", filepath); return Ok(()); } - - let (popcorn_directives, error) = utils::get_popcorn_directives(filepath)?; - - if let Some(error_msg) = error { - println!("Error: {}", error_msg); + + let (popcorn_directives, has_multiple_gpus) = utils::get_popcorn_directives(filepath)?; + + if has_multiple_gpus { + println!("Error: multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", popcorn_directives.gpus[0]); print!("Continue? [y/N] "); io::stdout().flush()?; - + let mut input = String::new(); io::stdin().read_line(&mut input)?; - + if input.trim().to_lowercase() != "y" { return Ok(()); } } - + // Initialize app let mut app = App::new(filepath); app.initialize_with_directives(popcorn_directives); - + // Initialize terminal enable_raw_mode()?; - let stdout = io::stdout(); - let backend = CrosstermBackend::new(stdout); + crossterm::execute!(io::stdout(), EnterAlternateScreen)?; + let backend = CrosstermBackend::new(io::stdout()); let mut terminal = Terminal::new(backend)?; terminal.clear()?; - + // Load initial data if app.modal_state == ModelState::LeaderboardSelection { - app.load_leaderboards().await?; + match app.load_leaderboards().await { + Ok(_) => {} + Err(e) => { + app.final_status = Some(format!("Error: {}", e)); + app.should_quit = true; + } + } } - + if app.modal_state == ModelState::GpuSelection { - app.load_gpus().await?; + match app.load_gpus().await { + Ok(_) => {} + Err(e) => { + app.final_status = Some(format!("Error: {}", e)); + app.should_quit = true; + } + } } - + // Main event loop loop { terminal.draw(|frame| ui(&app, frame))?; - + if app.is_loading && app.modal_state == ModelState::WaitingForResult { if let Err(e) = app.submit_solution().await { app.final_status = Some(format!("Error: {}", e)); @@ -380,29 +425,40 @@ pub async fn execute() -> Result<()> { } app.is_loading = false; } - + if app.should_quit { break; } - + if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { app.handle_key_event(key)?; } } } - - // Restore terminal - disable_raw_mode()?; + terminal.clear()?; - terminal.show_cursor()?; - - // Display results + disable_raw_mode()?; + + crossterm::execute!( + io::stdout(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::cursor::Show + )?; + + std::thread::sleep(std::time::Duration::from_millis(100)); + + crossterm::execute!( + io::stdout(), + crossterm::terminal::Clear(crossterm::terminal::ClearType::All), + crossterm::cursor::MoveTo(0, 0) + )?; + utils::display_ascii_art(); - + if let Some(status) = app.final_status { println!("\nResult:\n\n{}\n", status); } - + Ok(()) -} \ No newline at end of file +} diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index 6c5c967..aea05bf 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -52,7 +52,8 @@ pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { let status = resp.status(); if !status.is_success() { - return Err(anyhow!("Failed to fetch GPUs: {}", status)); + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); } let gpus: Vec = resp.json().await?; @@ -112,4 +113,4 @@ pub async fn submit_solution>( }; Ok(pretty_result) -} \ No newline at end of file +} diff --git a/rust/src/utils/mod.rs b/rust/src/utils/mod.rs index 47fcfca..cb29342 100644 --- a/rust/src/utils/mod.rs +++ b/rust/src/utils/mod.rs @@ -7,12 +7,12 @@ pub struct PopcornDirectives { pub gpus: Vec, } -pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, Option)> { +pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, bool)> { let content = fs::read_to_string(filepath)?; let mut gpus: Vec = Vec::new(); let mut leaderboard_name = String::new(); - let mut error = None; + let mut has_multiple_gpus = false; for line in content.lines() { if !line.starts_with("//") && !line.starts_with("#") { @@ -35,7 +35,7 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir } if gpus.len() > 1 { - error = Some(format!("multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", gpus[0])); + has_multiple_gpus = true; gpus = vec![gpus[0].clone()]; } @@ -44,7 +44,7 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir leaderboard_name, gpus, }, - error + has_multiple_gpus )) } @@ -77,4 +77,4 @@ pub fn display_ascii_art() { ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ "#; println!("{}", art); -} \ No newline at end of file +} From c8ffe91a3dd49a2a5fb6c52f07b84fa0bcb102e9 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 17:56:59 +0200 Subject: [PATCH 007/111] Refactor: huge refactor --- rust/Cargo.toml | 3 +- rust/src/cmd/mod.rs | 354 ++++++++++++++++++++++++++++------------ rust/src/service/mod.rs | 83 +++++----- 3 files changed, 295 insertions(+), 145 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 93e7771..2718973 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -13,4 +13,5 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" ratatui = "0.26.1" crossterm = "0.27.0" -anyhow = "1.0" \ No newline at end of file +anyhow = "1.0" +ctrlc = "3.4.6" diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 02cd0cf..9ca80c9 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -8,6 +8,7 @@ use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScree use ratatui::prelude::*; use ratatui::style::{Color, Style}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use tokio::task::JoinHandle; use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; use crate::service; @@ -26,8 +27,11 @@ pub struct App { pub selected_submission_mode: Option, pub modal_state: ModelState, pub final_status: Option, - pub is_loading: bool, + pub loading_message: Option, pub should_quit: bool, + pub submission_task: Option>>, + pub leaderboards_task: Option, anyhow::Error>>>, + pub gpus_task: Option, anyhow::Error>>>, } impl App { @@ -78,8 +82,11 @@ impl App { selected_submission_mode: None, modal_state: ModelState::LeaderboardSelection, final_status: None, - is_loading: false, + loading_message: None, should_quit: false, + submission_task: None, + leaderboards_task: None, + gpus_task: None, }; // Initialize list states @@ -100,14 +107,33 @@ impl App { } else { self.modal_state = ModelState::GpuSelection; } + } else if !popcorn_directives.gpus.is_empty() { + self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + if !popcorn_directives.leaderboard_name.is_empty() { + self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::LeaderboardSelection; + } + } else { + self.modal_state = ModelState::LeaderboardSelection; } } pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { + // Allow quitting anytime, even while loading + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.should_quit = true; + return Ok(true); + } + + // Ignore other keys while loading + if self.loading_message.is_some() { + return Ok(false); + } + match key.code { - KeyCode::Char('q') | KeyCode::Char('c') - if key.modifiers.contains(KeyModifiers::CONTROL) => - { + KeyCode::Char('q') => { self.should_quit = true; return Ok(true); } @@ -120,6 +146,13 @@ impl App { if self.selected_gpu.is_none() { self.modal_state = ModelState::GpuSelection; + // Spawn GPU loading task + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + } } else { self.modal_state = ModelState::SubmissionModeSelection; } @@ -141,13 +174,19 @@ impl App { if idx < self.submission_modes.len() { self.selected_submission_mode = Some(self.submission_modes[idx].value.clone()); - self.modal_state = ModelState::WaitingForResult; - self.is_loading = true; + self.modal_state = ModelState::WaitingForResult; // State for logic, UI uses loading msg + // Spawn the submission task + if let Err(e) = self.spawn_submit_solution() { + self.set_error_and_quit(format!( + "Error starting submission: {}", + e + )); + } return Ok(true); } } } - _ => {} + _ => {} // WaitingForResult state doesn't handle Enter }, KeyCode::Up => { self.move_selection_up(); @@ -157,12 +196,19 @@ impl App { self.move_selection_down(); return Ok(true); } - _ => {} + _ => {} // Ignore other keys } Ok(false) } + // Helper to reduce repetition + fn set_error_and_quit(&mut self, error_message: String) { + self.final_status = Some(error_message); + self.should_quit = true; + self.loading_message = None; // Clear loading on error + } + fn move_selection_up(&mut self) { match self.modal_state { ModelState::LeaderboardSelection => { @@ -194,21 +240,21 @@ impl App { match self.modal_state { ModelState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { - if idx < self.leaderboards.len() - 1 { + if idx < self.leaderboards.len().saturating_sub(1) { self.leaderboards_state.select(Some(idx + 1)); } } } ModelState::GpuSelection => { if let Some(idx) = self.gpus_state.selected() { - if idx < self.gpus.len() - 1 { + if idx < self.gpus.len().saturating_sub(1) { self.gpus_state.select(Some(idx + 1)); } } } ModelState::SubmissionModeSelection => { if let Some(idx) = self.submission_modes_state.selected() { - if idx < self.submission_modes.len() - 1 { + if idx < self.submission_modes.len().saturating_sub(1) { self.submission_modes_state.select(Some(idx + 1)); } } @@ -217,75 +263,166 @@ impl App { } } - pub async fn load_leaderboards(&mut self) -> Result<()> { - match service::fetch_leaderboards().await { - Ok(leaderboards) => { - self.leaderboards = leaderboards; - } - Err(e) => { - return Err(e); - } + pub fn spawn_load_leaderboards(&mut self) -> Result<()> { + if self.leaderboards_task.is_some() { + return Ok(()); } + self.loading_message = Some("Fetching leaderboards...".to_string()); + let handle = tokio::spawn(async { service::fetch_leaderboards().await }); + self.leaderboards_task = Some(handle); Ok(()) } - pub async fn load_gpus(&mut self) -> Result<()> { - if let Some(leaderboard) = &self.selected_leaderboard { - match service::fetch_available_gpus(leaderboard).await { - Ok(gpus) => { - self.gpus = gpus; - if self.gpus.is_empty() { - return Err(anyhow!("No GPUs available for this leaderboard.")); - } - } - Err(e) => { - if e.to_string().contains("Invalid leaderboard name") { - return Err(anyhow!("Invalid leaderboard name: '{}'. Please check if the leaderboard exists.", leaderboard)); - } - return Err(e); - } - } + + pub fn spawn_load_gpus(&mut self) -> Result<()> { + if self.gpus_task.is_some() { + return Ok(()); } + let leaderboard = self + .selected_leaderboard + .clone() + .ok_or_else(|| anyhow!("Cannot load GPUs without a selected leaderboard."))?; + + self.loading_message = Some("Fetching GPUs...".to_string()); + + let handle = tokio::spawn(async move { service::fetch_available_gpus(&leaderboard).await }); + self.gpus_task = Some(handle); Ok(()) } - pub async fn submit_solution(&mut self) -> Result<()> { + pub fn spawn_submit_solution(&mut self) -> Result<()> { + if self.submission_task.is_some() { + return Ok(()); + } let leaderboard = self .selected_leaderboard - .as_ref() - .ok_or_else(|| anyhow!("No leaderboard selected"))?; + .clone() + .ok_or_else(|| anyhow!("Internal Error: No leaderboard selected"))?; let gpu = self .selected_gpu - .as_ref() - .ok_or_else(|| anyhow!("No GPU selected"))?; + .clone() + .ok_or_else(|| anyhow!("Internal Error: No GPU selected"))?; let submission_mode = self .selected_submission_mode - .as_ref() - .ok_or_else(|| anyhow!("No submission mode selected"))?; - - let file_content = fs::read(&self.filepath)?; - - let result = service::submit_solution( - leaderboard, - gpu, - submission_mode, - &self.filepath, - &file_content, - ) - .await; - - match result { - Ok(result) => { - self.final_status = Some(result); - self.should_quit = true; + .clone() + .ok_or_else(|| anyhow!("Internal Error: No submission mode selected"))?; + + let filepath = self.filepath.clone(); + + self.loading_message = Some("Submitting solution...".to_string()); + + let handle = tokio::spawn(async move { + match fs::read(&filepath) { + Ok(file_content) => { + service::submit_solution( + &leaderboard, + &gpu, + &submission_mode, + &filepath, + &file_content, + ) + .await + } + Err(e) => Err(anyhow!("Failed to read file {}: {}", filepath, e)), } - Err(e) => { - return Err(e); + }); + self.submission_task = Some(handle); + Ok(()) + } + + pub async fn check_leaderboard_task(&mut self) { + let mut result_to_process: Option<_> = None; + if let Some(handle) = self.leaderboards_task.as_mut() { + if handle.is_finished() { + // Task is finished, take it and await the result. + if let Some(h) = self.leaderboards_task.take() { + result_to_process = Some(h.await); + } } } - Ok(()) + if let Some(join_result) = result_to_process { + match join_result { + Ok(Ok(leaderboards)) => { + self.leaderboards = leaderboards; + if !self.leaderboards.is_empty() { + self.leaderboards_state.select(Some(0)); + } else { + self.leaderboards_state.select(None); // Ensure selection is cleared if empty + } + self.loading_message = None; // Clear loading on success + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)); + } + Err(e) => { + // This usually means the task panicked. + self.set_error_and_quit(format!("Leaderboard fetch task failed: {}", e)); + } + } + } + } + + pub async fn check_gpu_task(&mut self) { + let mut result_to_process: Option<_> = None; + if let Some(handle) = self.gpus_task.as_mut() { + if handle.is_finished() { + if let Some(h) = self.gpus_task.take() { + result_to_process = Some(h.await); + } + } + } + + if let Some(join_result) = result_to_process { + match join_result { + Ok(Ok(gpus)) => { + self.gpus = gpus; + if self.gpus.is_empty() { + self.set_error_and_quit( + "No GPUs available for the selected leaderboard.".to_string(), + ); + self.gpus_state.select(None); // Clear selection if empty + } else { + self.gpus_state.select(Some(0)); + } + self.loading_message = None; // Clear loading on success + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Error fetching GPUs: {}", e)); + } + Err(e) => { + self.set_error_and_quit(format!("GPU fetch task failed: {}", e)); + } + } + } + } + + pub async fn check_submission_task(&mut self) { + let mut result_to_process: Option<_> = None; + if let Some(handle) = self.submission_task.as_mut() { + if handle.is_finished() { + if let Some(h) = self.submission_task.take() { + result_to_process = Some(h.await); + } + } + } + + if let Some(join_result) = result_to_process { + match join_result { + Ok(Ok(result)) => { + self.final_status = Some(result); + self.should_quit = true; + self.loading_message = None; + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Submission failed: {}", e)); + } + Err(e) => { + self.set_error_and_quit(format!("Submission task failed: {}", e)); + } + } + } } } @@ -295,6 +432,15 @@ pub fn ui(app: &App, frame: &mut Frame) { .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); + if let Some(message) = &app.loading_message { + let text = format!("{} (Press Ctrl+C to quit)", message); + let paragraph = Paragraph::new(text) + .block(Block::default().title("Status").borders(Borders::ALL)) + .alignment(Alignment::Center); + frame.render_widget(paragraph, chunks[0]); + return; + } + match app.modal_state { ModelState::LeaderboardSelection => { let items: Vec = app @@ -340,12 +486,8 @@ pub fn ui(app: &App, frame: &mut Frame) { frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); } ModelState::WaitingForResult => { - let text = "Submitting solution... press Ctrl+C to quit"; - - let paragraph = Paragraph::new(text) - .block(Block::default().title("Status").borders(Borders::ALL)) - .alignment(Alignment::Center); - + let paragraph = + Paragraph::new("").block(Block::default().title("Status").borders(Borders::ALL)); frame.render_widget(paragraph, chunks[0]); } } @@ -370,16 +512,10 @@ pub async fn execute() -> Result<()> { let (popcorn_directives, has_multiple_gpus) = utils::get_popcorn_directives(filepath)?; if has_multiple_gpus { - println!("Error: multiple GPUs are not yet supported, continue with the first gpu? ({}) [y/N]", popcorn_directives.gpus[0]); - print!("Continue? [y/N] "); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if input.trim().to_lowercase() != "y" { - return Ok(()); - } + println!( + "Warning: multiple GPUs specified, only the first one ({}) will be used.", + popcorn_directives.gpus[0] + ); } // Initialize app @@ -393,61 +529,65 @@ pub async fn execute() -> Result<()> { let mut terminal = Terminal::new(backend)?; terminal.clear()?; - // Load initial data - if app.modal_state == ModelState::LeaderboardSelection { - match app.load_leaderboards().await { - Ok(_) => {} - Err(e) => { - app.final_status = Some(format!("Error: {}", e)); + // Perform initial data loading by spawning tasks + match app.modal_state { + ModelState::LeaderboardSelection => { + // Spawn the task, handle immediate spawn error + if let Err(e) = app.spawn_load_leaderboards() { + // Error during spawning itself (rare) + app.final_status = Some(format!("Error starting leaderboard fetch: {}", e)); app.should_quit = true; } } - } - - if app.modal_state == ModelState::GpuSelection { - match app.load_gpus().await { - Ok(_) => {} - Err(e) => { - app.final_status = Some(format!("Error: {}", e)); + ModelState::GpuSelection => { + // Spawn the task, handle immediate spawn error + if let Err(e) = app.spawn_load_gpus() { + // Error during spawning itself (e.g., no leaderboard selected) + app.final_status = Some(format!("Error starting GPU fetch: {}", e)); app.should_quit = true; } } + _ => { /* No initial loading needed for other states */ } } // Main event loop - loop { + while !app.should_quit { + // Draw UI (shows loading screen if loading_message is Some) terminal.draw(|frame| ui(&app, frame))?; - if app.is_loading && app.modal_state == ModelState::WaitingForResult { - if let Err(e) = app.submit_solution().await { - app.final_status = Some(format!("Error: {}", e)); - app.should_quit = true; + // Handle events first (to ensure Ctrl+C works during checks below) + if crossterm::event::poll(std::time::Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + app.handle_key_event(key)?; + // If event handling caused quit, break early + if app.should_quit { + break; + } + } } - app.is_loading = false; } - if app.should_quit { - break; - } + app.check_leaderboard_task().await; - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - app.handle_key_event(key)?; - } - } + app.check_gpu_task().await; + + app.check_submission_task().await; } + // Cleanup terminal terminal.clear()?; disable_raw_mode()?; - crossterm::execute!( io::stdout(), crossterm::terminal::LeaveAlternateScreen, crossterm::cursor::Show )?; - std::thread::sleep(std::time::Duration::from_millis(100)); + // Brief pause allows the terminal to restore properly before printing final output + std::thread::sleep(std::time::Duration::from_millis(50)); + // Clear screen again and move cursor to top-left for final output crossterm::execute!( io::stdout(), crossterm::terminal::Clear(crossterm::terminal::ClearType::All), diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index aea05bf..8b42fb9 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use reqwest::multipart::{Form, Part}; use reqwest::Client; use serde_json::Value; @@ -6,62 +6,69 @@ use std::env; use std::path::Path; use std::time::Duration; -use crate::models::{LeaderboardItem, GpuItem}; +use crate::models::{GpuItem, LeaderboardItem}; pub async fn fetch_leaderboards() -> Result> { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let client = Client::new(); let resp = client .get(format!("{}/leaderboards", base_url)) .timeout(Duration::from_secs(30)) .send() .await?; - + let status = resp.status(); if !status.is_success() { - return Err(anyhow!("Failed to fetch leaderboards: {}", status)); + let error_text = resp.text().await?; + return Err(anyhow!("Server returned status {}: {}", status, error_text)); } - + let leaderboards: Vec = resp.json().await?; - + let mut leaderboard_items = Vec::new(); for lb in leaderboards { - let task = lb["task"].as_object().ok_or_else(|| anyhow!("Invalid JSON structure"))?; - let name = lb["name"].as_str().ok_or_else(|| anyhow!("Invalid JSON structure"))?; - let description = task["description"].as_str().ok_or_else(|| anyhow!("Invalid JSON structure"))?; - + let task = lb["task"] + .as_object() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let name = lb["name"] + .as_str() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + let description = task["description"] + .as_str() + .ok_or_else(|| anyhow!("Invalid JSON structure"))?; + leaderboard_items.push(LeaderboardItem::new( name.to_string(), description.to_string(), )); } - + Ok(leaderboard_items) } pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let client = Client::new(); let resp = client .get(format!("{}/gpus/{}", base_url, leaderboard)) - .timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(120)) .send() .await?; - + let status = resp.status(); if !status.is_success() { let error_text = resp.text().await?; return Err(anyhow!("Server returned status {}: {}", status, error_text)); } - + let gpus: Vec = resp.json().await?; - - let gpu_items = gpus.into_iter() - .map(|gpu| GpuItem::new(gpu)) - .collect(); - + + let gpu_items = gpus.into_iter().map(|gpu| GpuItem::new(gpu)).collect(); + Ok(gpu_items) } @@ -72,25 +79,27 @@ pub async fn submit_solution>( filename: P, file_content: &[u8], ) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - - let filename = filename.as_ref() + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let filename = filename + .as_ref() .file_name() .ok_or_else(|| anyhow!("Invalid filename"))? .to_string_lossy(); - - let part = Part::bytes(file_content.to_vec()) - .file_name(filename.to_string()); - + + let part = Part::bytes(file_content.to_vec()).file_name(filename.to_string()); + let form = Form::new().part("file", part); - - let url = format!("{}/{}/{}/{}", + + let url = format!( + "{}/{}/{}/{}", base_url, leaderboard.to_lowercase(), gpu.to_lowercase(), submission_mode.to_lowercase() ); - + let client = Client::new(); let resp = client .post(&url) @@ -98,19 +107,19 @@ pub async fn submit_solution>( .timeout(Duration::from_secs(60)) .send() .await?; - + let status = resp.status(); if !status.is_success() { let error_text = resp.text().await?; return Err(anyhow!("Server returned status {}: {}", status, error_text)); } - + let result: Value = resp.json().await?; - + let pretty_result = match result.get("result") { Some(result_obj) => serde_json::to_string_pretty(result_obj)?, None => return Err(anyhow!("Invalid response structure")), }; - + Ok(pretty_result) } From 9ad1ff06e8fbbd544afb9fb006a739ee4a22a296 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 19:12:26 +0200 Subject: [PATCH 008/111] Feat: login + extra refactor --- rust/Cargo.toml | 6 +- rust/src/cmd/mod.rs | 570 +++++++++++++++++++++++++--------------- rust/src/main.rs | 14 +- rust/src/models/mod.rs | 22 +- rust/src/service/mod.rs | 26 +- 5 files changed, 385 insertions(+), 253 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 2718973..920ff3b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -clap = "4.5.3" +clap = { version = "4.5.3", features = ["derive"] } reqwest = { version = "0.11", features = ["json", "multipart"] } tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } @@ -15,3 +15,7 @@ ratatui = "0.26.1" crossterm = "0.27.0" anyhow = "1.0" ctrlc = "3.4.6" +dirs = "5.0" +serde_yaml = "0.9" +webbrowser = "0.8" +base64-url = "3.0.0" diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 9ca80c9..6e607f6 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -1,12 +1,14 @@ -use std::fs; -use std::io::{self, Write}; +use std::fs::File; +use std::io::{self, Read}; use std::path::Path; use anyhow::{anyhow, Result}; +use clap::{Parser, Subcommand}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen}; use ratatui::prelude::*; -use ratatui::style::{Color, Style}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use tokio::task::JoinHandle; @@ -14,6 +16,29 @@ use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; use crate::service; use crate::utils; +mod login; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + command: Option, + + /// Optional: Path to the solution file + filepath: Option, +} + +#[derive(Subcommand, Debug)] +enum Commands { + /// Login to Popcorn via Discord + Login, + /// Submit a solution (default command) + Submit { + /// Path to the solution file + filepath: Option, + }, +} + pub struct App { pub filepath: String, pub leaderboards: Vec, @@ -264,162 +289,155 @@ impl App { } pub fn spawn_load_leaderboards(&mut self) -> Result<()> { - if self.leaderboards_task.is_some() { - return Ok(()); - } - self.loading_message = Some("Fetching leaderboards...".to_string()); - let handle = tokio::spawn(async { service::fetch_leaderboards().await }); - self.leaderboards_task = Some(handle); + let client = service::create_client()?; + self.leaderboards_task = Some(tokio::spawn(async move { + service::fetch_leaderboards(&client).await + })); + self.loading_message = Some("Loading leaderboards...".to_string()); Ok(()) } pub fn spawn_load_gpus(&mut self) -> Result<()> { - if self.gpus_task.is_some() { - return Ok(()); - } - let leaderboard = self + let client = service::create_client()?; + let leaderboard_name = self .selected_leaderboard .clone() - .ok_or_else(|| anyhow!("Cannot load GPUs without a selected leaderboard."))?; - - self.loading_message = Some("Fetching GPUs...".to_string()); - - let handle = tokio::spawn(async move { service::fetch_available_gpus(&leaderboard).await }); - self.gpus_task = Some(handle); + .ok_or_else(|| anyhow!("Leaderboard not selected"))?; + self.gpus_task = Some(tokio::spawn(async move { + service::fetch_gpus(&client, &leaderboard_name).await + })); + self.loading_message = Some("Loading GPUs...".to_string()); Ok(()) } pub fn spawn_submit_solution(&mut self) -> Result<()> { - if self.submission_task.is_some() { - return Ok(()); - } + let client = service::create_client()?; + let filepath = self.filepath.clone(); let leaderboard = self .selected_leaderboard .clone() - .ok_or_else(|| anyhow!("Internal Error: No leaderboard selected"))?; - + .ok_or_else(|| anyhow!("Leaderboard not selected"))?; let gpu = self .selected_gpu .clone() - .ok_or_else(|| anyhow!("Internal Error: No GPU selected"))?; - - let submission_mode = self + .ok_or_else(|| anyhow!("GPU not selected"))?; + let mode = self .selected_submission_mode .clone() - .ok_or_else(|| anyhow!("Internal Error: No submission mode selected"))?; + .ok_or_else(|| anyhow!("Submission mode not selected"))?; - let filepath = self.filepath.clone(); + // Read file content + let mut file = File::open(&filepath)?; + let mut file_content = String::new(); + file.read_to_string(&mut file_content)?; + self.submission_task = Some(tokio::spawn(async move { + service::submit_solution(&client, &filepath, &file_content, &leaderboard, &gpu, &mode) + .await + })); self.loading_message = Some("Submitting solution...".to_string()); - - let handle = tokio::spawn(async move { - match fs::read(&filepath) { - Ok(file_content) => { - service::submit_solution( - &leaderboard, - &gpu, - &submission_mode, - &filepath, - &file_content, - ) - .await - } - Err(e) => Err(anyhow!("Failed to read file {}: {}", filepath, e)), - } - }); - self.submission_task = Some(handle); Ok(()) } pub async fn check_leaderboard_task(&mut self) { - let mut result_to_process: Option<_> = None; - if let Some(handle) = self.leaderboards_task.as_mut() { + if let Some(handle) = &mut self.leaderboards_task { if handle.is_finished() { - // Task is finished, take it and await the result. - if let Some(h) = self.leaderboards_task.take() { - result_to_process = Some(h.await); - } - } - } + let task = self.leaderboards_task.take().unwrap(); + match task.await { + Ok(Ok(leaderboards)) => { + self.leaderboards = leaderboards; + // If a leaderboard was pre-selected (e.g., from directives), try to find and select it + if let Some(selected_name) = &self.selected_leaderboard { + if let Some(index) = self + .leaderboards + .iter() + .position(|lb| &lb.title_text == selected_name) + { + self.leaderboards_state.select(Some(index)); + // If GPU was also pre-selected, move to submission mode selection + // Otherwise, spawn GPU loading task + if self.selected_gpu.is_some() { + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::GpuSelection; + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + return; // Exit early on error + } + } + } else { + // Pre-selected leaderboard not found, reset selection and state + self.selected_leaderboard = None; + self.leaderboards_state.select(Some(0)); // Select first available + self.modal_state = ModelState::LeaderboardSelection; + // Stay here + } + } else { + self.leaderboards_state.select(Some(0)); // Select first if no pre-selection + } - if let Some(join_result) = result_to_process { - match join_result { - Ok(Ok(leaderboards)) => { - self.leaderboards = leaderboards; - if !self.leaderboards.is_empty() { - self.leaderboards_state.select(Some(0)); - } else { - self.leaderboards_state.select(None); // Ensure selection is cleared if empty + self.loading_message = None; } - self.loading_message = None; // Clear loading on success - } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)); - } - Err(e) => { - // This usually means the task panicked. - self.set_error_and_quit(format!("Leaderboard fetch task failed: {}", e)); + Ok(Err(e)) => { + self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) + } + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), } } } } pub async fn check_gpu_task(&mut self) { - let mut result_to_process: Option<_> = None; - if let Some(handle) = self.gpus_task.as_mut() { + if let Some(handle) = &mut self.gpus_task { if handle.is_finished() { - if let Some(h) = self.gpus_task.take() { - result_to_process = Some(h.await); - } - } - } + let task = self.gpus_task.take().unwrap(); + match task.await { + Ok(Ok(gpus)) => { + self.gpus = gpus; + // If a GPU was pre-selected, try to find and select it + if let Some(selected_name) = &self.selected_gpu { + if let Some(index) = self + .gpus + .iter() + .position(|gpu| &gpu.title_text == selected_name) + { + self.gpus_state.select(Some(index)); + self.modal_state = ModelState::SubmissionModeSelection; + // Move to next step + } else { + // Pre-selected GPU not found, reset selection + self.selected_gpu = None; + self.gpus_state.select(Some(0)); // Select first available + self.modal_state = ModelState::GpuSelection; // Stay here + } + } else { + self.gpus_state.select(Some(0)); // Select first if no pre-selection + } - if let Some(join_result) = result_to_process { - match join_result { - Ok(Ok(gpus)) => { - self.gpus = gpus; - if self.gpus.is_empty() { - self.set_error_and_quit( - "No GPUs available for the selected leaderboard.".to_string(), - ); - self.gpus_state.select(None); // Clear selection if empty - } else { - self.gpus_state.select(Some(0)); + self.loading_message = None; } - self.loading_message = None; // Clear loading on success - } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Error fetching GPUs: {}", e)); - } - Err(e) => { - self.set_error_and_quit(format!("GPU fetch task failed: {}", e)); + Ok(Err(e)) => self.set_error_and_quit(format!("Error fetching GPUs: {}", e)), + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), } } } } pub async fn check_submission_task(&mut self) { - let mut result_to_process: Option<_> = None; - if let Some(handle) = self.submission_task.as_mut() { + if let Some(handle) = &mut self.submission_task { if handle.is_finished() { - if let Some(h) = self.submission_task.take() { - result_to_process = Some(h.await); - } - } - } - - if let Some(join_result) = result_to_process { - match join_result { - Ok(Ok(result)) => { - self.final_status = Some(result); - self.should_quit = true; - self.loading_message = None; - } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Submission failed: {}", e)); - } - Err(e) => { - self.set_error_and_quit(format!("Submission task failed: {}", e)); + let task = self.submission_task.take().unwrap(); + match task.await { + Ok(Ok(status)) => { + self.final_status = Some(status); + self.should_quit = true; // Quit after showing final status + self.loading_message = None; + } + Ok(Err(e)) => self.set_error_and_quit(format!("Submission error: {}", e)), + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), } } } @@ -427,177 +445,293 @@ impl App { } pub fn ui(app: &App, frame: &mut Frame) { - let chunks = Layout::default() - .margin(1) + let main_layout = Layout::default() + .direction(Direction::Vertical) .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); - if let Some(message) = &app.loading_message { - let text = format!("{} (Press Ctrl+C to quit)", message); - let paragraph = Paragraph::new(text) - .block(Block::default().title("Status").borders(Borders::ALL)) + // Determine the area available for the list *before* the match statement + let list_area = main_layout[0]; + // Calculate usable width for text wrapping (subtract borders, padding, highlight symbol) + let available_width = list_area.width.saturating_sub(4) as usize; + + if let Some(ref msg) = app.loading_message { + let loading_paragraph = Paragraph::new(msg.clone()) + .block(Block::default().title("Loading").borders(Borders::ALL)) .alignment(Alignment::Center); - frame.render_widget(paragraph, chunks[0]); - return; + + let area = centered_rect(60, 20, frame.size()); + frame.render_widget(loading_paragraph, area); + return; // Don't render anything else while loading } + let list_block = Block::default().borders(Borders::ALL); + let list_style = Style::default().fg(Color::White); + match app.modal_state { ModelState::LeaderboardSelection => { let items: Vec = app .leaderboards .iter() - .map(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) + .map(|lb| { + let title_line = Line::from(Span::styled( + lb.title_text.clone(), + Style::default().fg(Color::White).bold(), + )); + // Create lines for the description, splitting by newline + let mut lines = vec![title_line]; + for desc_part in lb.task_description.split('\n') { + lines.push(Line::from(Span::styled( + desc_part.to_string(), + Style::default().fg(Color::Gray).dim(), + ))); + } + ListItem::new(lines) // Use the combined vector of lines + }) .collect(); - let list = List::new(items) - .block(Block::default().title("Leaderboards").borders(Borders::ALL)) - .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - - frame.render_stateful_widget(list, chunks[0], &mut app.leaderboards_state.clone()); + .block(list_block.title("Select Leaderboard")) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, main_layout[0], &mut app.leaderboards_state.clone()); } ModelState::GpuSelection => { let items: Vec = app .gpus .iter() - .map(|item| ListItem::new(item.title())) + .map(|gpu| { + // GPUs still only have a title line + let line = Line::from(vec![Span::styled( + gpu.title_text.clone(), + Style::default().fg(Color::White).bold(), + )]); + ListItem::new(line) // Keep as single line + }) .collect(); - let list = List::new(items) - .block(Block::default().title("GPUs").borders(Borders::ALL)) - .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - - frame.render_stateful_widget(list, chunks[0], &mut app.gpus_state.clone()); + .block(list_block.title(format!( + "Select GPU for '{}'", + app.selected_leaderboard.as_deref().unwrap_or("N/A") + ))) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, main_layout[0], &mut app.gpus_state.clone()); } ModelState::SubmissionModeSelection => { let items: Vec = app .submission_modes .iter() - .map(|item| ListItem::new(format!("{}\n{}", item.title(), item.description()))) - .collect(); + .map(|mode| { + let title_line = Line::from(Span::styled( + mode.title_text.clone(), + Style::default().fg(Color::White).bold(), + )); + + let mut lines = vec![title_line]; + let description_text = &mode.description_text; + + // Manual wrapping logic + if available_width > 0 { + let mut current_line = String::with_capacity(available_width); + for word in description_text.split_whitespace() { + // Check if the word itself is too long + if word.len() > available_width { + // If a line is currently being built, push it first + if !current_line.is_empty() { + lines.push(Line::from(Span::styled( + current_line.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + current_line.clear(); + } + // Push the long word on its own line + lines.push(Line::from(Span::styled( + word.to_string(), + Style::default().fg(Color::Gray).dim(), + ))); + } else if current_line.is_empty() { + // Start a new line + current_line.push_str(word); + } else if current_line.len() + word.len() + 1 <= available_width { + // Add word to current line + current_line.push(' '); + current_line.push_str(word); + } else { + // Word doesn't fit, push the completed line + lines.push(Line::from(Span::styled( + current_line.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + // Start a new line with the current word + current_line.clear(); + current_line.push_str(word); + } + } + // Push the last remaining line if it's not empty + if !current_line.is_empty() { + lines.push(Line::from(Span::styled( + current_line, + Style::default().fg(Color::Gray).dim(), + ))); + } + } else { + // Fallback: push the original description as one line if width is zero + lines.push(Line::from(Span::styled( + description_text.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + } + ListItem::new(lines) + }) + .collect(); let list = List::new(items) - .block( - Block::default() - .title("Submission Mode") - .borders(Borders::ALL), - ) - .highlight_style(Style::default().bg(Color::White).fg(Color::Black)); - - frame.render_stateful_widget(list, chunks[0], &mut app.submission_modes_state.clone()); + .block(list_block.title(format!( + "Select Submission Mode for '{}' on '{}'", + app.selected_leaderboard.as_deref().unwrap_or("N/A"), + app.selected_gpu.as_deref().unwrap_or("N/A") + ))) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget( + list, + main_layout[0], + &mut app.submission_modes_state.clone(), + ); } ModelState::WaitingForResult => { - let paragraph = - Paragraph::new("").block(Block::default().title("Status").borders(Borders::ALL)); - frame.render_widget(paragraph, chunks[0]); + // This state is handled by the loading message check at the beginning } } } -pub async fn execute() -> Result<()> { - let args: Vec = std::env::args().collect(); +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} - if args.len() < 2 { - println!("Usage: popcorn "); - return Ok(()); +pub async fn execute(cli: Cli) -> Result<()> { + match cli.command { + Some(Commands::Login) => login::run_login().await, + Some(Commands::Submit { filepath }) => { + let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level + run_submit_tui(file_to_submit).await + } + None => { + // Default behavior: run submit TUI, potentially with top-level filepath + run_submit_tui(cli.filepath).await + } } +} - let filepath = &args[1]; - let path = Path::new(filepath); +async fn run_submit_tui(filepath: Option) -> Result<()> { + let file_to_submit = match filepath { + Some(fp) => fp, + None => { + // Prompt user for filepath if not provided + println!("Please enter the path to your solution file:"); + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + input.trim().to_string() + } + }; - if !path.exists() { - println!("File does not exist: {}", filepath); - return Ok(()); + if !Path::new(&file_to_submit).exists() { + return Err(anyhow!("File not found: {}", file_to_submit)); } - let (popcorn_directives, has_multiple_gpus) = utils::get_popcorn_directives(filepath)?; + let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; if has_multiple_gpus { - println!( - "Warning: multiple GPUs specified, only the first one ({}) will be used.", - popcorn_directives.gpus[0] - ); + return Err(anyhow!( + "Multiple GPUs are not supported yet. Please specify only one GPU." + )); } - // Initialize app - let mut app = App::new(filepath); - app.initialize_with_directives(popcorn_directives); + let mut app = App::new(&file_to_submit); + app.initialize_with_directives(directives); - // Initialize terminal enable_raw_mode()?; - crossterm::execute!(io::stdout(), EnterAlternateScreen)?; - let backend = CrosstermBackend::new(io::stdout()); + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - terminal.clear()?; - // Perform initial data loading by spawning tasks - match app.modal_state { - ModelState::LeaderboardSelection => { - // Spawn the task, handle immediate spawn error - if let Err(e) = app.spawn_load_leaderboards() { - // Error during spawning itself (rare) - app.final_status = Some(format!("Error starting leaderboard fetch: {}", e)); - app.should_quit = true; - } + if app.modal_state == ModelState::LeaderboardSelection { + if let Err(e) = app.spawn_load_leaderboards() { + // Cleanup terminal before exiting on initial load error + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + return Err(anyhow!("Error starting leaderboard fetch: {}", e)); } - ModelState::GpuSelection => { - // Spawn the task, handle immediate spawn error - if let Err(e) = app.spawn_load_gpus() { - // Error during spawning itself (e.g., no leaderboard selected) - app.final_status = Some(format!("Error starting GPU fetch: {}", e)); - app.should_quit = true; - } + } else if app.modal_state == ModelState::GpuSelection { + if let Err(e) = app.spawn_load_gpus() { + // Cleanup terminal before exiting on initial load error + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + return Err(anyhow!("Error starting GPU fetch: {}", e)); } - _ => { /* No initial loading needed for other states */ } } - // Main event loop + // Main application loop while !app.should_quit { - // Draw UI (shows loading screen if loading_message is Some) - terminal.draw(|frame| ui(&app, frame))?; + terminal.draw(|f| ui(&app, f))?; + + // Check for finished async tasks without blocking drawing + app.check_leaderboard_task().await; + app.check_gpu_task().await; + app.check_submission_task().await; - // Handle events first (to ensure Ctrl+C works during checks below) - if crossterm::event::poll(std::time::Duration::from_millis(50))? { + // Handle input events + if event::poll(std::time::Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { app.handle_key_event(key)?; - // If event handling caused quit, break early - if app.should_quit { - break; - } } } } - - app.check_leaderboard_task().await; - - app.check_gpu_task().await; - - app.check_submission_task().await; } - // Cleanup terminal - terminal.clear()?; + // Restore terminal disable_raw_mode()?; crossterm::execute!( - io::stdout(), - crossterm::terminal::LeaveAlternateScreen, - crossterm::cursor::Show - )?; - - // Brief pause allows the terminal to restore properly before printing final output - std::thread::sleep(std::time::Duration::from_millis(50)); - - // Clear screen again and move cursor to top-left for final output - crossterm::execute!( - io::stdout(), - crossterm::terminal::Clear(crossterm::terminal::ClearType::All), - crossterm::cursor::MoveTo(0, 0) + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen )?; + terminal.show_cursor()?; utils::display_ascii_art(); if let Some(status) = app.final_status { - println!("\nResult:\n\n{}\n", status); + println!("{}", status); + } else { + println!("Operation cancelled."); // Or some other default message if quit early } Ok(()) diff --git a/rust/src/main.rs b/rust/src/main.rs index 7c3446e..ccf767c 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -3,18 +3,26 @@ mod models; mod service; mod utils; +use crate::cmd::Cli; +use clap::Parser; use std::env; use std::process; #[tokio::main] async fn main() { + // Parse command line arguments + let cli = Cli::parse(); + + // Popcorn API URL check (needed for most commands) + // We might want to move this check inside specific commands later if some don't need it. if env::var("POPCORN_API_URL").is_err() { eprintln!("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API."); process::exit(1); } - - if let Err(e) = cmd::execute().await { + + // Execute the parsed command + if let Err(e) = cmd::execute(cli).await { eprintln!("Application error: {}", e); process::exit(1); } -} \ No newline at end of file +} diff --git a/rust/src/models/mod.rs b/rust/src/models/mod.rs index b9ce252..257f751 100644 --- a/rust/src/models/mod.rs +++ b/rust/src/models/mod.rs @@ -13,14 +13,6 @@ impl LeaderboardItem { task_description, } } - - pub fn title(&self) -> &str { - &self.title_text - } - - pub fn description(&self) -> &str { - &self.task_description - } } #[derive(Clone, Debug)] @@ -32,10 +24,6 @@ impl GpuItem { pub fn new(title_text: String) -> Self { Self { title_text } } - - pub fn title(&self) -> &str { - &self.title_text - } } #[derive(Clone, Debug)] @@ -53,14 +41,6 @@ impl SubmissionModeItem { value, } } - - pub fn title(&self) -> &str { - &self.title_text - } - - pub fn description(&self) -> &str { - &self.description_text - } } #[derive(Clone, Copy, Debug, PartialEq)] @@ -72,4 +52,4 @@ pub enum ModelState { } #[derive(Debug, Serialize, Deserialize)] -pub struct SubmissionResultMsg(pub String); \ No newline at end of file +pub struct SubmissionResultMsg(pub String); diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index 8b42fb9..489d45e 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -8,11 +8,18 @@ use std::time::Duration; use crate::models::{GpuItem, LeaderboardItem}; -pub async fn fetch_leaderboards() -> Result> { +// Helper function to create a reusable reqwest client +pub fn create_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(60)) // Set a default timeout + .build() + .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) +} + +pub async fn fetch_leaderboards(client: &Client) -> Result> { let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - let client = Client::new(); let resp = client .get(format!("{}/leaderboards", base_url)) .timeout(Duration::from_secs(30)) @@ -48,11 +55,10 @@ pub async fn fetch_leaderboards() -> Result> { Ok(leaderboard_items) } -pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { +pub async fn fetch_gpus(client: &Client, leaderboard: &str) -> Result> { let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - let client = Client::new(); let resp = client .get(format!("{}/gpus/{}", base_url, leaderboard)) .timeout(Duration::from_secs(120)) @@ -73,22 +79,23 @@ pub async fn fetch_available_gpus(leaderboard: &str) -> Result> { } pub async fn submit_solution>( + client: &Client, + filepath: P, + file_content: &str, leaderboard: &str, gpu: &str, submission_mode: &str, - filename: P, - file_content: &[u8], ) -> Result { let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; - let filename = filename + let filename = filepath .as_ref() .file_name() - .ok_or_else(|| anyhow!("Invalid filename"))? + .ok_or_else(|| anyhow!("Invalid filepath"))? .to_string_lossy(); - let part = Part::bytes(file_content.to_vec()).file_name(filename.to_string()); + let part = Part::bytes(file_content.as_bytes().to_vec()).file_name(filename.to_string()); let form = Form::new().part("file", part); @@ -100,7 +107,6 @@ pub async fn submit_solution>( submission_mode.to_lowercase() ); - let client = Client::new(); let resp = client .post(&url) .multipart(form) From ea3f4ab47c59f70f0fd243beafdcb41358a47d2a Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 23:16:32 +0200 Subject: [PATCH 009/111] Feat: reregister --- rust/src/cmd/mod.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 6e607f6..fe0471c 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -31,7 +31,9 @@ pub struct Cli { #[derive(Subcommand, Debug)] enum Commands { /// Login to Popcorn via Discord - Login, + Reregister, + /// Register to Popcorn via Discord + Register, /// Submit a solution (default command) Submit { /// Path to the solution file @@ -630,7 +632,8 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { pub async fn execute(cli: Cli) -> Result<()> { match cli.command { - Some(Commands::Login) => login::run_login().await, + Some(Commands::Reregister) => login::run_auth(true).await, + Some(Commands::Register) => login::run_auth(false).await, Some(Commands::Submit { filepath }) => { let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level run_submit_tui(file_to_submit).await From eb0cdcdc7b070a547c90695965dab7666bd14c1f Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 12 Apr 2025 23:55:38 +0200 Subject: [PATCH 010/111] Feat: github --- rust/Cargo.toml | 1 + rust/src/cmd/mod.rs | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 920ff3b..d7f3774 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -19,3 +19,4 @@ dirs = "5.0" serde_yaml = "0.9" webbrowser = "0.8" base64-url = "3.0.0" +urlencoding = "2.1.3" diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index fe0471c..0db2d2e 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -28,12 +28,26 @@ pub struct Cli { filepath: Option, } +#[derive(Subcommand, Debug)] +enum AuthProvider { + /// Use Discord for authentication + Discord, + /// Use GitHub for authentication + Github, +} + #[derive(Subcommand, Debug)] enum Commands { - /// Login to Popcorn via Discord - Reregister, - /// Register to Popcorn via Discord - Register, + /// Re-register with Popcorn (links existing account) + Reregister { + #[command(subcommand)] + provider: AuthProvider, + }, + /// Register a new account with Popcorn + Register { + #[command(subcommand)] + provider: AuthProvider, + }, /// Submit a solution (default command) Submit { /// Path to the solution file @@ -632,8 +646,20 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { pub async fn execute(cli: Cli) -> Result<()> { match cli.command { - Some(Commands::Reregister) => login::run_auth(true).await, - Some(Commands::Register) => login::run_auth(false).await, + Some(Commands::Reregister { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + login::run_auth(true, provider_str).await + } + Some(Commands::Register { provider }) => { + let provider_str = match provider { + AuthProvider::Discord => "discord", + AuthProvider::Github => "github", + }; + login::run_auth(false, provider_str).await + } Some(Commands::Submit { filepath }) => { let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level run_submit_tui(file_to_submit).await From d6f654baad64cdf228baf48460c7aee96fcb4f0b Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:17:15 +0200 Subject: [PATCH 011/111] feat: refactor + auth --- rust/src/cmd/auth.rs | 145 ++++++++ rust/src/cmd/mod.rs | 764 +++------------------------------------- rust/src/cmd/submit.rs | 702 ++++++++++++++++++++++++++++++++++++ rust/src/service/mod.rs | 19 +- 4 files changed, 917 insertions(+), 713 deletions(-) create mode 100644 rust/src/cmd/auth.rs create mode 100644 rust/src/cmd/submit.rs diff --git a/rust/src/cmd/auth.rs b/rust/src/cmd/auth.rs new file mode 100644 index 0000000..3498353 --- /dev/null +++ b/rust/src/cmd/auth.rs @@ -0,0 +1,145 @@ +use anyhow::{anyhow, Result}; +use base64_url; +use dirs; +use serde::{Deserialize, Serialize}; +use serde_yaml; +use std::fs::{File, OpenOptions}; +use std::path::PathBuf; +use urlencoding; +use webbrowser; + +use crate::service; // Assuming service::create_client is needed + +// Configuration structure +#[derive(Serialize, Deserialize, Debug, Default)] +struct Config { + cli_id: Option, +} + +// Helper function to get the config file path +fn get_config_path() -> Result { + dirs::home_dir() + .map(|mut path| { + path.push(".popcorn.yaml"); + path + }) + .ok_or_else(|| anyhow!("Could not find home directory")) +} + +// Helper function to load config +fn load_config() -> Result { + let path = get_config_path()?; + if !path.exists() { + return Ok(Config::default()); + } + let file = File::open(path)?; + serde_yaml::from_reader(file).map_err(|e| anyhow!("Failed to parse config file: {}", e)) +} + +// Helper function to save config +fn save_config(config: &Config) -> Result<()> { + let path = get_config_path()?; + let file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) // Overwrite existing file + .open(path)?; + serde_yaml::to_writer(file, config).map_err(|e| anyhow!("Failed to write config file: {}", e)) +} + +// Structure for the API response +#[derive(Deserialize)] +struct AuthInitResponse { + state: String, // This is the cli_id +} + +// Function to handle the login logic +pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { + println!("Attempting authentication via {}...", auth_provider); + + let popcorn_api_url = std::env::var("POPCORN_API_URL") + .map_err(|_| anyhow!("POPCORN_API_URL environment variable not set"))?; + + let client = service::create_client(None)?; + + let init_url = format!("{}/auth/init?provider={}", popcorn_api_url, auth_provider); + println!("Requesting CLI ID from {}", init_url); + + let init_resp = client.get(&init_url).send().await?; + + let status = init_resp.status(); + + if !status.is_success() { + let error_text = init_resp.text().await?; + return Err(anyhow!( + "Failed to initialize auth ({}): {}", + status, + error_text + )); + } + + let auth_init_data: AuthInitResponse = init_resp.json().await?; + let cli_id = auth_init_data.state; + println!("Received CLI ID: {}", cli_id); + + let state_json = serde_json::json!({ + "cli_id": cli_id, + "is_reset": reset + }) + .to_string(); + let state_b64 = base64_url::encode(&state_json); + + let auth_url = match auth_provider { + "discord" => { + let base_auth_url = "https://discord.com/oauth2/authorize?client_id=1357446383497511096&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauth%2Fcli%2Fdiscord&scope=identify"; + format!("{}&state={}", base_auth_url, state_b64) + } + "github" => { + let client_id = "Ov23lieFd2onYk4OnKIR"; + let redirect_uri = "http://localhost:8000/auth/cli/github"; + // URL encode the redirect URI + let encoded_redirect_uri = urlencoding::encode(redirect_uri); + format!( + "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}", + client_id, encoded_redirect_uri, state_b64 + ) + } + _ => { + return Err(anyhow!( + "Unsupported authentication provider: {}", + auth_provider + )) + } + }; + + println!( + "\n>>> Please open the following URL in your browser to log in via {}:", + auth_provider + ); + println!("{}", auth_url); + println!("\nWaiting for you to complete the authentication in your browser..."); + println!( + "After successful authentication with {}, the CLI ID will be saved.", + auth_provider + ); + + if webbrowser::open(&auth_url).is_err() { + println!( + "Could not automatically open the browser. Please copy the URL above and paste it manually." + ); + } + + // Save the cli_id to config file optimistically + let mut config = load_config().unwrap_or_default(); + config.cli_id = Some(cli_id.clone()); + save_config(&config)?; + + println!( + "\nSuccessfully initiated authentication. Your CLI ID ({}) has been saved to {}. To use the CLI on different machines, you can copy the config file.", + cli_id, + get_config_path()?.display() + ); + println!("You can now use other commands that require authentication."); + + Ok(()) +} diff --git a/rust/src/cmd/mod.rs b/rust/src/cmd/mod.rs index 0db2d2e..4fde558 100644 --- a/rust/src/cmd/mod.rs +++ b/rust/src/cmd/mod.rs @@ -1,22 +1,39 @@ -use std::fs::File; -use std::io::{self, Read}; -use std::path::Path; - use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; -use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen}; -use ratatui::prelude::*; -use ratatui::style::{Color, Style, Stylize}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; -use tokio::task::JoinHandle; +use dirs; +use serde::{Deserialize, Serialize}; +use serde_yaml; +use std::fs::File; +use std::path::PathBuf; + +mod auth; +mod submit; -use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; -use crate::service; -use crate::utils; +#[derive(Serialize, Deserialize, Debug, Default)] +struct Config { + cli_id: Option, +} + +fn get_config_path() -> Result { + dirs::home_dir() + .map(|mut path| { + path.push(".popcorn.yaml"); + path + }) + .ok_or_else(|| anyhow!("Could not find home directory")) +} -mod login; +fn load_config() -> Result { + let path = get_config_path()?; + if !path.exists() { + return Err(anyhow!( + "Config file not found at {}. Please run `popcorn register` first.", + path.display() + )); + } + let file = File::open(path)?; + serde_yaml::from_reader(file).map_err(|e| anyhow!("Failed to parse config file: {}", e)) +} #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -30,620 +47,25 @@ pub struct Cli { #[derive(Subcommand, Debug)] enum AuthProvider { - /// Use Discord for authentication Discord, - /// Use GitHub for authentication Github, } #[derive(Subcommand, Debug)] enum Commands { - /// Re-register with Popcorn (links existing account) Reregister { #[command(subcommand)] provider: AuthProvider, }, - /// Register a new account with Popcorn Register { #[command(subcommand)] provider: AuthProvider, }, - /// Submit a solution (default command) Submit { - /// Path to the solution file filepath: Option, }, } -pub struct App { - pub filepath: String, - pub leaderboards: Vec, - pub leaderboards_state: ListState, - pub selected_leaderboard: Option, - pub gpus: Vec, - pub gpus_state: ListState, - pub selected_gpu: Option, - pub submission_modes: Vec, - pub submission_modes_state: ListState, - pub selected_submission_mode: Option, - pub modal_state: ModelState, - pub final_status: Option, - pub loading_message: Option, - pub should_quit: bool, - pub submission_task: Option>>, - pub leaderboards_task: Option, anyhow::Error>>>, - pub gpus_task: Option, anyhow::Error>>>, -} - -impl App { - pub fn new>(filepath: P) -> Self { - let submission_modes = vec![ - SubmissionModeItem::new( - "Test".to_string(), - "Test the solution and give detailed results about passed/failed tests.".to_string(), - "test".to_string(), - ), - SubmissionModeItem::new( - "Benchmark".to_string(), - "Benchmark the solution, this also runs the tests and afterwards runs the benchmark, returning detailed timing results".to_string(), - "benchmark".to_string(), - ), - SubmissionModeItem::new( - "Leaderboard".to_string(), - "Submit to the leaderboard, this first runs public tests and then private tests. If both pass, the submission is evaluated and submit to the leaderboard.".to_string(), - "leaderboard".to_string(), - ), - SubmissionModeItem::new( - "Private".to_string(), - "TODO".to_string(), - "private".to_string(), - ), - SubmissionModeItem::new( - "Script".to_string(), - "TODO".to_string(), - "script".to_string(), - ), - SubmissionModeItem::new( - "Profile".to_string(), - "TODO".to_string(), - "profile".to_string(), - ), - ]; - - let mut app = Self { - filepath: filepath.as_ref().to_string_lossy().to_string(), - leaderboards: Vec::new(), - leaderboards_state: ListState::default(), - selected_leaderboard: None, - gpus: Vec::new(), - gpus_state: ListState::default(), - selected_gpu: None, - submission_modes, - submission_modes_state: ListState::default(), - selected_submission_mode: None, - modal_state: ModelState::LeaderboardSelection, - final_status: None, - loading_message: None, - should_quit: false, - submission_task: None, - leaderboards_task: None, - gpus_task: None, - }; - - // Initialize list states - app.leaderboards_state.select(Some(0)); - app.gpus_state.select(Some(0)); - app.submission_modes_state.select(Some(0)); - - app - } - - pub fn initialize_with_directives(&mut self, popcorn_directives: utils::PopcornDirectives) { - if !popcorn_directives.leaderboard_name.is_empty() { - self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); - - if !popcorn_directives.gpus.is_empty() { - self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); - self.modal_state = ModelState::SubmissionModeSelection; - } else { - self.modal_state = ModelState::GpuSelection; - } - } else if !popcorn_directives.gpus.is_empty() { - self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); - if !popcorn_directives.leaderboard_name.is_empty() { - self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); - self.modal_state = ModelState::SubmissionModeSelection; - } else { - self.modal_state = ModelState::LeaderboardSelection; - } - } else { - self.modal_state = ModelState::LeaderboardSelection; - } - } - - pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { - // Allow quitting anytime, even while loading - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { - self.should_quit = true; - return Ok(true); - } - - // Ignore other keys while loading - if self.loading_message.is_some() { - return Ok(false); - } - - match key.code { - KeyCode::Char('q') => { - self.should_quit = true; - return Ok(true); - } - KeyCode::Enter => match self.modal_state { - ModelState::LeaderboardSelection => { - if let Some(idx) = self.leaderboards_state.selected() { - if idx < self.leaderboards.len() { - self.selected_leaderboard = - Some(self.leaderboards[idx].title_text.clone()); - - if self.selected_gpu.is_none() { - self.modal_state = ModelState::GpuSelection; - // Spawn GPU loading task - if let Err(e) = self.spawn_load_gpus() { - self.set_error_and_quit(format!( - "Error starting GPU fetch: {}", - e - )); - } - } else { - self.modal_state = ModelState::SubmissionModeSelection; - } - return Ok(true); - } - } - } - ModelState::GpuSelection => { - if let Some(idx) = self.gpus_state.selected() { - if idx < self.gpus.len() { - self.selected_gpu = Some(self.gpus[idx].title_text.clone()); - self.modal_state = ModelState::SubmissionModeSelection; - return Ok(true); - } - } - } - ModelState::SubmissionModeSelection => { - if let Some(idx) = self.submission_modes_state.selected() { - if idx < self.submission_modes.len() { - self.selected_submission_mode = - Some(self.submission_modes[idx].value.clone()); - self.modal_state = ModelState::WaitingForResult; // State for logic, UI uses loading msg - // Spawn the submission task - if let Err(e) = self.spawn_submit_solution() { - self.set_error_and_quit(format!( - "Error starting submission: {}", - e - )); - } - return Ok(true); - } - } - } - _ => {} // WaitingForResult state doesn't handle Enter - }, - KeyCode::Up => { - self.move_selection_up(); - return Ok(true); - } - KeyCode::Down => { - self.move_selection_down(); - return Ok(true); - } - _ => {} // Ignore other keys - } - - Ok(false) - } - - // Helper to reduce repetition - fn set_error_and_quit(&mut self, error_message: String) { - self.final_status = Some(error_message); - self.should_quit = true; - self.loading_message = None; // Clear loading on error - } - - fn move_selection_up(&mut self) { - match self.modal_state { - ModelState::LeaderboardSelection => { - if let Some(idx) = self.leaderboards_state.selected() { - if idx > 0 { - self.leaderboards_state.select(Some(idx - 1)); - } - } - } - ModelState::GpuSelection => { - if let Some(idx) = self.gpus_state.selected() { - if idx > 0 { - self.gpus_state.select(Some(idx - 1)); - } - } - } - ModelState::SubmissionModeSelection => { - if let Some(idx) = self.submission_modes_state.selected() { - if idx > 0 { - self.submission_modes_state.select(Some(idx - 1)); - } - } - } - _ => {} - } - } - - fn move_selection_down(&mut self) { - match self.modal_state { - ModelState::LeaderboardSelection => { - if let Some(idx) = self.leaderboards_state.selected() { - if idx < self.leaderboards.len().saturating_sub(1) { - self.leaderboards_state.select(Some(idx + 1)); - } - } - } - ModelState::GpuSelection => { - if let Some(idx) = self.gpus_state.selected() { - if idx < self.gpus.len().saturating_sub(1) { - self.gpus_state.select(Some(idx + 1)); - } - } - } - ModelState::SubmissionModeSelection => { - if let Some(idx) = self.submission_modes_state.selected() { - if idx < self.submission_modes.len().saturating_sub(1) { - self.submission_modes_state.select(Some(idx + 1)); - } - } - } - _ => {} - } - } - - pub fn spawn_load_leaderboards(&mut self) -> Result<()> { - let client = service::create_client()?; - self.leaderboards_task = Some(tokio::spawn(async move { - service::fetch_leaderboards(&client).await - })); - self.loading_message = Some("Loading leaderboards...".to_string()); - Ok(()) - } - - pub fn spawn_load_gpus(&mut self) -> Result<()> { - let client = service::create_client()?; - let leaderboard_name = self - .selected_leaderboard - .clone() - .ok_or_else(|| anyhow!("Leaderboard not selected"))?; - self.gpus_task = Some(tokio::spawn(async move { - service::fetch_gpus(&client, &leaderboard_name).await - })); - self.loading_message = Some("Loading GPUs...".to_string()); - Ok(()) - } - - pub fn spawn_submit_solution(&mut self) -> Result<()> { - let client = service::create_client()?; - let filepath = self.filepath.clone(); - let leaderboard = self - .selected_leaderboard - .clone() - .ok_or_else(|| anyhow!("Leaderboard not selected"))?; - let gpu = self - .selected_gpu - .clone() - .ok_or_else(|| anyhow!("GPU not selected"))?; - let mode = self - .selected_submission_mode - .clone() - .ok_or_else(|| anyhow!("Submission mode not selected"))?; - - // Read file content - let mut file = File::open(&filepath)?; - let mut file_content = String::new(); - file.read_to_string(&mut file_content)?; - - self.submission_task = Some(tokio::spawn(async move { - service::submit_solution(&client, &filepath, &file_content, &leaderboard, &gpu, &mode) - .await - })); - self.loading_message = Some("Submitting solution...".to_string()); - Ok(()) - } - - pub async fn check_leaderboard_task(&mut self) { - if let Some(handle) = &mut self.leaderboards_task { - if handle.is_finished() { - let task = self.leaderboards_task.take().unwrap(); - match task.await { - Ok(Ok(leaderboards)) => { - self.leaderboards = leaderboards; - // If a leaderboard was pre-selected (e.g., from directives), try to find and select it - if let Some(selected_name) = &self.selected_leaderboard { - if let Some(index) = self - .leaderboards - .iter() - .position(|lb| &lb.title_text == selected_name) - { - self.leaderboards_state.select(Some(index)); - // If GPU was also pre-selected, move to submission mode selection - // Otherwise, spawn GPU loading task - if self.selected_gpu.is_some() { - self.modal_state = ModelState::SubmissionModeSelection; - } else { - self.modal_state = ModelState::GpuSelection; - if let Err(e) = self.spawn_load_gpus() { - self.set_error_and_quit(format!( - "Error starting GPU fetch: {}", - e - )); - return; // Exit early on error - } - } - } else { - // Pre-selected leaderboard not found, reset selection and state - self.selected_leaderboard = None; - self.leaderboards_state.select(Some(0)); // Select first available - self.modal_state = ModelState::LeaderboardSelection; - // Stay here - } - } else { - self.leaderboards_state.select(Some(0)); // Select first if no pre-selection - } - - self.loading_message = None; - } - Ok(Err(e)) => { - self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) - } - Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), - } - } - } - } - - pub async fn check_gpu_task(&mut self) { - if let Some(handle) = &mut self.gpus_task { - if handle.is_finished() { - let task = self.gpus_task.take().unwrap(); - match task.await { - Ok(Ok(gpus)) => { - self.gpus = gpus; - // If a GPU was pre-selected, try to find and select it - if let Some(selected_name) = &self.selected_gpu { - if let Some(index) = self - .gpus - .iter() - .position(|gpu| &gpu.title_text == selected_name) - { - self.gpus_state.select(Some(index)); - self.modal_state = ModelState::SubmissionModeSelection; - // Move to next step - } else { - // Pre-selected GPU not found, reset selection - self.selected_gpu = None; - self.gpus_state.select(Some(0)); // Select first available - self.modal_state = ModelState::GpuSelection; // Stay here - } - } else { - self.gpus_state.select(Some(0)); // Select first if no pre-selection - } - - self.loading_message = None; - } - Ok(Err(e)) => self.set_error_and_quit(format!("Error fetching GPUs: {}", e)), - Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), - } - } - } - } - - pub async fn check_submission_task(&mut self) { - if let Some(handle) = &mut self.submission_task { - if handle.is_finished() { - let task = self.submission_task.take().unwrap(); - match task.await { - Ok(Ok(status)) => { - self.final_status = Some(status); - self.should_quit = true; // Quit after showing final status - self.loading_message = None; - } - Ok(Err(e)) => self.set_error_and_quit(format!("Submission error: {}", e)), - Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), - } - } - } - } -} - -pub fn ui(app: &App, frame: &mut Frame) { - let main_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0)].as_ref()) - .split(frame.size()); - - // Determine the area available for the list *before* the match statement - let list_area = main_layout[0]; - // Calculate usable width for text wrapping (subtract borders, padding, highlight symbol) - let available_width = list_area.width.saturating_sub(4) as usize; - - if let Some(ref msg) = app.loading_message { - let loading_paragraph = Paragraph::new(msg.clone()) - .block(Block::default().title("Loading").borders(Borders::ALL)) - .alignment(Alignment::Center); - - let area = centered_rect(60, 20, frame.size()); - frame.render_widget(loading_paragraph, area); - return; // Don't render anything else while loading - } - - let list_block = Block::default().borders(Borders::ALL); - let list_style = Style::default().fg(Color::White); - - match app.modal_state { - ModelState::LeaderboardSelection => { - let items: Vec = app - .leaderboards - .iter() - .map(|lb| { - let title_line = Line::from(Span::styled( - lb.title_text.clone(), - Style::default().fg(Color::White).bold(), - )); - // Create lines for the description, splitting by newline - let mut lines = vec![title_line]; - for desc_part in lb.task_description.split('\n') { - lines.push(Line::from(Span::styled( - desc_part.to_string(), - Style::default().fg(Color::Gray).dim(), - ))); - } - ListItem::new(lines) // Use the combined vector of lines - }) - .collect(); - let list = List::new(items) - .block(list_block.title("Select Leaderboard")) - .style(list_style) - .highlight_style(Style::default().bg(Color::DarkGray)) - .highlight_symbol("> "); - frame.render_stateful_widget(list, main_layout[0], &mut app.leaderboards_state.clone()); - } - ModelState::GpuSelection => { - let items: Vec = app - .gpus - .iter() - .map(|gpu| { - // GPUs still only have a title line - let line = Line::from(vec![Span::styled( - gpu.title_text.clone(), - Style::default().fg(Color::White).bold(), - )]); - ListItem::new(line) // Keep as single line - }) - .collect(); - let list = List::new(items) - .block(list_block.title(format!( - "Select GPU for '{}'", - app.selected_leaderboard.as_deref().unwrap_or("N/A") - ))) - .style(list_style) - .highlight_style(Style::default().bg(Color::DarkGray)) - .highlight_symbol("> "); - frame.render_stateful_widget(list, main_layout[0], &mut app.gpus_state.clone()); - } - ModelState::SubmissionModeSelection => { - let items: Vec = app - .submission_modes - .iter() - .map(|mode| { - let title_line = Line::from(Span::styled( - mode.title_text.clone(), - Style::default().fg(Color::White).bold(), - )); - - let mut lines = vec![title_line]; - let description_text = &mode.description_text; - - // Manual wrapping logic - if available_width > 0 { - let mut current_line = String::with_capacity(available_width); - for word in description_text.split_whitespace() { - // Check if the word itself is too long - if word.len() > available_width { - // If a line is currently being built, push it first - if !current_line.is_empty() { - lines.push(Line::from(Span::styled( - current_line.clone(), - Style::default().fg(Color::Gray).dim(), - ))); - current_line.clear(); - } - // Push the long word on its own line - lines.push(Line::from(Span::styled( - word.to_string(), - Style::default().fg(Color::Gray).dim(), - ))); - } else if current_line.is_empty() { - // Start a new line - current_line.push_str(word); - } else if current_line.len() + word.len() + 1 <= available_width { - // Add word to current line - current_line.push(' '); - current_line.push_str(word); - } else { - // Word doesn't fit, push the completed line - lines.push(Line::from(Span::styled( - current_line.clone(), - Style::default().fg(Color::Gray).dim(), - ))); - // Start a new line with the current word - current_line.clear(); - current_line.push_str(word); - } - } - // Push the last remaining line if it's not empty - if !current_line.is_empty() { - lines.push(Line::from(Span::styled( - current_line, - Style::default().fg(Color::Gray).dim(), - ))); - } - } else { - // Fallback: push the original description as one line if width is zero - lines.push(Line::from(Span::styled( - description_text.clone(), - Style::default().fg(Color::Gray).dim(), - ))); - } - - ListItem::new(lines) - }) - .collect(); - let list = List::new(items) - .block(list_block.title(format!( - "Select Submission Mode for '{}' on '{}'", - app.selected_leaderboard.as_deref().unwrap_or("N/A"), - app.selected_gpu.as_deref().unwrap_or("N/A") - ))) - .style(list_style) - .highlight_style(Style::default().bg(Color::DarkGray)) - .highlight_symbol("> "); - frame.render_stateful_widget( - list, - main_layout[0], - &mut app.submission_modes_state.clone(), - ); - } - ModelState::WaitingForResult => { - // This state is handled by the loading message check at the beginning - } - } -} - -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] -} - pub async fn execute(cli: Cli) -> Result<()> { match cli.command { Some(Commands::Reregister { provider }) => { @@ -651,117 +73,37 @@ pub async fn execute(cli: Cli) -> Result<()> { AuthProvider::Discord => "discord", AuthProvider::Github => "github", }; - login::run_auth(true, provider_str).await + auth::run_auth(true, provider_str).await } Some(Commands::Register { provider }) => { let provider_str = match provider { AuthProvider::Discord => "discord", AuthProvider::Github => "github", }; - login::run_auth(false, provider_str).await + auth::run_auth(false, provider_str).await } Some(Commands::Submit { filepath }) => { - let file_to_submit = filepath.or(cli.filepath); // Use filepath from subcommand first, then top-level - run_submit_tui(file_to_submit).await - } - None => { - // Default behavior: run submit TUI, potentially with top-level filepath - run_submit_tui(cli.filepath).await + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + let file_to_submit = filepath.or(cli.filepath); + submit::run_submit_tui(file_to_submit, cli_id).await } - } -} - -async fn run_submit_tui(filepath: Option) -> Result<()> { - let file_to_submit = match filepath { - Some(fp) => fp, None => { - // Prompt user for filepath if not provided - println!("Please enter the path to your solution file:"); - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - input.trim().to_string() - } - }; - - if !Path::new(&file_to_submit).exists() { - return Err(anyhow!("File not found: {}", file_to_submit)); - } - - let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; - - if has_multiple_gpus { - return Err(anyhow!( - "Multiple GPUs are not supported yet. Please specify only one GPU." - )); - } - - let mut app = App::new(&file_to_submit); - app.initialize_with_directives(directives); - - enable_raw_mode()?; - let mut stdout = io::stdout(); - crossterm::execute!(stdout, EnterAlternateScreen)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - if app.modal_state == ModelState::LeaderboardSelection { - if let Err(e) = app.spawn_load_leaderboards() { - // Cleanup terminal before exiting on initial load error - disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::terminal::LeaveAlternateScreen - )?; - terminal.show_cursor()?; - return Err(anyhow!("Error starting leaderboard fetch: {}", e)); - } - } else if app.modal_state == ModelState::GpuSelection { - if let Err(e) = app.spawn_load_gpus() { - // Cleanup terminal before exiting on initial load error - disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::terminal::LeaveAlternateScreen - )?; - terminal.show_cursor()?; - return Err(anyhow!("Error starting GPU fetch: {}", e)); + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + submit::run_submit_tui(cli.filepath, cli_id).await } } - - // Main application loop - while !app.should_quit { - terminal.draw(|f| ui(&app, f))?; - - // Check for finished async tasks without blocking drawing - app.check_leaderboard_task().await; - app.check_gpu_task().await; - app.check_submission_task().await; - - // Handle input events - if event::poll(std::time::Duration::from_millis(50))? { - if let Event::Key(key) = event::read()? { - if key.kind == KeyEventKind::Press { - app.handle_key_event(key)?; - } - } - } - } - - // Restore terminal - disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::terminal::LeaveAlternateScreen - )?; - terminal.show_cursor()?; - - utils::display_ascii_art(); - - if let Some(status) = app.final_status { - println!("{}", status); - } else { - println!("Operation cancelled."); // Or some other default message if quit early - } - - Ok(()) } diff --git a/rust/src/cmd/submit.rs b/rust/src/cmd/submit.rs new file mode 100644 index 0000000..8a776e2 --- /dev/null +++ b/rust/src/cmd/submit.rs @@ -0,0 +1,702 @@ +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; + +use anyhow::{anyhow, Result}; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen}; +use ratatui::prelude::*; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use tokio::task::JoinHandle; + +use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; +use crate::service; +use crate::utils; + +pub struct App { + pub filepath: String, + pub cli_id: String, + pub leaderboards: Vec, + pub leaderboards_state: ListState, + pub selected_leaderboard: Option, + pub gpus: Vec, + pub gpus_state: ListState, + pub selected_gpu: Option, + pub submission_modes: Vec, + pub submission_modes_state: ListState, + pub selected_submission_mode: Option, + pub modal_state: ModelState, + pub final_status: Option, + pub loading_message: Option, + pub should_quit: bool, + pub submission_task: Option>>, + pub leaderboards_task: Option, anyhow::Error>>>, + pub gpus_task: Option, anyhow::Error>>>, +} + +impl App { + pub fn new>(filepath: P, cli_id: String) -> Self { + let submission_modes = vec![ + SubmissionModeItem::new( + "Test".to_string(), + "Test the solution and give detailed results about passed/failed tests.".to_string(), + "test".to_string(), + ), + SubmissionModeItem::new( + "Benchmark".to_string(), + "Benchmark the solution, this also runs the tests and afterwards runs the benchmark, returning detailed timing results".to_string(), + "benchmark".to_string(), + ), + SubmissionModeItem::new( + "Leaderboard".to_string(), + "Submit to the leaderboard, this first runs public tests and then private tests. If both pass, the submission is evaluated and submit to the leaderboard.".to_string(), + "leaderboard".to_string(), + ), + SubmissionModeItem::new( + "Private".to_string(), + "TODO".to_string(), + "private".to_string(), + ), + SubmissionModeItem::new( + "Script".to_string(), + "TODO".to_string(), + "script".to_string(), + ), + SubmissionModeItem::new( + "Profile".to_string(), + "TODO".to_string(), + "profile".to_string(), + ), + ]; + + let mut app = Self { + filepath: filepath.as_ref().to_string_lossy().to_string(), + cli_id, + leaderboards: Vec::new(), + leaderboards_state: ListState::default(), + selected_leaderboard: None, + gpus: Vec::new(), + gpus_state: ListState::default(), + selected_gpu: None, + submission_modes, + submission_modes_state: ListState::default(), + selected_submission_mode: None, + modal_state: ModelState::LeaderboardSelection, + final_status: None, + loading_message: None, + should_quit: false, + submission_task: None, + leaderboards_task: None, + gpus_task: None, + }; + + // Initialize list states + app.leaderboards_state.select(Some(0)); + app.gpus_state.select(Some(0)); + app.submission_modes_state.select(Some(0)); + + app + } + + pub fn initialize_with_directives(&mut self, popcorn_directives: utils::PopcornDirectives) { + if !popcorn_directives.leaderboard_name.is_empty() { + self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); + + if !popcorn_directives.gpus.is_empty() { + self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::GpuSelection; + } + } else if !popcorn_directives.gpus.is_empty() { + self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); + if !popcorn_directives.leaderboard_name.is_empty() { + self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::LeaderboardSelection; + } + } else { + self.modal_state = ModelState::LeaderboardSelection; + } + } + + pub fn handle_key_event(&mut self, key: KeyEvent) -> Result { + // Allow quitting anytime, even while loading + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + self.should_quit = true; + return Ok(true); + } + + // Ignore other keys while loading + if self.loading_message.is_some() { + return Ok(false); + } + + match key.code { + KeyCode::Char('q') => { + self.should_quit = true; + return Ok(true); + } + KeyCode::Enter => match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len() { + self.selected_leaderboard = + Some(self.leaderboards[idx].title_text.clone()); + + if self.selected_gpu.is_none() { + self.modal_state = ModelState::GpuSelection; + // Spawn GPU loading task + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + } + } else { + self.modal_state = ModelState::SubmissionModeSelection; + } + return Ok(true); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len() { + self.selected_gpu = Some(self.gpus[idx].title_text.clone()); + self.modal_state = ModelState::SubmissionModeSelection; + return Ok(true); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx < self.submission_modes.len() { + self.selected_submission_mode = + Some(self.submission_modes[idx].value.clone()); + self.modal_state = ModelState::WaitingForResult; // State for logic, UI uses loading msg + // Spawn the submission task + if let Err(e) = self.spawn_submit_solution() { + self.set_error_and_quit(format!( + "Error starting submission: {}", + e + )); + } + return Ok(true); + } + } + } + _ => {} // WaitingForResult state doesn't handle Enter + }, + KeyCode::Up => { + self.move_selection_up(); + return Ok(true); + } + KeyCode::Down => { + self.move_selection_down(); + return Ok(true); + } + _ => {} // Ignore other keys + } + + Ok(false) + } + + // Helper to reduce repetition + fn set_error_and_quit(&mut self, error_message: String) { + self.final_status = Some(error_message); + self.should_quit = true; + self.loading_message = None; // Clear loading on error + } + + fn move_selection_up(&mut self) { + match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx > 0 { + self.leaderboards_state.select(Some(idx - 1)); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx > 0 { + self.gpus_state.select(Some(idx - 1)); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx > 0 { + self.submission_modes_state.select(Some(idx - 1)); + } + } + } + _ => {} + } + } + + fn move_selection_down(&mut self) { + match self.modal_state { + ModelState::LeaderboardSelection => { + if let Some(idx) = self.leaderboards_state.selected() { + if idx < self.leaderboards.len().saturating_sub(1) { + self.leaderboards_state.select(Some(idx + 1)); + } + } + } + ModelState::GpuSelection => { + if let Some(idx) = self.gpus_state.selected() { + if idx < self.gpus.len().saturating_sub(1) { + self.gpus_state.select(Some(idx + 1)); + } + } + } + ModelState::SubmissionModeSelection => { + if let Some(idx) = self.submission_modes_state.selected() { + if idx < self.submission_modes.len().saturating_sub(1) { + self.submission_modes_state.select(Some(idx + 1)); + } + } + } + _ => {} + } + } + + pub fn spawn_load_leaderboards(&mut self) -> Result<()> { + let client = service::create_client(Some(self.cli_id.clone()))?; + self.leaderboards_task = Some(tokio::spawn(async move { + service::fetch_leaderboards(&client).await + })); + self.loading_message = Some("Loading leaderboards...".to_string()); + Ok(()) + } + + pub fn spawn_load_gpus(&mut self) -> Result<()> { + let client = service::create_client(Some(self.cli_id.clone()))?; + let leaderboard_name = self + .selected_leaderboard + .clone() + .ok_or_else(|| anyhow!("Leaderboard not selected"))?; + self.gpus_task = Some(tokio::spawn(async move { + service::fetch_gpus(&client, &leaderboard_name).await + })); + self.loading_message = Some("Loading GPUs...".to_string()); + Ok(()) + } + + pub fn spawn_submit_solution(&mut self) -> Result<()> { + let client = service::create_client(Some(self.cli_id.clone()))?; + let filepath = self.filepath.clone(); + let leaderboard = self + .selected_leaderboard + .clone() + .ok_or_else(|| anyhow!("Leaderboard not selected"))?; + let gpu = self + .selected_gpu + .clone() + .ok_or_else(|| anyhow!("GPU not selected"))?; + let mode = self + .selected_submission_mode + .clone() + .ok_or_else(|| anyhow!("Submission mode not selected"))?; + + // Read file content + let mut file = File::open(&filepath)?; + let mut file_content = String::new(); + file.read_to_string(&mut file_content)?; + + self.submission_task = Some(tokio::spawn(async move { + service::submit_solution(&client, &filepath, &file_content, &leaderboard, &gpu, &mode) + .await + })); + self.loading_message = Some("Submitting solution...".to_string()); + Ok(()) + } + + pub async fn check_leaderboard_task(&mut self) { + if let Some(handle) = &mut self.leaderboards_task { + if handle.is_finished() { + let task = self.leaderboards_task.take().unwrap(); + match task.await { + Ok(Ok(leaderboards)) => { + self.leaderboards = leaderboards; + // If a leaderboard was pre-selected (e.g., from directives), try to find and select it + if let Some(selected_name) = &self.selected_leaderboard { + if let Some(index) = self + .leaderboards + .iter() + .position(|lb| &lb.title_text == selected_name) + { + self.leaderboards_state.select(Some(index)); + // If GPU was also pre-selected, move to submission mode selection + // Otherwise, spawn GPU loading task + if self.selected_gpu.is_some() { + self.modal_state = ModelState::SubmissionModeSelection; + } else { + self.modal_state = ModelState::GpuSelection; + if let Err(e) = self.spawn_load_gpus() { + self.set_error_and_quit(format!( + "Error starting GPU fetch: {}", + e + )); + return; // Exit early on error + } + } + } else { + // Pre-selected leaderboard not found, reset selection and state + self.selected_leaderboard = None; + self.leaderboards_state.select(Some(0)); // Select first available + self.modal_state = ModelState::LeaderboardSelection; + // Stay here + } + } else { + self.leaderboards_state.select(Some(0)); // Select first if no pre-selection + } + + self.loading_message = None; + } + Ok(Err(e)) => { + self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) + } + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + } + } + } + } + + pub async fn check_gpu_task(&mut self) { + if let Some(handle) = &mut self.gpus_task { + if handle.is_finished() { + let task = self.gpus_task.take().unwrap(); + match task.await { + Ok(Ok(gpus)) => { + self.gpus = gpus; + // If a GPU was pre-selected, try to find and select it + if let Some(selected_name) = &self.selected_gpu { + if let Some(index) = self + .gpus + .iter() + .position(|gpu| &gpu.title_text == selected_name) + { + self.gpus_state.select(Some(index)); + self.modal_state = ModelState::SubmissionModeSelection; + // Move to next step + } else { + // Pre-selected GPU not found, reset selection + self.selected_gpu = None; + self.gpus_state.select(Some(0)); // Select first available + self.modal_state = ModelState::GpuSelection; // Stay here + } + } else { + self.gpus_state.select(Some(0)); // Select first if no pre-selection + } + + self.loading_message = None; + } + Ok(Err(e)) => self.set_error_and_quit(format!("Error fetching GPUs: {}", e)), + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + } + } + } + } + + pub async fn check_submission_task(&mut self) { + if let Some(handle) = &mut self.submission_task { + if handle.is_finished() { + let task = self.submission_task.take().unwrap(); + match task.await { + Ok(Ok(status)) => { + self.final_status = Some(status); + self.should_quit = true; // Quit after showing final status + self.loading_message = None; + } + Ok(Err(e)) => self.set_error_and_quit(format!("Submission error: {}", e)), + Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), + } + } + } + } +} + +pub fn ui(app: &App, frame: &mut Frame) { + let main_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0)].as_ref()) + .split(frame.size()); + + // Determine the area available for the list *before* the match statement + let list_area = main_layout[0]; + // Calculate usable width for text wrapping (subtract borders, padding, highlight symbol) + let available_width = list_area.width.saturating_sub(4) as usize; + + if let Some(ref msg) = app.loading_message { + let loading_paragraph = Paragraph::new(msg.clone()) + .block(Block::default().title("Loading").borders(Borders::ALL)) + .alignment(Alignment::Center); + + let area = centered_rect(60, 20, frame.size()); + frame.render_widget(loading_paragraph, area); + return; // Don't render anything else while loading + } + + let list_block = Block::default().borders(Borders::ALL); + let list_style = Style::default().fg(Color::White); + + match app.modal_state { + ModelState::LeaderboardSelection => { + let items: Vec = app + .leaderboards + .iter() + .map(|lb| { + let title_line = Line::from(Span::styled( + lb.title_text.clone(), + Style::default().fg(Color::White).bold(), + )); + // Create lines for the description, splitting by newline + let mut lines = vec![title_line]; + for desc_part in lb.task_description.split('\n') { + lines.push(Line::from(Span::styled( + desc_part.to_string(), + Style::default().fg(Color::Gray).dim(), + ))); + } + ListItem::new(lines) // Use the combined vector of lines + }) + .collect(); + let list = List::new(items) + .block(list_block.title("Select Leaderboard")) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, main_layout[0], &mut app.leaderboards_state.clone()); + } + ModelState::GpuSelection => { + let items: Vec = app + .gpus + .iter() + .map(|gpu| { + // GPUs still only have a title line + let line = Line::from(vec![Span::styled( + gpu.title_text.clone(), + Style::default().fg(Color::White).bold(), + )]); + ListItem::new(line) // Keep as single line + }) + .collect(); + let list = List::new(items) + .block(list_block.title(format!( + "Select GPU for '{}'", + app.selected_leaderboard.as_deref().unwrap_or("N/A") + ))) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget(list, main_layout[0], &mut app.gpus_state.clone()); + } + ModelState::SubmissionModeSelection => { + let items: Vec = app + .submission_modes + .iter() + .map(|mode| { + let title_line = Line::from(Span::styled( + mode.title_text.clone(), + Style::default().fg(Color::White).bold(), + )); + + let mut lines = vec![title_line]; + let description_text = &mode.description_text; + + // Manual wrapping logic + if available_width > 0 { + let mut current_line = String::with_capacity(available_width); + for word in description_text.split_whitespace() { + // Check if the word itself is too long + if word.len() > available_width { + // If a line is currently being built, push it first + if !current_line.is_empty() { + lines.push(Line::from(Span::styled( + current_line.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + current_line.clear(); + } + // Push the long word on its own line + lines.push(Line::from(Span::styled( + word.to_string(), + Style::default().fg(Color::Gray).dim(), + ))); + } else if current_line.is_empty() { + // Start a new line + current_line.push_str(word); + } else if current_line.len() + word.len() + 1 <= available_width { + // Add word to current line + current_line.push(' '); + current_line.push_str(word); + } else { + // Word doesn't fit, push the completed line + lines.push(Line::from(Span::styled( + current_line.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + // Start a new line with the current word + current_line.clear(); + current_line.push_str(word); + } + } + // Push the last remaining line if it's not empty + if !current_line.is_empty() { + lines.push(Line::from(Span::styled( + current_line, + Style::default().fg(Color::Gray).dim(), + ))); + } + } else { + // Fallback: push the original description as one line if width is zero + lines.push(Line::from(Span::styled( + description_text.clone(), + Style::default().fg(Color::Gray).dim(), + ))); + } + + ListItem::new(lines) + }) + .collect(); + let list = List::new(items) + .block(list_block.title(format!( + "Select Submission Mode for '{}' on '{}'", + app.selected_leaderboard.as_deref().unwrap_or("N/A"), + app.selected_gpu.as_deref().unwrap_or("N/A") + ))) + .style(list_style) + .highlight_style(Style::default().bg(Color::DarkGray)) + .highlight_symbol("> "); + frame.render_stateful_widget( + list, + main_layout[0], + &mut app.submission_modes_state.clone(), + ); + } + ModelState::WaitingForResult => { + // This state is handled by the loading message check at the beginning + } + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] +} + +pub async fn run_submit_tui(filepath: Option, cli_id: String) -> Result<()> { + let file_to_submit = match filepath { + Some(fp) => fp, + None => { + // Prompt user for filepath if not provided + println!("Please enter the path to your solution file:"); + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + input.trim().to_string() + } + }; + + if !Path::new(&file_to_submit).exists() { + return Err(anyhow!("File not found: {}", file_to_submit)); + } + + let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; + + if has_multiple_gpus { + return Err(anyhow!( + "Multiple GPUs are not supported yet. Please specify only one GPU." + )); + } + + let mut app = App::new(&file_to_submit, cli_id); + app.initialize_with_directives(directives); + + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + if app.modal_state == ModelState::LeaderboardSelection { + if let Err(e) = app.spawn_load_leaderboards() { + // Cleanup terminal before exiting on initial load error + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + return Err(anyhow!("Error starting leaderboard fetch: {}", e)); + } + } else if app.modal_state == ModelState::GpuSelection { + if let Err(e) = app.spawn_load_gpus() { + // Cleanup terminal before exiting on initial load error + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + return Err(anyhow!("Error starting GPU fetch: {}", e)); + } + } + + // Main application loop + while !app.should_quit { + terminal.draw(|f| ui(&app, f))?; + + // Check for finished async tasks without blocking drawing + app.check_leaderboard_task().await; + app.check_gpu_task().await; + app.check_submission_task().await; + + // Handle input events + if event::poll(std::time::Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + app.handle_key_event(key)?; + } + } + } + } + + // Restore terminal + disable_raw_mode()?; + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen + )?; + terminal.show_cursor()?; + + utils::display_ascii_art(); + + if let Some(status) = app.final_status { + println!("{}", status); + } else { + println!("Operation cancelled."); // Or some other default message if quit early + } + + Ok(()) +} diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index 489d45e..db5fc02 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, Result}; +use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::multipart::{Form, Part}; use reqwest::Client; use serde_json::Value; @@ -9,9 +10,23 @@ use std::time::Duration; use crate::models::{GpuItem, LeaderboardItem}; // Helper function to create a reusable reqwest client -pub fn create_client() -> Result { +pub fn create_client(cli_id: Option) -> Result { + let mut default_headers = HeaderMap::new(); + + if let Some(id) = cli_id { + match HeaderValue::from_str(&id) { + Ok(val) => { + default_headers.insert("X-Popcorn-Cli-Id", val); + } + Err(_) => { + return Err(anyhow!("Invalid cli_id format for HTTP header")); + } + } + } + Client::builder() - .timeout(Duration::from_secs(60)) // Set a default timeout + .timeout(Duration::from_secs(60)) + .default_headers(default_headers) .build() .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) } From 3cc5a99af8258032deed0d7d9e157ebf7e391949 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:41:49 +0200 Subject: [PATCH 012/111] Feat: build --- .github/workflows/build.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 60ad366..1a076f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,8 +22,12 @@ jobs: with: fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 + name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 From 85b639a2af0e4a1d30bf100ce45c763128a51a58 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:49:13 +0200 Subject: [PATCH 013/111] Fix: build --- .goreleaser.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 4147330..1e9eefa 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,12 +1,7 @@ version: 2 builds: - - env: - - CGO_ENABLED=0 - goos: - - linux - - windows - - darwin - goarch: - - amd64 - - arm64 + - id: rust + builder: rust + dir: rust binary: popcorn-cli + command: build From 3fb6315ef1f977cde6e814015e96fcb9c416c08f Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:55:20 +0200 Subject: [PATCH 014/111] Fix: build --- .goreleaser.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 1e9eefa..2027660 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,3 +5,8 @@ builds: dir: rust binary: popcorn-cli command: build + targets: + - x86_64-unknown-linux-gnu + - x86_64-apple-darwin + - x86_64-pc-windows-gnu + - aarch64-unknown-linux-gnu From e00e952fafc41a18c9b44c026cec5f1f97b039f6 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 01:59:17 +0200 Subject: [PATCH 015/111] Fix: build2 --- .github/workflows/build.yml | 3 +++ .goreleaser.yml | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a076f7..12cc441 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,6 +28,9 @@ jobs: toolchain: stable profile: minimal override: true + - + name: Set up Zig + uses: mlugg/setup-zig@v1 - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.goreleaser.yml b/.goreleaser.yml index 2027660..3f6f2fb 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,7 +4,7 @@ builds: builder: rust dir: rust binary: popcorn-cli - command: build + command: zigbuild targets: - x86_64-unknown-linux-gnu - x86_64-apple-darwin From f1e983fc1796c5c891dd4228ba2c671bc6331ace Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:02:33 +0200 Subject: [PATCH 016/111] Fix: build. --- .github/workflows/build.yml | 7 ------- .goreleaser.yml | 5 ----- 2 files changed, 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 12cc441..699c707 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,13 +21,6 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Set up Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true - name: Set up Zig uses: mlugg/setup-zig@v1 diff --git a/.goreleaser.yml b/.goreleaser.yml index 3f6f2fb..99f7258 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -5,8 +5,3 @@ builds: dir: rust binary: popcorn-cli command: zigbuild - targets: - - x86_64-unknown-linux-gnu - - x86_64-apple-darwin - - x86_64-pc-windows-gnu - - aarch64-unknown-linux-gnu From 8698a7d4be4524622ca9a017baf9c8af28e36542 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:05:55 +0200 Subject: [PATCH 017/111] Fix: build. --- .goreleaser.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 99f7258..40da2d5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,4 +4,3 @@ builds: builder: rust dir: rust binary: popcorn-cli - command: zigbuild From 97c50520a2a8a3df802aec9d52474485a84cb727 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:07:10 +0200 Subject: [PATCH 018/111] Fix: build. --- .github/workflows/build.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 699c707..993bfa9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,6 +24,16 @@ jobs: - name: Set up Zig uses: mlugg/setup-zig@v1 + - + name: Set up Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + - + name: Install zigbuild + run: cargo install cargo-zigbuild - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 From 5333f75996f1f31fa7d05fcd023c5baf7949cebc Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 02:15:39 +0200 Subject: [PATCH 019/111] Fix: build. --- .github/workflows/build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 993bfa9..4d46d1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,10 @@ jobs: - name: Install zigbuild run: cargo install cargo-zigbuild + - + name: Install openssl + run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 From 1aa5582abc672a179ca3b1458d089ba60c4a384d Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sun, 13 Apr 2025 13:20:32 +0200 Subject: [PATCH 020/111] Fix: timeouts --- rust/src/service/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/src/service/mod.rs b/rust/src/service/mod.rs index db5fc02..6fd4d2c 100644 --- a/rust/src/service/mod.rs +++ b/rust/src/service/mod.rs @@ -25,7 +25,7 @@ pub fn create_client(cli_id: Option) -> Result { } Client::builder() - .timeout(Duration::from_secs(60)) + .timeout(Duration::from_secs(180)) .default_headers(default_headers) .build() .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) @@ -125,7 +125,7 @@ pub async fn submit_solution>( let resp = client .post(&url) .multipart(form) - .timeout(Duration::from_secs(60)) + .timeout(Duration::from_secs(180)) .send() .await?; @@ -137,7 +137,7 @@ pub async fn submit_solution>( let result: Value = resp.json().await?; - let pretty_result = match result.get("result") { + let pretty_result = match result.get("results") { Some(result_obj) => serde_json::to_string_pretty(result_obj)?, None => return Err(anyhow!("Invalid response structure")), }; From 01b389647e750b2eb218c0a13437f30fab2f56cd Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 16:20:56 +0200 Subject: [PATCH 021/111] Feat: readme --- README.md | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5361cda..61291c6 100644 --- a/README.md +++ b/README.md @@ -12,27 +12,26 @@ A command-line interface tool for submitting solutions to the [Popcorn Discord B ### Option 2: Building from source -If you want to build from source, you'll need: -1. Install [Go](https://golang.org/doc/install) -2. Run: -```bash -GOPROXY=direct go install github.com/s1ro1/popcorn-cli@latest -``` -3. Make sure the `popcorn-cli` binary is in your PATH +This app is written in Rust, so you can just install it via `cargo install` ## Usage -Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API +Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode). + +Then, you need to be registered to use this app. You can register by running: `popcorn-cli register [discord|github]`. We strongly reccomend using your Discord account to register, as this will match your submissions to your Discord account. +Once you're registered, there is a file created in your `$HOME` called `.popcorn-cli.yaml` that contains your registration token. This token is sent with each request. + +If you want to re-register (you can do this any number of times), you can run `popcorn-cli reregister [discord|github]`. + +After this, you can submit a solution by running: -Then, simply run the binary: ```bash -popcorn-cli +popcorn-cli submit ``` The interactive CLI will guide you through the process of: 1. Selecting a leaderboard -2. Choosing a runner -3. Selecting GPU options -4. Setting submission mode -5. Submitting your work +2 Selecting GPU options +3. Setting submission mode +4. Submitting your work From 3205bead07d20cc1be50fd3c7d73bac526401d5e Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 16:21:47 +0200 Subject: [PATCH 022/111] Fix: readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 61291c6..073a9f0 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ popcorn-cli submit The interactive CLI will guide you through the process of: 1. Selecting a leaderboard -2 Selecting GPU options +2. Selecting GPU options 3. Setting submission mode 4. Submitting your work - From be54afdf1c1fca7bfc7fc6cdf5590280b5b6ea66 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 16:45:09 +0200 Subject: [PATCH 023/111] Fix: build. --- rust/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d7f3774..c8d98f1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -20,3 +20,7 @@ serde_yaml = "0.9" webbrowser = "0.8" base64-url = "3.0.0" urlencoding = "2.1.3" + +[dependencies.openssl-sys] +version = "0.9" +features = ["vendored"] From 45ba09cf5c7d875b37a5fffe0391b271693ea5ab Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 17:33:22 +0200 Subject: [PATCH 024/111] Fix; build --- .goreleaser.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.goreleaser.yml b/.goreleaser.yml index 40da2d5..23fbe81 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,3 +4,8 @@ builds: builder: rust dir: rust binary: popcorn-cli + targets: + - x86_64-unknown-linux-gnu + - x86_64-pc-windows-gnu + - aarch64-unknown-linux-gnu + - aarch64-apple-darwin From 1acb7be7d4b0afa76ee7f45cd8b006ef37d96581 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 17:52:10 +0200 Subject: [PATCH 025/111] Fix; build --- rust/Cargo.toml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index c8d98f1..018a8b0 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,6 +21,9 @@ webbrowser = "0.8" base64-url = "3.0.0" urlencoding = "2.1.3" -[dependencies.openssl-sys] -version = "0.9" -features = ["vendored"] +[features] +static_ssl = ['openssl/vendored'] + +[dependencies.openssl] +optional = true +version = "0.10" From cba3153b3235532dd017b8dda6c3db68a23ce294 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 17:58:22 +0200 Subject: [PATCH 026/111] Fix; build --- rust/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 018a8b0..8464857 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -27,3 +27,4 @@ static_ssl = ['openssl/vendored'] [dependencies.openssl] optional = true version = "0.10" +features = ["vendored"] From 8a74a00d2b3253b394670ebc596a3a773b249582 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:10:47 +0200 Subject: [PATCH 027/111] fix: build --- .github/workflows/build.yml | 58 +++++++++++-------------------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4d46d1f..1c9ee8d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,49 +13,25 @@ permissions: contents: write jobs: - goreleaser: + release: + name: release ${{ matrix.target }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-gnu + archive: zip + - target: x86_64-unknown-linux-musl + archive: tar.gz tar.xz tar.zst + - target: x86_64-apple-darwin + archive: zip steps: - - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Zig - uses: mlugg/setup-zig@v1 - - - name: Set up Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - profile: minimal - override: true - - - name: Install zigbuild - run: cargo install cargo-zigbuild - - - name: Install openssl - run: sudo apt-get update && sudo apt-get install -y libssl-dev pkg-config - - - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 - if: startsWith(github.ref, 'refs/tags/') - with: - distribution: goreleaser - version: '~> v2' - args: release --clean + - uses: actions/checkout@master + - name: Compile and release + uses: rust-build/rust-build.action@v1.4.5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - - name: Run GoReleaser Snapshot - uses: goreleaser/goreleaser-action@v6 - if: "!startsWith(github.ref, 'refs/tags/')" with: - distribution: goreleaser - version: '~> v2' - args: release --clean --snapshot - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUSTTARGET: ${{ matrix.target }} + ARCHIVE_TYPES: ${{ matrix.archive }} From b84a985b5a4e0d9fea85c6c6b0784add0a5702a1 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:13:16 +0200 Subject: [PATCH 028/111] Feat: rust top level --- .goreleaser.yml | 11 - Cargo.lock | 2221 ++++++++++++++++++++++++++++++ rust/Cargo.toml => Cargo.toml | 0 README.md | 37 +- rust/build.sh => build.sh | 0 go.mod | 29 - go.sum | 47 - main.go | 18 - rust/.gitignore | 18 - rust/README.md | 3 - {rust/src => src}/cmd/auth.rs | 0 {rust/src => src}/cmd/mod.rs | 0 src/cmd/popcorn-cli.go | 282 ---- {rust/src => src}/cmd/submit.rs | 0 {rust/src => src}/main.rs | 0 {rust/src => src}/models/mod.rs | 0 src/models/types.go | 54 - src/service/api.go | 161 --- {rust/src => src}/service/mod.rs | 0 {rust/src => src}/utils/mod.rs | 0 src/utils/utils.go | 82 -- 21 files changed, 2223 insertions(+), 740 deletions(-) delete mode 100644 .goreleaser.yml create mode 100644 Cargo.lock rename rust/Cargo.toml => Cargo.toml (100%) rename rust/build.sh => build.sh (100%) delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 main.go delete mode 100644 rust/.gitignore delete mode 100644 rust/README.md rename {rust/src => src}/cmd/auth.rs (100%) rename {rust/src => src}/cmd/mod.rs (100%) delete mode 100644 src/cmd/popcorn-cli.go rename {rust/src => src}/cmd/submit.rs (100%) rename {rust/src => src}/main.rs (100%) rename {rust/src => src}/models/mod.rs (100%) delete mode 100644 src/models/types.go delete mode 100644 src/service/api.go rename {rust/src => src}/service/mod.rs (100%) rename {rust/src => src}/utils/mod.rs (100%) delete mode 100644 src/utils/utils.go diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index 23fbe81..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -builds: - - id: rust - builder: rust - dir: rust - binary: popcorn-cli - targets: - - x86_64-unknown-linux-gnu - - x86_64-pc-windows-gnu - - aarch64-unknown-linux-gnu - - aarch64-apple-darwin diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b218a24 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2221 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" +dependencies = [ + "anstyle", + "once_cell", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-url" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2b6c78c06f7288d5e3c3d683bde35a79531127c83b087e5d0d77c974b4b28" +dependencies = [ + "base64 0.22.1", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "bumpalo" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3a13707ac958681c13b39b458c073d0d9bc8a22cb1b2f4c8e55eb72c13f362" +dependencies = [ + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2df961d8c8a0d08aa9945718ccf584145eee3f3aa06cddbeac12933781102e04" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "132dbda40fb6753878316a489d5a1242a8ef2f0d9e47ba01c951ea8aa7d013a5" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.9.0", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "ctrlc" +version = "3.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697b5419f348fd5ae2478e8018cb016c00a5881c7f46c717de98ffd135a5651c" +dependencies = [ + "nix", + "windows-sys 0.59.0", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.52.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-src" +version = "300.5.0+3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "popcorn-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64-url", + "clap", + "crossterm", + "ctrlc", + "dirs", + "openssl", + "ratatui", + "reqwest", + "serde", + "serde_json", + "serde_yaml", + "tokio", + "urlencoding", + "webbrowser", +] + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags 2.9.0", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "redox_syscall" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +dependencies = [ + "bitflags 2.9.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" + +[[package]] +name = "socket2" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f5fd57c80058a56cf5c777ab8a126398ece8e442983605d280a44ce79d0edef" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +dependencies = [ + "fastrand", + "getrandom 0.3.2", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.44.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio 1.0.3", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" +dependencies = [ + "core-foundation", + "home", + "jni", + "log", + "ndk-context", + "objc", + "raw-window-handle", + "url", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/rust/Cargo.toml b/Cargo.toml similarity index 100% rename from rust/Cargo.toml rename to Cargo.toml diff --git a/README.md b/README.md index 073a9f0..81e3225 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,3 @@ -# Popcorn CLI +# Popcorn CLI (Rust Version) -A command-line interface tool for submitting solutions to the [Popcorn Discord Bot](https://github.com/gpu-mode/discord-cluster-manager) - -## Installation - -### Option 1: Using pre-built binaries (Recommended) - -1. Download the latest release for your platform from the releases page -2. Extract the archive -3. Move the binary to a location in your PATH - -### Option 2: Building from source - -This app is written in Rust, so you can just install it via `cargo install` - -## Usage - -Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode). - -Then, you need to be registered to use this app. You can register by running: `popcorn-cli register [discord|github]`. We strongly reccomend using your Discord account to register, as this will match your submissions to your Discord account. -Once you're registered, there is a file created in your `$HOME` called `.popcorn-cli.yaml` that contains your registration token. This token is sent with each request. - -If you want to re-register (you can do this any number of times), you can run `popcorn-cli reregister [discord|github]`. - -After this, you can submit a solution by running: - -```bash -popcorn-cli submit -``` - -The interactive CLI will guide you through the process of: -1. Selecting a leaderboard -2. Selecting GPU options -3. Setting submission mode -4. Submitting your work +Run `./build.sh` and then ` POPCORN_API_URL="http://127.0.0.1:8000" target/release/popcorn-cli ../../discord/discord-cluster-manager/reference-kernels/problems/pmpp/grayscale_py/submission.py` diff --git a/rust/build.sh b/build.sh similarity index 100% rename from rust/build.sh rename to build.sh diff --git a/go.mod b/go.mod deleted file mode 100644 index 5303f73..0000000 --- a/go.mod +++ /dev/null @@ -1,29 +0,0 @@ -module github.com/S1ro1/popcorn-cli - -go 1.21 - -require ( - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.25.0 - github.com/charmbracelet/lipgloss v0.9.1 -) - -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.18 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect - github.com/rivo/uniseg v0.4.6 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect - golang.org/x/sync v0.1.0 // indirect - golang.org/x/sys v0.12.0 // indirect - golang.org/x/term v0.6.0 // indirect - golang.org/x/text v0.3.8 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index e44c6a2..0000000 --- a/go.sum +++ /dev/null @@ -1,47 +0,0 @@ -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg= -github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg= -github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/main.go b/main.go deleted file mode 100644 index 62de05d..0000000 --- a/main.go +++ /dev/null @@ -1,18 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/S1ro1/popcorn-cli/src/cmd" -) - - -func main() { - - if os.Getenv("POPCORN_API_URL") == "" { - fmt.Println("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API.") - os.Exit(1) - } - cmd.Execute() -} diff --git a/rust/.gitignore b/rust/.gitignore deleted file mode 100644 index 8a6bea3..0000000 --- a/rust/.gitignore +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Cargo -/target/ - -# Backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - -# IDEs and editors -/.idea/ -/.vscode/ -*.swp -*.swo \ No newline at end of file diff --git a/rust/README.md b/rust/README.md deleted file mode 100644 index 81e3225..0000000 --- a/rust/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Popcorn CLI (Rust Version) - -Run `./build.sh` and then ` POPCORN_API_URL="http://127.0.0.1:8000" target/release/popcorn-cli ../../discord/discord-cluster-manager/reference-kernels/problems/pmpp/grayscale_py/submission.py` diff --git a/rust/src/cmd/auth.rs b/src/cmd/auth.rs similarity index 100% rename from rust/src/cmd/auth.rs rename to src/cmd/auth.rs diff --git a/rust/src/cmd/mod.rs b/src/cmd/mod.rs similarity index 100% rename from rust/src/cmd/mod.rs rename to src/cmd/mod.rs diff --git a/src/cmd/popcorn-cli.go b/src/cmd/popcorn-cli.go deleted file mode 100644 index df50f71..0000000 --- a/src/cmd/popcorn-cli.go +++ /dev/null @@ -1,282 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/lipgloss" - - "github.com/S1ro1/popcorn-cli/src/models" - "github.com/S1ro1/popcorn-cli/src/service" - - "github.com/S1ro1/popcorn-cli/src/utils" - - tea "github.com/charmbracelet/bubbletea" -) - -var submissionModeItems = []list.Item{ - models.SubmissionModeItem{TitleText: "Test", DescriptionText: "Test the solution and give detailed results about passed/failed tests.", Value: "test"}, - models.SubmissionModeItem{TitleText: "Benchmark", DescriptionText: "Benchmark the solution, this also runs the tests and afterwards runs the benchmark, returning detailed timing results", Value: "benchmark"}, - models.SubmissionModeItem{TitleText: "Leaderboard", DescriptionText: "Submit to the leaderboard, this first runs public tests and then private tests. If both pass, the submission is evaluated and submit to the leaderboard.", Value: "leaderboard"}, - models.SubmissionModeItem{TitleText: "Private", DescriptionText: "TODO", Value: "private"}, - models.SubmissionModeItem{TitleText: "Script", DescriptionText: "TODO", Value: "script"}, - models.SubmissionModeItem{TitleText: "Profile", DescriptionText: "TODO", Value: "profile"}, -} - -var docStyle = lipgloss.NewStyle().Margin(1, 2) -var p *tea.Program - -type model struct { - filepath string - leaderboardsList list.Model - selectedLeaderboard string - gpusList list.Model - selectedGpu string - submissionModeList list.Model - selectedSubmissionMode string - modalState models.ModelState - width int - height int - - finalStatus string - finishedOkay bool - - spinner spinner.Model -} - -func (m model) Init() tea.Cmd { - return tea.EnterAltScreen -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmd tea.Cmd - - if len(m.gpusList.Items()) == 0 && m.modalState == models.ModelStateGpuSelection { - gpus, err := service.GetListItems(func() ([]models.GpuItem, error) { - return service.FetchAvailableGpus(m.selectedLeaderboard) - }) - if err != nil { - m.SetError(fmt.Sprintf("Error fetching GPUs: %s", err)) - return m, tea.Quit - } - m.gpusList = list.New(gpus, list.NewDefaultDelegate(), m.width-2, m.height-2) - m.gpusList.SetSize(m.width-2, m.height-2) - } - if !m.finishedOkay { - return m, tea.Quit - } - - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - if msg.String() == "enter" { - switch m.modalState { - case models.ModelStateLeaderboardSelection: - if i := m.leaderboardsList.SelectedItem(); i != nil { - m.selectedLeaderboard = i.(models.LeaderboardItem).TitleText - // No gpu selected in popcorn directives, fetch gpus and move to gpu selection - if m.selectedGpu == "" { - gpus, err := service.GetListItems(func() ([]models.GpuItem, error) { - return service.FetchAvailableGpus(m.selectedLeaderboard) - }) - if err != nil { - m.SetError(fmt.Sprintf("Error fetching GPUs: %s", err)) - return m, tea.Quit - } - if len(gpus) == 0 { - m.SetError("No GPUs available for this leaderboard.") - return m, tea.Quit - } - m.gpusList = list.New(gpus, list.NewDefaultDelegate(), m.width-2, m.height-2) - m.gpusList.SetSize(m.width-2, m.height-2) - m.modalState = models.ModelStateGpuSelection - } else { - m.modalState = models.ModelStateSubmissionModeSelection - m.submissionModeList.SetSize(m.width-2, m.height-2) - } - } - case models.ModelStateGpuSelection: - if i := m.gpusList.SelectedItem(); i != nil { - m.selectedGpu = i.(models.GpuItem).TitleText - m.modalState = models.ModelStateSubmissionModeSelection - m.submissionModeList.SetSize(m.width-2, m.height-2) - } - case models.ModelStateSubmissionModeSelection: - if i := m.submissionModeList.SelectedItem(); i != nil { - m.selectedSubmissionMode = i.(models.SubmissionModeItem).Value - m.modalState = models.ModelStateWaitingForResult - return m, m.Submit() - } - case models.ModelStateWaitingForResult: - return m, nil - } - } - - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - h, v := docStyle.GetFrameSize() - listWidth := msg.Width - h - listHeight := msg.Height - v - - switch m.modalState { - case models.ModelStateLeaderboardSelection: - m.leaderboardsList.SetSize(listWidth, listHeight) - case models.ModelStateGpuSelection: - m.gpusList.SetSize(listWidth, listHeight) - case models.ModelStateSubmissionModeSelection: - m.submissionModeList.SetSize(listWidth, listHeight) - } - } - - switch m.modalState { - case models.ModelStateLeaderboardSelection: - m.leaderboardsList, cmd = m.leaderboardsList.Update(msg) - case models.ModelStateGpuSelection: - m.gpusList, cmd = m.gpusList.Update(msg) - case models.ModelStateSubmissionModeSelection: - m.submissionModeList, cmd = m.submissionModeList.Update(msg) - case models.ModelStateWaitingForResult: - m.spinner, cmd = m.spinner.Update(msg) - } - - switch msg := msg.(type) { - case models.ErrorMsg: - m.SetError(msg.Err.Error()) - return m, nil - case models.SubmissionResultMsg: - m.finalStatus = string(msg) - m.finishedOkay = true - return m, tea.Quit - } - - return m, cmd -} - -func (m model) View() string { - var content string - switch m.modalState { - case models.ModelStateLeaderboardSelection: - content = m.leaderboardsList.View() - case models.ModelStateGpuSelection: - content = m.gpusList.View() - case models.ModelStateSubmissionModeSelection: - content = m.submissionModeList.View() - case models.ModelStateWaitingForResult: - str := fmt.Sprintf("\n\n %s Submitting solution...press ctrl+c to quit\n\n", m.spinner.View()) - content = str - } - return docStyle.Render(content) -} - -func (m *model) SetError(err string) { - m.finalStatus = err - m.finishedOkay = false -} - -func (m model) Submit() tea.Cmd { - return func() tea.Msg { - go func() { - fileContent, err := os.ReadFile(m.filepath) - if err != nil { - p.Send(models.ErrorMsg{Err: fmt.Errorf("error reading file: %s", err)}) - m.SetError(fmt.Sprintf("Error reading file: %s", err)) - return - } - - prettyResult, err := service.SubmitSolution(m.selectedLeaderboard, m.selectedGpu, m.selectedSubmissionMode, m.filepath, fileContent) - if err != nil { - p.Send(models.ErrorMsg{Err: fmt.Errorf("error submitting solution: %s", err)}) - m.SetError(fmt.Sprintf("Error submitting solution: %s", err)) - return - } - - p.Send(models.SubmissionResultMsg(prettyResult)) - }() - - return m.spinner.Tick() - } -} - -func Execute() { - args := os.Args[1:] - - if len(args) == 0 { - fmt.Println("Usage: popgorn ") - return - } - - filepath := args[0] - if _, err := os.Stat(filepath); os.IsNotExist(err) { - fmt.Println("File does not exist: ", filepath) - return - } - - popcornDirectives, err := utils.GetPopcornDirectives(filepath) - if err != nil { - fmt.Println("Error:", err) - var input string - fmt.Scanln(&input) - if strings.ToLower(input) != "y" { - return - } - } - - var modalState models.ModelState - if popcornDirectives.LeaderboardName != "" && len(popcornDirectives.Gpus) > 0 { - modalState = models.ModelStateSubmissionModeSelection - } else if popcornDirectives.LeaderboardName != "" { - modalState = models.ModelStateGpuSelection - } else { - modalState = models.ModelStateLeaderboardSelection - } - - var selectedGpu string - if len(popcornDirectives.Gpus) > 0 { - selectedGpu = popcornDirectives.Gpus[0] - } - - leaderboardItems, err := service.GetListItems(service.FetchLeaderboards) - if err != nil { - fmt.Println("Error fetching leaderboards:", err) - - } - - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205")) - - m := model{ - filepath: filepath, - leaderboardsList: list.New(leaderboardItems, list.NewDefaultDelegate(), 0, 0), - submissionModeList: list.New(submissionModeItems, list.NewDefaultDelegate(), 0, 0), - gpusList: list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0), - spinner: s, - modalState: modalState, - finishedOkay: true, - finalStatus: "", - selectedLeaderboard: popcornDirectives.LeaderboardName, - selectedGpu: selectedGpu, - } - m.leaderboardsList.Title = "Leaderboards" - - p = tea.NewProgram(m) - finalModel, err := p.Run() - if err != nil { - fmt.Println("Error running program:", err) - return - } - - m, ok := finalModel.(model) - utils.DisplayAsciiArt() - if ok && m.finishedOkay { - fmt.Printf("\nResult:\n\n%s\n", m.finalStatus) - } else if ok && !m.finishedOkay { - fmt.Printf("\nError:\n\n%s\n", m.finalStatus) - } -} diff --git a/rust/src/cmd/submit.rs b/src/cmd/submit.rs similarity index 100% rename from rust/src/cmd/submit.rs rename to src/cmd/submit.rs diff --git a/rust/src/main.rs b/src/main.rs similarity index 100% rename from rust/src/main.rs rename to src/main.rs diff --git a/rust/src/models/mod.rs b/src/models/mod.rs similarity index 100% rename from rust/src/models/mod.rs rename to src/models/mod.rs diff --git a/src/models/types.go b/src/models/types.go deleted file mode 100644 index 83d6469..0000000 --- a/src/models/types.go +++ /dev/null @@ -1,54 +0,0 @@ -package models - -type LeaderboardItem struct { - TitleText string - TaskDescription string -} - -func (i LeaderboardItem) FilterValue() string { return i.TitleText } -func (i LeaderboardItem) Title() string { return i.TitleText } -func (i LeaderboardItem) Description() string { return i.TaskDescription } - -type GpuItem struct { - TitleText string -} - -func (i GpuItem) FilterValue() string { return i.TitleText } -func (i GpuItem) Title() string { return i.TitleText } -func (i GpuItem) Description() string { return "" } - -type RunnerItem struct { - TitleText string - DescriptionText string - Value string -} - -func (i RunnerItem) FilterValue() string { return i.TitleText } -func (i RunnerItem) Title() string { return i.TitleText } -func (i RunnerItem) Description() string { return i.DescriptionText } - -type SubmissionModeItem struct { - TitleText string - DescriptionText string - Value string -} - -func (i SubmissionModeItem) FilterValue() string { return i.TitleText } -func (i SubmissionModeItem) Title() string { return i.TitleText } -func (i SubmissionModeItem) Description() string { return i.DescriptionText } - -type ModelState int - -const ( - ModelStateLeaderboardSelection ModelState = iota - ModelStateRunnerSelection - ModelStateGpuSelection - ModelStateSubmissionModeSelection - ModelStateWaitingForResult -) - -type ErrorMsg struct { - Err error -} - -type SubmissionResultMsg string diff --git a/src/service/api.go b/src/service/api.go deleted file mode 100644 index f45e137..0000000 --- a/src/service/api.go +++ /dev/null @@ -1,161 +0,0 @@ -package service - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - "github.com/charmbracelet/bubbles/list" - - "github.com/S1ro1/popcorn-cli/src/models" -) - -var BASE_URL = os.Getenv("POPCORN_API_URL") - -func FetchLeaderboards() ([]models.LeaderboardItem, error) { - resp, err := http.Get(BASE_URL + "/leaderboards") - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch leaderboards: %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var leaderboards []map[string]interface{} - err = json.Unmarshal(body, &leaderboards) - if err != nil { - return nil, err - } - - leaderboardNames := make([]models.LeaderboardItem, len(leaderboards)) - for i, lb := range leaderboards { - task := lb["task"].(map[string]interface{}) - leaderboardNames[i] = models.LeaderboardItem{ - TitleText: lb["name"].(string), - TaskDescription: task["description"].(string), - } - } - - return leaderboardNames, nil -} - -func FetchAvailableGpus(leaderboard string) ([]models.GpuItem, error) { - resp, err := http.Get(BASE_URL + "/gpus/" + leaderboard) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch GPUs: %s", resp.Status) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - var gpus []string - err = json.Unmarshal(body, &gpus) - if err != nil { - return nil, err - } - - gpuItems := make([]models.GpuItem, len(gpus)) - for i, gpu := range gpus { - gpuItems[i] = models.GpuItem{TitleText: gpu} - } - - return gpuItems, nil -} - -func SubmitSolution(leaderboard string, gpu string, submissionMode string, filename string, fileContent []byte) (string, error) { - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) - - part, err := writer.CreateFormFile("file", filepath.Base(filename)) - if err != nil { - return "", fmt.Errorf("error creating form file: %s", err) - } - - if _, err := part.Write(fileContent); err != nil { - return "", fmt.Errorf("error writing file to form: %s", err) - } - - if err := writer.Close(); err != nil { - return "", fmt.Errorf("error closing form: %s", err) - } - - url := fmt.Sprintf("%s/%s/%s/%s", - BASE_URL, - strings.ToLower(leaderboard), - strings.ToLower(gpu), - strings.ToLower(submissionMode)) - - req, err := http.NewRequest("POST", url, body) - if err != nil { - return "", fmt.Errorf("error creating request: %s", err) - } - - req.Header.Set("Content-Type", writer.FormDataContentType()) - - client := &http.Client{Timeout: 60 * time.Second} - - resp, err := client.Do(req) - if err != nil { - return "", fmt.Errorf("error sending request: %s", err) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("error reading response body: %s", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(respBody)) - } - - var result struct { - Status string `json:"status"` - Result map[string]any `json:"result"` - } - if err := json.Unmarshal(respBody, &result); err != nil { - return "", fmt.Errorf("error unmarshalling response body: %s", err) - } - - prettyResult, err := json.MarshalIndent(result.Result, "", " ") - if err != nil { - return "", fmt.Errorf("error marshalling response body: %s", err) - } - - return string(prettyResult), nil -} - -func GetListItems[T list.Item](fetchFn func() ([]T, error)) ([]list.Item, error) { - items, err := fetchFn() - if err != nil { - return nil, err - } - - listItems := make([]list.Item, len(items)) - for i, item := range items { - listItems[i] = list.Item(item) - } - - return listItems, nil -} diff --git a/rust/src/service/mod.rs b/src/service/mod.rs similarity index 100% rename from rust/src/service/mod.rs rename to src/service/mod.rs diff --git a/rust/src/utils/mod.rs b/src/utils/mod.rs similarity index 100% rename from rust/src/utils/mod.rs rename to src/utils/mod.rs diff --git a/src/utils/utils.go b/src/utils/utils.go deleted file mode 100644 index 0ebcf42..0000000 --- a/src/utils/utils.go +++ /dev/null @@ -1,82 +0,0 @@ -package utils - -import ( - "fmt" - "os" - "strings" -) - -type PopcornDirectives struct { - LeaderboardName string - Gpus []string -} - -func GetPopcornDirectives(filepath string) (*PopcornDirectives, error) { - var err error = nil - content, err := os.ReadFile(filepath) - - var gpus []string = []string{} - var leaderboard_name string = "" - - if err != nil { - return nil, err - } - - lines := strings.Split(string(content), "\n") - for _, line := range lines { - if !strings.HasPrefix(line, "//") && !strings.HasPrefix(line, "#") { - continue - } - - parts := strings.Split(line, " ") - if parts[0] == "//!POPCORN" || parts[0] == "#!POPCORN" { - arg := strings.ToLower(parts[1]) - if arg == "gpu" || arg == "gpus" { - gpus = parts[2:] - } else if arg == "leaderboard" { - leaderboard_name = parts[2] - } - } - } - - if len(gpus) > 1 { - err = fmt.Errorf("multiple GPUs are not yet supported, continue with the first gpu? (%s) [y/N]", gpus[0]) - gpus = []string{gpus[0]} - } - - return &PopcornDirectives{ - LeaderboardName: leaderboard_name, - Gpus: gpus, - }, err -} - -func DisplayAsciiArt() { - art := ` - _ __ _ ______ _ -| | / / | | | ___ \ | | -| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ -| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| -| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ -\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| - - POPCORN CLI - GPU MODE - - ┌───────────────────────────────────────┐ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ - │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ - │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ - │ └─────┘ └─────┘ └─────┘ │████████│ │▒ - │ │████████│ │▒ - │ ┌────────────────────────┐ │████████│ │▒ - │ │ │ │████████│ │▒ - │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ - │ │ │ │▒ - │ └────────────────────────┘ │▒ - │ │▒ - └───────────────────────────────────────┘▒ - ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ -` - fmt.Println(art) -} From 35e05a75f298f4f847687106c1f52c0dbd71986e Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:17:25 +0200 Subject: [PATCH 029/111] Fix: build --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c9ee8d..2e26a93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,5 +33,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: + TOOLCHAIN_VERSION: 1.83.0 RUSTTARGET: ${{ matrix.target }} ARCHIVE_TYPES: ${{ matrix.archive }} From 2954c322a846dfa2eaeeed4ed04d8a71110c93d0 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:22:08 +0200 Subject: [PATCH 030/111] Fix: build --- .github/workflows/build.yml | 14 ++++++++++++++ Cargo.toml | 7 ------- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2e26a93..3e1176f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,13 +26,27 @@ jobs: archive: tar.gz tar.xz tar.zst - target: x86_64-apple-darwin archive: zip + - target: aarch64-unknown-linux-gnu + archive: tar.gz tar.xz tar.zst + - target: aarch64-apple-darwin + archive: zip + steps: - uses: actions/checkout@master - name: Compile and release uses: rust-build/rust-build.action@v1.4.5 + id: compile env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: TOOLCHAIN_VERSION: 1.83.0 RUSTTARGET: ${{ matrix.target }} ARCHIVE_TYPES: ${{ matrix.archive }} + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: Binary + path: | + ${{ steps.compile.outputs.BUILT_ARCHIVE }} + ${{ steps.compile.outputs.BUILT_CHECKSUM }} diff --git a/Cargo.toml b/Cargo.toml index 8464857..9d76abc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,10 +21,3 @@ webbrowser = "0.8" base64-url = "3.0.0" urlencoding = "2.1.3" -[features] -static_ssl = ['openssl/vendored'] - -[dependencies.openssl] -optional = true -version = "0.10" -features = ["vendored"] From 4e4b62fb342e137738a208ef7550d3329b518648 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:23:23 +0200 Subject: [PATCH 031/111] Fix: build --- .github/workflows/build.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3e1176f..d69fa92 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,11 +42,3 @@ jobs: TOOLCHAIN_VERSION: 1.83.0 RUSTTARGET: ${{ matrix.target }} ARCHIVE_TYPES: ${{ matrix.archive }} - - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: Binary - path: | - ${{ steps.compile.outputs.BUILT_ARCHIVE }} - ${{ steps.compile.outputs.BUILT_CHECKSUM }} From e593158bd20c9fd1304d6dc353ae2d81f698f510 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:44:13 +0200 Subject: [PATCH 032/111] Fix: build --- .github/workflows/build.yml | 122 +++++++++++++++++++++++++++++------- Cargo.lock | 11 ---- 2 files changed, 100 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d69fa92..1bc8998 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,37 +8,115 @@ on: # Keep pull request builds for testing pull_request: + workflow_dispatch: permissions: contents: write jobs: - release: - name: release ${{ matrix.target }} - runs-on: ubuntu-latest + build: + name: Build + needs: test + runs-on: ${{ matrix.os }} strategy: - fail-fast: false matrix: include: - - target: x86_64-pc-windows-gnu - archive: zip - - target: x86_64-unknown-linux-musl - archive: tar.gz tar.xz tar.zst - - target: x86_64-apple-darwin - archive: zip - - target: aarch64-unknown-linux-gnu - archive: tar.gz tar.xz tar.zst - - target: aarch64-apple-darwin - archive: zip + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_name: app-linux-amd64 + asset_name: app-linux-amd64.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz + + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + artifact_name: app-linux-arm64 + asset_name: app-linux-arm64.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz + + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: app-windows-amd64 + asset_name: app-windows-amd64.zip + compress_cmd: 7z a + compress_ext: .zip + + - os: macos-latest + target: x86_64-apple-darwin + artifact_name: app-macos-amd64 + asset_name: app-macos-amd64.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz + + - os: macos-latest + target: aarch64-apple-darwin + artifact_name: app-macos-arm64 + asset_name: app-macos-arm64.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz steps: - - uses: actions/checkout@master - - name: Compile and release - uses: rust-build/rust-build.action@v1.4.5 - id: compile + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Set up cargo cache + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install cross-compilation dependencies (Linux ARM) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Build release binary + run: cargo build --release --target ${{ matrix.target }} + + - name: Prepare artifact + shell: bash + run: | + mkdir -p dist + if [[ "${{ matrix.os }}" == "windows-latest" ]]; then + cp target/${{ matrix.target }}/release/your_app_name.exe dist/your_app_name.exe + else + cp target/${{ matrix.target }}/release/your_app_name dist/your_app_name + chmod +x dist/your_app_name + fi + cd dist + ${{ matrix.compress_cmd }} ../${{ matrix.asset_name }} * + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: ${{ matrix.asset_name }} + retention-days: 7 + + release: + name: Create Release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + app-linux-amd64/app-linux-amd64.tar.gz + app-linux-arm64/app-linux-arm64.tar.gz + app-windows-amd64/app-windows-amd64.zip + app-macos-amd64/app-macos-amd64.tar.gz + app-macos-arm64/app-macos-arm64.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - TOOLCHAIN_VERSION: 1.83.0 - RUSTTARGET: ${{ matrix.target }} - ARCHIVE_TYPES: ${{ matrix.archive }} diff --git a/Cargo.lock b/Cargo.lock index b218a24..eb4c0e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1020,15 +1020,6 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" -[[package]] -name = "openssl-src" -version = "300.5.0+3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" -dependencies = [ - "cc", -] - [[package]] name = "openssl-sys" version = "0.9.107" @@ -1037,7 +1028,6 @@ checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" dependencies = [ "cc", "libc", - "openssl-src", "pkg-config", "vcpkg", ] @@ -1111,7 +1101,6 @@ dependencies = [ "crossterm", "ctrlc", "dirs", - "openssl", "ratatui", "reqwest", "serde", From 49e83238ea24f5eb78bde7702bda62fc6b3dfefd Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:45:21 +0200 Subject: [PATCH 033/111] Fix: build --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1bc8998..3b7b629 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,6 @@ permissions: jobs: build: name: Build - needs: test runs-on: ${{ matrix.os }} strategy: matrix: From 6df8fff6c5b4d96e56fedabd22b4c7946646a927 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:48:54 +0200 Subject: [PATCH 034/111] Fix: build --- .github/workflows/build.yml | 7 ------- Cargo.toml | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b7b629..6e52bd9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,13 +27,6 @@ jobs: compress_cmd: tar -czf compress_ext: .tar.gz - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - artifact_name: app-linux-arm64 - asset_name: app-linux-arm64.tar.gz - compress_cmd: tar -czf - compress_ext: .tar.gz - - os: windows-latest target: x86_64-pc-windows-msvc artifact_name: app-windows-amd64 diff --git a/Cargo.toml b/Cargo.toml index 9d76abc..ff33978 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ webbrowser = "0.8" base64-url = "3.0.0" urlencoding = "2.1.3" + From 40856969a28d19b7dc2f334b245c0d594d4cc3c2 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 18:54:56 +0200 Subject: [PATCH 035/111] Fix: build --- .github/workflows/build.yml | 33 ++++++++++++--------------------- 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6e52bd9..947dbd1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,29 +22,22 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - artifact_name: app-linux-amd64 - asset_name: app-linux-amd64.tar.gz + artifact_name: popcorn-cli + asset_name: popcorn-cli-linux.tar.gz compress_cmd: tar -czf compress_ext: .tar.gz - os: windows-latest target: x86_64-pc-windows-msvc - artifact_name: app-windows-amd64 - asset_name: app-windows-amd64.zip + artifact_name: popcorn-cli + asset_name: popcorn-cli-windows.zip compress_cmd: 7z a compress_ext: .zip - - os: macos-latest - target: x86_64-apple-darwin - artifact_name: app-macos-amd64 - asset_name: app-macos-amd64.tar.gz - compress_cmd: tar -czf - compress_ext: .tar.gz - - os: macos-latest target: aarch64-apple-darwin - artifact_name: app-macos-arm64 - asset_name: app-macos-arm64.tar.gz + artifact_name: popcorn-cli + asset_name: popcorn-cli-macos.tar.gz compress_cmd: tar -czf compress_ext: .tar.gz @@ -76,10 +69,10 @@ jobs: run: | mkdir -p dist if [[ "${{ matrix.os }}" == "windows-latest" ]]; then - cp target/${{ matrix.target }}/release/your_app_name.exe dist/your_app_name.exe + cp target/${{ matrix.target }}/release/popcorn-cli.exe dist/popcorn-cli.exe else - cp target/${{ matrix.target }}/release/your_app_name dist/your_app_name - chmod +x dist/your_app_name + cp target/${{ matrix.target }}/release/popcorn-cli dist/popcorn-cli + chmod +x dist/popcorn-cli fi cd dist ${{ matrix.compress_cmd }} ../${{ matrix.asset_name }} * @@ -105,10 +98,8 @@ jobs: uses: softprops/action-gh-release@v1 with: files: | - app-linux-amd64/app-linux-amd64.tar.gz - app-linux-arm64/app-linux-arm64.tar.gz - app-windows-amd64/app-windows-amd64.zip - app-macos-amd64/app-macos-amd64.tar.gz - app-macos-arm64/app-macos-arm64.tar.gz + popcorn-cli/popcorn-cli-linux.tar.gz + popcorn-cli/popcorn-cli-windows.zip + popcorn-cli/popcorn-cli-macos.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From c6804b492074c8b105148e281c06a0ef88a56a9c Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 19:00:34 +0200 Subject: [PATCH 036/111] Fix: build --- .github/workflows/build.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 947dbd1..0035414 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,7 +80,7 @@ jobs: - name: Upload artifacts uses: actions/upload-artifact@v4 with: - name: ${{ matrix.artifact_name }} + name: ${{ matrix.asset_name }} path: ${{ matrix.asset_name }} retention-days: 7 @@ -98,8 +98,8 @@ jobs: uses: softprops/action-gh-release@v1 with: files: | - popcorn-cli/popcorn-cli-linux.tar.gz - popcorn-cli/popcorn-cli-windows.zip - popcorn-cli/popcorn-cli-macos.tar.gz + popcorn-cli-linux.tar.gz + popcorn-cli-windows.zip + popcorn-cli-macos.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 4244be226d9da4cc5a215e68fc6973086f476f30 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 19:09:16 +0200 Subject: [PATCH 037/111] Fix: readme --- README.md | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 81e3225..073a9f0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ -# Popcorn CLI (Rust Version) +# Popcorn CLI -Run `./build.sh` and then ` POPCORN_API_URL="http://127.0.0.1:8000" target/release/popcorn-cli ../../discord/discord-cluster-manager/reference-kernels/problems/pmpp/grayscale_py/submission.py` +A command-line interface tool for submitting solutions to the [Popcorn Discord Bot](https://github.com/gpu-mode/discord-cluster-manager) + +## Installation + +### Option 1: Using pre-built binaries (Recommended) + +1. Download the latest release for your platform from the releases page +2. Extract the archive +3. Move the binary to a location in your PATH + +### Option 2: Building from source + +This app is written in Rust, so you can just install it via `cargo install` + +## Usage + +Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode). + +Then, you need to be registered to use this app. You can register by running: `popcorn-cli register [discord|github]`. We strongly reccomend using your Discord account to register, as this will match your submissions to your Discord account. +Once you're registered, there is a file created in your `$HOME` called `.popcorn-cli.yaml` that contains your registration token. This token is sent with each request. + +If you want to re-register (you can do this any number of times), you can run `popcorn-cli reregister [discord|github]`. + +After this, you can submit a solution by running: + +```bash +popcorn-cli submit +``` + +The interactive CLI will guide you through the process of: +1. Selecting a leaderboard +2. Selecting GPU options +3. Setting submission mode +4. Submitting your work From 3ecd8ec2925c8883d976722abf37f2424df6a6e6 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 19:12:15 +0200 Subject: [PATCH 038/111] Fix: build --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0035414..4f23522 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -98,8 +98,8 @@ jobs: uses: softprops/action-gh-release@v1 with: files: | - popcorn-cli-linux.tar.gz - popcorn-cli-windows.zip - popcorn-cli-macos.tar.gz + popcorn-cli-linux.tar.gz/popcorn-cli-linux.tar.gz + popcorn-cli-windows.zip/popcorn-cli-windows.zip + popcorn-cli-macos.tar.gz/popcorn-cli-macos.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From cf2742c8a3c0b551d539887323cfdea2fa84f847 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 14 Apr 2025 20:33:47 +0200 Subject: [PATCH 039/111] Feat: works --- .gitignore | 1 + src/service/mod.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e8c1567..0240a06 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ submission.* +target/ diff --git a/src/service/mod.rs b/src/service/mod.rs index 6fd4d2c..8a6c4d9 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -118,7 +118,7 @@ pub async fn submit_solution>( "{}/{}/{}/{}", base_url, leaderboard.to_lowercase(), - gpu.to_lowercase(), + gpu, submission_mode.to_lowercase() ); From 321af110b45d573729d52e6cfdea79607c138e8a Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Tue, 15 Apr 2025 00:16:30 +0200 Subject: [PATCH 040/111] Feat: launch --- src/cmd/submit.rs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 8a776e2..408654b 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -54,19 +54,9 @@ impl App { "Submit to the leaderboard, this first runs public tests and then private tests. If both pass, the submission is evaluated and submit to the leaderboard.".to_string(), "leaderboard".to_string(), ), - SubmissionModeItem::new( - "Private".to_string(), - "TODO".to_string(), - "private".to_string(), - ), - SubmissionModeItem::new( - "Script".to_string(), - "TODO".to_string(), - "script".to_string(), - ), SubmissionModeItem::new( "Profile".to_string(), - "TODO".to_string(), + "Work in progress...".to_string(), "profile".to_string(), ), ]; From 16c70cac4e6a231ffe05595252f9f3c69df9ad42 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Tue, 15 Apr 2025 17:32:37 +0200 Subject: [PATCH 041/111] Fix: github auth --- src/cmd/auth.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index 3498353..c55f246 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; use serde_yaml; use std::fs::{File, OpenOptions}; use std::path::PathBuf; -use urlencoding; use webbrowser; use crate::service; // Assuming service::create_client is needed @@ -57,6 +56,10 @@ struct AuthInitResponse { pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { println!("Attempting authentication via {}...", auth_provider); + if auth_provider == "discord" { + panic!("Discord authentication is not supported yet... WIP"); + } + let popcorn_api_url = std::env::var("POPCORN_API_URL") .map_err(|_| anyhow!("POPCORN_API_URL environment variable not set"))?; @@ -91,17 +94,14 @@ pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { let auth_url = match auth_provider { "discord" => { - let base_auth_url = "https://discord.com/oauth2/authorize?client_id=1357446383497511096&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A8000%2Fauth%2Fcli%2Fdiscord&scope=identify"; + let base_auth_url = "https://discord.com/oauth2/authorize?client_id=1361364685491802243&response_type=code&redirect_uri=https%3A%2F%2Fdiscord-cluster-manager-1f6c4782e60a.herokuapp.com%2Fauth%2Fcli%2Fdiscord&scope=identify"; format!("{}&state={}", base_auth_url, state_b64) } "github" => { let client_id = "Ov23lieFd2onYk4OnKIR"; - let redirect_uri = "http://localhost:8000/auth/cli/github"; - // URL encode the redirect URI - let encoded_redirect_uri = urlencoding::encode(redirect_uri); format!( - "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}", - client_id, encoded_redirect_uri, state_b64 + "https://github.com/login/oauth/authorize?client_id={}&state={}", + client_id, state_b64 ) } _ => { From 86f599e906c9265abba404ecc5cbde84f273564e Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Tue, 15 Apr 2025 20:48:32 +0200 Subject: [PATCH 042/111] Feat: launch authorization --- src/cmd/auth.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index c55f246..80cb079 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -56,10 +56,6 @@ struct AuthInitResponse { pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { println!("Attempting authentication via {}...", auth_provider); - if auth_provider == "discord" { - panic!("Discord authentication is not supported yet... WIP"); - } - let popcorn_api_url = std::env::var("POPCORN_API_URL") .map_err(|_| anyhow!("POPCORN_API_URL environment variable not set"))?; @@ -99,9 +95,12 @@ pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { } "github" => { let client_id = "Ov23lieFd2onYk4OnKIR"; + let redirect_uri = + "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com/auth/cli/github"; + let encoded_redirect_uri = urlencoding::encode(redirect_uri); format!( - "https://github.com/login/oauth/authorize?client_id={}&state={}", - client_id, state_b64 + "https://github.com/login/oauth/authorize?client_id={}&state={}&redirect_uri={}", + client_id, state_b64, encoded_redirect_uri ) } _ => { From 4f3e49db0c96b3c1135e1c7a0fcfb38c4ffd7fc8 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Wed, 16 Apr 2025 00:54:17 +0200 Subject: [PATCH 043/111] Feat: streaming response --- Cargo.lock | 15 +++++++ Cargo.toml | 2 + src/service/mod.rs | 104 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 111 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eb4c0e3..29e4754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,6 +427,17 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -446,9 +457,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -1097,10 +1110,12 @@ version = "0.1.0" dependencies = [ "anyhow", "base64-url", + "bytes", "clap", "crossterm", "ctrlc", "dirs", + "futures-util", "ratatui", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index ff33978..c3303d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,5 +20,7 @@ serde_yaml = "0.9" webbrowser = "0.8" base64-url = "3.0.0" urlencoding = "2.1.3" +bytes = "1.10.1" +futures-util = "0.3.31" diff --git a/src/service/mod.rs b/src/service/mod.rs index 8a6c4d9..a9198ca 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -6,6 +6,7 @@ use serde_json::Value; use std::env; use std::path::Path; use std::time::Duration; +use tokio::io::AsyncWriteExt; use crate::models::{GpuItem, LeaderboardItem}; @@ -125,22 +126,105 @@ pub async fn submit_solution>( let resp = client .post(&url) .multipart(form) - .timeout(Duration::from_secs(180)) + .timeout(Duration::from_secs(300)) .send() .await?; let status = resp.status(); if !status.is_success() { let error_text = resp.text().await?; - return Err(anyhow!("Server returned status {}: {}", status, error_text)); + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); } - let result: Value = resp.json().await?; - - let pretty_result = match result.get("results") { - Some(result_obj) => serde_json::to_string_pretty(result_obj)?, - None => return Err(anyhow!("Invalid response structure")), - }; - - Ok(pretty_result) + if resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map_or(false, |s| s.starts_with("text/event-stream")) + { + let mut resp = resp; + let mut buffer = String::new(); + let mut stderr = tokio::io::stderr(); + + while let Some(chunk) = resp.chunk().await? { + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(pos) = buffer.find("\n\n") { + let message_str = buffer.drain(..pos + 2).collect::(); + let mut event_type = None; + let mut data_json = None; + + for line in message_str.lines() { + if line.starts_with("event:") { + event_type = Some(line["event:".len()..].trim()); + } else if line.starts_with("data:") { + data_json = Some(line["data:".len()..].trim()); + } + } + + if let (Some(event), Some(data)) = (event_type, data_json) { + match event { + "status" => (), + "result" => { + let result_val: Value = serde_json::from_str(data)?; + let pretty_result = match result_val.get("results") { + Some(result_obj) => serde_json::to_string_pretty(result_obj)?, + None => { + return Err(anyhow!( + "Invalid 'result' event structure: missing 'results' field" + )) + } + }; + return Ok(pretty_result); + } + "error" => { + let error_val: Value = serde_json::from_str(data)?; + let detail = error_val + .get("detail") + .and_then(|d| d.as_str()) + .unwrap_or("Unknown server error"); + let status_code = error_val.get("status_code").and_then(|s| s.as_i64()); + let raw_error = error_val.get("raw_error").and_then(|e| e.as_str()); + + let mut error_msg = format!("Server processing error: {}", detail); + if let Some(sc) = status_code { + error_msg.push_str(&format!(" (Status Code: {})", sc)); + } + if let Some(re) = raw_error { + error_msg.push_str(&format!(" | Raw Error: {}", re)); + } + + return Err(anyhow!(error_msg)); + } + _ => { + stderr + .write_all( + format!("Ignoring unknown SSE event: {}\n", event).as_bytes(), + ) + .await?; + stderr.flush().await?; + } + } + } + } + } + Err(anyhow!( + "Stream ended unexpectedly without a final result or error event." + )) + } else { + let result: Value = resp.json().await?; + let pretty_result = match result.get("results") { + Some(result_obj) => serde_json::to_string_pretty(result_obj)?, + None => return Err(anyhow!("Invalid non-streaming response structure")), + }; + Ok(pretty_result) + } } From 280943ad7299aa532bd9d7c3ea7a270fcf0fafcb Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Thu, 17 Apr 2025 08:38:36 -0700 Subject: [PATCH 044/111] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 073a9f0..e9ec8d6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This app is written in Rust, so you can just install it via `cargo install` ## Usage -Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode). +Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode) - go to the submissions chanel and `/get-api-url` Then, you need to be registered to use this app. You can register by running: `popcorn-cli register [discord|github]`. We strongly reccomend using your Discord account to register, as this will match your submissions to your Discord account. Once you're registered, there is a file created in your `$HOME` called `.popcorn-cli.yaml` that contains your registration token. This token is sent with each request. From b71dd0a0a55a2aa207eb8cc7895fe527ea123bed Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Thu, 17 Apr 2025 09:19:01 -0700 Subject: [PATCH 045/111] Improve README --- README.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 073a9f0..56742b4 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ A command-line interface tool for submitting solutions to the [Popcorn Discord Bot](https://github.com/gpu-mode/discord-cluster-manager) +Tested on linux and mac but should just work on Windows as well. + ## Installation ### Option 1: Using pre-built binaries (Recommended) @@ -12,16 +14,23 @@ A command-line interface tool for submitting solutions to the [Popcorn Discord B ### Option 2: Building from source -This app is written in Rust, so you can just install it via `cargo install` +1. Download rust `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` +2. `cd popcorn-cli && cargo install --path .` -## Usage +## Authentication -Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode). +Since we're effectively giving out GPUs for free we rely on either github or discord authentication to prove that you're a real human before you access our service. + +1. Go to the [GPU Mode Discord server](https://discord.gg/gpumode) and type in `/get-api-url/` +2. Copy paste that url out `export POPCORN_API_URL="result_of_get_api_url"` +3. We recommend you authenticate via your Discord as this will guarantee that your name will show up correctly on the leaderboard, you can do this via `popcorn-cli register discord`. However in case this doesn't work for you we also support Github based authentication with `popcorn-cli register github` +4. To ensure the above worked you can run `cat $HOME/.popcorn.yaml` which should print your client ID which is what will be sent to us on every request -Then, you need to be registered to use this app. You can register by running: `popcorn-cli register [discord|github]`. We strongly reccomend using your Discord account to register, as this will match your submissions to your Discord account. -Once you're registered, there is a file created in your `$HOME` called `.popcorn-cli.yaml` that contains your registration token. This token is sent with each request. +Sometimes you'll get an error that you're already authenticated despite being unable to submit in which case you can run `popcorn-cli reregister [discord|github]`. -If you want to re-register (you can do this any number of times), you can run `popcorn-cli reregister [discord|github]`. +Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode). + +## Make your first submission After this, you can submit a solution by running: @@ -34,3 +43,5 @@ The interactive CLI will guide you through the process of: 2. Selecting GPU options 3. Setting submission mode 4. Submitting your work + +glhf! \ No newline at end of file From 842f75bc1b2e94df3872483b3b2cde5e02f90c75 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Thu, 17 Apr 2025 09:27:30 -0700 Subject: [PATCH 046/111] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ce63003..394794f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Tested on linux and mac but should just work on Windows as well. ### Option 2: Building from source 1. Download rust `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` -2. `cd popcorn-cli && cargo install --path .` +2. `cd popcorn-cli && ./build.sh` ## Authentication @@ -44,4 +44,4 @@ The interactive CLI will guide you through the process of: 3. Setting submission mode 4. Submitting your work -glhf! \ No newline at end of file +glhf! From 9b98671fcf955096e25e85f265acece143dbb64b Mon Sep 17 00:00:00 2001 From: Matej Sirovatka <54212263+S1ro1@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:51:00 +0200 Subject: [PATCH 047/111] Fix: readme typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 394794f..7a694a7 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Tested on linux and mac but should just work on Windows as well. Since we're effectively giving out GPUs for free we rely on either github or discord authentication to prove that you're a real human before you access our service. -1. Go to the [GPU Mode Discord server](https://discord.gg/gpumode) and type in `/get-api-url/` +1. Go to the [GPU Mode Discord server](https://discord.gg/gpumode) and type in `/get-api-url` 2. Copy paste that url out `export POPCORN_API_URL="result_of_get_api_url"` 3. We recommend you authenticate via your Discord as this will guarantee that your name will show up correctly on the leaderboard, you can do this via `popcorn-cli register discord`. However in case this doesn't work for you we also support Github based authentication with `popcorn-cli register github` 4. To ensure the above worked you can run `cat $HOME/.popcorn.yaml` which should print your client ID which is what will be sent to us on every request From fe8f3d8f0bb5ec65a129d1203173c726bca60e41 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Tue, 22 Apr 2025 08:59:00 -0700 Subject: [PATCH 048/111] Create LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e46d508 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 GPU MODE + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From a4cee43d923448b0b2536dd2f23cd08b9cc0a161 Mon Sep 17 00:00:00 2001 From: Shaurya Veer Singh <92371232+svskaushik@users.noreply.github.com> Date: Sun, 27 Apr 2025 20:17:36 -0600 Subject: [PATCH 049/111] Feat: submission flags (#12) * Feat: submission flags * Fix: flag handling parity between submit and implicit submit * fixes --------- Co-authored-by: Mark Saroufim --- README.md | 9 +++- src/cmd/mod.rs | 77 +++++++++++++++++++++++++++----- src/cmd/submit.rs | 111 +++++++++++++++++++++++++++++++++++----------- 3 files changed, 158 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 7a694a7..c1ad77a 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,14 @@ Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. Yo ## Make your first submission -After this, you can submit a solution by running: +```bash +wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/main/problems/pmpp/grayscale_py/submission.py +popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submission.py +``` + +## Discover new problems + +The CLI supports (almost) everything Discord does, so you can also discovery which leaderboards are available. To make discovery more pleasant we also offer a CLI experience. ```bash popcorn-cli submit diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 4fde558..f52c6b6 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -43,6 +43,18 @@ pub struct Cli { /// Optional: Path to the solution file filepath: Option, + + /// Optional: Directly specify the GPU to use (e.g., "mi300") + #[arg(long)] + pub gpu: Option, + + /// Optional: Directly specify the leaderboard (e.g., "fp8") + #[arg(long)] + pub leaderboard: Option, + + /// Optional: Specify submission mode (test, benchmark, leaderboard, profile) + #[arg(long)] + pub mode: Option, } #[derive(Subcommand, Debug)] @@ -62,7 +74,20 @@ enum Commands { provider: AuthProvider, }, Submit { + /// Optional: Path to the solution file (can also be provided as a top-level argument) filepath: Option, + + /// Optional: Directly specify the GPU to use (e.g., "MI300") + #[arg(long)] + gpu: Option, + + /// Optional: Directly specify the leaderboard (e.g., "amd-fp8-mm") + #[arg(long)] + leaderboard: Option, + + /// Optional: Specify submission mode (test, benchmark, leaderboard, profile) + #[arg(long)] + mode: Option, }, } @@ -82,7 +107,7 @@ pub async fn execute(cli: Cli) -> Result<()> { }; auth::run_auth(false, provider_str).await } - Some(Commands::Submit { filepath }) => { + Some(Commands::Submit { filepath, gpu, leaderboard, mode }) => { let config = load_config()?; let cli_id = config.cli_id.ok_or_else(|| { anyhow!( @@ -91,19 +116,49 @@ pub async fn execute(cli: Cli) -> Result<()> { .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) ) })?; - let file_to_submit = filepath.or(cli.filepath); - submit::run_submit_tui(file_to_submit, cli_id).await + // Use filepath from Submit command first, fallback to top-level filepath + let final_filepath = filepath.or(cli.filepath); + submit::run_submit_tui( + final_filepath, // Resolved filepath + gpu, // From Submit command + leaderboard, // From Submit command + mode, // From Submit command + cli_id, + ) + .await } None => { - let config = load_config()?; - let cli_id = config.cli_id.ok_or_else(|| { - anyhow!( - "cli_id not found in config file ({}). Please run `popcorn register` first.", - get_config_path() - .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + // Check if any of the submission-related flags were used at the top level + if cli.gpu.is_some() || cli.leaderboard.is_some() || cli.mode.is_some() { + return Err(anyhow!( + "Please use the 'submit' subcommand when specifying submission options:\n\ + popcorn-cli submit [--gpu GPU] [--leaderboard LEADERBOARD] [--mode MODE] FILEPATH" + )); + } + + // Handle the case where only a filepath is provided (for backward compatibility) + if let Some(top_level_filepath) = cli.filepath { + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + + // Run TUI with only filepath, no other options + submit::run_submit_tui( + Some(top_level_filepath), + None, // No GPU option + None, // No leaderboard option + None, // No mode option + cli_id, ) - })?; - submit::run_submit_tui(cli.filepath, cli_id).await + .await + } else { + Err(anyhow!("No command or submission file specified. Use --help for usage.")) + } } } } diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 408654b..3ae894c 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -596,7 +596,13 @@ fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { .split(popup_layout[1])[1] } -pub async fn run_submit_tui(filepath: Option, cli_id: String) -> Result<()> { +pub async fn run_submit_tui( + filepath: Option, + gpu: Option, + leaderboard: Option, + mode: Option, + cli_id: String, +) -> Result<()> { let file_to_submit = match filepath { Some(fp) => fp, None => { @@ -620,40 +626,91 @@ pub async fn run_submit_tui(filepath: Option, cli_id: String) -> Result< )); } + // Perform direct submission if all required parameters are provided via CLI + if let (Some(gpu_flag), Some(leaderboard_flag), Some(mode_flag)) = (&gpu, &leaderboard, &mode) { + // Read file content + let mut file = File::open(&file_to_submit)?; + let mut file_content = String::new(); + file.read_to_string(&mut file_content)?; + + // Create client and submit directly + let client = service::create_client(Some(cli_id))?; + println!("Submitting solution directly with:"); + println!(" File: {}", file_to_submit); + println!(" Leaderboard: {}", leaderboard_flag); + println!(" GPU: {}", gpu_flag); + println!(" Mode: {}", mode_flag); + + // Make the submission + let result = service::submit_solution( + &client, + &file_to_submit, + &file_content, + leaderboard_flag, + gpu_flag, + mode_flag + ).await?; + + utils::display_ascii_art(); + println!("{}", result); + return Ok(()); + } + let mut app = App::new(&file_to_submit, cli_id); - app.initialize_with_directives(directives); + // Override directives with CLI flags if provided + if let Some(gpu_flag) = gpu { + app.selected_gpu = Some(gpu_flag); + } + if let Some(leaderboard_flag) = leaderboard { + app.selected_leaderboard = Some(leaderboard_flag); + } + if let Some(mode_flag) = mode { + app.selected_submission_mode = Some(mode_flag); + // Skip to submission if we have all required fields + if app.selected_gpu.is_some() && app.selected_leaderboard.is_some() { + app.modal_state = ModelState::WaitingForResult; + } + } + + // If no CLI flags, use directives + if app.selected_gpu.is_none() && app.selected_leaderboard.is_none() { + app.initialize_with_directives(directives); + } + + // Spawn the initial task based on the starting state BEFORE setting up the TUI + // If spawning fails here, we just return the error directly without TUI cleanup. + match app.modal_state { + ModelState::LeaderboardSelection => { + if let Err(e) = app.spawn_load_leaderboards() { + return Err(anyhow!("Error starting leaderboard fetch: {}", e)); + } + } + ModelState::GpuSelection => { + if let Err(e) = app.spawn_load_gpus() { + return Err(anyhow!("Error starting GPU fetch: {}", e)); + } + } + ModelState::WaitingForResult => { + // This state occurs when all flags (gpu, leaderboard, mode) are provided + if let Err(e) = app.spawn_submit_solution() { + return Err(anyhow!("Error starting submission: {}", e)); + } + } + _ => { + // Other states like SubmissionModeSelection shouldn't be the *initial* state + // unless there's a logic error elsewhere. We'll proceed to TUI. + } + } + + // Now, set up the TUI enable_raw_mode()?; let mut stdout = io::stdout(); crossterm::execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - if app.modal_state == ModelState::LeaderboardSelection { - if let Err(e) = app.spawn_load_leaderboards() { - // Cleanup terminal before exiting on initial load error - disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::terminal::LeaveAlternateScreen - )?; - terminal.show_cursor()?; - return Err(anyhow!("Error starting leaderboard fetch: {}", e)); - } - } else if app.modal_state == ModelState::GpuSelection { - if let Err(e) = app.spawn_load_gpus() { - // Cleanup terminal before exiting on initial load error - disable_raw_mode()?; - crossterm::execute!( - terminal.backend_mut(), - crossterm::terminal::LeaveAlternateScreen - )?; - terminal.show_cursor()?; - return Err(anyhow!("Error starting GPU fetch: {}", e)); - } - } - - // Main application loop + // Main application loop - this remains largely the same while !app.should_quit { terminal.draw(|f| ui(&app, f))?; From 488050ef9160ae1d16a591d54cfb8d713b320596 Mon Sep 17 00:00:00 2001 From: Sahan Paliskara Date: Tue, 6 May 2025 08:52:14 -0700 Subject: [PATCH 050/111] extend timeout (#13) --- src/service/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service/mod.rs b/src/service/mod.rs index a9198ca..76b1731 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -126,7 +126,7 @@ pub async fn submit_solution>( let resp = client .post(&url) .multipart(form) - .timeout(Duration::from_secs(300)) + .timeout(Duration::from_secs(3600)) .send() .await?; From 8f4dcc71ea6c0705d53d2d81bf92de775f1c3832 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 7 May 2025 10:24:24 -0700 Subject: [PATCH 051/111] Automate release (#14) * Automate release * update * remove comment --- .github/workflows/build.yml | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f23522..f4eba0c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,8 @@ name: release on: push: - # Sequence of patterns matched against refs/tags + branches: + - main tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 @@ -14,8 +15,27 @@ permissions: contents: write jobs: + version: + name: Generate Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate Version + id: version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch + release_branches: main + build: name: Build + needs: version runs-on: ${{ matrix.os }} strategy: matrix: @@ -86,9 +106,9 @@ jobs: release: name: Create Release - needs: build + needs: [build, version] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') + if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' steps: - name: Download all artifacts @@ -97,6 +117,8 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v1 with: + tag_name: v${{ needs.version.outputs.version }} + name: Release v${{ needs.version.outputs.version }} files: | popcorn-cli-linux.tar.gz/popcorn-cli-linux.tar.gz popcorn-cli-windows.zip/popcorn-cli-windows.zip From d45baa3aa9a69f1c4ee310d8d7e6b11b1aeabc4a Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 7 May 2025 10:40:47 -0700 Subject: [PATCH 052/111] Update build.yml --- .github/workflows/build.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4eba0c..0ab9a58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,14 +19,14 @@ jobs: name: Generate Version runs-on: ubuntu-latest outputs: - version: ${{ steps.version.outputs.version }} + new_tag: ${{ steps.tag_version.outputs.new_tag }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Generate Version - id: version + - name: Bump version and push tag + id: tag_version uses: mathieudutour/github-tag-action@v6.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -117,8 +117,8 @@ jobs: - name: Create Release uses: softprops/action-gh-release@v1 with: - tag_name: v${{ needs.version.outputs.version }} - name: Release v${{ needs.version.outputs.version }} + tag_name: ${{ needs.version.outputs.new_tag }} + name: Release ${{ needs.version.outputs.new_tag }} files: | popcorn-cli-linux.tar.gz/popcorn-cli-linux.tar.gz popcorn-cli-windows.zip/popcorn-cli-windows.zip From 108d61e14bce95cbb1824ce97d60023aba615a08 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 7 May 2025 18:41:44 -0700 Subject: [PATCH 053/111] Update README.md --- README.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/README.md b/README.md index c1ad77a..02038f3 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,6 @@ Since we're effectively giving out GPUs for free we rely on either github or dis Sometimes you'll get an error that you're already authenticated despite being unable to submit in which case you can run `popcorn-cli reregister [discord|github]`. -Set the `POPCORN_API_URL` environment variable to the URL of the Popcorn API. You can get this from the [GPU Mode Discord server](https://discord.gg/gpumode) - go to the submissions chanel and `/get-api-url` - ## Make your first submission ```bash @@ -39,16 +37,10 @@ popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submiss ## Discover new problems -The CLI supports (almost) everything Discord does, so you can also discovery which leaderboards are available. To make discovery more pleasant we also offer a CLI experience. +The CLI supports (almost) everything Discord does, so you can also discovery which leaderboards are available. To make discovery more pleasant we also offer a TUI experience. ```bash popcorn-cli submit ``` -The interactive CLI will guide you through the process of: -1. Selecting a leaderboard -2. Selecting GPU options -3. Setting submission mode -4. Submitting your work - glhf! From d9d2c5cb139ea6dfe967468021d3789590920098 Mon Sep 17 00:00:00 2001 From: Matej Sirovatka <54212263+S1ro1@users.noreply.github.com> Date: Sun, 8 Jun 2025 22:49:28 +0200 Subject: [PATCH 054/111] Feat/better results (#15) * Feat: display proper markdown * Cleanup and looks better imo * Feat: more cleanup * Feat: loading works * Feat: result page * Unused imports --- src/cmd/submit.rs | 357 ++++++++++++++++---------------------- src/main.rs | 1 + src/models/mod.rs | 5 +- src/service/mod.rs | 11 +- src/utils/mod.rs | 89 +++++++--- src/views/loading_page.rs | 73 ++++++++ src/views/mod.rs | 2 + src/views/result_page.rs | 149 ++++++++++++++++ 8 files changed, 440 insertions(+), 247 deletions(-) create mode 100644 src/views/loading_page.rs create mode 100644 src/views/mod.rs create mode 100644 src/views/result_page.rs diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 3ae894c..6745ea2 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -8,32 +8,43 @@ use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScree use ratatui::prelude::*; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState}; use tokio::task::JoinHandle; -use crate::models::{GpuItem, LeaderboardItem, ModelState, SubmissionModeItem}; +use crate::models::{AppState, GpuItem, LeaderboardItem, SubmissionModeItem}; use crate::service; use crate::utils; +use crate::views::loading_page::{LoadingPage, LoadingPageState}; +use crate::views::result_page::{ResultPage, ResultPageState}; +#[derive(Default, Debug)] pub struct App { pub filepath: String, pub cli_id: String, + pub leaderboards: Vec, pub leaderboards_state: ListState, pub selected_leaderboard: Option, + pub gpus: Vec, pub gpus_state: ListState, pub selected_gpu: Option, + pub submission_modes: Vec, pub submission_modes_state: ListState, pub selected_submission_mode: Option, - pub modal_state: ModelState, + + pub app_state: AppState, pub final_status: Option, - pub loading_message: Option, + pub should_quit: bool, pub submission_task: Option>>, pub leaderboards_task: Option, anyhow::Error>>>, pub gpus_task: Option, anyhow::Error>>>, + + pub loading_page_state: LoadingPageState, + + pub result_page_state: ResultPageState, } impl App { @@ -64,52 +75,54 @@ impl App { let mut app = Self { filepath: filepath.as_ref().to_string_lossy().to_string(), cli_id, - leaderboards: Vec::new(), - leaderboards_state: ListState::default(), - selected_leaderboard: None, - gpus: Vec::new(), - gpus_state: ListState::default(), - selected_gpu: None, submission_modes, - submission_modes_state: ListState::default(), selected_submission_mode: None, - modal_state: ModelState::LeaderboardSelection, - final_status: None, - loading_message: None, - should_quit: false, - submission_task: None, - leaderboards_task: None, - gpus_task: None, + ..Default::default() }; - // Initialize list states app.leaderboards_state.select(Some(0)); app.gpus_state.select(Some(0)); app.submission_modes_state.select(Some(0)); - app } + pub fn update_loading_page_state(&mut self, terminal_width: u16) { + if self.app_state != AppState::WaitingForResult { + return; + } + + let st = &mut self.loading_page_state; + st.progress_column = { + if st.progress_column < terminal_width { + st.progress_column + 1 + } else { + st.loop_count += 1; + 0 + } + }; + st.progress_bar = f64::from(st.progress_column) * 100.0 / f64::from(terminal_width); + } + pub fn initialize_with_directives(&mut self, popcorn_directives: utils::PopcornDirectives) { if !popcorn_directives.leaderboard_name.is_empty() { self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); if !popcorn_directives.gpus.is_empty() { self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } else { - self.modal_state = ModelState::GpuSelection; + self.app_state = AppState::GpuSelection; } } else if !popcorn_directives.gpus.is_empty() { self.selected_gpu = Some(popcorn_directives.gpus[0].clone()); if !popcorn_directives.leaderboard_name.is_empty() { self.selected_leaderboard = Some(popcorn_directives.leaderboard_name); - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } else { - self.modal_state = ModelState::LeaderboardSelection; + self.app_state = AppState::LeaderboardSelection; } } else { - self.modal_state = ModelState::LeaderboardSelection; + self.app_state = AppState::LeaderboardSelection; } } @@ -120,26 +133,20 @@ impl App { return Ok(true); } - // Ignore other keys while loading - if self.loading_message.is_some() { - return Ok(false); - } - match key.code { KeyCode::Char('q') => { self.should_quit = true; return Ok(true); } - KeyCode::Enter => match self.modal_state { - ModelState::LeaderboardSelection => { + KeyCode::Enter => match self.app_state { + AppState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { if idx < self.leaderboards.len() { self.selected_leaderboard = Some(self.leaderboards[idx].title_text.clone()); if self.selected_gpu.is_none() { - self.modal_state = ModelState::GpuSelection; - // Spawn GPU loading task + self.app_state = AppState::GpuSelection; if let Err(e) = self.spawn_load_gpus() { self.set_error_and_quit(format!( "Error starting GPU fetch: {}", @@ -147,28 +154,27 @@ impl App { )); } } else { - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } return Ok(true); } } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Some(idx) = self.gpus_state.selected() { if idx < self.gpus.len() { self.selected_gpu = Some(self.gpus[idx].title_text.clone()); - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; return Ok(true); } } } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { if let Some(idx) = self.submission_modes_state.selected() { if idx < self.submission_modes.len() { self.selected_submission_mode = Some(self.submission_modes[idx].value.clone()); - self.modal_state = ModelState::WaitingForResult; // State for logic, UI uses loading msg - // Spawn the submission task + self.app_state = AppState::WaitingForResult; if let Err(e) = self.spawn_submit_solution() { self.set_error_and_quit(format!( "Error starting submission: {}", @@ -179,7 +185,7 @@ impl App { } } } - _ => {} // WaitingForResult state doesn't handle Enter + _ => {} }, KeyCode::Up => { self.move_selection_up(); @@ -189,36 +195,33 @@ impl App { self.move_selection_down(); return Ok(true); } - _ => {} // Ignore other keys + _ => {} } - Ok(false) } - // Helper to reduce repetition fn set_error_and_quit(&mut self, error_message: String) { self.final_status = Some(error_message); self.should_quit = true; - self.loading_message = None; // Clear loading on error } fn move_selection_up(&mut self) { - match self.modal_state { - ModelState::LeaderboardSelection => { + match self.app_state { + AppState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { if idx > 0 { self.leaderboards_state.select(Some(idx - 1)); } } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Some(idx) = self.gpus_state.selected() { if idx > 0 { self.gpus_state.select(Some(idx - 1)); } } } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { if let Some(idx) = self.submission_modes_state.selected() { if idx > 0 { self.submission_modes_state.select(Some(idx - 1)); @@ -230,22 +233,22 @@ impl App { } fn move_selection_down(&mut self) { - match self.modal_state { - ModelState::LeaderboardSelection => { + match self.app_state { + AppState::LeaderboardSelection => { if let Some(idx) = self.leaderboards_state.selected() { if idx < self.leaderboards.len().saturating_sub(1) { self.leaderboards_state.select(Some(idx + 1)); } } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Some(idx) = self.gpus_state.selected() { if idx < self.gpus.len().saturating_sub(1) { self.gpus_state.select(Some(idx + 1)); } } } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { if let Some(idx) = self.submission_modes_state.selected() { if idx < self.submission_modes.len().saturating_sub(1) { self.submission_modes_state.select(Some(idx + 1)); @@ -261,7 +264,6 @@ impl App { self.leaderboards_task = Some(tokio::spawn(async move { service::fetch_leaderboards(&client).await })); - self.loading_message = Some("Loading leaderboards...".to_string()); Ok(()) } @@ -274,7 +276,6 @@ impl App { self.gpus_task = Some(tokio::spawn(async move { service::fetch_gpus(&client, &leaderboard_name).await })); - self.loading_message = Some("Loading GPUs...".to_string()); Ok(()) } @@ -303,7 +304,6 @@ impl App { service::submit_solution(&client, &filepath, &file_content, &leaderboard, &gpu, &mode) .await })); - self.loading_message = Some("Submitting solution...".to_string()); Ok(()) } @@ -314,7 +314,6 @@ impl App { match task.await { Ok(Ok(leaderboards)) => { self.leaderboards = leaderboards; - // If a leaderboard was pre-selected (e.g., from directives), try to find and select it if let Some(selected_name) = &self.selected_leaderboard { if let Some(index) = self .leaderboards @@ -322,32 +321,26 @@ impl App { .position(|lb| &lb.title_text == selected_name) { self.leaderboards_state.select(Some(index)); - // If GPU was also pre-selected, move to submission mode selection - // Otherwise, spawn GPU loading task if self.selected_gpu.is_some() { - self.modal_state = ModelState::SubmissionModeSelection; + self.app_state = AppState::SubmissionModeSelection; } else { - self.modal_state = ModelState::GpuSelection; + self.app_state = AppState::GpuSelection; if let Err(e) = self.spawn_load_gpus() { self.set_error_and_quit(format!( "Error starting GPU fetch: {}", e )); - return; // Exit early on error + return; } } } else { - // Pre-selected leaderboard not found, reset selection and state self.selected_leaderboard = None; - self.leaderboards_state.select(Some(0)); // Select first available - self.modal_state = ModelState::LeaderboardSelection; - // Stay here + self.leaderboards_state.select(Some(0)); + self.app_state = AppState::LeaderboardSelection; } } else { - self.leaderboards_state.select(Some(0)); // Select first if no pre-selection + self.leaderboards_state.select(Some(0)); } - - self.loading_message = None; } Ok(Err(e)) => { self.set_error_and_quit(format!("Error fetching leaderboards: {}", e)) @@ -365,7 +358,6 @@ impl App { match task.await { Ok(Ok(gpus)) => { self.gpus = gpus; - // If a GPU was pre-selected, try to find and select it if let Some(selected_name) = &self.selected_gpu { if let Some(index) = self .gpus @@ -373,19 +365,15 @@ impl App { .position(|gpu| &gpu.title_text == selected_name) { self.gpus_state.select(Some(index)); - self.modal_state = ModelState::SubmissionModeSelection; - // Move to next step + self.app_state = AppState::SubmissionModeSelection; } else { - // Pre-selected GPU not found, reset selection self.selected_gpu = None; - self.gpus_state.select(Some(0)); // Select first available - self.modal_state = ModelState::GpuSelection; // Stay here + self.gpus_state.select(Some(0)); + self.app_state = AppState::GpuSelection; } } else { - self.gpus_state.select(Some(0)); // Select first if no pre-selection + self.gpus_state.select(Some(0)); } - - self.loading_message = None; } Ok(Err(e)) => self.set_error_and_quit(format!("Error fetching GPUs: {}", e)), Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), @@ -402,7 +390,6 @@ impl App { Ok(Ok(status)) => { self.final_status = Some(status); self.should_quit = true; // Quit after showing final status - self.loading_message = None; } Ok(Err(e)) => self.set_error_and_quit(format!("Submission error: {}", e)), Err(e) => self.set_error_and_quit(format!("Task join error: {}", e)), @@ -418,26 +405,14 @@ pub fn ui(app: &App, frame: &mut Frame) { .constraints([Constraint::Min(0)].as_ref()) .split(frame.size()); - // Determine the area available for the list *before* the match statement let list_area = main_layout[0]; - // Calculate usable width for text wrapping (subtract borders, padding, highlight symbol) let available_width = list_area.width.saturating_sub(4) as usize; - if let Some(ref msg) = app.loading_message { - let loading_paragraph = Paragraph::new(msg.clone()) - .block(Block::default().title("Loading").borders(Borders::ALL)) - .alignment(Alignment::Center); - - let area = centered_rect(60, 20, frame.size()); - frame.render_widget(loading_paragraph, area); - return; // Don't render anything else while loading - } - let list_block = Block::default().borders(Borders::ALL); let list_style = Style::default().fg(Color::White); - match app.modal_state { - ModelState::LeaderboardSelection => { + match app.app_state { + AppState::LeaderboardSelection => { let items: Vec = app .leaderboards .iter() @@ -446,7 +421,6 @@ pub fn ui(app: &App, frame: &mut Frame) { lb.title_text.clone(), Style::default().fg(Color::White).bold(), )); - // Create lines for the description, splitting by newline let mut lines = vec![title_line]; for desc_part in lb.task_description.split('\n') { lines.push(Line::from(Span::styled( @@ -454,7 +428,7 @@ pub fn ui(app: &App, frame: &mut Frame) { Style::default().fg(Color::Gray).dim(), ))); } - ListItem::new(lines) // Use the combined vector of lines + ListItem::new(lines) }) .collect(); let list = List::new(items) @@ -464,17 +438,16 @@ pub fn ui(app: &App, frame: &mut Frame) { .highlight_symbol("> "); frame.render_stateful_widget(list, main_layout[0], &mut app.leaderboards_state.clone()); } - ModelState::GpuSelection => { + AppState::GpuSelection => { let items: Vec = app .gpus .iter() .map(|gpu| { - // GPUs still only have a title line let line = Line::from(vec![Span::styled( gpu.title_text.clone(), Style::default().fg(Color::White).bold(), )]); - ListItem::new(line) // Keep as single line + ListItem::new(line) }) .collect(); let list = List::new(items) @@ -487,74 +460,37 @@ pub fn ui(app: &App, frame: &mut Frame) { .highlight_symbol("> "); frame.render_stateful_widget(list, main_layout[0], &mut app.gpus_state.clone()); } - ModelState::SubmissionModeSelection => { + AppState::SubmissionModeSelection => { let items: Vec = app .submission_modes .iter() .map(|mode| { - let title_line = Line::from(Span::styled( + let strings = utils::custom_wrap( mode.title_text.clone(), - Style::default().fg(Color::White).bold(), - )); - - let mut lines = vec![title_line]; - let description_text = &mode.description_text; - - // Manual wrapping logic - if available_width > 0 { - let mut current_line = String::with_capacity(available_width); - for word in description_text.split_whitespace() { - // Check if the word itself is too long - if word.len() > available_width { - // If a line is currently being built, push it first - if !current_line.is_empty() { - lines.push(Line::from(Span::styled( - current_line.clone(), - Style::default().fg(Color::Gray).dim(), - ))); - current_line.clear(); - } - // Push the long word on its own line - lines.push(Line::from(Span::styled( - word.to_string(), - Style::default().fg(Color::Gray).dim(), - ))); - } else if current_line.is_empty() { - // Start a new line - current_line.push_str(word); - } else if current_line.len() + word.len() + 1 <= available_width { - // Add word to current line - current_line.push(' '); - current_line.push_str(word); + mode.description_text.clone(), + available_width, + ); + + let lines: Vec = strings + .into_iter() + .enumerate() + .map(|(i, line)| { + if i == 0 { + Line::from(Span::styled( + line, + Style::default().fg(Color::White).bold(), + )) } else { - // Word doesn't fit, push the completed line - lines.push(Line::from(Span::styled( - current_line.clone(), + Line::from(Span::styled( + line.clone(), Style::default().fg(Color::Gray).dim(), - ))); - // Start a new line with the current word - current_line.clear(); - current_line.push_str(word); + )) } - } - // Push the last remaining line if it's not empty - if !current_line.is_empty() { - lines.push(Line::from(Span::styled( - current_line, - Style::default().fg(Color::Gray).dim(), - ))); - } - } else { - // Fallback: push the original description as one line if width is zero - lines.push(Line::from(Span::styled( - description_text.clone(), - Style::default().fg(Color::Gray).dim(), - ))); - } - + }) + .collect::>(); ListItem::new(lines) }) - .collect(); + .collect::>(); let list = List::new(items) .block(list_block.title(format!( "Select Submission Mode for '{}' on '{}'", @@ -570,32 +506,17 @@ pub fn ui(app: &App, frame: &mut Frame) { &mut app.submission_modes_state.clone(), ); } - ModelState::WaitingForResult => { - // This state is handled by the loading message check at the beginning + AppState::WaitingForResult => { + let loading_page = LoadingPage::default(); + frame.render_stateful_widget( + &loading_page, + main_layout[0], + &mut app.loading_page_state.clone(), + ) } } } -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] -} - pub async fn run_submit_tui( filepath: Option, gpu: Option, @@ -632,7 +553,7 @@ pub async fn run_submit_tui( let mut file = File::open(&file_to_submit)?; let mut file_content = String::new(); file.read_to_string(&mut file_content)?; - + // Create client and submit directly let client = service::create_client(Some(cli_id))?; println!("Submitting solution directly with:"); @@ -640,19 +561,21 @@ pub async fn run_submit_tui( println!(" Leaderboard: {}", leaderboard_flag); println!(" GPU: {}", gpu_flag); println!(" Mode: {}", mode_flag); - + // Make the submission let result = service::submit_solution( - &client, - &file_to_submit, - &file_content, - leaderboard_flag, - gpu_flag, - mode_flag - ).await?; - + &client, + &file_to_submit, + &file_content, + leaderboard_flag, + gpu_flag, + mode_flag, + ) + .await?; + + println!("Submission result: {}", result); + utils::display_ascii_art(); - println!("{}", result); return Ok(()); } @@ -669,7 +592,7 @@ pub async fn run_submit_tui( app.selected_submission_mode = Some(mode_flag); // Skip to submission if we have all required fields if app.selected_gpu.is_some() && app.selected_leaderboard.is_some() { - app.modal_state = ModelState::WaitingForResult; + app.app_state = AppState::WaitingForResult; } } @@ -680,27 +603,23 @@ pub async fn run_submit_tui( // Spawn the initial task based on the starting state BEFORE setting up the TUI // If spawning fails here, we just return the error directly without TUI cleanup. - match app.modal_state { - ModelState::LeaderboardSelection => { + match app.app_state { + AppState::LeaderboardSelection => { if let Err(e) = app.spawn_load_leaderboards() { return Err(anyhow!("Error starting leaderboard fetch: {}", e)); } } - ModelState::GpuSelection => { + AppState::GpuSelection => { if let Err(e) = app.spawn_load_gpus() { return Err(anyhow!("Error starting GPU fetch: {}", e)); } } - ModelState::WaitingForResult => { - // This state occurs when all flags (gpu, leaderboard, mode) are provided + AppState::WaitingForResult => { if let Err(e) = app.spawn_submit_solution() { return Err(anyhow!("Error starting submission: {}", e)); } } - _ => { - // Other states like SubmissionModeSelection shouldn't be the *initial* state - // unless there's a logic error elsewhere. We'll proceed to TUI. - } + _ => {} } // Now, set up the TUI @@ -710,16 +629,15 @@ pub async fn run_submit_tui( let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Main application loop - this remains largely the same while !app.should_quit { terminal.draw(|f| ui(&app, f))?; - // Check for finished async tasks without blocking drawing app.check_leaderboard_task().await; app.check_gpu_task().await; app.check_submission_task().await; - // Handle input events + app.update_loading_page_state(terminal.size()?.width); + if event::poll(std::time::Duration::from_millis(50))? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { @@ -729,6 +647,33 @@ pub async fn run_submit_tui( } } + let mut result_text = "Submission cancelled.".to_string(); + + if let Some(status) = app.final_status { + let trimmed = status.trim(); + let content = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + + let content = content.replace("\\n", "\n"); + + result_text = content.to_string(); + } + + let state = &mut app.result_page_state; + + let mut result_page = ResultPage::new(result_text.clone(), state); + while !state.ack { + terminal + .draw(|frame: &mut Frame| { + frame.render_stateful_widget(&result_page, frame.size(), state); + }) + .unwrap(); + result_page.handle_key_event(state); + } + // Restore terminal disable_raw_mode()?; crossterm::execute!( @@ -737,13 +682,7 @@ pub async fn run_submit_tui( )?; terminal.show_cursor()?; - utils::display_ascii_art(); - - if let Some(status) = app.final_status { - println!("{}", status); - } else { - println!("Operation cancelled."); // Or some other default message if quit early - } + // utils::display_ascii_art(); Ok(()) } diff --git a/src/main.rs b/src/main.rs index ccf767c..7f87c22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod cmd; mod models; mod service; mod utils; +mod views; use crate::cmd::Cli; use clap::Parser; diff --git a/src/models/mod.rs b/src/models/mod.rs index 257f751..5a9c8a8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -43,8 +43,9 @@ impl SubmissionModeItem { } } -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ModelState { +#[derive(Clone, Copy, Debug, PartialEq, Default)] +pub enum AppState { + #[default] LeaderboardSelection, GpuSelection, SubmissionModeSelection, diff --git a/src/service/mod.rs b/src/service/mod.rs index 76b1731..8c9a971 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -175,15 +175,8 @@ pub async fn submit_solution>( "status" => (), "result" => { let result_val: Value = serde_json::from_str(data)?; - let pretty_result = match result_val.get("results") { - Some(result_obj) => serde_json::to_string_pretty(result_obj)?, - None => { - return Err(anyhow!( - "Invalid 'result' event structure: missing 'results' field" - )) - } - }; - return Ok(pretty_result); + let reports = result_val.get("reports").unwrap(); + return Ok(reports.to_string()); } "error" => { let error_val: Value = serde_json::from_str(data)?; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index cb29342..4e940ad 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -48,33 +48,68 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir )) } -pub fn display_ascii_art() { +pub fn get_ascii_art() -> String { let art = r#" - _ __ _ ______ _ -| | / / | | | ___ \ | | -| |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ -| \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| -| |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ -\_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| - - POPCORN CLI - GPU MODE - - ┌───────────────────────────────────────┐ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ - │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ - │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ - │ └─────┘ └─────┘ └─────┘ │████████│ │▒ - │ │████████│ │▒ - │ ┌────────────────────────┐ │████████│ │▒ - │ │ │ │████████│ │▒ - │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ - │ │ │ │▒ - │ └────────────────────────┘ │▒ - │ │▒ - └───────────────────────────────────────┘▒ - ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ -"#; + _ __ _ ______ _ + | | / / | | | ___ \ | | + | |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ + | \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| + | |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ + \_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| + + POPCORN CLI - GPU MODE + + ┌───────────────────────────────────────┐ + │ ┌─────┐ ┌─────┐ ┌─────┐ │ + │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ + │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ + │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ + │ └─────┘ └─────┘ └─────┘ │████████│ │▒ + │ │████████│ │▒ + │ ┌────────────────────────┐ │████████│ │▒ + │ │ │ │████████│ │▒ + │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ + │ │ │ │▒ + │ └────────────────────────┘ │▒ + │ │▒ + └───────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + "#; + + art.to_string() +} + + +pub fn display_ascii_art() { + let art = get_ascii_art(); println!("{}", art); } + +pub fn custom_wrap(initial_text: String, remaining_text: String, available_width: usize) -> Vec { + let mut lines = vec![initial_text]; + let mut current_line = String::with_capacity(available_width); + for word in remaining_text.split_whitespace() { + if word.len() > available_width { + if !current_line.is_empty() { + lines.push(current_line.clone()); + current_line.clear(); + } + lines.push(word.to_string()); + } else if current_line.is_empty() { + current_line.push_str(word); + } else if current_line.len() + word.len() + 1 <= available_width { + current_line.push(' '); + current_line.push_str(word); + } else { + lines.push(current_line.clone()); + current_line.clear(); + current_line.push_str(word); + } + } + + if !current_line.is_empty() { + lines.push(current_line); + } + lines +} diff --git a/src/views/loading_page.rs b/src/views/loading_page.rs new file mode 100644 index 0000000..a01512f --- /dev/null +++ b/src/views/loading_page.rs @@ -0,0 +1,73 @@ +use ratatui::{ + buffer::Buffer, + layout::{Alignment, Layout, Rect}, + style::{Color, Stylize}, + widgets::{Block, Gauge, Padding, Paragraph, StatefulWidget, Widget}, +}; + +#[derive(Debug, Default, Clone)] +pub struct LoadingPageState { + pub loop_count: u16, + pub progress_column: u16, + pub progress_bar: f64, +} + +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub struct LoadingPage { + header_area: Rect, + gauge_area: Rect, + footer_area: Rect, +} + +const GAUGE_COLOR: Color = ratatui::style::palette::tailwind::RED.c800; + +impl StatefulWidget for &LoadingPage { + type State = LoadingPageState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + use ratatui::layout::Constraint::Percentage; + + let layout = Layout::vertical([Percentage(45), Percentage(10), Percentage(45)]); + + let [_, gauge_area, footer_area] = layout.areas(area); + + render_gauge(gauge_area, buf, state); + render_footer(footer_area, buf, state); + } +} + +fn render_gauge(area: Rect, buf: &mut Buffer, state: &mut LoadingPageState) { + let blk = Block::default().padding(Padding::horizontal(20)); + Gauge::default() + .block(blk) + .gauge_style(GAUGE_COLOR) + .ratio(state.progress_bar / 100.0) + .render(area, buf); +} + +fn get_footer_text(state: &LoadingPageState) -> String { + let percentage = state.progress_bar; + + if state.loop_count > 0 { + return "Did you know we have zero idea how long this will take?".to_string(); + } + + if percentage > 75.0 { + return "Almost there!".to_string(); + } else if percentage > 35.0 { + return "Crunching numbers...".to_string(); + } else { + return "This is taking a while, huh?".to_string(); + } +} + +fn render_footer(area: Rect, buf: &mut Buffer, state: &LoadingPageState) { + let blk = Block::default().padding(Padding::vertical(1)); + let text = Paragraph::new(get_footer_text(state)) + .alignment(Alignment::Center) + .fg(Color::White) + .bold() + .block(blk); + + text.render(area, buf); +} diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..a0e6eff --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,2 @@ +pub mod result_page; +pub mod loading_page; diff --git a/src/views/result_page.rs b/src/views/result_page.rs new file mode 100644 index 0000000..a749416 --- /dev/null +++ b/src/views/result_page.rs @@ -0,0 +1,149 @@ +use crate::utils; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use ratatui::{ + layout::{Alignment, Constraint, Layout, Margin, Rect}, + prelude::Buffer, + style::{Color, Style}, + symbols::scrollbar, + widgets::{Block, BorderType, Paragraph, Scrollbar, ScrollbarState, StatefulWidget, Widget}, +}; + +#[derive(Default, Debug)] +pub struct ResultPageState { + pub vertical_scroll: u16, + pub vertical_scroll_state: ScrollbarState, + pub horizontal_scroll: u16, + pub horizontal_scroll_state: ScrollbarState, + pub ack: bool, +} + +#[derive(Default, Debug)] +pub struct ResultPage { + result_text: Paragraph<'static>, +} + +impl ResultPage { + pub fn new(result_text: String, state: &mut ResultPageState) -> Self { + let max_width = result_text + .lines() + .map(|line| line.len()) + .max() + .unwrap_or(0); + + let num_lines = result_text.lines().count(); + + state.vertical_scroll_state = state + .vertical_scroll_state + .content_length(num_lines); + + state.horizontal_scroll_state = state.horizontal_scroll_state.content_length(max_width); + + Self { + result_text: Paragraph::new(result_text), + } + } + + fn render_left(&self, buf: &mut Buffer, left: Rect) { + let left_block = Block::bordered() + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Yellow)); + + let left_text = Paragraph::new(utils::get_ascii_art()); + + left_text.block(left_block).render(left, buf); + } + + fn render_right(&self, buf: &mut Buffer, right: Rect, state: &mut ResultPageState) { + let right_block = Block::bordered() + .border_type(BorderType::Plain) + .border_style(Style::default().fg(Color::Yellow)) + .title_bottom("Press q to quit...") + .title_style(Style::default().fg(Color::Red)) + .title_alignment(Alignment::Right); + + let result_text = self + .result_text + .clone() + .block(right_block) + .scroll((state.vertical_scroll as u16, state.horizontal_scroll as u16)); + result_text.render(right, buf); + } + + pub fn handle_key_event(&mut self, state: &mut ResultPageState) { + if event::poll(std::time::Duration::from_millis(50)).unwrap() { + if let Event::Key(key) = event::read().unwrap() { + if key.kind != KeyEventKind::Press { + return; + } + if key.code == KeyCode::Char('q') { + state.ack = true; + } + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + state.vertical_scroll = state.vertical_scroll.saturating_add(1); + state.vertical_scroll_state = state + .vertical_scroll_state + .position(state.vertical_scroll as usize); + } + KeyCode::Char('k') | KeyCode::Up => { + state.vertical_scroll = state.vertical_scroll.saturating_sub(1); + state.vertical_scroll_state = state + .vertical_scroll_state + .position(state.vertical_scroll as usize); + } + KeyCode::Char('h') | KeyCode::Left => { + state.horizontal_scroll = state.horizontal_scroll.saturating_sub(1); + state.horizontal_scroll_state = state + .horizontal_scroll_state + .position(state.horizontal_scroll as usize); + } + KeyCode::Char('l') | KeyCode::Right => { + state.horizontal_scroll = state.horizontal_scroll.saturating_add(1); + state.horizontal_scroll_state = state + .horizontal_scroll_state + .position(state.horizontal_scroll as usize); + } + _ => {} + } + } + } + } +} + +impl StatefulWidget for &ResultPage { + type State = ResultPageState; + + fn render(self, area: Rect, buf: &mut Buffer, state: &mut ResultPageState) { + let layout = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]); + let [left, right] = layout.areas(area); + + self.render_left(buf, left); + self.render_right(buf, right, state); + + let vertical_scrollbar = + Scrollbar::new(ratatui::widgets::ScrollbarOrientation::VerticalLeft) + .symbols(scrollbar::VERTICAL); + + let horizontal_scrollbar = + Scrollbar::new(ratatui::widgets::ScrollbarOrientation::HorizontalBottom) + .symbols(scrollbar::HORIZONTAL); + + vertical_scrollbar.render( + right.inner(&Margin { + vertical: 1, + horizontal: 0, + }), + buf, + &mut state.vertical_scroll_state, + ); + horizontal_scrollbar.render( + right.inner(&Margin { + vertical: 0, + horizontal: 1, + }), + buf, + &mut state.horizontal_scroll_state, + ); + } +} From cdd6bf9b76b3b5e0b47074720d474cbf8baf0bbb Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 8 Jun 2025 14:01:58 -0700 Subject: [PATCH 055/111] Add hackathon installation scripts (#16) * Add hackathon install scripts and restrict submission modes - cross-platform install scripts, hardcoded API URL, test/benchmark modes only * Add automatic Discord registration to install scripts - eliminates manual registration step for hackathon participants * Revert anonymous access, switch to GitHub auth for hackathons - Remove --anonymous flag from CLI - Update install scripts to use GitHub authentication - Re-enable all submission modes - Simplify authentication flow to GitHub OAuth only * update * delete cargo.lock * rename * revert move * update instructions * update * fix gitignore stuff * rename * Update .gitignore * Update src/main.rs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update install.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix latest thingy * installsujdhasdouihgsda --------- --- .gitignore | 1 + docs/AMD_workshop/README.md | 60 +++++++++++++++ docs/AMD_workshop/example.py | 56 ++++++++++++++ install.ps1 | 119 ++++++++++++++++++++++++++++++ install.sh | 139 +++++++++++++++++++++++++++++++++++ src/cmd/mod.rs | 3 +- src/main.rs | 11 +-- 7 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 docs/AMD_workshop/README.md create mode 100644 docs/AMD_workshop/example.py create mode 100644 install.ps1 create mode 100755 install.sh diff --git a/.gitignore b/.gitignore index 0240a06..556b613 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ submission.* target/ +scratch.md diff --git a/docs/AMD_workshop/README.md b/docs/AMD_workshop/README.md new file mode 100644 index 0000000..3b4cc99 --- /dev/null +++ b/docs/AMD_workshop/README.md @@ -0,0 +1,60 @@ +# 🍿 Popcorn CLI - Hackathon Quick Install + +Get started with Popcorn CLI in seconds! Choose your installation method based on your operating system. + +## 🚀 One-Line Install Commands + +### For Linux/macOS/Unix: +```bash +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash +``` + +### For Windows (PowerShell): +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr -UseBasicParsing https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.ps1 | iex" +``` + +## 📋 Quick Start After Installation + +1. **Restart your terminal** (or run `source ~/.bashrc` / `source ~/.zshrc`) + +2. **Register with GitHub** (one-time setup): + ```bash + popcorn-cli register github + ``` + +3. **Submit your solution:** + ```bash + popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py + ``` + +4. **Interactive mode** (choose GPU and options): + ```bash + popcorn-cli submit my_solution.py + ``` + +## 🛠️ Manual Installation + +If the scripts don't work, you can manually install: + +1. Download the binary for your OS from [releases](https://github.com/gpu-mode/popcorn-cli/releases/tag/v1.1.6) +2. Extract the archive +3. Move the binary to a directory in your PATH +4. Make it executable (Linux/macOS): `chmod +x popcorn-cli` + +## 🆘 Troubleshooting + +### Command not found after installation +- Restart your terminal +- Check if the install directory is in your PATH: + - Linux/macOS: `echo $PATH` + - Windows: `echo $env:PATH` +- Check if POPCORN_API_URL is set to https://discord-cluster-manager-1f6c4782e60a.herokuapp.com + - Linux/macOS: `echo $POPCORN_API_URL` + - Windows: `echo $env:POPCORN_API_URL` + +## 💡 Need Help? + +- Run `popcorn-cli --help` for usage information +- Check the [main repository](https://github.com/gpu-mode/popcorn-cli) and open an issue +- Join the [GPU Mode Discord](https://discord.gg/gpumode) and ask a question in #amd-competition \ No newline at end of file diff --git a/docs/AMD_workshop/example.py b/docs/AMD_workshop/example.py new file mode 100644 index 0000000..b5fd9ab --- /dev/null +++ b/docs/AMD_workshop/example.py @@ -0,0 +1,56 @@ +import torch +from task import input_t, output_t + +def custom_kernel(data: input_t) -> output_t: + """ + Reference implementation of block-scale fp8 gemm + Args: + data: Tuple that expands to: + a: torch.Tensor[float8_e4m3fnuz] of shape [m, k], + b: torch.Tensor[float8_e4m3fnuz] of shape [n, k], + a_scale: torch.Tensor[float32] of shape [m, k // 128], + b_scale: torch.Tensor[float32] of shape [n // 128, k // 128], + c: torch.Tensor[bfloat16] of shape [m, n] + Returns: + Tensor containing output in bf16 + """ + # c: [m, n] is pre-allocated memory to avoid timing allocation overhead. + a, b, a_scale, b_scale, c = data + + # a is M x K in column-major order, we convert here for simplicity. + a = a.contiguous() + a_scale = a_scale.contiguous() + b_scale = b_scale.contiguous() + + # constants + m = a.shape[0] + n = b.shape[0] + k = a.shape[1] + block_shape_n = 128 + block_shape_k = 128 + scale_n = b_scale.shape[0] + scale_k = b_scale.shape[1] + + # Apply scaling to input 'a' + a_scale = a_scale.unsqueeze(-1).repeat(1, 1, block_shape_k) # Shape: [m, scale_k, block_shape_k] + a_scale = a_scale.reshape(m, scale_k * block_shape_k) + a_scale = a_scale[:, :k] + + # Dequantize 'a', in your implementation you should do this at the end. + a = a.to(a_scale.dtype) * a_scale + + # Apply scaling to input 'b' + b_scale = ( + b_scale.view(-1, 1) + .repeat(1, block_shape_n * block_shape_k) + .view(scale_n, scale_k, block_shape_n, block_shape_k) + .permute(0, 2, 1, 3) # Reorder dimensions: [scale_n, blk_n, scale_k, blk_k] + .reshape(scale_n * block_shape_n, scale_k * block_shape_k) + ) + b_scale = b_scale[:n, :k] + + # Dequantize 'b', in your implementation you should do this at the end. + b = b.to(b_scale.dtype) * b_scale + + c[...] = (a @ b.T).to(torch.bfloat16) + return c \ No newline at end of file diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..0a1e41a --- /dev/null +++ b/install.ps1 @@ -0,0 +1,119 @@ +# Popcorn CLI Hackathon Installer for Windows +# Run with: powershell -ExecutionPolicy Bypass -File install.ps1 + +param( + [switch]$Force = $false +) + +Write-Host "Installing Popcorn CLI for Hackathon (Windows)..." -ForegroundColor Yellow + +# Check if running as administrator (optional but recommended) +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Host "Not running as administrator. Installation will be user-scoped." -ForegroundColor Yellow +} + +# Set variables +$downloadUrl = "https://github.com/gpu-mode/popcorn-cli/releases/latest/download/popcorn-cli-windows.zip" +$tempDir = "$env:TEMP\popcorn-cli-install" +$installDir = "$env:LOCALAPPDATA\popcorn-cli" +$binaryPath = "$installDir\popcorn-cli.exe" + +# Create directories +try { + if (Test-Path $tempDir) { + Remove-Item $tempDir -Recurse -Force + } + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + Write-Host "Created installation directories" -ForegroundColor Green +} catch { + Write-Host "Failed to create directories: $_" -ForegroundColor Red + exit 1 +} + +# Download the binary +Write-Host "Downloading from: $downloadUrl" -ForegroundColor Cyan +try { + $zipPath = "$tempDir\popcorn-cli-windows.zip" + Invoke-WebRequest -Uri $downloadUrl -OutFile $zipPath -UseBasicParsing + Write-Host "Download completed" -ForegroundColor Green +} catch { + Write-Host "Download failed: $_" -ForegroundColor Red + exit 1 +} + +# Extract the binary +Write-Host "Extracting binary..." -ForegroundColor Cyan +try { + Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force + + # Find the binary (it might be in a subdirectory) + $binarySource = Get-ChildItem -Path $tempDir -Name "popcorn-cli.exe" -Recurse | Select-Object -First 1 + if ($binarySource) { + $fullBinaryPath = Join-Path $tempDir $binarySource + Copy-Item $fullBinaryPath $binaryPath -Force + Write-Host "Binary extracted and copied" -ForegroundColor Green + } else { + Write-Host "popcorn-cli.exe not found in archive" -ForegroundColor Red + exit 1 + } +} catch { + Write-Host "Extraction failed: $_" -ForegroundColor Red + exit 1 +} + +# Add to PATH +Write-Host "Adding to PATH..." -ForegroundColor Cyan +try { + $userPath = [Environment]::GetEnvironmentVariable("PATH", "User") + if ($userPath -notlike "*$installDir*") { + $newPath = "$installDir;$userPath" + [Environment]::SetEnvironmentVariable("PATH", $newPath, "User") + Write-Host "Added $installDir to user PATH" -ForegroundColor Green + Write-Host "Please restart your terminal or PowerShell session" -ForegroundColor Yellow + } else { + Write-Host "$installDir already in PATH" -ForegroundColor Green + } + + # Also add to current session + $env:PATH = "$installDir;$env:PATH" +} catch { + Write-Host "Could not modify PATH automatically: $_" -ForegroundColor Yellow + Write-Host "Please manually add $installDir to your PATH" -ForegroundColor Yellow +} + +# Cleanup +Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue + +# Test installation +Write-Host "Testing installation..." -ForegroundColor Cyan +try { + $version = & $binaryPath --version 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Host "Installation successful!" -ForegroundColor Green + } else { + Write-Host "Binary installed but may not be working correctly" -ForegroundColor Yellow + } +} catch { + Write-Host "Could not test binary: $_" -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "Popcorn CLI installed and ready for hackathon!" -ForegroundColor Green +Write-Host "" +Write-Host "Quick Start:" -ForegroundColor Cyan +Write-Host " 1. Restart your terminal/PowerShell" -ForegroundColor White +Write-Host " 2. Register with GitHub: popcorn-cli register github" -ForegroundColor White +Write-Host " 3. Submit your solution: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test " -ForegroundColor White +Write-Host "" +Write-Host "Hackathon mode features:" -ForegroundColor Cyan +Write-Host " - API URL pre-configured" -ForegroundColor White +Write-Host " - GitHub authentication (no Discord setup needed)" -ForegroundColor White +Write-Host " - All modes available: test, benchmark, leaderboard, profile" -ForegroundColor White +Write-Host " - Clean user identification" -ForegroundColor White +Write-Host "" +Write-Host "Need help? Run: popcorn-cli --help" -ForegroundColor White +Write-Host "Example: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py" -ForegroundColor White +Write-Host "" +Write-Host "Installation location: $installDir" -ForegroundColor Gray \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..80e1dec --- /dev/null +++ b/install.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +set -e + +# Popcorn CLI Hackathon Installer (Unix/Linux/macOS) +# For Windows users: Use install.ps1 instead +echo "🍿 Installing Popcorn CLI for Hackathon (Unix/Linux/macOS)..." + +# Check if we're on Windows +if [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]] || [[ "$OSTYPE" == "cygwin" ]]; then + echo "⚠️ Detected Windows environment" + echo "For native Windows, please use install.ps1 instead:" + echo " powershell -ExecutionPolicy Bypass -File install.ps1" + echo "" + echo "This script will continue assuming you're in a Unix-like environment (WSL/Git Bash/MSYS2)" + read -p "Continue? (y/N): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 0 + fi +fi + +# Detect OS +OS="" +ARCH="" +BINARY_NAME="" +EXTENSION="" + +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + OS="linux" + EXTENSION=".tar.gz" + BINARY_NAME="popcorn-cli" +elif [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + EXTENSION=".tar.gz" + BINARY_NAME="popcorn-cli" +elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]] || [[ "$OSTYPE" == "cygwin" ]]; then + OS="windows" + EXTENSION=".zip" + BINARY_NAME="popcorn-cli.exe" +else + echo "❌ Unsupported operating system: $OSTYPE" + exit 1 +fi + +echo "✅ Detected OS: $OS" + +# Download URL +DOWNLOAD_URL="https://github.com/gpu-mode/popcorn-cli/releases/latest/download/popcorn-cli-${OS}${EXTENSION}" +TEMP_DIR="/tmp/popcorn-cli-install" +INSTALL_DIR="$HOME/.local/bin" + +# Create directories +mkdir -p "$TEMP_DIR" +mkdir -p "$INSTALL_DIR" + +echo "📥 Downloading from: $DOWNLOAD_URL" + +# Download the binary +if command -v curl >/dev/null 2>&1; then + curl -L -o "$TEMP_DIR/popcorn-cli${EXTENSION}" "$DOWNLOAD_URL" +elif command -v wget >/dev/null 2>&1; then + wget -O "$TEMP_DIR/popcorn-cli${EXTENSION}" "$DOWNLOAD_URL" +else + echo "❌ Neither curl nor wget found. Please install one of them." + exit 1 +fi + +echo "📦 Extracting binary..." + +# Extract the binary +cd "$TEMP_DIR" +if [[ "$EXTENSION" == ".tar.gz" ]]; then + tar -xzf "popcorn-cli${EXTENSION}" +elif [[ "$EXTENSION" == ".zip" ]]; then + unzip "popcorn-cli${EXTENSION}" +fi + +# Find and move the binary +if [[ -f "$BINARY_NAME" ]]; then + chmod +x "$BINARY_NAME" + mv "$BINARY_NAME" "$INSTALL_DIR/" + echo "✅ Binary installed to $INSTALL_DIR/$BINARY_NAME" +else + echo "❌ Binary not found after extraction" + exit 1 +fi + +# Add to PATH +SHELL_RC="" +if [[ -n "$ZSH_VERSION" ]]; then + SHELL_RC="$HOME/.zshrc" +elif [[ -n "$BASH_VERSION" ]]; then + SHELL_RC="$HOME/.bashrc" +else + # Try to detect shell + case "$SHELL" in + */zsh) + SHELL_RC="$HOME/.zshrc" + ;; + */bash) + SHELL_RC="$HOME/.bashrc" + ;; + *) + SHELL_RC="$HOME/.profile" + ;; + esac +fi + +# Check if PATH already contains the directory +if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then + echo "🔧 Adding $INSTALL_DIR to PATH in $SHELL_RC" + echo "" >> "$SHELL_RC" + echo "# Added by Popcorn CLI installer" >> "$SHELL_RC" + echo "export PATH=\"$INSTALL_DIR:\$PATH\"" >> "$SHELL_RC" + export PATH="$INSTALL_DIR:$PATH" +else + echo "✅ $INSTALL_DIR already in PATH" +fi + +# Cleanup +rm -rf "$TEMP_DIR" + +echo "" +echo "🎉 Popcorn CLI installed and ready for hackathon!" +echo "" +echo "📋 Quick Start:" +echo " 1. Restart your terminal or run: source $SHELL_RC" +echo " 2. Register with GitHub: popcorn-cli register github" +echo " 3. Submit your solution: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test " +echo "" +echo "🚀 Hackathon mode features:" +echo " - ✅ API URL pre-configured" +echo " - ✅ GitHub authentication (no Discord setup needed)" +echo " - ✅ All modes available: test, benchmark, leaderboard, profile" +echo " - ✅ Clean user identification" +echo "" +echo "💡 Need help? Run: popcorn-cli --help" +echo "🔗 Example: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py" \ No newline at end of file diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index f52c6b6..617bfad 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -111,11 +111,12 @@ pub async fn execute(cli: Cli) -> Result<()> { let config = load_config()?; let cli_id = config.cli_id.ok_or_else(|| { anyhow!( - "cli_id not found in config file ({}). Please run `popcorn register` first.", + "cli_id not found in config file ({}). Please run 'popcorn-cli register' first.", get_config_path() .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) ) })?; + // Use filepath from Submit command first, fallback to top-level filepath let final_filepath = filepath.or(cli.filepath); submit::run_submit_tui( diff --git a/src/main.rs b/src/main.rs index 7f87c22..8154bc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,15 +11,12 @@ use std::process; #[tokio::main] async fn main() { - // Parse command line arguments - let cli = Cli::parse(); - - // Popcorn API URL check (needed for most commands) - // We might want to move this check inside specific commands later if some don't need it. + // Set the API URL FIRST - before anything else if env::var("POPCORN_API_URL").is_err() { - eprintln!("POPCORN_API_URL is not set. Please set it to the URL of the Popcorn API."); - process::exit(1); + env::set_var("POPCORN_API_URL", "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com"); } + // Parse command line arguments + let cli = Cli::parse(); // Execute the parsed command if let Err(e) = cmd::execute(cli).await { From 73b610e1874c7367d78e9cb35e33aab2fab76747 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 8 Jun 2025 14:15:02 -0700 Subject: [PATCH 056/111] Update README.md --- docs/AMD_workshop/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/AMD_workshop/README.md b/docs/AMD_workshop/README.md index 3b4cc99..b0b6d71 100644 --- a/docs/AMD_workshop/README.md +++ b/docs/AMD_workshop/README.md @@ -30,7 +30,7 @@ powershell -ExecutionPolicy Bypass -Command "iwr -UseBasicParsing https://raw.gi 4. **Interactive mode** (choose GPU and options): ```bash - popcorn-cli submit my_solution.py + popcorn-cli submit example.py ``` ## 🛠️ Manual Installation @@ -57,4 +57,4 @@ If the scripts don't work, you can manually install: - Run `popcorn-cli --help` for usage information - Check the [main repository](https://github.com/gpu-mode/popcorn-cli) and open an issue -- Join the [GPU Mode Discord](https://discord.gg/gpumode) and ask a question in #amd-competition \ No newline at end of file +- Join the [GPU Mode Discord](https://discord.gg/gpumode) and ask a question in #amd-competition From 1b96e9e556b94c6b0fe585c2632a344490b93099 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 8 Jun 2025 14:15:28 -0700 Subject: [PATCH 057/111] Update README.md --- docs/AMD_workshop/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/AMD_workshop/README.md b/docs/AMD_workshop/README.md index b0b6d71..30e5386 100644 --- a/docs/AMD_workshop/README.md +++ b/docs/AMD_workshop/README.md @@ -37,7 +37,7 @@ powershell -ExecutionPolicy Bypass -Command "iwr -UseBasicParsing https://raw.gi If the scripts don't work, you can manually install: -1. Download the binary for your OS from [releases](https://github.com/gpu-mode/popcorn-cli/releases/tag/v1.1.6) +1. Download the binary for your OS from [releases](https://github.com/gpu-mode/popcorn-cli/releases/latest) 2. Extract the archive 3. Move the binary to a directory in your PATH 4. Make it executable (Linux/macOS): `chmod +x popcorn-cli` From 144885375b22b9d8abc5f26e63c96dfdc4eab968 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Mon, 9 Jun 2025 20:23:58 -0700 Subject: [PATCH 058/111] updated workshop examples (#17) * updated workshop examples * update --- docs/AMD_workshop/README.md | 57 +++++++-- docs/AMD_workshop/{example.py => v1.py} | 3 + docs/AMD_workshop/v2.py | 125 +++++++++++++++++++ docs/AMD_workshop/v3.py | 152 ++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 9 deletions(-) rename docs/AMD_workshop/{example.py => v1.py} (97%) create mode 100644 docs/AMD_workshop/v2.py create mode 100644 docs/AMD_workshop/v3.py diff --git a/docs/AMD_workshop/README.md b/docs/AMD_workshop/README.md index 30e5386..85629e8 100644 --- a/docs/AMD_workshop/README.md +++ b/docs/AMD_workshop/README.md @@ -23,15 +23,46 @@ powershell -ExecutionPolicy Bypass -Command "iwr -UseBasicParsing https://raw.gi popcorn-cli register github ``` -3. **Submit your solution:** - ```bash - popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py - ``` - -4. **Interactive mode** (choose GPU and options): - ```bash - popcorn-cli submit example.py - ``` +## 🏃 Run Examples + +Try out the example implementations to get familiar with the system: + +### For Linux/macOS: +```bash +# Download and test v1.py (reference implementation) +wget https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v1.py +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v1.py + +# Download and test v2.py (basic optimization) +wget https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v2.py +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v2.py + +# Download and test v3.py (advanced optimization) +wget https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v3.py +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v3.py +``` + +### For Windows (PowerShell): +```powershell +# Download and test v1.py (reference implementation) +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v1.py" -OutFile "v1.py" +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v1.py + +# Download and test v2.py (basic optimization) +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v2.py" -OutFile "v2.py" +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v2.py + +# Download and test v3.py (advanced optimization) +Invoke-WebRequest -Uri "https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/docs/AMD_workshop/v3.py" -OutFile "v3.py" +popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test v3.py +``` + +### 💡 Pro Tips: +- Start with **v1.py** (reference implementation) to understand the baseline +- Try **v2.py** for basic optimizations +- Challenge yourself with **v3.py** for advanced Triton optimizations +- Use `--mode benchmark` instead of `--mode test` to see performance metrics + ## 🛠️ Manual Installation @@ -58,3 +89,11 @@ If the scripts don't work, you can manually install: - Run `popcorn-cli --help` for usage information - Check the [main repository](https://github.com/gpu-mode/popcorn-cli) and open an issue - Join the [GPU Mode Discord](https://discord.gg/gpumode) and ask a question in #amd-competition + +## 🧑‍🎓 Learn more from our favorite writeups + +* https://github.com/luongthecong123/fp8-quant-matmul +* https://seb-v.github.io/optimization/update/2025/01/20/Fast-GPU-Matrix-multiplication.html +* https://akashkarnatak.github.io/amd-challenge/ +* https://www.bilibili.com/read/cv41954307/?opus_fallback=1 +* https://github.com/Snektron/gpumode-amd-fp8-mm \ No newline at end of file diff --git a/docs/AMD_workshop/example.py b/docs/AMD_workshop/v1.py similarity index 97% rename from docs/AMD_workshop/example.py rename to docs/AMD_workshop/v1.py index b5fd9ab..1b0d847 100644 --- a/docs/AMD_workshop/example.py +++ b/docs/AMD_workshop/v1.py @@ -1,3 +1,6 @@ +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + import torch from task import input_t, output_t diff --git a/docs/AMD_workshop/v2.py b/docs/AMD_workshop/v2.py new file mode 100644 index 0000000..fb10609 --- /dev/null +++ b/docs/AMD_workshop/v2.py @@ -0,0 +1,125 @@ +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + +from task import input_t, output_t +import torch +import triton +import triton.language as tl + + +@triton.jit +def kernel( + A_ptr, + B_ptr, + A_scale_ptr, + B_scale_ptr, + C_ptr, + M: tl.constexpr, + N: tl.constexpr, + K: tl.constexpr, + BLOCK_M: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_K: tl.constexpr, + BLOCK_Q: tl.constexpr = 128, +): + program_id = tl.program_id(0) + num_pid_across_n = tl.cdiv(N, BLOCK_N) + + program_id_m = program_id // num_pid_across_n + program_id_n = program_id % num_pid_across_n + + # Simple stride assumptions (no transpose) + A_stride_m, A_stride_k = 1, M + B_stride_n, B_stride_k = 1, N + C_stride_m, C_stride_n = N, 1 + + # Scale matrices: A is 1x128, B is 128x128 chunks + A_scale_stride_m, A_scale_stride_k = 1, M + B_scale_stride_n, B_scale_stride_k = 1, tl.cdiv(N, BLOCK_Q) + + # Calculate output block position + offset_m = program_id_m * BLOCK_M + offset_n = program_id_n * BLOCK_N + + # Create block offset arrays + block_offsets_m = offset_m + tl.arange(0, BLOCK_M) + block_offsets_n = offset_n + tl.arange(0, BLOCK_N) + block_offsets_k = tl.arange(0, BLOCK_K) + + # Create pointers for A and B blocks + A_block_ptrs = A_ptr + ( + block_offsets_m[:, None] * A_stride_m + block_offsets_k[None, :] * A_stride_k + ) + B_block_ptrs = B_ptr + ( + block_offsets_k[:, None] * B_stride_k + block_offsets_n[None, :] * B_stride_n + ) + + # Scale pointers + A_scale_block_ptrs = A_scale_ptr + (block_offsets_m[:, None] * A_scale_stride_m) + B_scale_block_ptrs = B_scale_ptr + (offset_n // BLOCK_Q) * B_scale_stride_n + + # Main accumulator + master_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + + # Process K dimension in BLOCK_Q chunks (128 elements at a time) + num_k_iters = K // BLOCK_Q + for _ in range(0, num_k_iters): + # Inner accumulator for current 128-element K chunk + inner_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + + # Process the 128-element chunk in smaller BLOCK_K pieces + for _ in tl.range(0, BLOCK_Q // BLOCK_K): + A_block = tl.load(A_block_ptrs) # (BLOCK_M, BLOCK_K) + B_block = tl.load(B_block_ptrs) # (BLOCK_K, BLOCK_N) + inner_accumulator = tl.dot(A_block, B_block, inner_accumulator) + + # Move to next K chunk + A_block_ptrs += BLOCK_K * A_stride_k + B_block_ptrs += BLOCK_K * B_stride_k + + # Load scales and apply to inner result + A_scales = tl.load(A_scale_block_ptrs) # (BLOCK_M, 1) + B_scales = tl.load(B_scale_block_ptrs) # scalar + master_accumulator += inner_accumulator * (A_scales * B_scales) + + # Move to next scale block + A_scale_block_ptrs += A_scale_stride_k + B_scale_block_ptrs += B_scale_stride_k + + # Store final result + block_offsets_m = (program_id_m * BLOCK_M + tl.arange(0, BLOCK_M)[:, None]) + block_offsets_n = (program_id_n * BLOCK_N + tl.arange(0, BLOCK_N)[None, :]) + mask = (block_offsets_m < M) & (block_offsets_n < N) + C_block_ptrs = C_ptr + (block_offsets_m * C_stride_m + block_offsets_n * C_stride_n) + tl.store(C_block_ptrs, master_accumulator, mask=mask) + + +def custom_kernel(data: input_t) -> output_t: + A_tensor, B_tensor, A_scale_tensor, B_scale_tensor, C_tensor = data + + M, K = A_tensor.shape + N, _ = B_tensor.shape + + # Fixed, simple configuration - no dynamic tuning + BLOCK_M = 64 + BLOCK_N = 64 + BLOCK_K = 32 + + # Launch grid + num_blocks = triton.cdiv(M, BLOCK_M) * triton.cdiv(N, BLOCK_N) + + kernel[(num_blocks,)]( + A_tensor, + B_tensor, + A_scale_tensor, + B_scale_tensor, + C_tensor, + M, N, K, + BLOCK_M=BLOCK_M, + BLOCK_N=BLOCK_N, + BLOCK_K=BLOCK_K, + num_warps=4, + num_stages=2, + ) + + return C_tensor \ No newline at end of file diff --git a/docs/AMD_workshop/v3.py b/docs/AMD_workshop/v3.py new file mode 100644 index 0000000..e0a57d8 --- /dev/null +++ b/docs/AMD_workshop/v3.py @@ -0,0 +1,152 @@ +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + +from task import input_t, output_t +import torch +import triton +import triton.language as tl + +NUM_SMS = torch.cuda.get_device_properties("cuda").multi_processor_count + + +@triton.jit +def kernel( + A_ptr, + B_ptr, + A_scale_ptr, + B_scale_ptr, + C_ptr, + M: tl.constexpr, + N: tl.constexpr, + K: tl.constexpr, + BLOCK_M: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_K: tl.constexpr, + BLOCK_Q: tl.constexpr = 128, + TRANSPOSE: tl.constexpr = False, +): + program_id = tl.program_id(0) + num_pid_across_n = tl.cdiv(N, BLOCK_N) + + program_id_m = program_id // num_pid_across_n + program_id_n = program_id % num_pid_across_n + + if not TRANSPOSE: + A_stride_m, A_stride_k = 1, M + B_stride_n, B_stride_k = 1, N + else: + A_stride_m, A_stride_k = K, 1 + B_stride_n, B_stride_k = K, 1 + C_stride_m, C_stride_n = N, 1 + # Scale matrices are stored in column-major order, with A being 1x128 and B being 128x128 chunks + # BLOCK_Q is 128 + A_scale_stride_m, A_scale_stride_k = 1, M + B_scale_stride_n, B_scale_stride_k = 1, tl.cdiv(N, BLOCK_Q) + + # Calculate the row and column indices in the output matrix for the current pid + offset_m = program_id_m * BLOCK_M + offset_n = program_id_n * BLOCK_N + + # Arange to make a row and column ptrs + block_offsets_m = offset_m + tl.arange(0, BLOCK_M) + block_offsets_n = offset_n + tl.arange(0, BLOCK_N) + block_offsets_k = tl.arange(0, BLOCK_K) + + # ptrs for BLOCK_M rows of A and BLOCK_N columns of B + A_block_ptrs = A_ptr + ( + block_offsets_m[:, None] * A_stride_m + block_offsets_k[None, :] * A_stride_k + ) + B_block_ptrs = B_ptr + ( + block_offsets_k[:, None] * B_stride_k + block_offsets_n[None, :] * B_stride_n + ) + # since a_scales are 1x128, a_scale_ptrs need to be of shape (BLOCK_M, 1) + # since N, K <= BLOCK_Q, b_scale_ptrs is always a scalar ptr + A_scale_block_ptrs = A_scale_ptr + (block_offsets_m[:, None] * A_scale_stride_m) + B_scale_block_ptrs = B_scale_ptr + (offset_n // BLOCK_Q) * B_scale_stride_n + + # Initialize accumulator for the currrent pid (responsible for BLOCK_M * BLOCK_N elements) + master_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + + # In each iteration we we load BLOCK_Q elements from K dimension for BLOCK_M rows, resp. BLOCK_N columns + # We choose this to use only 1 scale per iteration + num_k_iters = K // BLOCK_Q + for _ in range(0, num_k_iters): + # Initialize accumulator for the current k iteration + inner_accumulator = tl.zeros((BLOCK_M, BLOCK_N), dtype=tl.float32) + # In each iteration we load BLOCK_K elements from K dimension for BLOCK_M rows, resp. BLOCK_N columns + # We choose this to use small `tl.dot` for the inner accumulator + for _ in tl.range(0, BLOCK_Q // BLOCK_K): + A_block = tl.load(A_block_ptrs) # (BLOCK_M, BLOCK_K) + B_block = tl.load(B_block_ptrs) # (BLOCK_K, BLOCK_N) + inner_accumulator = tl.dot( + A_block, B_block, inner_accumulator + ) # (BLOCK_M, BLOCK_N) + + # Move along the K dimension of A, B + A_block_ptrs += BLOCK_K * A_stride_k + B_block_ptrs += BLOCK_K * B_stride_k + + A_scales = tl.load(A_scale_block_ptrs) # (BLOCK_M, 1) + B_scales = tl.load(B_scale_block_ptrs) # () + master_accumulator += inner_accumulator * (A_scales * B_scales) + + # Move along the K dimension of A, B scales + A_scale_block_ptrs += A_scale_stride_k + B_scale_block_ptrs += B_scale_stride_k + + # Store the result for the current pid + block_offsets_m = ( + program_id_m * BLOCK_M + tl.arange(0, BLOCK_M)[:, None] + ) # (BLOCK_M, 1) + block_offsets_n = ( + program_id_n * BLOCK_N + tl.arange(0, BLOCK_N)[None, :] + ) # (1, BLOCK_N) + mask = (block_offsets_m < M) & (block_offsets_n < N) # (BLOCK_M, BLOCK_N) + C_block_ptrs = C_ptr + (block_offsets_m * C_stride_m + block_offsets_n * C_stride_n) + tl.store(C_block_ptrs, master_accumulator, mask=mask) + + +@torch.compile(dynamic=False, mode="max-autotune-no-cudagraphs") +def contiguous(x): + return x.contiguous() + + +def get_config(M, N, K): + num_blocks_ref = (M // 128) * (N // 128) + TRANSPOSE = False + matrix_instr_nonkdim = 16 + BLOCK_M, BLOCK_N, BLOCK_K = (128, 128, 64) + if num_blocks_ref * 8 < NUM_SMS: # 2 and 7 + BLOCK_M, BLOCK_N, BLOCK_K = (32, 64, 128) + matrix_instr_nonkdim = 16 + elif num_blocks_ref < NUM_SMS: + BLOCK_M, BLOCK_N, BLOCK_K = (64, 64, 64) + + config = dict( + BLOCK_M=BLOCK_M, + BLOCK_N=BLOCK_N, + BLOCK_K=BLOCK_K, + waves_per_eu=2, + matrix_instr_nonkdim=matrix_instr_nonkdim, + num_warps=4, + num_stages=2, + TRANSPOSE=TRANSPOSE, + ) + return config + + +def custom_kernel(data: input_t) -> output_t: + A_tensor, B_tensor, A_scale_tensor, B_scale_tensor, C_tensor = data + + M, K = A_tensor.shape + N, _ = B_tensor.shape + + # heuristic + config = get_config(M, N, K) + + num_blocks = triton.cdiv(M, config["BLOCK_M"]) * triton.cdiv(N, config["BLOCK_N"]) + kernel[(num_blocks,)]( + A_tensor, B_tensor, A_scale_tensor, B_scale_tensor, C_tensor, M, N, K, **config + ) + + return C_tensor From 651d96d89794a7b45b02175a7948673404bf7b20 Mon Sep 17 00:00:00 2001 From: Alex Zhang Date: Tue, 10 Jun 2025 00:24:12 -0400 Subject: [PATCH 059/111] Add colors and animation update --- src/cmd/submit.rs | 16 ++++-- src/utils/mod.rs | 107 +++++++++++++++++++++++++++----------- src/views/loading_page.rs | 14 ++++- src/views/result_page.rs | 27 ++++++---- 4 files changed, 119 insertions(+), 45 deletions(-) diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 6745ea2..938ff81 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -665,12 +665,18 @@ pub async fn run_submit_tui( let state = &mut app.result_page_state; let mut result_page = ResultPage::new(result_text.clone(), state); + let mut last_draw = std::time::Instant::now(); while !state.ack { - terminal - .draw(|frame: &mut Frame| { - frame.render_stateful_widget(&result_page, frame.size(), state); - }) - .unwrap(); + // Force redraw every 100ms for smooth animation + let now = std::time::Instant::now(); + if now.duration_since(last_draw) >= std::time::Duration::from_millis(100) { + terminal + .draw(|frame: &mut Frame| { + frame.render_stateful_widget(&result_page, frame.size(), state); + }) + .unwrap(); + last_draw = now; + } result_page.handle_key_event(state); } diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 4e940ad..d44304a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -48,38 +48,87 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir )) } -pub fn get_ascii_art() -> String { - let art = r#" - _ __ _ ______ _ - | | / / | | | ___ \ | | - | |/ / ___ _ __ _ __ ___ | | | |_/ / ___ _| |_ - | \ / _ \ '__| '_ \ / _ \| | | ___ \ / _ \| | __| - | |\ \ __/ | | | | | __/| | | |_/ /| (_) | | |_ - \_| \_/\___|_| |_| |_|\___|_/ \____/ \___/|_|\__| - - POPCORN CLI - GPU MODE - - ┌───────────────────────────────────────┐ - │ ┌─────┐ ┌─────┐ ┌─────┐ │ - │ │ooOoo│ │ooOoo│ │ooOoo│ │▒ - │ │oOOOo│ │oOOOo│ │oOOOo│ │▒ - │ │ooOoo│ │ooOoo│ │ooOoo│ ┌────────┐ │▒ - │ └─────┘ └─────┘ └─────┘ │████████│ │▒ - │ │████████│ │▒ - │ ┌────────────────────────┐ │████████│ │▒ - │ │ │ │████████│ │▒ - │ │ POPCORN GPU COMPUTE │ └────────┘ │▒ - │ │ │ │▒ - │ └────────────────────────┘ │▒ - │ │▒ - └───────────────────────────────────────┘▒ - ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - "#; +pub fn get_ascii_art_frame(frame: u16) -> String { + let frame = frame % 3; + match frame { + 0 => r#" + ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ + ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ + ▐▛▚▖ ▐▛▀▀▘▐▛▀▚▖▐▌ ▝▜▌▐▛▀▀▘▐▌ ▐▛▀▚▖▐▌ ▐▌ █ + ▐▌ ▐▌▐▙▄▄▖▐▌ ▐▌▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖▐▙▄▞▘▝▚▄▞▘ █ + + POPCORN CLI - GPU MODE + + ┌────────────────────────────────────────────┐ + │ ╔══════════════════════════════════╗ ϟ │ + │ ║ ▄▄ Graphics Processing Unit ▄▄║ ║ │▒ + │ ║ ██████ 80GB HBM3 MEMORY █║ ║ │▒ + │ ║ ▀▀▀▀▀▀ 700W TDP █║ ║ │▒ + │ ╚══════════════════════════════════╝ │▒ + │ ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ │▒ + │ │:::::││:::::││:::::││:::::││:::::│ │▒ + │ └─────┘└─────┘└─────┘└─────┘└─────┘ │▒ + │ ┌──────────────────────────────────┐ │▒ + │ │ discord.com/invite/gpumode │ │▒ + │ │ ═══╧═══╧═══╧═══╧═══╧═══╧═══ │ │▒ + │ └──────────────────────────────────┘ │▒ + └────────────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + 1 => r#" + ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ + ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ + ▐▛▚▖ ▐▛▀▀▘▐▛▀▚▖▐▌ ▝▜▌▐▛▀▀▘▐▌ ▐▛▀▚▖▐▌ ▐▌ █ + ▐▌ ▐▌▐▙▄▄▖▐▌ ▐▌▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖▐▙▄▞▘▝▚▄▞▘ █ + + POPCORN CLI - GPU MODE + + ┌────────────────────────────────────────────┐ + │ ╔══════════════════════════════════╗ ϟϟ │ + │ ║ ▄▄ Graphics Processing Unit ▄▄║ ║ │▒ + │ ║ ██████ 80GB HBM3 MEMORY ███║ ║ │▒ + │ ║ ▀▀▀▀▀▀ 700W TDP ███║ ║ │▒ + │ ╚══════════════════════════════════╝ │▒ + │ ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ │▒ + │ │:::::││:::::││:::::││:::::││:::::│ │▒ + │ └─────┘└─────┘└─────┘└─────┘└─────┘ │▒ + │ ┌──────────────────────────────────┐ │▒ + │ │ discord.com/invite/gpumode │ │▒ + │ │ ═══╧═══╧═══╧═══╧═══╧═══╧═══ │ │▒ + │ └──────────────────────────────────┘ │▒ + └────────────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + _ => r#" + ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ + ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ + ▐▛▚▖ ▐▛▀▀▘▐▛▀▚▖▐▌ ▝▜▌▐▛▀▀▘▐▌ ▐▛▀▚▖▐▌ ▐▌ █ + ▐▌ ▐▌▐▙▄▄▖▐▌ ▐▌▐▌ ▐▌▐▙▄▄▖▐▙▄▄▖▐▙▄▞▘▝▚▄▞▘ █ - art.to_string() + POPCORN CLI - GPU MODE + + ┌────────────────────────────────────────────┐ + │ ╔══════════════════════════════════╗ ϟϟϟ │ + │ ║ ▄▄ Graphics Processing Unit ▄▄║ ║ │▒ + │ ║ ██████ 80GB HBM3 MEMORY █████║ ║ │▒ + │ ║ ▀▀▀▀▀▀ 700W TDP █████║ ║ │▒ + │ ╚══════════════════════════════════╝ │▒ + │ ┌─────┐┌─────┐┌─────┐┌─────┐┌─────┐ │▒ + │ │:::::││:::::││:::::││:::::││:::::│ │▒ + │ └─────┘└─────┘└─────┘└─────┘└─────┘ │▒ + │ ┌──────────────────────────────────┐ │▒ + │ │ discord.com/invite/gpumode │ │▒ + │ │ ═══╧═══╧═══╧═══╧═══╧═══╧═══ │ │▒ + │ └──────────────────────────────────┘ │▒ + └────────────────────────────────────────────┘▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string() + } } +pub fn get_ascii_art() -> String { + get_ascii_art_frame(0) +} pub fn display_ascii_art() { let art = get_ascii_art(); diff --git a/src/views/loading_page.rs b/src/views/loading_page.rs index a01512f..9bf0d9d 100644 --- a/src/views/loading_page.rs +++ b/src/views/loading_page.rs @@ -19,7 +19,17 @@ pub struct LoadingPage { footer_area: Rect, } -const GAUGE_COLOR: Color = ratatui::style::palette::tailwind::RED.c800; +fn get_gradient_color(progress: f64) -> Color { + // Convert progress from 0-100 to 0-1 + let t = progress / 100.0; + + // Start with red (255, 0, 0) and end with green (0, 255, 0) + let r = ((1.0 - t) * 255.0) as u8; + let g = (t * 255.0) as u8; + let b = 0; + + Color::Rgb(r, g, b) +} impl StatefulWidget for &LoadingPage { type State = LoadingPageState; @@ -40,7 +50,7 @@ fn render_gauge(area: Rect, buf: &mut Buffer, state: &mut LoadingPageState) { let blk = Block::default().padding(Padding::horizontal(20)); Gauge::default() .block(blk) - .gauge_style(GAUGE_COLOR) + .gauge_style(get_gradient_color(state.progress_bar)) .ratio(state.progress_bar / 100.0) .render(area, buf); } diff --git a/src/views/result_page.rs b/src/views/result_page.rs index a749416..267add1 100644 --- a/src/views/result_page.rs +++ b/src/views/result_page.rs @@ -15,6 +15,7 @@ pub struct ResultPageState { pub horizontal_scroll: u16, pub horizontal_scroll_state: ScrollbarState, pub ack: bool, + pub animation_frame: u16, } #[derive(Default, Debug)] @@ -37,18 +38,21 @@ impl ResultPage { .content_length(num_lines); state.horizontal_scroll_state = state.horizontal_scroll_state.content_length(max_width); + state.animation_frame = 0; Self { result_text: Paragraph::new(result_text), } } - fn render_left(&self, buf: &mut Buffer, left: Rect) { + fn render_left(&self, buf: &mut Buffer, left: Rect, state: &mut ResultPageState) { let left_block = Block::bordered() .border_type(BorderType::Plain) - .border_style(Style::default().fg(Color::Yellow)); + .border_style(Style::default().fg(Color::Rgb(255, 165, 0))) + .title("GPU MODE") + .title_alignment(Alignment::Center); - let left_text = Paragraph::new(utils::get_ascii_art()); + let left_text = Paragraph::new(utils::get_ascii_art_frame(state.animation_frame / 5)); left_text.block(left_block).render(left, buf); } @@ -56,10 +60,11 @@ impl ResultPage { fn render_right(&self, buf: &mut Buffer, right: Rect, state: &mut ResultPageState) { let right_block = Block::bordered() .border_type(BorderType::Plain) - .border_style(Style::default().fg(Color::Yellow)) + .border_style(Style::default().fg(Color::Rgb(255, 165, 0))) + .title_alignment(Alignment::Center) + .title("Submission Results") .title_bottom("Press q to quit...") - .title_style(Style::default().fg(Color::Red)) - .title_alignment(Alignment::Right); + .title_style(Style::default().fg(Color::Magenta)); let result_text = self .result_text @@ -70,8 +75,9 @@ impl ResultPage { } pub fn handle_key_event(&mut self, state: &mut ResultPageState) { - if event::poll(std::time::Duration::from_millis(50)).unwrap() { - if let Event::Key(key) = event::read().unwrap() { + // Use a non-blocking poll + if let Ok(true) = event::poll(std::time::Duration::from_millis(0)) { + if let Ok(Event::Key(key)) = event::read() { if key.kind != KeyEventKind::Press { return; } @@ -115,10 +121,13 @@ impl StatefulWidget for &ResultPage { type State = ResultPageState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut ResultPageState) { + // Increment animation frame on every render + state.animation_frame = state.animation_frame.wrapping_add(1); + let layout = Layout::horizontal([Constraint::Percentage(45), Constraint::Percentage(55)]); let [left, right] = layout.areas(area); - self.render_left(buf, left); + self.render_left(buf, left, state); self.render_right(buf, right, state); let vertical_scrollbar = From 63f29438be59afa3862b899fa4a04110d51ce025 Mon Sep 17 00:00:00 2001 From: az Date: Tue, 10 Jun 2025 11:18:06 -0400 Subject: [PATCH 060/111] Update README.md with Teaser Image --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 02038f3..f3ba659 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Popcorn CLI A command-line interface tool for submitting solutions to the [Popcorn Discord Bot](https://github.com/gpu-mode/discord-cluster-manager) +Screenshot 2025-06-10 at 11 17 45 AM Tested on linux and mac but should just work on Windows as well. From 8e8c6b1271821901f8bad681147e15c845e1795f Mon Sep 17 00:00:00 2001 From: Matej Sirovatka <54212263+S1ro1@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:20:20 -0700 Subject: [PATCH 061/111] Feat: quick path has correct ui (#19) --- src/cmd/submit.rs | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 938ff81..83411be 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -547,38 +547,6 @@ pub async fn run_submit_tui( )); } - // Perform direct submission if all required parameters are provided via CLI - if let (Some(gpu_flag), Some(leaderboard_flag), Some(mode_flag)) = (&gpu, &leaderboard, &mode) { - // Read file content - let mut file = File::open(&file_to_submit)?; - let mut file_content = String::new(); - file.read_to_string(&mut file_content)?; - - // Create client and submit directly - let client = service::create_client(Some(cli_id))?; - println!("Submitting solution directly with:"); - println!(" File: {}", file_to_submit); - println!(" Leaderboard: {}", leaderboard_flag); - println!(" GPU: {}", gpu_flag); - println!(" Mode: {}", mode_flag); - - // Make the submission - let result = service::submit_solution( - &client, - &file_to_submit, - &file_content, - leaderboard_flag, - gpu_flag, - mode_flag, - ) - .await?; - - println!("Submission result: {}", result); - - utils::display_ascii_art(); - return Ok(()); - } - let mut app = App::new(&file_to_submit, cli_id); // Override directives with CLI flags if provided From 31ada4dddd4344d9e103aa8a7a5c4c090eb4be50 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 11 Jun 2025 22:13:22 -0700 Subject: [PATCH 062/111] Create load_inline_sample.py --- docs/AMD_workshop/load_inline_sample.py | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/AMD_workshop/load_inline_sample.py diff --git a/docs/AMD_workshop/load_inline_sample.py b/docs/AMD_workshop/load_inline_sample.py new file mode 100644 index 0000000..aba6d46 --- /dev/null +++ b/docs/AMD_workshop/load_inline_sample.py @@ -0,0 +1,27 @@ +# Credit https://github.com/Snektron/gpumode-amd-fp8-mm/blob/main/fp8_gemm.py +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 +from task import input_t, output_t +import torch +from torch.utils.cpp_extension import load_inline +import time +import os +import sys + +if "PYTORCH_ROCM_ARCH" not in os.environ: + os.environ["PYTORCH_ROCM_ARCH"] = "gfx942:xnack-" + +TESTING = os.environ.get("GPUMODE_TESTING", None) + +with open('solution.hip', 'r') as f: + kernel_cpp = f.read() + +hip_module = load_inline( + name="fp8", + cpp_sources="", + cuda_sources=kernel_cpp, + with_cuda=True, + verbose=False, + extra_cuda_cflags=(["-save-temps"] if TESTING is not None else []) + ["-std=c++20", "-Werror"], + build_directory="/workspace/build/" if TESTING == "vscode" else "/gpumode/amd/fp8/build/" if TESTING is not None else None, + **({'no_implicit_headers': True} if TESTING != "vscode" else {}), From cca33fd68100b8c3bf6a020c8d96ae889cb0b39c Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Thu, 12 Jun 2025 13:21:36 -0700 Subject: [PATCH 063/111] Feat: ctrl + c result page --- src/views/result_page.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/views/result_page.rs b/src/views/result_page.rs index 267add1..14ad4ad 100644 --- a/src/views/result_page.rs +++ b/src/views/result_page.rs @@ -1,5 +1,5 @@ use crate::utils; -use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::{ layout::{Alignment, Constraint, Layout, Margin, Rect}, prelude::Buffer, @@ -84,6 +84,9 @@ impl ResultPage { if key.code == KeyCode::Char('q') { state.ack = true; } + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + state.ack = true; + } match key.code { KeyCode::Char('j') | KeyCode::Down => { From 4ac8ffd8766bd9a96723ac0bd745695725182140 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 28 Jun 2025 15:41:14 +0200 Subject: [PATCH 064/111] Fix: correct task field --- src/service/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/service/mod.rs b/src/service/mod.rs index 8c9a971..2636afc 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -58,7 +58,7 @@ pub async fn fetch_leaderboards(client: &Client) -> Result> let name = lb["name"] .as_str() .ok_or_else(|| anyhow!("Invalid JSON structure"))?; - let description = task["description"] + let description = lb["description"] .as_str() .ok_or_else(|| anyhow!("Invalid JSON structure"))?; From 5a296e232723f8decf8ada029f28a7d56d06e823 Mon Sep 17 00:00:00 2001 From: Thien Tran Date: Mon, 15 Sep 2025 20:13:20 +0800 Subject: [PATCH 065/111] add output flag --- src/cmd/mod.rs | 26 +++++++++++++++++++++----- src/cmd/submit.rs | 12 ++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 617bfad..2ff54f8 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -51,10 +51,14 @@ pub struct Cli { /// Optional: Directly specify the leaderboard (e.g., "fp8") #[arg(long)] pub leaderboard: Option, - + /// Optional: Specify submission mode (test, benchmark, leaderboard, profile) #[arg(long)] pub mode: Option, + + // Optional: Specify output file + #[arg(short, long)] + pub output: Option, } #[derive(Subcommand, Debug)] @@ -88,6 +92,10 @@ enum Commands { /// Optional: Specify submission mode (test, benchmark, leaderboard, profile) #[arg(long)] mode: Option, + + // Optional: Specify output file + #[arg(short, long)] + output: Option, }, } @@ -107,7 +115,13 @@ pub async fn execute(cli: Cli) -> Result<()> { }; auth::run_auth(false, provider_str).await } - Some(Commands::Submit { filepath, gpu, leaderboard, mode }) => { + Some(Commands::Submit { + filepath, + gpu, + leaderboard, + mode, + output, + }) => { let config = load_config()?; let cli_id = config.cli_id.ok_or_else(|| { anyhow!( @@ -116,7 +130,7 @@ pub async fn execute(cli: Cli) -> Result<()> { .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) ) })?; - + // Use filepath from Submit command first, fallback to top-level filepath let final_filepath = filepath.or(cli.filepath); submit::run_submit_tui( @@ -125,6 +139,7 @@ pub async fn execute(cli: Cli) -> Result<()> { leaderboard, // From Submit command mode, // From Submit command cli_id, + output, // From Submit command ) .await } @@ -136,7 +151,7 @@ pub async fn execute(cli: Cli) -> Result<()> { popcorn-cli submit [--gpu GPU] [--leaderboard LEADERBOARD] [--mode MODE] FILEPATH" )); } - + // Handle the case where only a filepath is provided (for backward compatibility) if let Some(top_level_filepath) = cli.filepath { let config = load_config()?; @@ -147,7 +162,7 @@ pub async fn execute(cli: Cli) -> Result<()> { .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) ) })?; - + // Run TUI with only filepath, no other options submit::run_submit_tui( Some(top_level_filepath), @@ -155,6 +170,7 @@ pub async fn execute(cli: Cli) -> Result<()> { None, // No leaderboard option None, // No mode option cli_id, + None, // No output option ) .await } else { diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 83411be..4b8f04c 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -523,6 +523,7 @@ pub async fn run_submit_tui( leaderboard: Option, mode: Option, cli_id: String, + output: Option, ) -> Result<()> { let file_to_submit = match filepath { Some(fp) => fp, @@ -630,6 +631,17 @@ pub async fn run_submit_tui( result_text = content.to_string(); } + // write to file if output is specified + if let Some(output_path) = output { + // create parent directories if they don't exist + if let Some(parent) = Path::new(&output_path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("Failed to create directories for {}: {}", output_path, e))?; + } + std::fs::write(&output_path, &result_text) + .map_err(|e| anyhow!("Failed to write result to file {}: {}", output_path, e))?; + } + let state = &mut app.result_page_state; let mut result_page = ResultPage::new(result_text.clone(), state); From f9cbd7d7b670819861de1f02e4fe3b62503499c4 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Mon, 10 Nov 2025 19:17:49 +0100 Subject: [PATCH 066/111] Fix: add better profile message --- src/cmd/submit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 4b8f04c..9c593ee 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -67,7 +67,7 @@ impl App { ), SubmissionModeItem::new( "Profile".to_string(), - "Work in progress...".to_string(), + "Profile is currently supported only via Discord. We'll add this feature to the CLI soon.".to_string(), "profile".to_string(), ), ]; From 35f42ff897d59d8f2be68b273795761d87fc5264 Mon Sep 17 00:00:00 2001 From: Ryan Rong <69167945+Ryan-Rong-24@users.noreply.github.com> Date: Tue, 2 Dec 2025 13:30:22 -0800 Subject: [PATCH 067/111] feat: add --no-tui flag for plain text output Add option to skip the TUI interface and print results directly to stdout. This is useful for CI/CD pipelines and scripting scenarios. --- src/cmd/mod.rs | 40 +++++++++++---- src/cmd/submit.rs | 119 ++++++++++++++++++++++++++++++++++++++++++++- src/service/mod.rs | 68 +++++++++++++++++++++++--- 3 files changed, 208 insertions(+), 19 deletions(-) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 2ff54f8..9de3637 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -59,6 +59,10 @@ pub struct Cli { // Optional: Specify output file #[arg(short, long)] pub output: Option, + + /// Skip the TUI and print results directly to stdout + #[arg(long)] + pub no_tui: bool, } #[derive(Subcommand, Debug)] @@ -96,6 +100,10 @@ enum Commands { // Optional: Specify output file #[arg(short, long)] output: Option, + + /// Skip the TUI and print results directly to stdout + #[arg(long)] + no_tui: bool, }, } @@ -121,6 +129,7 @@ pub async fn execute(cli: Cli) -> Result<()> { leaderboard, mode, output, + no_tui, }) => { let config = load_config()?; let cli_id = config.cli_id.ok_or_else(|| { @@ -133,15 +142,28 @@ pub async fn execute(cli: Cli) -> Result<()> { // Use filepath from Submit command first, fallback to top-level filepath let final_filepath = filepath.or(cli.filepath); - submit::run_submit_tui( - final_filepath, // Resolved filepath - gpu, // From Submit command - leaderboard, // From Submit command - mode, // From Submit command - cli_id, - output, // From Submit command - ) - .await + + if no_tui { + submit::run_submit_plain( + final_filepath, // Resolved filepath + gpu, // From Submit command + leaderboard, // From Submit command + mode, // From Submit command + cli_id, + output, // From Submit command + ) + .await + } else { + submit::run_submit_tui( + final_filepath, // Resolved filepath + gpu, // From Submit command + leaderboard, // From Submit command + mode, // From Submit command + cli_id, + output, // From Submit command + ) + .await + } } None => { // Check if any of the submission-related flags were used at the top level diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 9c593ee..87face5 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -301,8 +301,16 @@ impl App { file.read_to_string(&mut file_content)?; self.submission_task = Some(tokio::spawn(async move { - service::submit_solution(&client, &filepath, &file_content, &leaderboard, &gpu, &mode) - .await + service::submit_solution( + &client, + &filepath, + &file_content, + &leaderboard, + &gpu, + &mode, + None, + ) + .await })); Ok(()) } @@ -672,3 +680,110 @@ pub async fn run_submit_tui( Ok(()) } + +pub async fn run_submit_plain( + filepath: Option, + gpu: Option, + leaderboard: Option, + mode: Option, + cli_id: String, + output: Option, +) -> Result<()> { + let file_to_submit = match filepath { + Some(fp) => fp, + None => { + return Err(anyhow!("File path is required when using --no-tui")); + } + }; + + if !Path::new(&file_to_submit).exists() { + return Err(anyhow!("File not found: {}", file_to_submit)); + } + + let (directives, has_multiple_gpus) = utils::get_popcorn_directives(&file_to_submit)?; + + if has_multiple_gpus { + return Err(anyhow!( + "Multiple GPUs are not supported yet. Please specify only one GPU." + )); + } + + // Determine final values + let final_gpu = gpu + .or_else(|| { + if !directives.gpus.is_empty() { + Some(directives.gpus[0].clone()) + } else { + None + } + }) + .ok_or_else(|| anyhow!("GPU not specified. Use --gpu flag or add GPU directive to file"))?; + + let final_leaderboard = leaderboard + .or_else(|| { + if !directives.leaderboard_name.is_empty() { + Some(directives.leaderboard_name.clone()) + } else { + None + } + }) + .ok_or_else(|| { + anyhow!("Leaderboard not specified. Use --leaderboard flag or add leaderboard directive to file") + })?; + + let final_mode = mode.ok_or_else(|| { + anyhow!("Submission mode not specified. Use --mode flag (test, benchmark, leaderboard, profile)") + })?; + + // Read file content + let mut file = File::open(&file_to_submit)?; + let mut file_content = String::new(); + file.read_to_string(&mut file_content)?; + + eprintln!("Submitting to leaderboard: {}", final_leaderboard); + eprintln!("GPU: {}", final_gpu); + eprintln!("Mode: {}", final_mode); + eprintln!("File: {}", file_to_submit); + eprintln!("\nWaiting for results..."); + + // Create client and submit + let client = service::create_client(Some(cli_id))?; + let result = service::submit_solution( + &client, + &file_to_submit, + &file_content, + &final_leaderboard, + &final_gpu, + &final_mode, + Some(Box::new(|msg| { + eprintln!("{}", msg); + })), + ) + .await?; + + // Clean up the result text + let trimmed = result.trim(); + let content = if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 { + &trimmed[1..trimmed.len() - 1] + } else { + trimmed + }; + + let content = content.replace("\\n", "\n"); + + // Write to file if output is specified + if let Some(output_path) = output { + if let Some(parent) = Path::new(&output_path).parent() { + std::fs::create_dir_all(parent) + .map_err(|e| anyhow!("Failed to create directories for {}: {}", output_path, e))?; + } + std::fs::write(&output_path, &content) + .map_err(|e| anyhow!("Failed to write result to file {}: {}", output_path, e))?; + eprintln!("\nResults written to: {}", output_path); + } + + // Print to stdout + println!("\n{}", content); + + Ok(()) +} diff --git a/src/service/mod.rs b/src/service/mod.rs index 2636afc..03cc006 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -101,6 +101,7 @@ pub async fn submit_solution>( leaderboard: &str, gpu: &str, submission_mode: &str, + on_log: Option>, ) -> Result { let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; @@ -172,11 +173,62 @@ pub async fn submit_solution>( if let (Some(event), Some(data)) = (event_type, data_json) { match event { - "status" => (), + "status" => { + if let Some(ref cb) = on_log { + // Try to parse as JSON and extract "message" or just return raw data + if let Ok(val) = serde_json::from_str::(data) { + if let Some(msg) = val.get("message").and_then(|m| m.as_str()) { + cb(msg.to_string()); + } else { + cb(data.to_string()); + } + } else { + cb(data.to_string()); + } + } + } "result" => { let result_val: Value = serde_json::from_str(data)?; - let reports = result_val.get("reports").unwrap(); - return Ok(reports.to_string()); + + if let Some(ref cb) = on_log { + // Handle "results" array + if let Some(results_array) = result_val.get("results").and_then(|v| v.as_array()) { + for (i, result_item) in results_array.iter().enumerate() { + let mode_key = submission_mode.to_lowercase(); + + if let Some(run_obj) = result_item.get("runs") + .and_then(|r| r.get(&mode_key)) + .and_then(|t| t.get("run")) + { + if let Some(stdout) = run_obj.get("stdout").and_then(|s| s.as_str()) { + if !stdout.is_empty() { + cb(format!("STDOUT (Run {}):\n{}", i + 1, stdout)); + } + } + // Also check stderr + if let Some(stderr) = run_obj.get("stderr").and_then(|s| s.as_str()) { + if !stderr.is_empty() { + cb(format!("STDERR (Run {}):\n{}", i + 1, stderr)); + } + } + } + } + } else { + // Fallback for single object or different structure + if let Some(stdout) = result_val.get("stdout").and_then(|s| s.as_str()) { + if !stdout.is_empty() { + cb(format!("STDOUT:\n{}", stdout)); + } + } + } + } + + if let Some(reports) = result_val.get("reports") { + return Ok(reports.to_string()); + } else { + // If no reports, return the whole result as a string + return Ok(serde_json::to_string_pretty(&result_val)?); + } } "error" => { let error_val: Value = serde_json::from_str(data)?; @@ -198,11 +250,11 @@ pub async fn submit_solution>( return Err(anyhow!(error_msg)); } _ => { - stderr - .write_all( - format!("Ignoring unknown SSE event: {}\n", event).as_bytes(), - ) - .await?; + let msg = format!("Ignoring unknown SSE event: {}\n", event); + if let Some(ref cb) = on_log { + cb(msg.clone()); + } + stderr.write_all(msg.as_bytes()).await?; stderr.flush().await?; } } From 1f107000be197a219883896521f25837badf9078 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Mon, 12 Jan 2026 19:52:45 -0800 Subject: [PATCH 068/111] Profiling support (#27) --- .gitignore | 2 + Cargo.lock | 116 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 + README.md | 4 ++ docs/profiling.md | 65 ++++++++++++++++++++++ src/cmd/submit.rs | 2 +- src/service/mod.rs | 134 ++++++++++++++++++++++++++++++++++++++++----- 7 files changed, 309 insertions(+), 16 deletions(-) create mode 100644 docs/profiling.md diff --git a/.gitignore b/.gitignore index 556b613..300478e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ submission.* target/ scratch.md +*claude +*.zip diff --git a/Cargo.lock b/Cargo.lock index 29e4754..4dcb102 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.6.18" @@ -187,6 +196,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.5.36" @@ -609,6 +631,30 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -977,6 +1023,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "objc" version = "0.2.7" @@ -1109,8 +1164,10 @@ name = "popcorn-cli" version = "0.1.0" dependencies = [ "anyhow", + "base64 0.22.1", "base64-url", "bytes", + "chrono", "clap", "crossterm", "ctrlc", @@ -1912,6 +1969,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index c3303d0..d212bd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ dirs = "5.0" serde_yaml = "0.9" webbrowser = "0.8" base64-url = "3.0.0" +base64 = "0.22" +chrono = "0.4" urlencoding = "2.1.3" bytes = "1.10.1" futures-util = "0.3.31" diff --git a/README.md b/README.md index f3ba659..287a8c9 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ A command-line interface tool for submitting solutions to the [Popcorn Discord B Tested on linux and mac but should just work on Windows as well. +## New: Nsight Compute Profiling + +Profile your kernels with `--mode profile` and get detailed metrics. Currently only available for the NVFP4 Blackwell competition (Modal, which we use for other competitions, does not support NCU). See [docs/profiling.md](docs/profiling.md) for details. + ## Installation ### Option 1: Using pre-built binaries (Recommended) diff --git a/docs/profiling.md b/docs/profiling.md new file mode 100644 index 0000000..5aba6d6 --- /dev/null +++ b/docs/profiling.md @@ -0,0 +1,65 @@ +# Nsight Compute Profiling + +Profile your kernels directly from the CLI and get detailed Nsight Compute metrics. This is particularly useful for the NVIDIA NVFP4 Blackwell competition where you need to optimize tensor core utilization. + +**Note:** Profiling is currently only available for the NVFP4 Blackwell competition. Modal, which we use for other competitions, does not support NCU. + +## Quick Start + +```bash +popcorn-cli submit submission.py --leaderboard nvfp4_dual_gemm --gpu NVIDIA --mode profile --no-tui +``` + +## Expected Output + +The profiler returns three key metric tables for each benchmark: + +**GPU Throughput** - Overall utilization: +``` +Metric Name Metric Unit Metric Value +---------------- ----------- ------------ +Memory [%] % 32.48 +Compute (SM) [%] % 13.23 +``` + +**Pipe Utilization** - Which pipelines are active: +``` +Metric Name Metric Unit Metric Value +-------------------- ----------- ------------ +TC % 16.67 +TMEM (Tensor Memory) % 15.27 +Tensor (FP) % 12.58 +ALU % 2.38 +TMA % 0.29 +``` + +**Warp State** - Where your warps are stalling: +``` +Metric Name Metric Unit Metric Value +------------------------ ----------- ------------ +Stall Long Scoreboard inst 18.31 +Stall Wait inst 1.88 +Stall Short Scoreboard inst 1.23 +Selected inst 1.00 +Stall Barrier inst 0.75 +``` + +## Trace Files + +After profiling, a zip file is saved to your current directory: +``` +profile_20260113_031052_run0.zip +``` + +This contains a `.ncu-rep` file (the full Nsight Compute report): +``` +$ unzip -l profile_20260113_031052_run0.zip + Length Date Time Name +--------- ---------- ----- ---- + 2178383 01-13-2026 03:10 profile.ncu-rep +``` + +You can open this file in the Nsight Compute GUI for detailed analysis: +```bash +ncu-ui profile.ncu-rep +``` diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 87face5..325b5f1 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -67,7 +67,7 @@ impl App { ), SubmissionModeItem::new( "Profile".to_string(), - "Profile is currently supported only via Discord. We'll add this feature to the CLI soon.".to_string(), + "Profile the solution using Nsight Compute (NVIDIA) or rocPROF (AMD). Downloads profiling data to current directory.".to_string(), "profile".to_string(), ), ]; diff --git a/src/service/mod.rs b/src/service/mod.rs index 03cc006..264b428 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1,4 +1,6 @@ use anyhow::{anyhow, Result}; +use base64::Engine; +use chrono::Utc; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::multipart::{Form, Part}; use reqwest::Client; @@ -189,26 +191,40 @@ pub async fn submit_solution>( } "result" => { let result_val: Value = serde_json::from_str(data)?; - + if let Some(ref cb) = on_log { // Handle "results" array if let Some(results_array) = result_val.get("results").and_then(|v| v.as_array()) { - for (i, result_item) in results_array.iter().enumerate() { - let mode_key = submission_mode.to_lowercase(); - - if let Some(run_obj) = result_item.get("runs") - .and_then(|r| r.get(&mode_key)) - .and_then(|t| t.get("run")) - { - if let Some(stdout) = run_obj.get("stdout").and_then(|s| s.as_str()) { - if !stdout.is_empty() { - cb(format!("STDOUT (Run {}):\n{}", i + 1, stdout)); + let mode_key = submission_mode.to_lowercase(); + + // Special handling for profile mode + if mode_key == "profile" { + for (i, result_item) in results_array.iter().enumerate() { + if let Some(runs) = result_item.get("runs").and_then(|r| r.as_object()) { + for (key, run_data) in runs.iter() { + if key.starts_with("profile") { + handle_profile_result(cb, run_data, i); + } } } - // Also check stderr - if let Some(stderr) = run_obj.get("stderr").and_then(|s| s.as_str()) { - if !stderr.is_empty() { - cb(format!("STDERR (Run {}):\n{}", i + 1, stderr)); + } + } else { + // Existing handling for non-profile modes + for (i, result_item) in results_array.iter().enumerate() { + if let Some(run_obj) = result_item.get("runs") + .and_then(|r| r.get(&mode_key)) + .and_then(|t| t.get("run")) + { + if let Some(stdout) = run_obj.get("stdout").and_then(|s| s.as_str()) { + if !stdout.is_empty() { + cb(format!("STDOUT (Run {}):\n{}", i + 1, stdout)); + } + } + // Also check stderr + if let Some(stderr) = run_obj.get("stderr").and_then(|s| s.as_str()) { + if !stderr.is_empty() { + cb(format!("STDERR (Run {}):\n{}", i + 1, stderr)); + } } } } @@ -273,3 +289,91 @@ pub async fn submit_solution>( Ok(pretty_result) } } + +/// Handle profile mode results by decoding and displaying profile data, +/// and saving trace files to the current directory. +fn handle_profile_result( + cb: &Box, + run_data: &Value, + run_idx: usize, +) { + // 1. Get profiler type and display it + if let Some(profile) = run_data.get("profile") { + let profiler = profile + .get("profiler") + .and_then(|p| p.as_str()) + .unwrap_or("Unknown"); + cb(format!("\n=== Profiler: {} ===", profiler)); + + // 2. Decode and display profile report from run.result + if let Some(run) = run_data.get("run") { + // Display stdout/stderr if present + if let Some(stdout) = run.get("stdout").and_then(|s| s.as_str()) { + if !stdout.is_empty() { + cb(format!("STDOUT:\n{}", stdout)); + } + } + if let Some(stderr) = run.get("stderr").and_then(|s| s.as_str()) { + if !stderr.is_empty() { + cb(format!("STDERR:\n{}", stderr)); + } + } + + // Extract and decode profile report from result + if let Some(result) = run.get("result").and_then(|r| r.as_object()) { + let bench_count = result + .get("benchmark-count") + .and_then(|c| c.as_i64()) + .unwrap_or(0); + + for i in 0..bench_count { + // Get benchmark spec + let spec_key = format!("benchmark.{}.spec", i); + let spec = result + .get(&spec_key) + .and_then(|s| s.as_str()) + .unwrap_or("unknown"); + cb(format!("\nBenchmark: {}", spec)); + + // Decode and display the profile report + let report_key = format!("benchmark.{}.report", i); + if let Some(encoded_report) = result.get(&report_key).and_then(|r| r.as_str()) { + match base64::engine::general_purpose::STANDARD.decode(encoded_report) { + Ok(decoded) => { + if let Ok(report_text) = String::from_utf8(decoded) { + cb(format!("\n{}", report_text)); + } + } + Err(e) => cb(format!("Failed to decode profile report: {}", e)), + } + } + } + } + } + + // 3. Save trace file with unique timestamp + if let Some(trace_b64) = profile.get("trace").and_then(|t| t.as_str()) { + if !trace_b64.is_empty() { + match base64::engine::general_purpose::STANDARD.decode(trace_b64) { + Ok(trace_data) => { + // Generate unique filename with timestamp and run index + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let filename = format!("profile_{}_run{}.zip", timestamp, run_idx); + match std::fs::write(&filename, &trace_data) { + Ok(_) => cb(format!("\nSaved profile trace to: {}", filename)), + Err(e) => cb(format!("Failed to save trace file: {}", e)), + } + } + Err(e) => cb(format!("Failed to decode trace data: {}", e)), + } + } + } + + // 4. Show download URL if available + if let Some(url) = profile.get("download_url").and_then(|u| u.as_str()) { + if !url.is_empty() { + cb(format!("Download full profile: {}", url)); + } + } + } +} From c576fa56f6791ab5e2d8ea90c706055c3dc7bcbb Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 21 Jan 2026 21:48:23 -0800 Subject: [PATCH 069/111] Revise README with new links and sections Updated project description and added sections for reference kernels and staying updated. --- README.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 287a8c9..ce4d459 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Popcorn CLI -A command-line interface tool for submitting solutions to the [Popcorn Discord Bot](https://github.com/gpu-mode/discord-cluster-manager) +A command-line interface tool for submitting solutions to the [gpumode.com](https://gpumode.com) Screenshot 2025-06-10 at 11 17 45 AM Tested on linux and mac but should just work on Windows as well. @@ -40,9 +40,22 @@ wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/mai popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submission.py ``` -## Discover new problems +## Reference Kernels -The CLI supports (almost) everything Discord does, so you can also discovery which leaderboards are available. To make discovery more pleasant we also offer a TUI experience. +All reference kernels are available at [gpu-mode/reference-kernels](https://github.com/gpu-mode/reference-kernels). Each problem directory contains: +- `reference.py` - The reference implementation to beat +- `submission.py` - A sample submission you can use as a starting point +- `task.yml` - Input shapes and problem configuration + +Our entire evaluation infrastructure is open source and you can learn more [here](https://github.com/gpu-mode/kernelbot). Development happens on the [KernelBot discord](https://discord.gg/FjYsdHDv7J) + +## Stay Updated + +Interested in new kernel competitions? Join [discord.gg/gpumode](https://discord.gg/gpumode) and check out the **#announcements** channel to be notified when new challenges drop. + +## Discover Problems + +The CLI supports (almost) everything Discord does, so you can also discover which leaderboards are available. To make discovery more pleasant we also offer a TUI experience. ```bash popcorn-cli submit From 6d4752a537862acd99eedb799b6bc969c2410680 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Wed, 21 Jan 2026 21:54:28 -0800 Subject: [PATCH 070/111] update installation instructions in readme.md --- README.md | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ce4d459..20a4469 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,43 @@ Profile your kernels with `--mode profile` and get detailed metrics. Currently o ## Installation -### Option 1: Using pre-built binaries (Recommended) +### Option 1: One-Line Install (Recommended) -1. Download the latest release for your platform from the releases page +**Linux/macOS/Unix:** +```bash +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash +``` + +**Windows (PowerShell):** +```powershell +powershell -ExecutionPolicy Bypass -Command "iwr -UseBasicParsing https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.ps1 | iex" +``` + +After installation, restart your terminal (or run `source ~/.bashrc` / `source ~/.zshrc`). + +### Option 2: Manual Installation + +1. Download the binary for your OS from [releases](https://github.com/gpu-mode/popcorn-cli/releases/latest) 2. Extract the archive -3. Move the binary to a location in your PATH +3. Move the binary to a directory in your PATH +4. Make it executable (Linux/macOS): `chmod +x popcorn-cli` -### Option 2: Building from source +### Option 3: Building from source 1. Download rust `curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh` 2. `cd popcorn-cli && ./build.sh` +### Troubleshooting + +**Command not found after installation:** +- Restart your terminal +- Check if the install directory is in your PATH: + - Linux/macOS: `echo $PATH` + - Windows: `echo $env:PATH` +- Check if POPCORN_API_URL is set to https://discord-cluster-manager-1f6c4782e60a.herokuapp.com + - Linux/macOS: `echo $POPCORN_API_URL` + - Windows: `echo $env:POPCORN_API_URL` + ## Authentication Since we're effectively giving out GPUs for free we rely on either github or discord authentication to prove that you're a real human before you access our service. From 605d87254eb2f4bea4b7cbcb8f2faa980f2a7aba Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sat, 24 Jan 2026 18:32:01 -0800 Subject: [PATCH 071/111] update README' with instructions on single file native submisions --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 20a4469..bd169cd 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,12 @@ wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/mai popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submission.py ``` +## Submission Format + +Submissions are always a single Python file. If you want to submit native CUDA code, you can use PyTorch's `load_inline` feature (which uses nvcc) or the more experimental [`compile_kernel` API](https://x.com/gaunernst/status/2015242181049745607) for fast compilation. See [this example](https://github.com/gpu-mode/reference-kernels/blob/main/problems/pmpp_v2/vectoradd_py/solutions/correct/submission_cuda_inline.py) for reference. + +For syntax highlighting of both C++ and Python in your IDE, you can use the [PyTorch Load Inline Highlighter](https://marketplace.visualstudio.com/items?itemName=msaroufim.pytorch-load-inline-highlighter) VS Code extension. + ## Reference Kernels All reference kernels are available at [gpu-mode/reference-kernels](https://github.com/gpu-mode/reference-kernels). Each problem directory contains: From a27024185cdcbd2123b51fbde010282a8d55cb48 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Tue, 27 Jan 2026 21:10:02 -0800 Subject: [PATCH 072/111] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index bd169cd..472d99c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/mai popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submission.py ``` +We regularly run competitions with clear due dates but for beginners we will always keep open the PMPP_v2 problem set https://github.com/gpu-mode/reference-kernels/tree/main/problems/pmpp_v2 + ## Submission Format Submissions are always a single Python file. If you want to submit native CUDA code, you can use PyTorch's `load_inline` feature (which uses nvcc) or the more experimental [`compile_kernel` API](https://x.com/gaunernst/status/2015242181049745607) for fast compilation. See [this example](https://github.com/gpu-mode/reference-kernels/blob/main/problems/pmpp_v2/vectoradd_py/solutions/correct/submission_cuda_inline.py) for reference. From 297253fb102da7f7b6e50c2f089161bd3dae4737 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 12:15:25 -0800 Subject: [PATCH 073/111] Add admin CLI commands (#29) * Add admin CLI commands - Add admin subcommand with start, stop, stats, and CRUD operations - Add admin service functions with Bearer token authentication - New commands: - popcorn admin start/stop - control job acceptance - popcorn admin stats - view server statistics - popcorn admin get-submission/delete-submission - popcorn admin create-leaderboard/delete-leaderboard Requires POPCORN_ADMIN_TOKEN environment variable for authentication. * Simplify create-leaderboard to match kernelbot API - Only require directory as positional arg (e.g., "identity_py") - Accept --gpu multiple times for multiple GPU types - Name and deadline auto-derived by server * Simplify create-leaderboard to only require directory - Remove --gpu argument (GPUs now come from task.yml) - Remove unimplemented LoadCompetition stub - Service only sends directory in payload * Add update-problems admin command Adds CLI support for updating problems from a GitHub repository, mirroring the Discord /admin update-problems command. Supports --problem-set, --repository, --branch, and --force options. * Clarify CLI support for Discord functionalities Removed 'almost' from the description of CLI capabilities. --- README.md | 2 +- src/cmd/admin.rs | 179 ++++++++++++++++++++++++++++++++++++++++++++ src/cmd/mod.rs | 13 +++- src/service/mod.rs | 181 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/cmd/admin.rs diff --git a/README.md b/README.md index 472d99c..f6c440e 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ Interested in new kernel competitions? Join [discord.gg/gpumode](https://discord ## Discover Problems -The CLI supports (almost) everything Discord does, so you can also discover which leaderboards are available. To make discovery more pleasant we also offer a TUI experience. +The CLI supports everything Discord does, so you can also discover which leaderboards are available. To make discovery more pleasant we also offer a TUI experience. ```bash popcorn-cli submit diff --git a/src/cmd/admin.rs b/src/cmd/admin.rs new file mode 100644 index 0000000..338f373 --- /dev/null +++ b/src/cmd/admin.rs @@ -0,0 +1,179 @@ +use anyhow::{anyhow, Result}; +use clap::Subcommand; +use std::env; + +use crate::service; + +#[derive(Subcommand, Debug)] +pub enum AdminAction { + /// Start accepting jobs on the server + Start, + /// Stop accepting jobs on the server + Stop, + /// Get server statistics + Stats { + /// Only show stats for the last 24 hours + #[arg(long)] + last_day: bool, + }, + /// Get a submission by ID + GetSubmission { + /// The submission ID to retrieve + id: i64, + }, + /// Delete a submission by ID + DeleteSubmission { + /// The submission ID to delete + id: i64, + }, + /// Create a dev leaderboard from a problem directory (requires gpus in task.yml) + CreateLeaderboard { + /// Problem directory name (e.g., "identity_py") + directory: String, + }, + /// Delete a leaderboard + DeleteLeaderboard { + /// Name of the leaderboard to delete + name: String, + /// Force deletion even if there are submissions + #[arg(long)] + force: bool, + }, + /// Update problems from a GitHub repository (mirrors Discord /admin update-problems) + UpdateProblems { + /// Problem set name (e.g., "nvidia", "pmpp_v2"). If not specified, updates all. + #[arg(long)] + problem_set: Option, + + /// Repository in format "owner/repo" (default: gpu-mode/reference-kernels) + #[arg(long, default_value = "gpu-mode/reference-kernels")] + repository: String, + + /// Branch to pull from (default: main) + #[arg(long, default_value = "main")] + branch: String, + + /// Force update even if task definition changed significantly + #[arg(long)] + force: bool, + }, +} + +fn get_admin_token() -> Result { + env::var("POPCORN_ADMIN_TOKEN").map_err(|_| { + anyhow!( + "POPCORN_ADMIN_TOKEN environment variable is not set.\n\ + Set it to your admin token to use admin commands:\n\ + export POPCORN_ADMIN_TOKEN=your_token_here" + ) + }) +} + +pub async fn handle_admin(action: AdminAction) -> Result<()> { + let admin_token = get_admin_token()?; + let client = service::create_admin_client(&admin_token)?; + + match action { + AdminAction::Start => { + let result = service::admin_start(&client).await?; + println!("Server started accepting jobs"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::Stop => { + let result = service::admin_stop(&client).await?; + println!("Server stopped accepting jobs"); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::Stats { last_day } => { + let result = service::admin_stats(&client, last_day).await?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::GetSubmission { id } => { + let result = service::admin_get_submission(&client, id).await?; + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::DeleteSubmission { id } => { + let result = service::admin_delete_submission(&client, id).await?; + println!("Deleted submission {}", id); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::CreateLeaderboard { directory } => { + let result = service::admin_create_leaderboard(&client, &directory).await?; + let name = result["leaderboard"].as_str().unwrap_or(&directory); + println!("Created leaderboard '{}'", name); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::DeleteLeaderboard { name, force } => { + let result = service::admin_delete_leaderboard(&client, &name, force).await?; + println!("Deleted leaderboard '{}'", name); + println!("{}", serde_json::to_string_pretty(&result)?); + } + AdminAction::UpdateProblems { + problem_set, + repository, + branch, + force, + } => { + println!( + "Updating problems from {}/tree/{}{}...", + repository, + branch, + problem_set + .as_ref() + .map(|ps| format!(" (problem set: {})", ps)) + .unwrap_or_default() + ); + let result = service::admin_update_problems( + &client, + problem_set.as_deref(), + &repository, + &branch, + force, + ) + .await?; + + // Pretty print the results + if let Some(created) = result.get("created").and_then(|v| v.as_array()) { + if !created.is_empty() { + println!("\nCreated {} leaderboard(s):", created.len()); + for name in created { + println!(" + {}", name.as_str().unwrap_or("unknown")); + } + } + } + if let Some(updated) = result.get("updated").and_then(|v| v.as_array()) { + if !updated.is_empty() { + println!("\nUpdated {} leaderboard(s):", updated.len()); + for name in updated { + println!(" ~ {}", name.as_str().unwrap_or("unknown")); + } + } + } + if let Some(skipped) = result.get("skipped").and_then(|v| v.as_array()) { + if !skipped.is_empty() { + println!("\nSkipped {} leaderboard(s):", skipped.len()); + for item in skipped { + let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("unknown"); + let reason = item + .get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("no changes"); + println!(" - {} ({})", name, reason); + } + } + } + if let Some(errors) = result.get("errors").and_then(|v| v.as_array()) { + if !errors.is_empty() { + println!("\nErrors ({}):", errors.len()); + for item in errors { + let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("unknown"); + let error = item.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); + println!(" ! {}: {}", name, error); + } + } + } + } + } + + Ok(()) +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 9de3637..6da81bc 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -6,9 +6,12 @@ use serde_yaml; use std::fs::File; use std::path::PathBuf; +mod admin; mod auth; mod submit; +pub use admin::AdminAction; + #[derive(Serialize, Deserialize, Debug, Default)] struct Config { cli_id: Option, @@ -105,6 +108,11 @@ enum Commands { #[arg(long)] no_tui: bool, }, + /// Admin commands (requires POPCORN_ADMIN_TOKEN env var) + Admin { + #[command(subcommand)] + action: AdminAction, + }, } pub async fn execute(cli: Cli) -> Result<()> { @@ -142,7 +150,7 @@ pub async fn execute(cli: Cli) -> Result<()> { // Use filepath from Submit command first, fallback to top-level filepath let final_filepath = filepath.or(cli.filepath); - + if no_tui { submit::run_submit_plain( final_filepath, // Resolved filepath @@ -165,6 +173,9 @@ pub async fn execute(cli: Cli) -> Result<()> { .await } } + Some(Commands::Admin { action }) => { + admin::handle_admin(action).await + } None => { // Check if any of the submission-related flags were used at the top level if cli.gpu.is_some() || cli.leaderboard.is_some() || cli.mode.is_some() { diff --git a/src/service/mod.rs b/src/service/mod.rs index 264b428..b5b6409 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -34,6 +34,187 @@ pub fn create_client(cli_id: Option) -> Result { .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) } +/// Create an HTTP client with admin token authentication +pub fn create_admin_client(admin_token: &str) -> Result { + let mut default_headers = HeaderMap::new(); + + let auth_value = format!("Bearer {}", admin_token); + match HeaderValue::from_str(&auth_value) { + Ok(val) => { + default_headers.insert("Authorization", val); + } + Err(_) => { + return Err(anyhow!("Invalid admin token format for HTTP header")); + } + } + + Client::builder() + .timeout(Duration::from_secs(60)) + .default_headers(default_headers) + .build() + .map_err(|e| anyhow!("Failed to create HTTP client: {}", e)) +} + +/// Start accepting jobs on the server +pub async fn admin_start(client: &Client) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .post(format!("{}/admin/start", base_url)) + .header("Content-Length", "0") + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Stop accepting jobs on the server +pub async fn admin_stop(client: &Client) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .post(format!("{}/admin/stop", base_url)) + .header("Content-Length", "0") + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Get server stats +pub async fn admin_stats(client: &Client, last_day_only: bool) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let url = if last_day_only { + format!("{}/admin/stats?last_day_only=true", base_url) + } else { + format!("{}/admin/stats", base_url) + }; + + let resp = client + .get(url) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Get a submission by ID +pub async fn admin_get_submission(client: &Client, submission_id: i64) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/admin/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Delete a submission by ID +pub async fn admin_delete_submission(client: &Client, submission_id: i64) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .delete(format!("{}/admin/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Create a dev leaderboard from a problem directory +pub async fn admin_create_leaderboard( + client: &Client, + directory: &str, +) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let payload = serde_json::json!({ + "directory": directory + }); + + let resp = client + .post(format!("{}/admin/leaderboards", base_url)) + .json(&payload) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Delete a leaderboard +pub async fn admin_delete_leaderboard(client: &Client, leaderboard_name: &str, force: bool) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let url = if force { + format!("{}/admin/leaderboards/{}?force=true", base_url, leaderboard_name) + } else { + format!("{}/admin/leaderboards/{}", base_url, leaderboard_name) + }; + + let resp = client + .delete(url) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Update problems from a GitHub repository +pub async fn admin_update_problems( + client: &Client, + problem_set: Option<&str>, + repository: &str, + branch: &str, + force: bool, +) -> Result { + let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let mut payload = serde_json::json!({ + "repository": repository, + "branch": branch, + "force": force + }); + + if let Some(ps) = problem_set { + payload["problem_set"] = serde_json::Value::String(ps.to_string()); + } + + let resp = client + .post(format!("{}/admin/update-problems", base_url)) + .json(&payload) + .timeout(Duration::from_secs(120)) // Longer timeout for repo download + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Helper to handle admin API responses +async fn handle_admin_response(resp: reqwest::Response) -> Result { + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + resp.json().await.map_err(|e| anyhow!("Failed to parse response: {}", e)) +} + pub async fn fetch_leaderboards(client: &Client) -> Result> { let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; From 90fd941f2f991747df95b3fc172aa641fe9a27c4 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 12:37:36 -0800 Subject: [PATCH 074/111] Add test suite, CI workflow, and fix all clippy warnings (#30) * Add test suite, CI workflow, and fix all clippy warnings - Add 20 unit tests for core logic (directive parsing, text wrapping, HTTP client) - Add GitHub Actions workflow for tests on Linux/macOS/Windows - Fix all clippy warnings to enable -D warnings in CI - Add CLAUDE.md with contribution guidelines and architecture overview - Add tempfile dev-dependency for file-based tests Warnings fixed: - Remove redundant imports - Remove unused SubmissionResultMsg struct - Remove unused get_ascii_art/display_ascii_art functions - Fix int_plus_one, map_or, manual_strip, borrowed_box, redundant_closure - Fix needless_return and unnecessary_cast * Fix formatting in admin code after merge from main --- .github/workflows/test.yml | 68 +++++++++++++ CLAUDE.md | 92 +++++++++++++++++ Cargo.lock | 1 + Cargo.toml | 3 +- src/cmd/admin.rs | 15 ++- src/cmd/auth.rs | 4 - src/cmd/mod.rs | 10 +- src/cmd/submit.rs | 1 - src/main.rs | 5 +- src/models/mod.rs | 5 - src/service/mod.rs | 197 ++++++++++++++++++++++++++++++------- src/utils/mod.rs | 187 +++++++++++++++++++++++++++++++---- src/views/loading_page.rs | 10 +- src/views/mod.rs | 2 +- src/views/result_page.rs | 6 +- 15 files changed, 523 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 CLAUDE.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e7b1a74 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,68 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Run Tests + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + rust: [stable] + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + components: clippy, rustfmt + + - name: Set up cargo cache + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.os }}-test + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Run coverage + run: cargo tarpaulin --out Xml --verbose + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: cobertura.xml + fail_ci_if_error: false diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..42e9985 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md + +## Contributing + +### Development Setup + +```bash +# Build +cargo build + +# Run tests +cargo test + +# Format code (required before commits) +cargo fmt --all + +# Lint (must pass with no warnings) +cargo clippy --all-targets --all-features -- -D warnings +``` + +### CI Requirements + +All PRs must pass: +- `cargo fmt --all -- --check` - Code formatting +- `cargo clippy -- -D warnings` - No clippy warnings allowed +- `cargo test` - All tests pass +- Builds on Linux, macOS, and Windows + +## Architecture Overview + +Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. + +### Directory Structure + +``` +src/ +├── main.rs # Entry point, sets POPCORN_API_URL +├── cmd/ # Command handling +│ ├── mod.rs # CLI argument parsing (clap), config loading +│ ├── auth.rs # OAuth authentication (Discord/GitHub) +│ └── submit.rs # Submission logic, TUI app state machine +├── service/ +│ └── mod.rs # HTTP client, API calls, SSE streaming +├── models/ +│ └── mod.rs # Data structures (LeaderboardItem, GpuItem, AppState) +├── utils/ +│ └── mod.rs # Directive parsing, text wrapping, ASCII art +└── views/ + ├── loading_page.rs # TUI loading screen with progress bar + └── result_page.rs # TUI results display with scrolling +``` + +### Core Flow + +1. **Authentication** (`cmd/auth.rs`): User registers via Discord/GitHub OAuth. CLI ID stored in `~/.popcorn.yaml`. + +2. **Submission** (`cmd/submit.rs`): + - TUI mode: Interactive selection of leaderboard → GPU → mode + - Plain mode (`--no-tui`): Direct submission with CLI flags + - Reads solution file with optional `#!POPCORN` directives for defaults + +3. **API Communication** (`service/mod.rs`): + - Fetches available leaderboards and GPUs + - Submits solutions via multipart form POST + - Handles SSE (Server-Sent Events) streaming for real-time results + - Supports modes: `test`, `benchmark`, `leaderboard`, `profile` + +### File Directives + +Users can embed defaults in their solution files: + +```python +#!POPCORN leaderboard amd-fp8-mm +#!POPCORN gpu MI300 + +def solution(): + ... +``` + +Or C++ style: +```cpp +//!POPCORN leaderboard nvidia-matmul +//!POPCORN gpu H100 +``` + +### Key Dependencies + +- `clap` - CLI argument parsing +- `ratatui` + `crossterm` - Terminal UI +- `reqwest` - HTTP client with SSE streaming +- `tokio` - Async runtime +- `serde` / `serde_yaml` / `serde_json` - Serialization diff --git a/Cargo.lock b/Cargo.lock index 4dcb102..ffff48a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1178,6 +1178,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "tokio", "urlencoding", "webbrowser", diff --git a/Cargo.toml b/Cargo.toml index d212bd7..779842d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,4 +25,5 @@ urlencoding = "2.1.3" bytes = "1.10.1" futures-util = "0.3.31" - +[dev-dependencies] +tempfile = "3.10" diff --git a/src/cmd/admin.rs b/src/cmd/admin.rs index 338f373..f34ef0e 100644 --- a/src/cmd/admin.rs +++ b/src/cmd/admin.rs @@ -153,7 +153,10 @@ pub async fn handle_admin(action: AdminAction) -> Result<()> { if !skipped.is_empty() { println!("\nSkipped {} leaderboard(s):", skipped.len()); for item in skipped { - let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("unknown"); + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); let reason = item .get("reason") .and_then(|r| r.as_str()) @@ -166,8 +169,14 @@ pub async fn handle_admin(action: AdminAction) -> Result<()> { if !errors.is_empty() { println!("\nErrors ({}):", errors.len()); for item in errors { - let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("unknown"); - let error = item.get("error").and_then(|e| e.as_str()).unwrap_or("unknown"); + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown"); + let error = item + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("unknown"); println!(" ! {}: {}", name, error); } } diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index 80cb079..483c3b1 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -1,11 +1,7 @@ use anyhow::{anyhow, Result}; -use base64_url; -use dirs; use serde::{Deserialize, Serialize}; -use serde_yaml; use std::fs::{File, OpenOptions}; use std::path::PathBuf; -use webbrowser; use crate::service; // Assuming service::create_client is needed diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 6da81bc..66a8909 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,8 +1,6 @@ use anyhow::{anyhow, Result}; use clap::{Parser, Subcommand}; -use dirs; use serde::{Deserialize, Serialize}; -use serde_yaml; use std::fs::File; use std::path::PathBuf; @@ -173,9 +171,7 @@ pub async fn execute(cli: Cli) -> Result<()> { .await } } - Some(Commands::Admin { action }) => { - admin::handle_admin(action).await - } + Some(Commands::Admin { action }) => admin::handle_admin(action).await, None => { // Check if any of the submission-related flags were used at the top level if cli.gpu.is_some() || cli.leaderboard.is_some() || cli.mode.is_some() { @@ -207,7 +203,9 @@ pub async fn execute(cli: Cli) -> Result<()> { ) .await } else { - Err(anyhow!("No command or submission file specified. Use --help for usage.")) + Err(anyhow!( + "No command or submission file specified. Use --help for usage." + )) } } } diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index 325b5f1..a508b12 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -338,7 +338,6 @@ impl App { "Error starting GPU fetch: {}", e )); - return; } } } else { diff --git a/src/main.rs b/src/main.rs index 8154bc8..5b4e5a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,10 @@ use std::process; async fn main() { // Set the API URL FIRST - before anything else if env::var("POPCORN_API_URL").is_err() { - env::set_var("POPCORN_API_URL", "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com"); + env::set_var( + "POPCORN_API_URL", + "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com", + ); } // Parse command line arguments let cli = Cli::parse(); diff --git a/src/models/mod.rs b/src/models/mod.rs index 5a9c8a8..dd199e1 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,3 @@ -use serde::{Deserialize, Serialize}; - #[derive(Clone, Debug)] pub struct LeaderboardItem { pub title_text: String, @@ -51,6 +49,3 @@ pub enum AppState { SubmissionModeSelection, WaitingForResult, } - -#[derive(Debug, Serialize, Deserialize)] -pub struct SubmissionResultMsg(pub String); diff --git a/src/service/mod.rs b/src/service/mod.rs index b5b6409..1eb135e 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -57,7 +57,8 @@ pub fn create_admin_client(admin_token: &str) -> Result { /// Start accepting jobs on the server pub async fn admin_start(client: &Client) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .post(format!("{}/admin/start", base_url)) @@ -71,7 +72,8 @@ pub async fn admin_start(client: &Client) -> Result { /// Stop accepting jobs on the server pub async fn admin_stop(client: &Client) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .post(format!("{}/admin/stop", base_url)) @@ -85,7 +87,8 @@ pub async fn admin_stop(client: &Client) -> Result { /// Get server stats pub async fn admin_stats(client: &Client, last_day_only: bool) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let url = if last_day_only { format!("{}/admin/stats?last_day_only=true", base_url) @@ -104,7 +107,8 @@ pub async fn admin_stats(client: &Client, last_day_only: bool) -> Result /// Get a submission by ID pub async fn admin_get_submission(client: &Client, submission_id: i64) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .get(format!("{}/admin/submissions/{}", base_url, submission_id)) @@ -117,7 +121,8 @@ pub async fn admin_get_submission(client: &Client, submission_id: i64) -> Result /// Delete a submission by ID pub async fn admin_delete_submission(client: &Client, submission_id: i64) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let resp = client .delete(format!("{}/admin/submissions/{}", base_url, submission_id)) @@ -129,11 +134,9 @@ pub async fn admin_delete_submission(client: &Client, submission_id: i64) -> Res } /// Create a dev leaderboard from a problem directory -pub async fn admin_create_leaderboard( - client: &Client, - directory: &str, -) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; +pub async fn admin_create_leaderboard(client: &Client, directory: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let payload = serde_json::json!({ "directory": directory @@ -150,11 +153,19 @@ pub async fn admin_create_leaderboard( } /// Delete a leaderboard -pub async fn admin_delete_leaderboard(client: &Client, leaderboard_name: &str, force: bool) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; +pub async fn admin_delete_leaderboard( + client: &Client, + leaderboard_name: &str, + force: bool, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let url = if force { - format!("{}/admin/leaderboards/{}?force=true", base_url, leaderboard_name) + format!( + "{}/admin/leaderboards/{}?force=true", + base_url, leaderboard_name + ) } else { format!("{}/admin/leaderboards/{}", base_url, leaderboard_name) }; @@ -176,7 +187,8 @@ pub async fn admin_update_problems( branch: &str, force: bool, ) -> Result { - let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; let mut payload = serde_json::json!({ "repository": repository, @@ -212,7 +224,9 @@ async fn handle_admin_response(resp: reqwest::Response) -> Result { detail.unwrap_or(error_text) )); } - resp.json().await.map_err(|e| anyhow!("Failed to parse response: {}", e)) + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) } pub async fn fetch_leaderboards(client: &Client) -> Result> { @@ -235,7 +249,7 @@ pub async fn fetch_leaderboards(client: &Client) -> Result> let mut leaderboard_items = Vec::new(); for lb in leaderboards { - let task = lb["task"] + let _task = lb["task"] .as_object() .ok_or_else(|| anyhow!("Invalid JSON structure"))?; let name = lb["name"] @@ -272,7 +286,7 @@ pub async fn fetch_gpus(client: &Client, leaderboard: &str) -> Result = resp.json().await?; - let gpu_items = gpus.into_iter().map(|gpu| GpuItem::new(gpu)).collect(); + let gpu_items = gpus.into_iter().map(GpuItem::new).collect(); Ok(gpu_items) } @@ -332,7 +346,7 @@ pub async fn submit_solution>( .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) - .map_or(false, |s| s.starts_with("text/event-stream")) + .is_some_and(|s| s.starts_with("text/event-stream")) { let mut resp = resp; let mut buffer = String::new(); @@ -347,10 +361,10 @@ pub async fn submit_solution>( let mut data_json = None; for line in message_str.lines() { - if line.starts_with("event:") { - event_type = Some(line["event:".len()..].trim()); - } else if line.starts_with("data:") { - data_json = Some(line["data:".len()..].trim()); + if let Some(stripped) = line.strip_prefix("event:") { + event_type = Some(stripped.trim()); + } else if let Some(stripped) = line.strip_prefix("data:") { + data_json = Some(stripped.trim()); } } @@ -375,13 +389,17 @@ pub async fn submit_solution>( if let Some(ref cb) = on_log { // Handle "results" array - if let Some(results_array) = result_val.get("results").and_then(|v| v.as_array()) { + if let Some(results_array) = + result_val.get("results").and_then(|v| v.as_array()) + { let mode_key = submission_mode.to_lowercase(); // Special handling for profile mode if mode_key == "profile" { for (i, result_item) in results_array.iter().enumerate() { - if let Some(runs) = result_item.get("runs").and_then(|r| r.as_object()) { + if let Some(runs) = + result_item.get("runs").and_then(|r| r.as_object()) + { for (key, run_data) in runs.iter() { if key.starts_with("profile") { handle_profile_result(cb, run_data, i); @@ -392,19 +410,32 @@ pub async fn submit_solution>( } else { // Existing handling for non-profile modes for (i, result_item) in results_array.iter().enumerate() { - if let Some(run_obj) = result_item.get("runs") + if let Some(run_obj) = result_item + .get("runs") .and_then(|r| r.get(&mode_key)) .and_then(|t| t.get("run")) { - if let Some(stdout) = run_obj.get("stdout").and_then(|s| s.as_str()) { + if let Some(stdout) = + run_obj.get("stdout").and_then(|s| s.as_str()) + { if !stdout.is_empty() { - cb(format!("STDOUT (Run {}):\n{}", i + 1, stdout)); + cb(format!( + "STDOUT (Run {}):\n{}", + i + 1, + stdout + )); } } // Also check stderr - if let Some(stderr) = run_obj.get("stderr").and_then(|s| s.as_str()) { + if let Some(stderr) = + run_obj.get("stderr").and_then(|s| s.as_str()) + { if !stderr.is_empty() { - cb(format!("STDERR (Run {}):\n{}", i + 1, stderr)); + cb(format!( + "STDERR (Run {}):\n{}", + i + 1, + stderr + )); } } } @@ -412,7 +443,9 @@ pub async fn submit_solution>( } } else { // Fallback for single object or different structure - if let Some(stdout) = result_val.get("stdout").and_then(|s| s.as_str()) { + if let Some(stdout) = + result_val.get("stdout").and_then(|s| s.as_str()) + { if !stdout.is_empty() { cb(format!("STDOUT:\n{}", stdout)); } @@ -473,11 +506,7 @@ pub async fn submit_solution>( /// Handle profile mode results by decoding and displaying profile data, /// and saving trace files to the current directory. -fn handle_profile_result( - cb: &Box, - run_data: &Value, - run_idx: usize, -) { +fn handle_profile_result(cb: &(dyn Fn(String) + Send + Sync), run_data: &Value, run_idx: usize) { // 1. Get profiler type and display it if let Some(profile) = run_data.get("profile") { let profiler = profile @@ -558,3 +587,101 @@ fn handle_profile_result( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_client_without_cli_id() { + let client = create_client(None); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_valid_cli_id() { + let client = create_client(Some("valid-cli-id-123".to_string())); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_empty_cli_id() { + let client = create_client(Some("".to_string())); + + assert!(client.is_ok()); + } + + #[test] + fn test_create_client_with_invalid_header_chars() { + // Headers cannot contain newlines or certain control characters + let client = create_client(Some("invalid\nheader".to_string())); + + assert!(client.is_err()); + let err_msg = client.unwrap_err().to_string(); + assert!(err_msg.contains("Invalid cli_id format")); + } + + #[tokio::test] + async fn test_fetch_leaderboards_missing_env_var() { + // Temporarily unset the env var if set + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = fetch_leaderboards(&client).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + // Restore original value if it existed + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } + + #[tokio::test] + async fn test_fetch_gpus_missing_env_var() { + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = fetch_gpus(&client, "test-leaderboard").await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } + + #[tokio::test] + async fn test_submit_solution_missing_env_var() { + let original = std::env::var("POPCORN_API_URL").ok(); + std::env::remove_var("POPCORN_API_URL"); + + let client = create_client(None).unwrap(); + let result = submit_solution( + &client, + "test.py", + "print('hello')", + "test-leaderboard", + "H100", + "test", + None, + ) + .await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("POPCORN_API_URL")); + + if let Some(val) = original { + std::env::set_var("POPCORN_API_URL", val); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index d44304a..383a171 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,6 +1,6 @@ +use anyhow::Result; use std::fs; use std::path::Path; -use anyhow::Result; pub struct PopcornDirectives { pub leaderboard_name: String, @@ -9,7 +9,7 @@ pub struct PopcornDirectives { pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, bool)> { let content = fs::read_to_string(filepath)?; - + let mut gpus: Vec = Vec::new(); let mut leaderboard_name = String::new(); let mut has_multiple_gpus = false; @@ -44,7 +44,7 @@ pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDir leaderboard_name, gpus, }, - has_multiple_gpus + has_multiple_gpus, )) } @@ -74,7 +74,8 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), 1 => r#" ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ @@ -98,7 +99,8 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string(), + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), _ => r#" ▗▖ ▗▖▗▄▄▄▖▗▄▄▖ ▗▖ ▗▖▗▄▄▄▖▗▖ ▗▄▄▖ ▗▄▖ ▗▄▄▄▖ ▐▌▗▞▘▐▌ ▐▌ ▐▌▐▛▚▖▐▌▐▌ ▐▌ ▐▌ ▐▌▐▌ ▐▌ █ @@ -122,20 +124,16 @@ pub fn get_ascii_art_frame(frame: u16) -> String { │ └──────────────────────────────────┘ │▒ └────────────────────────────────────────────┘▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"#.to_string() + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀"# + .to_string(), } } -pub fn get_ascii_art() -> String { - get_ascii_art_frame(0) -} - -pub fn display_ascii_art() { - let art = get_ascii_art(); - println!("{}", art); -} - -pub fn custom_wrap(initial_text: String, remaining_text: String, available_width: usize) -> Vec { +pub fn custom_wrap( + initial_text: String, + remaining_text: String, + available_width: usize, +) -> Vec { let mut lines = vec![initial_text]; let mut current_line = String::with_capacity(available_width); for word in remaining_text.split_whitespace() { @@ -147,7 +145,7 @@ pub fn custom_wrap(initial_text: String, remaining_text: String, available_width lines.push(word.to_string()); } else if current_line.is_empty() { current_line.push_str(word); - } else if current_line.len() + word.len() + 1 <= available_width { + } else if current_line.len() + word.len() < available_width { current_line.push(' '); current_line.push_str(word); } else { @@ -162,3 +160,158 @@ pub fn custom_wrap(initial_text: String, remaining_text: String, available_width } lines } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + // Tests for get_popcorn_directives + + #[test] + fn test_parse_python_style_directives() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN leaderboard my-leaderboard").unwrap(); + writeln!(file, "#!POPCORN gpu H100").unwrap(); + writeln!(file).unwrap(); + writeln!(file, "def main():").unwrap(); + writeln!(file, " pass").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "my-leaderboard"); + assert_eq!(directives.gpus, vec!["H100"]); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_cpp_style_directives() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "//!POPCORN leaderboard amd-fp8-mm").unwrap(); + writeln!(file, "//!POPCORN gpu MI300").unwrap(); + writeln!(file).unwrap(); + writeln!(file, "int main() {{ return 0; }}").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "amd-fp8-mm"); + assert_eq!(directives.gpus, vec!["MI300"]); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_multiple_gpus_truncates_to_first() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN leaderboard test").unwrap(); + writeln!(file, "#!POPCORN gpus H100 MI300 A100").unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "test"); + assert_eq!(directives.gpus, vec!["H100"]); + assert!(has_multiple_gpus); + } + + #[test] + fn test_parse_gpu_vs_gpus_keyword() { + // Test "gpu" keyword + let mut file1 = NamedTempFile::new().unwrap(); + writeln!(file1, "#!POPCORN gpu A100").unwrap(); + let (directives1, _) = get_popcorn_directives(file1.path()).unwrap(); + assert_eq!(directives1.gpus, vec!["A100"]); + + // Test "gpus" keyword + let mut file2 = NamedTempFile::new().unwrap(); + writeln!(file2, "#!POPCORN gpus V100").unwrap(); + let (directives2, _) = get_popcorn_directives(file2.path()).unwrap(); + assert_eq!(directives2.gpus, vec!["V100"]); + } + + #[test] + fn test_parse_empty_file_returns_empty_directives() { + let file = NamedTempFile::new().unwrap(); + + let (directives, has_multiple_gpus) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, ""); + assert!(directives.gpus.is_empty()); + assert!(!has_multiple_gpus); + } + + #[test] + fn test_parse_ignores_non_directive_comments() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "# This is a regular comment").unwrap(); + writeln!(file, "// Another regular comment").unwrap(); + writeln!(file, "#!POPCORN leaderboard real-leaderboard").unwrap(); + writeln!(file, "# POPCORN gpu should-be-ignored").unwrap(); + + let (directives, _) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.leaderboard_name, "real-leaderboard"); + assert!(directives.gpus.is_empty()); + } + + #[test] + fn test_parse_case_insensitive_directive_args() { + let mut file = NamedTempFile::new().unwrap(); + writeln!(file, "#!POPCORN GPU H100").unwrap(); + writeln!(file, "#!POPCORN LEADERBOARD TEST").unwrap(); + + let (directives, _) = get_popcorn_directives(file.path()).unwrap(); + + assert_eq!(directives.gpus, vec!["H100"]); + assert_eq!(directives.leaderboard_name, "TEST"); + } + + #[test] + fn test_parse_nonexistent_file_returns_error() { + let result = get_popcorn_directives("/nonexistent/path/file.py"); + assert!(result.is_err()); + } + + // Tests for custom_wrap + + #[test] + fn test_wrap_simple_text() { + let result = custom_wrap("Header:".to_string(), "hello world".to_string(), 20); + + assert_eq!(result, vec!["Header:", "hello world"]); + } + + #[test] + fn test_wrap_breaks_at_width() { + let result = custom_wrap("".to_string(), "one two three four".to_string(), 10); + + assert_eq!(result, vec!["", "one two", "three four"]); + } + + #[test] + fn test_wrap_handles_long_words() { + let result = custom_wrap( + "".to_string(), + "short verylongwordthatexceedswidth short".to_string(), + 10, + ); + + assert_eq!( + result, + vec!["", "short", "verylongwordthatexceedswidth", "short"] + ); + } + + #[test] + fn test_wrap_empty_remaining_text() { + let result = custom_wrap("Header".to_string(), "".to_string(), 20); + + assert_eq!(result, vec!["Header"]); + } + + #[test] + fn test_wrap_preserves_initial_text() { + let result = custom_wrap("PREFIX: ".to_string(), "some text".to_string(), 20); + + assert_eq!(result[0], "PREFIX: "); + } +} diff --git a/src/views/loading_page.rs b/src/views/loading_page.rs index 9bf0d9d..02a809c 100644 --- a/src/views/loading_page.rs +++ b/src/views/loading_page.rs @@ -22,12 +22,12 @@ pub struct LoadingPage { fn get_gradient_color(progress: f64) -> Color { // Convert progress from 0-100 to 0-1 let t = progress / 100.0; - + // Start with red (255, 0, 0) and end with green (0, 255, 0) let r = ((1.0 - t) * 255.0) as u8; let g = (t * 255.0) as u8; let b = 0; - + Color::Rgb(r, g, b) } @@ -63,11 +63,11 @@ fn get_footer_text(state: &LoadingPageState) -> String { } if percentage > 75.0 { - return "Almost there!".to_string(); + "Almost there!".to_string() } else if percentage > 35.0 { - return "Crunching numbers...".to_string(); + "Crunching numbers...".to_string() } else { - return "This is taking a while, huh?".to_string(); + "This is taking a while, huh?".to_string() } } diff --git a/src/views/mod.rs b/src/views/mod.rs index a0e6eff..f396fbb 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,2 +1,2 @@ -pub mod result_page; pub mod loading_page; +pub mod result_page; diff --git a/src/views/result_page.rs b/src/views/result_page.rs index 14ad4ad..16f6f4d 100644 --- a/src/views/result_page.rs +++ b/src/views/result_page.rs @@ -33,9 +33,7 @@ impl ResultPage { let num_lines = result_text.lines().count(); - state.vertical_scroll_state = state - .vertical_scroll_state - .content_length(num_lines); + state.vertical_scroll_state = state.vertical_scroll_state.content_length(num_lines); state.horizontal_scroll_state = state.horizontal_scroll_state.content_length(max_width); state.animation_frame = 0; @@ -70,7 +68,7 @@ impl ResultPage { .result_text .clone() .block(right_block) - .scroll((state.vertical_scroll as u16, state.horizontal_scroll as u16)); + .scroll((state.vertical_scroll, state.horizontal_scroll)); result_text.render(right, buf); } From bb01cc4377c0757e927b5d1eeae34ea990aaf581 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 1 Feb 2026 12:52:00 -0800 Subject: [PATCH 075/111] Fix version mismatch by setting version from CI env var (#31) - Add build.rs that reads CLI_VERSION env var (defaults to "dev") - Update clap to use CLI_VERSION set by build script - Set Cargo.toml version to 0.0.0-dev as placeholder - CI passes git tag as CLI_VERSION when building releases Local builds show "dev", release builds show the tag version. Fixes #25 --- .github/workflows/build.yml | 4 +++- Cargo.lock | 2 +- Cargo.toml | 2 +- build.rs | 7 +++++++ src/cmd/mod.rs | 2 +- 5 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 build.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ab9a58..b86b9db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,7 +63,7 @@ jobs: steps: - uses: actions/checkout@v4 - + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@master with: @@ -83,6 +83,8 @@ jobs: - name: Build release binary run: cargo build --release --target ${{ matrix.target }} + env: + CLI_VERSION: ${{ needs.version.outputs.new_tag }} - name: Prepare artifact shell: bash diff --git a/Cargo.lock b/Cargo.lock index ffff48a..cd2d70a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1161,7 +1161,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "popcorn-cli" -version = "0.1.0" +version = "0.0.0-dev" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 779842d..bbbd750 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "popcorn-cli" -version = "0.1.0" +version = "0.0.0-dev" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..3497d5f --- /dev/null +++ b/build.rs @@ -0,0 +1,7 @@ +fn main() { + // CI sets CLI_VERSION env var from git tag, otherwise show "dev" + let version = std::env::var("CLI_VERSION") + .map(|v| v.trim_start_matches('v').to_string()) + .unwrap_or_else(|_| "dev".to_string()); + println!("cargo:rustc-env=CLI_VERSION={}", version); +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 66a8909..b2322b8 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -37,7 +37,7 @@ fn load_config() -> Result { } #[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] +#[command(author, version = env!("CLI_VERSION"), about, long_about = None)] pub struct Cli { #[command(subcommand)] command: Option, From 4ff2e2319310e95cc6a756d15a05a9ccfff5309e Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Tue, 3 Feb 2026 09:51:50 -0800 Subject: [PATCH 076/111] Fix https://github.com/gpu-mode/popcorn-cli/issues/23 --- .github/workflows/build.yml | 8 +- Cargo.lock | 196 +++++++++++++----------------------- Cargo.toml | 2 +- 3 files changed, 76 insertions(+), 130 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b86b9db..1640c6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: matrix: include: - os: ubuntu-latest - target: x86_64-unknown-linux-gnu + target: x86_64-unknown-linux-musl artifact_name: popcorn-cli asset_name: popcorn-cli-linux.tar.gz compress_cmd: tar -czf @@ -80,6 +80,12 @@ jobs: run: | sudo apt-get update sudo apt-get install -y gcc-aarch64-linux-gnu + + - name: Install musl tools (Linux musl) + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools - name: Build release binary run: cargo build --release --target ${{ matrix.target }} diff --git a/Cargo.lock b/Cargo.lock index cd2d70a..0830437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,21 +410,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -619,16 +604,17 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "hyper-rustls" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ - "bytes", + "futures-util", + "http", "hyper", - "native-tls", + "rustls", "tokio", - "tokio-native-tls", + "tokio-rustls", ] [[package]] @@ -988,23 +974,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk-context" version = "0.1.1" @@ -1056,50 +1025,6 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -[[package]] -name = "openssl" -version = "0.10.72" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" -dependencies = [ - "bitflags 2.9.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -1153,12 +1078,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "popcorn-cli" version = "0.0.0-dev" @@ -1269,16 +1188,16 @@ dependencies = [ "http", "http-body", "hyper", - "hyper-tls", + "hyper-rustls", "ipnet", "js-sys", "log", "mime", "mime_guess", - "native-tls", "once_cell", "percent-encoding", "pin-project-lite", + "rustls", "rustls-pemfile", "serde", "serde_json", @@ -1286,15 +1205,30 @@ dependencies = [ "sync_wrapper", "system-configuration", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", "winreg", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1314,6 +1248,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -1323,6 +1269,16 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.20" @@ -1344,15 +1300,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "scopeguard" version = "1.2.0" @@ -1360,26 +1307,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.0", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.14.0" +name = "sct" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "core-foundation-sys", - "libc", + "ring", + "untrusted", ] [[package]] @@ -1672,12 +1606,12 @@ dependencies = [ ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-rustls" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "native-tls", + "rustls", "tokio", ] @@ -1766,6 +1700,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.4" @@ -1801,12 +1741,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "walkdir" version = "2.5.0" @@ -1939,6 +1873,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index bbbd750..735ae80 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] clap = { version = "4.5.3", features = ["derive"] } -reqwest = { version = "0.11", features = ["json", "multipart"] } +reqwest = { version = "0.11", default-features = false, features = ["json", "multipart", "rustls-tls"] } tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" From dd46c00a68472bee3c377d1965190bd67d66d75a Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Fri, 6 Feb 2026 08:07:24 -0800 Subject: [PATCH 077/111] Add user submissions management commands (#32) --- CLAUDE.md | 130 +++++++++++++++++++++++++++++++ README.md | 119 ++++++++++++++++++++++++++--- src/cmd/mod.rs | 54 +++++++++++++ src/cmd/submissions.rs | 169 +++++++++++++++++++++++++++++++++++++++++ src/models/mod.rs | 44 +++++++++++ src/service/mod.rs | 158 +++++++++++++++++++++++++++++++++++++- 6 files changed, 664 insertions(+), 10 deletions(-) create mode 100644 src/cmd/submissions.rs diff --git a/CLAUDE.md b/CLAUDE.md index 42e9985..6372cb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,116 @@ All PRs must pass: - `cargo test` - All tests pass - Builds on Linux, macOS, and Windows +### Testing + +#### Unit Tests + +Tests are in the same file as the code (Rust convention): +- `src/service/mod.rs` - API client tests +- `src/utils/mod.rs` - Utility function tests + +Run all tests: +```bash +cargo test +``` + +Run specific tests: +```bash +cargo test test_name +``` + +#### Test Requirements + +When adding new functionality: + +1. **Service functions** (`src/service/mod.rs`): + - Add tests in the `#[cfg(test)] mod tests` block + - Test error handling, response parsing + +2. **Command handlers** (`src/cmd/`): + - Integration testing via E2E regression tests + +#### E2E Regression Testing + +Full end-to-end testing requires a running kernelbot API server. You can test against production or a local instance. + +##### Option A: Test Against Production + +```bash +export POPCORN_API_URL=https://discord-cluster-manager-1f6c4782e60a.herokuapp.com +cargo run -- submissions list --leaderboard grayscale +``` + +##### Option B: Test Against Local Server (Recommended for Development) + +This tests the complete flow: CLI → API → Database → Modal runner. + +**Step 1: Set up kernelbot server** (in the kernelbot repo): + +```bash +# Start PostgreSQL +brew services start postgresql@14 + +# Create database and run migrations +createdb kernelbot +export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot" +uv run yoyo apply --database "$DATABASE_URL" src/migrations/ + +# Create test user +psql "$DATABASE_URL" -c "INSERT INTO leaderboard.user_info (id, user_name, cli_id, cli_valid) +VALUES ('999999', 'testuser', 'test-cli-id-123', true) +ON CONFLICT (id) DO UPDATE SET cli_id = 'test-cli-id-123', cli_valid = true;" + +# Start API server +cd src/kernelbot +export ADMIN_TOKEN="your-admin-token" # Check .env for LOCAL_ADMIN_TOKEN +uv run python main.py --api-only +``` + +**Step 2: Sync leaderboards**: + +```bash +curl -X POST "http://localhost:8000/admin/update-problems" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"problem_set": "pmpp_v2"}' +``` + +**Step 3: Configure CLI for local testing**: + +```bash +# Backup and set test config +cp ~/.popcorn.yaml ~/.popcorn.yaml.bak +echo "cli_id: test-cli-id-123" > ~/.popcorn.yaml +``` + +**Step 4: Run CLI commands**: + +```bash +export POPCORN_API_URL=http://localhost:8000 + +# Test submissions commands +cargo run --release -- submissions list --leaderboard vectoradd_v2 +cargo run --release -- submissions show +cargo run --release -- submissions delete + +# Test actual submission (requires Modal account for GPU execution) +cargo run --release -- submit solution.py --gpu H100 --leaderboard vectoradd_v2 --mode test +``` + +**Step 5: Restore original config**: + +```bash +cp ~/.popcorn.yaml.bak ~/.popcorn.yaml && rm ~/.popcorn.yaml.bak +``` + +##### Troubleshooting + +- **401 Unauthorized**: CLI ID not registered in database - create test user first +- **404 Not Found**: Leaderboards not synced - run update-problems endpoint +- **Connection refused**: API server not running on localhost:8000 +- **"Device not configured"**: TTY issue - ensure POPCORN_API_URL is set + ## Architecture Overview Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. @@ -37,7 +147,9 @@ src/ ├── main.rs # Entry point, sets POPCORN_API_URL ├── cmd/ # Command handling │ ├── mod.rs # CLI argument parsing (clap), config loading +│ ├── admin.rs # Admin commands (requires POPCORN_ADMIN_TOKEN) │ ├── auth.rs # OAuth authentication (Discord/GitHub) +│ ├── submissions.rs # User submission management (list, show, delete) │ └── submit.rs # Submission logic, TUI app state machine ├── service/ │ └── mod.rs # HTTP client, API calls, SSE streaming @@ -50,6 +162,24 @@ src/ └── result_page.rs # TUI results display with scrolling ``` +### Before Adding New Features + +**Important:** Before implementing new functionality, check for existing code in both repos: + +1. **Check discord-cluster-manager** for existing Discord commands and database methods: + - `src/kernelbot/cogs/` - Discord bot commands + - `src/libkernelbot/leaderboard_db.py` - Database methods + - `src/kernelbot/api/main.py` - Existing API endpoints + +2. **Check popcorn-cli** for existing service functions and commands: + - `src/service/mod.rs` - API client functions + - `src/cmd/` - CLI command handlers + +3. **Reuse existing functionality** where possible: + - Database methods (e.g., `get_submission_by_id`, `delete_submission`) + - API response handling patterns + - Authentication validation (`validate_user_header`, `validate_cli_header`) + ### Core Flow 1. **Authentication** (`cmd/auth.rs`): User registers via Discord/GitHub OAuth. CLI ID stored in `~/.popcorn.yaml`. diff --git a/README.md b/README.md index f6c440e..b064a5f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,116 @@ wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/mai popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submission.py ``` -We regularly run competitions with clear due dates but for beginners we will always keep open the PMPP_v2 problem set https://github.com/gpu-mode/reference-kernels/tree/main/problems/pmpp_v2 +We regularly run competitions with clear due dates but for beginners we will always keep open the PMPP_v2 problem set https://github.com/gpu-mode/reference-kernels/tree/main/problems/pmpp_v2 + +## Commands + +### Submit + +Submit a solution to a leaderboard. Supports both TUI (interactive) and plain modes. + +```bash +# Interactive TUI mode - select leaderboard, GPU, and mode interactively +popcorn submit solution.py + +# Direct submission with all options +popcorn submit --leaderboard grayscale --gpu A100 --mode leaderboard solution.py + +# Plain output mode (no TUI, good for CI/scripts) +popcorn submit --no-tui --leaderboard grayscale --gpu A100 --mode test solution.py + +# Save results to a file +popcorn submit --output results.json --leaderboard grayscale --gpu A100 --mode benchmark solution.py +``` + +**Submission modes:** +- `test` - Quick test run to check correctness +- `benchmark` - Benchmark your solution (no leaderboard impact) +- `leaderboard` - Official ranked submission +- `profile` - Profile with Nsight Compute (limited availability) + +### Submissions + +Manage your past submissions. + +```bash +# List your submissions for a leaderboard +popcorn submissions list --leaderboard grayscale + +# Limit number of results +popcorn submissions list --leaderboard grayscale --limit 10 + +# View a specific submission with full code +popcorn submissions show + +# Delete a submission (with confirmation prompt) +popcorn submissions delete + +# Delete without confirmation +popcorn submissions delete --force +``` + +### Authentication + +Register or re-register your CLI with Discord or GitHub. + +```bash +# Initial registration (Discord recommended) +popcorn register discord +popcorn register github + +# Re-register if you need to link a new account +popcorn reregister discord +popcorn reregister github +``` + +### Admin Commands + +Admin commands require the `POPCORN_ADMIN_TOKEN` environment variable. + +```bash +# Server control +popcorn admin start # Start accepting jobs +popcorn admin stop # Stop accepting jobs +popcorn admin stats # Get server statistics +popcorn admin stats --last-day # Stats for last 24 hours only + +# Submission management +popcorn admin get-submission # Get any submission by ID +popcorn admin delete-submission # Delete any submission + +# Leaderboard management +popcorn admin create-leaderboard # Create leaderboard from problem directory +popcorn admin delete-leaderboard # Delete a leaderboard +popcorn admin delete-leaderboard --force # Force delete with submissions + +# Update problems from GitHub +popcorn admin update-problems +popcorn admin update-problems --problem-set nvidia --force +``` + +### File Directives + +You can embed default settings directly in your solution files: + +```python +#!POPCORN leaderboard grayscale +#!POPCORN gpu A100 + +def solution(): + ... +``` + +Or C++ style: +```cpp +//!POPCORN leaderboard nvidia-matmul +//!POPCORN gpu H100 +``` + +When these directives are present, you can submit with just: +```bash +popcorn submit solution.py +``` ## Submission Format @@ -87,12 +196,4 @@ Our entire evaluation infrastructure is open source and you can learn more [here Interested in new kernel competitions? Join [discord.gg/gpumode](https://discord.gg/gpumode) and check out the **#announcements** channel to be notified when new challenges drop. -## Discover Problems - -The CLI supports everything Discord does, so you can also discover which leaderboards are available. To make discovery more pleasant we also offer a TUI experience. - -```bash -popcorn-cli submit -``` - glhf! diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index b2322b8..148e74f 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; mod admin; mod auth; +mod submissions; mod submit; pub use admin::AdminAction; @@ -72,6 +73,34 @@ enum AuthProvider { Github, } +#[derive(Subcommand, Debug)] +enum SubmissionsAction { + /// List your submissions for a leaderboard + List { + /// Leaderboard name (required) + #[arg(long)] + leaderboard: String, + + /// Maximum number of submissions to show + #[arg(long, default_value = "50")] + limit: i32, + }, + /// Show a specific submission with full details and code + Show { + /// Submission ID + id: i64, + }, + /// Delete a submission + Delete { + /// Submission ID + id: i64, + + /// Skip confirmation prompt + #[arg(long)] + force: bool, + }, +} + #[derive(Subcommand, Debug)] enum Commands { Reregister { @@ -111,6 +140,11 @@ enum Commands { #[command(subcommand)] action: AdminAction, }, + /// Manage your submissions + Submissions { + #[command(subcommand)] + action: SubmissionsAction, + }, } pub async fn execute(cli: Cli) -> Result<()> { @@ -172,6 +206,26 @@ pub async fn execute(cli: Cli) -> Result<()> { } } Some(Commands::Admin { action }) => admin::handle_admin(action).await, + Some(Commands::Submissions { action }) => { + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + + match action { + SubmissionsAction::List { leaderboard, limit } => { + submissions::list_submissions(cli_id, leaderboard, Some(limit)).await + } + SubmissionsAction::Show { id } => submissions::show_submission(cli_id, id).await, + SubmissionsAction::Delete { id, force } => { + submissions::delete_submission(cli_id, id, force).await + } + } + } None => { // Check if any of the submission-related flags were used at the top level if cli.gpu.is_some() || cli.leaderboard.is_some() || cli.mode.is_some() { diff --git a/src/cmd/submissions.rs b/src/cmd/submissions.rs new file mode 100644 index 0000000..4a9fddc --- /dev/null +++ b/src/cmd/submissions.rs @@ -0,0 +1,169 @@ +use anyhow::Result; +use std::io::{self, Write}; + +use crate::service; + +/// List user's submissions for a leaderboard +pub async fn list_submissions( + cli_id: String, + leaderboard: String, + limit: Option, +) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + let submissions = service::get_user_submissions(&client, Some(&leaderboard), limit).await?; + + if submissions.is_empty() { + println!("No submissions found."); + return Ok(()); + } + + // Print header + println!( + "{:<8} {:<20} {:<20} {:<20} {:<12} {:<10} {:>10}", + "ID", "Leaderboard", "File", "Time", "GPU(s)", "Status", "Score" + ); + println!("{}", "-".repeat(105)); + + // Print each submission + for sub in submissions { + let status = if sub.done { "done" } else { "pending" }; + + // Collect all GPU types and best score from runs + let gpus: Vec<&str> = sub.runs.iter().map(|r| r.gpu_type.as_str()).collect(); + let gpu_display = if gpus.is_empty() { + "-".to_string() + } else { + gpus.join(",") + }; + + // Get best score (lowest) + let best_score = sub + .runs + .iter() + .filter_map(|r| r.score) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let score_display = best_score + .map(|s| format!("{:.4}", s)) + .unwrap_or_else(|| "-".to_string()); + + let time = truncate(&sub.submission_time, 19); + + println!( + "{:<8} {:<20} {:<20} {:<20} {:<12} {:<10} {:>10}", + sub.id, + truncate(&sub.leaderboard_name, 19), + truncate(&sub.file_name, 19), + time, + truncate(&gpu_display, 11), + status, + score_display + ); + } + + Ok(()) +} + +/// Show a specific submission with full details +pub async fn show_submission(cli_id: String, submission_id: i64) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + let sub = service::get_user_submission(&client, submission_id).await?; + + println!("Submission #{}", sub.id); + println!("{}", "=".repeat(60)); + println!( + "Leaderboard: {} (id: {})", + sub.leaderboard_name, sub.leaderboard_id + ); + println!("File: {}", sub.file_name); + println!("User ID: {}", sub.user_id); + println!("Submitted: {}", sub.submission_time); + println!( + "Status: {}", + if sub.done { "done" } else { "pending" } + ); + + if !sub.runs.is_empty() { + println!("\nRuns:"); + for run in &sub.runs { + let score_str = run + .score + .map(|s| format!("{:.4}", s)) + .unwrap_or_else(|| "-".to_string()); + let status = if run.passed { "passed" } else { "failed" }; + let secret_marker = if run.secret { " [secret]" } else { "" }; + let time_info = match (&run.start_time, &run.end_time) { + (Some(start), Some(end)) => format!(" ({} - {})", start, end), + (Some(start), None) => format!(" (started: {})", start), + _ => String::new(), + }; + println!( + " - {} on {}: {} (score: {}){}{}", + run.mode, run.runner, status, score_str, secret_marker, time_info + ); + } + } + + println!("\nCode:"); + println!("{}", "-".repeat(60)); + println!("{}", sub.code); + + Ok(()) +} + +/// Delete a submission with confirmation +pub async fn delete_submission(cli_id: String, submission_id: i64, force: bool) -> Result<()> { + let client = service::create_client(Some(cli_id))?; + + // Fetch submission first to show preview + let sub = service::get_user_submission(&client, submission_id).await?; + + println!("Submission #{}", sub.id); + println!("Leaderboard: {}", sub.leaderboard_name); + println!("File: {}", sub.file_name); + println!("Submitted: {}", sub.submission_time); + + // Show first 20 lines of code + println!("\nCode preview:"); + println!("{}", "-".repeat(60)); + let lines: Vec<&str> = sub.code.lines().take(20).collect(); + for line in &lines { + println!("{}", line); + } + if sub.code.lines().count() > 20 { + println!("... ({} more lines)", sub.code.lines().count() - 20); + } + println!("{}", "-".repeat(60)); + + // Ask for confirmation unless --force + if !force { + print!("\nDelete this submission? [y/N]: "); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if !input.trim().eq_ignore_ascii_case("y") { + println!("Cancelled."); + return Ok(()); + } + } + + // Delete the submission + let result = service::delete_user_submission(&client, submission_id).await?; + if result.get("status").and_then(|s| s.as_str()) == Some("ok") { + println!("Submission {} deleted successfully.", submission_id); + } else { + println!("Submission deleted."); + } + + Ok(()) +} + +/// Truncate a string to max length, adding "..." if truncated +fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len - 3]) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index dd199e1..4853d23 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -49,3 +49,47 @@ pub enum AppState { SubmissionModeSelection, WaitingForResult, } + +/// Summary of a user submission for list view +#[derive(Clone, Debug)] +pub struct UserSubmission { + pub id: i64, + pub leaderboard_name: String, + pub file_name: String, + pub submission_time: String, + pub done: bool, + pub runs: Vec, +} + +/// A run summary for list view (gpu_type and score only) +#[derive(Clone, Debug)] +pub struct UserSubmissionRun { + pub gpu_type: String, + pub score: Option, +} + +/// Full submission details including code and runs +#[derive(Clone, Debug)] +pub struct SubmissionDetails { + pub id: i64, + pub leaderboard_id: i64, + pub leaderboard_name: String, + pub file_name: String, + pub user_id: String, + pub submission_time: String, + pub done: bool, + pub code: String, + pub runs: Vec, +} + +/// A single run within a submission +#[derive(Clone, Debug)] +pub struct SubmissionRun { + pub start_time: Option, + pub end_time: Option, + pub mode: String, + pub secret: bool, + pub runner: String, + pub score: Option, + pub passed: bool, +} diff --git a/src/service/mod.rs b/src/service/mod.rs index 1eb135e..6293696 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -10,7 +10,9 @@ use std::path::Path; use std::time::Duration; use tokio::io::AsyncWriteExt; -use crate::models::{GpuItem, LeaderboardItem}; +use crate::models::{ + GpuItem, LeaderboardItem, SubmissionDetails, SubmissionRun, UserSubmission, UserSubmissionRun, +}; // Helper function to create a reusable reqwest client pub fn create_client(cli_id: Option) -> Result { @@ -291,6 +293,160 @@ pub async fn fetch_gpus(client: &Client, leaderboard: &str) -> Result, + limit: Option, +) -> Result> { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let mut url = format!("{}/user/submissions", base_url); + let mut params = Vec::new(); + if let Some(lb) = leaderboard { + params.push(format!("leaderboard={}", lb)); + } + if let Some(l) = limit { + params.push(format!("limit={}", l)); + } + if !params.is_empty() { + url = format!("{}?{}", url, params.join("&")); + } + + let resp = client + .get(&url) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + let submissions: Vec = resp.json().await?; + + let mut result = Vec::new(); + for sub in submissions { + let runs = sub["runs"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| UserSubmissionRun { + gpu_type: r["gpu_type"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + }) + .collect() + }) + .unwrap_or_default(); + + result.push(UserSubmission { + id: sub["id"].as_i64().unwrap_or(0), + leaderboard_name: sub["leaderboard_name"].as_str().unwrap_or("").to_string(), + file_name: sub["file_name"].as_str().unwrap_or("").to_string(), + submission_time: sub["submission_time"].as_str().unwrap_or("").to_string(), + done: sub["done"].as_bool().unwrap_or(false), + runs, + }); + } + + Ok(result) +} + +/// Get a specific submission by ID (with code) +pub async fn get_user_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!("{}/user/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + let sub: Value = resp.json().await?; + + let runs = sub["runs"] + .as_array() + .map(|arr| { + arr.iter() + .map(|r| SubmissionRun { + start_time: r["start_time"].as_str().map(str::to_string), + end_time: r["end_time"].as_str().map(str::to_string), + mode: r["mode"].as_str().unwrap_or("").to_string(), + secret: r["secret"].as_bool().unwrap_or(false), + runner: r["runner"].as_str().unwrap_or("").to_string(), + score: r["score"].as_f64(), + passed: r["passed"].as_bool().unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default(); + + Ok(SubmissionDetails { + id: sub["id"].as_i64().unwrap_or(0), + leaderboard_id: sub["leaderboard_id"].as_i64().unwrap_or(0), + leaderboard_name: sub["leaderboard_name"].as_str().unwrap_or("").to_string(), + file_name: sub["file_name"].as_str().unwrap_or("").to_string(), + user_id: sub["user_id"].as_str().unwrap_or("").to_string(), + submission_time: sub["submission_time"].as_str().unwrap_or("").to_string(), + done: sub["done"].as_bool().unwrap_or(false), + code: sub["code"].as_str().unwrap_or("").to_string(), + runs, + }) +} + +/// Delete a user's submission by ID +pub async fn delete_user_submission(client: &Client, submission_id: i64) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .delete(format!("{}/user/submissions/{}", base_url, submission_id)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) +} + pub async fn submit_solution>( client: &Client, filepath: P, From 7eca36da865fc9890081b8a9c0c18f68223ff1e6 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 8 Feb 2026 22:34:11 -0800 Subject: [PATCH 078/111] Add docs for installing extra Python dependencies in submissions --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index b064a5f..8a7582b 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,22 @@ popcorn submit solution.py Submissions are always a single Python file. If you want to submit native CUDA code, you can use PyTorch's `load_inline` feature (which uses nvcc) or the more experimental [`compile_kernel` API](https://x.com/gaunernst/status/2015242181049745607) for fast compilation. See [this example](https://github.com/gpu-mode/reference-kernels/blob/main/problems/pmpp_v2/vectoradd_py/solutions/correct/submission_cuda_inline.py) for reference. +### Installing Extra Dependencies + +If your submission requires a Python package that isn't pre-installed in the runtime environment, you can install it directly in your submission file: + +```python +import subprocess +import sys +subprocess.check_call([sys.executable, "-m", "pip", "install", "some_package"]) +``` + +This runs before the rest of your code executes, so the package will be available for import afterwards. + +If you find yourself installing the same package frequently, we're happy to add it to the runtime by default. Open a PR on [gpu-mode/kernelbot](https://github.com/gpu-mode/kernelbot): +- For Modal-based runners: edit [`src/runners/modal_runner.py`](https://github.com/gpu-mode/kernelbot/blob/main/src/runners/modal_runner.py) +- For on-prem hardware: look for the Dockerfiles in the same repo + For syntax highlighting of both C++ and Python in your IDE, you can use the [PyTorch Load Inline Highlighter](https://marketplace.visualstudio.com/items?itemName=msaroufim.pytorch-load-inline-highlighter) VS Code extension. ## Reference Kernels From 041d0e20ad27d30d74c423f969f0fe4b320be7f0 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sun, 8 Feb 2026 22:46:12 -0800 Subject: [PATCH 079/111] Move admin commands and contributor docs to CONTRIBUTING.md --- CLAUDE.md | 222 ++-------------------------------------------- CONTRIBUTING.md | 227 ++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 25 ------ 3 files changed, 233 insertions(+), 241 deletions(-) create mode 100644 CONTRIBUTING.md diff --git a/CLAUDE.md b/CLAUDE.md index 6372cb0..ca533de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,222 +1,12 @@ # CLAUDE.md -## Contributing +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, architecture, and admin commands. -### Development Setup +## Quick Reference ```bash -# Build -cargo build - -# Run tests -cargo test - -# Format code (required before commits) -cargo fmt --all - -# Lint (must pass with no warnings) -cargo clippy --all-targets --all-features -- -D warnings -``` - -### CI Requirements - -All PRs must pass: -- `cargo fmt --all -- --check` - Code formatting -- `cargo clippy -- -D warnings` - No clippy warnings allowed -- `cargo test` - All tests pass -- Builds on Linux, macOS, and Windows - -### Testing - -#### Unit Tests - -Tests are in the same file as the code (Rust convention): -- `src/service/mod.rs` - API client tests -- `src/utils/mod.rs` - Utility function tests - -Run all tests: -```bash -cargo test +cargo build # Build +cargo test # Run tests +cargo fmt --all # Format (required before commits) +cargo clippy --all-targets --all-features -- -D warnings # Lint (must pass with no warnings) ``` - -Run specific tests: -```bash -cargo test test_name -``` - -#### Test Requirements - -When adding new functionality: - -1. **Service functions** (`src/service/mod.rs`): - - Add tests in the `#[cfg(test)] mod tests` block - - Test error handling, response parsing - -2. **Command handlers** (`src/cmd/`): - - Integration testing via E2E regression tests - -#### E2E Regression Testing - -Full end-to-end testing requires a running kernelbot API server. You can test against production or a local instance. - -##### Option A: Test Against Production - -```bash -export POPCORN_API_URL=https://discord-cluster-manager-1f6c4782e60a.herokuapp.com -cargo run -- submissions list --leaderboard grayscale -``` - -##### Option B: Test Against Local Server (Recommended for Development) - -This tests the complete flow: CLI → API → Database → Modal runner. - -**Step 1: Set up kernelbot server** (in the kernelbot repo): - -```bash -# Start PostgreSQL -brew services start postgresql@14 - -# Create database and run migrations -createdb kernelbot -export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot" -uv run yoyo apply --database "$DATABASE_URL" src/migrations/ - -# Create test user -psql "$DATABASE_URL" -c "INSERT INTO leaderboard.user_info (id, user_name, cli_id, cli_valid) -VALUES ('999999', 'testuser', 'test-cli-id-123', true) -ON CONFLICT (id) DO UPDATE SET cli_id = 'test-cli-id-123', cli_valid = true;" - -# Start API server -cd src/kernelbot -export ADMIN_TOKEN="your-admin-token" # Check .env for LOCAL_ADMIN_TOKEN -uv run python main.py --api-only -``` - -**Step 2: Sync leaderboards**: - -```bash -curl -X POST "http://localhost:8000/admin/update-problems" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"problem_set": "pmpp_v2"}' -``` - -**Step 3: Configure CLI for local testing**: - -```bash -# Backup and set test config -cp ~/.popcorn.yaml ~/.popcorn.yaml.bak -echo "cli_id: test-cli-id-123" > ~/.popcorn.yaml -``` - -**Step 4: Run CLI commands**: - -```bash -export POPCORN_API_URL=http://localhost:8000 - -# Test submissions commands -cargo run --release -- submissions list --leaderboard vectoradd_v2 -cargo run --release -- submissions show -cargo run --release -- submissions delete - -# Test actual submission (requires Modal account for GPU execution) -cargo run --release -- submit solution.py --gpu H100 --leaderboard vectoradd_v2 --mode test -``` - -**Step 5: Restore original config**: - -```bash -cp ~/.popcorn.yaml.bak ~/.popcorn.yaml && rm ~/.popcorn.yaml.bak -``` - -##### Troubleshooting - -- **401 Unauthorized**: CLI ID not registered in database - create test user first -- **404 Not Found**: Leaderboards not synced - run update-problems endpoint -- **Connection refused**: API server not running on localhost:8000 -- **"Device not configured"**: TTY issue - ensure POPCORN_API_URL is set - -## Architecture Overview - -Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. - -### Directory Structure - -``` -src/ -├── main.rs # Entry point, sets POPCORN_API_URL -├── cmd/ # Command handling -│ ├── mod.rs # CLI argument parsing (clap), config loading -│ ├── admin.rs # Admin commands (requires POPCORN_ADMIN_TOKEN) -│ ├── auth.rs # OAuth authentication (Discord/GitHub) -│ ├── submissions.rs # User submission management (list, show, delete) -│ └── submit.rs # Submission logic, TUI app state machine -├── service/ -│ └── mod.rs # HTTP client, API calls, SSE streaming -├── models/ -│ └── mod.rs # Data structures (LeaderboardItem, GpuItem, AppState) -├── utils/ -│ └── mod.rs # Directive parsing, text wrapping, ASCII art -└── views/ - ├── loading_page.rs # TUI loading screen with progress bar - └── result_page.rs # TUI results display with scrolling -``` - -### Before Adding New Features - -**Important:** Before implementing new functionality, check for existing code in both repos: - -1. **Check discord-cluster-manager** for existing Discord commands and database methods: - - `src/kernelbot/cogs/` - Discord bot commands - - `src/libkernelbot/leaderboard_db.py` - Database methods - - `src/kernelbot/api/main.py` - Existing API endpoints - -2. **Check popcorn-cli** for existing service functions and commands: - - `src/service/mod.rs` - API client functions - - `src/cmd/` - CLI command handlers - -3. **Reuse existing functionality** where possible: - - Database methods (e.g., `get_submission_by_id`, `delete_submission`) - - API response handling patterns - - Authentication validation (`validate_user_header`, `validate_cli_header`) - -### Core Flow - -1. **Authentication** (`cmd/auth.rs`): User registers via Discord/GitHub OAuth. CLI ID stored in `~/.popcorn.yaml`. - -2. **Submission** (`cmd/submit.rs`): - - TUI mode: Interactive selection of leaderboard → GPU → mode - - Plain mode (`--no-tui`): Direct submission with CLI flags - - Reads solution file with optional `#!POPCORN` directives for defaults - -3. **API Communication** (`service/mod.rs`): - - Fetches available leaderboards and GPUs - - Submits solutions via multipart form POST - - Handles SSE (Server-Sent Events) streaming for real-time results - - Supports modes: `test`, `benchmark`, `leaderboard`, `profile` - -### File Directives - -Users can embed defaults in their solution files: - -```python -#!POPCORN leaderboard amd-fp8-mm -#!POPCORN gpu MI300 - -def solution(): - ... -``` - -Or C++ style: -```cpp -//!POPCORN leaderboard nvidia-matmul -//!POPCORN gpu H100 -``` - -### Key Dependencies - -- `clap` - CLI argument parsing -- `ratatui` + `crossterm` - Terminal UI -- `reqwest` - HTTP client with SSE streaming -- `tokio` - Async runtime -- `serde` / `serde_yaml` / `serde_json` - Serialization diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c06787a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,227 @@ +# Contributing + +## Development Setup + +```bash +# Build +cargo build + +# Run tests +cargo test + +# Format code (required before commits) +cargo fmt --all + +# Lint (must pass with no warnings) +cargo clippy --all-targets --all-features -- -D warnings +``` + +### CI Requirements + +All PRs must pass: +- `cargo fmt --all -- --check` - Code formatting +- `cargo clippy -- -D warnings` - No clippy warnings allowed +- `cargo test` - All tests pass +- Builds on Linux, macOS, and Windows + +## Testing + +### Unit Tests + +Tests are in the same file as the code (Rust convention): +- `src/service/mod.rs` - API client tests +- `src/utils/mod.rs` - Utility function tests + +Run all tests: +```bash +cargo test +``` + +Run specific tests: +```bash +cargo test test_name +``` + +### Test Requirements + +When adding new functionality: + +1. **Service functions** (`src/service/mod.rs`): + - Add tests in the `#[cfg(test)] mod tests` block + - Test error handling, response parsing + +2. **Command handlers** (`src/cmd/`): + - Integration testing via E2E regression tests + +### E2E Regression Testing + +Full end-to-end testing requires a running kernelbot API server. You can test against production or a local instance. + +#### Option A: Test Against Production + +```bash +export POPCORN_API_URL=https://discord-cluster-manager-1f6c4782e60a.herokuapp.com +cargo run -- submissions list --leaderboard grayscale +``` + +#### Option B: Test Against Local Server (Recommended for Development) + +This tests the complete flow: CLI → API → Database → Modal runner. + +**Step 1: Set up kernelbot server** (in the kernelbot repo): + +```bash +# Start PostgreSQL +brew services start postgresql@14 + +# Create database and run migrations +createdb kernelbot +export DATABASE_URL="postgresql://$(whoami)@localhost:5432/kernelbot" +uv run yoyo apply --database "$DATABASE_URL" src/migrations/ + +# Create test user +psql "$DATABASE_URL" -c "INSERT INTO leaderboard.user_info (id, user_name, cli_id, cli_valid) +VALUES ('999999', 'testuser', 'test-cli-id-123', true) +ON CONFLICT (id) DO UPDATE SET cli_id = 'test-cli-id-123', cli_valid = true;" + +# Start API server +cd src/kernelbot +export ADMIN_TOKEN="your-admin-token" # Check .env for LOCAL_ADMIN_TOKEN +uv run python main.py --api-only +``` + +**Step 2: Sync leaderboards**: + +```bash +curl -X POST "http://localhost:8000/admin/update-problems" \ + -H "Authorization: Bearer $ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"problem_set": "pmpp_v2"}' +``` + +**Step 3: Configure CLI for local testing**: + +```bash +# Backup and set test config +cp ~/.popcorn.yaml ~/.popcorn.yaml.bak +echo "cli_id: test-cli-id-123" > ~/.popcorn.yaml +``` + +**Step 4: Run CLI commands**: + +```bash +export POPCORN_API_URL=http://localhost:8000 + +# Test submissions commands +cargo run --release -- submissions list --leaderboard vectoradd_v2 +cargo run --release -- submissions show +cargo run --release -- submissions delete + +# Test actual submission (requires Modal account for GPU execution) +cargo run --release -- submit solution.py --gpu H100 --leaderboard vectoradd_v2 --mode test +``` + +**Step 5: Restore original config**: + +```bash +cp ~/.popcorn.yaml.bak ~/.popcorn.yaml && rm ~/.popcorn.yaml.bak +``` + +#### Troubleshooting + +- **401 Unauthorized**: CLI ID not registered in database - create test user first +- **404 Not Found**: Leaderboards not synced - run update-problems endpoint +- **Connection refused**: API server not running on localhost:8000 +- **"Device not configured"**: TTY issue - ensure POPCORN_API_URL is set + +## Admin Commands + +Admin commands require the `POPCORN_ADMIN_TOKEN` environment variable. + +```bash +# Server control +popcorn admin start # Start accepting jobs +popcorn admin stop # Stop accepting jobs +popcorn admin stats # Get server statistics +popcorn admin stats --last-day # Stats for last 24 hours only + +# Submission management +popcorn admin get-submission # Get any submission by ID +popcorn admin delete-submission # Delete any submission + +# Leaderboard management +popcorn admin create-leaderboard # Create leaderboard from problem directory +popcorn admin delete-leaderboard # Delete a leaderboard +popcorn admin delete-leaderboard --force # Force delete with submissions + +# Update problems from GitHub +popcorn admin update-problems +popcorn admin update-problems --problem-set nvidia --force +``` + +## Architecture Overview + +Popcorn CLI is a command-line tool for submitting GPU kernel optimization solutions to [gpumode.com](https://gpumode.com) competitions. + +### Directory Structure + +``` +src/ +├── main.rs # Entry point, sets POPCORN_API_URL +├── cmd/ # Command handling +│ ├── mod.rs # CLI argument parsing (clap), config loading +│ ├── admin.rs # Admin commands (requires POPCORN_ADMIN_TOKEN) +│ ├── auth.rs # OAuth authentication (Discord/GitHub) +│ ├── submissions.rs # User submission management (list, show, delete) +│ └── submit.rs # Submission logic, TUI app state machine +├── service/ +│ └── mod.rs # HTTP client, API calls, SSE streaming +├── models/ +│ └── mod.rs # Data structures (LeaderboardItem, GpuItem, AppState) +├── utils/ +│ └── mod.rs # Directive parsing, text wrapping, ASCII art +└── views/ + ├── loading_page.rs # TUI loading screen with progress bar + └── result_page.rs # TUI results display with scrolling +``` + +### Before Adding New Features + +**Important:** Before implementing new functionality, check for existing code in both repos: + +1. **Check discord-cluster-manager** for existing Discord commands and database methods: + - `src/kernelbot/cogs/` - Discord bot commands + - `src/libkernelbot/leaderboard_db.py` - Database methods + - `src/kernelbot/api/main.py` - Existing API endpoints + +2. **Check popcorn-cli** for existing service functions and commands: + - `src/service/mod.rs` - API client functions + - `src/cmd/` - CLI command handlers + +3. **Reuse existing functionality** where possible: + - Database methods (e.g., `get_submission_by_id`, `delete_submission`) + - API response handling patterns + - Authentication validation (`validate_user_header`, `validate_cli_header`) + +### Core Flow + +1. **Authentication** (`cmd/auth.rs`): User registers via Discord/GitHub OAuth. CLI ID stored in `~/.popcorn.yaml`. + +2. **Submission** (`cmd/submit.rs`): + - TUI mode: Interactive selection of leaderboard → GPU → mode + - Plain mode (`--no-tui`): Direct submission with CLI flags + - Reads solution file with optional `#!POPCORN` directives for defaults + +3. **API Communication** (`service/mod.rs`): + - Fetches available leaderboards and GPUs + - Submits solutions via multipart form POST + - Handles SSE (Server-Sent Events) streaming for real-time results + - Supports modes: `test`, `benchmark`, `leaderboard`, `profile` + +### Key Dependencies + +- `clap` - CLI argument parsing +- `ratatui` + `crossterm` - Terminal UI +- `reqwest` - HTTP client with SSE streaming +- `tokio` - Async runtime +- `serde` / `serde_yaml` / `serde_json` - Serialization diff --git a/README.md b/README.md index 8a7582b..95a4e06 100644 --- a/README.md +++ b/README.md @@ -129,31 +129,6 @@ popcorn reregister discord popcorn reregister github ``` -### Admin Commands - -Admin commands require the `POPCORN_ADMIN_TOKEN` environment variable. - -```bash -# Server control -popcorn admin start # Start accepting jobs -popcorn admin stop # Stop accepting jobs -popcorn admin stats # Get server statistics -popcorn admin stats --last-day # Stats for last 24 hours only - -# Submission management -popcorn admin get-submission # Get any submission by ID -popcorn admin delete-submission # Delete any submission - -# Leaderboard management -popcorn admin create-leaderboard # Create leaderboard from problem directory -popcorn admin delete-leaderboard # Delete a leaderboard -popcorn admin delete-leaderboard --force # Force delete with submissions - -# Update problems from GitHub -popcorn admin update-problems -popcorn admin update-problems --problem-set nvidia --force -``` - ### File Directives You can embed default settings directly in your solution files: From 178f408634c24f4b990f7ddce5ed229c169b6234 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Tue, 10 Feb 2026 18:49:15 -0800 Subject: [PATCH 080/111] Support archive (tarball/zip) submissions for model competitions (#34) - Read submission files as bytes (Vec) instead of String to handle binary archives without crashing on invalid UTF-8 - Change submit_solution signature from &str to &[u8] for file content - Add is_archive_file() helper to detect .tar.gz, .tgz, .zip files - Skip popcorn directive parsing for archive files (return empty directives) --- src/cmd/submit.rs | 12 ++++++------ src/service/mod.rs | 6 +++--- src/utils/mod.rs | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/cmd/submit.rs b/src/cmd/submit.rs index a508b12..ec8e6fb 100644 --- a/src/cmd/submit.rs +++ b/src/cmd/submit.rs @@ -295,10 +295,10 @@ impl App { .clone() .ok_or_else(|| anyhow!("Submission mode not selected"))?; - // Read file content + // Read file content as bytes (supports both text and archive files) let mut file = File::open(&filepath)?; - let mut file_content = String::new(); - file.read_to_string(&mut file_content)?; + let mut file_content = Vec::new(); + file.read_to_end(&mut file_content)?; self.submission_task = Some(tokio::spawn(async move { service::submit_solution( @@ -734,10 +734,10 @@ pub async fn run_submit_plain( anyhow!("Submission mode not specified. Use --mode flag (test, benchmark, leaderboard, profile)") })?; - // Read file content + // Read file content as bytes (supports both text and archive files) let mut file = File::open(&file_to_submit)?; - let mut file_content = String::new(); - file.read_to_string(&mut file_content)?; + let mut file_content = Vec::new(); + file.read_to_end(&mut file_content)?; eprintln!("Submitting to leaderboard: {}", final_leaderboard); eprintln!("GPU: {}", final_gpu); diff --git a/src/service/mod.rs b/src/service/mod.rs index 6293696..906877a 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -450,7 +450,7 @@ pub async fn delete_user_submission(client: &Client, submission_id: i64) -> Resu pub async fn submit_solution>( client: &Client, filepath: P, - file_content: &str, + file_content: &[u8], leaderboard: &str, gpu: &str, submission_mode: &str, @@ -465,7 +465,7 @@ pub async fn submit_solution>( .ok_or_else(|| anyhow!("Invalid filepath"))? .to_string_lossy(); - let part = Part::bytes(file_content.as_bytes().to_vec()).file_name(filename.to_string()); + let part = Part::bytes(file_content.to_vec()).file_name(filename.to_string()); let form = Form::new().part("file", part); @@ -824,7 +824,7 @@ mod tests { let result = submit_solution( &client, "test.py", - "print('hello')", + b"print('hello')", "test-leaderboard", "H100", "test", diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 383a171..7bf7b7a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -7,7 +7,28 @@ pub struct PopcornDirectives { pub gpus: Vec, } +pub fn is_archive_file>(filepath: P) -> bool { + let path = filepath.as_ref(); + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + name.ends_with(".tar.gz") || name.ends_with(".tgz") || name.ends_with(".zip") +} + pub fn get_popcorn_directives>(filepath: P) -> Result<(PopcornDirectives, bool)> { + // Archive files (tarballs, zips) are binary and cannot contain directives + if is_archive_file(&filepath) { + return Ok(( + PopcornDirectives { + leaderboard_name: String::new(), + gpus: Vec::new(), + }, + false, + )); + } + let content = fs::read_to_string(filepath)?; let mut gpus: Vec = Vec::new(); From e0706277066b6c8aba965331cc4db9a6d10a243b Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Tue, 17 Feb 2026 13:53:30 -0800 Subject: [PATCH 081/111] Update README examples to use pmpp_v2 leaderboard names --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 95a4e06..888b958 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ Sometimes you'll get an error that you're already authenticated despite being un ## Make your first submission ```bash -wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/main/problems/pmpp/grayscale_py/submission.py -popcorn-cli submit --gpu A100 --leaderboard grayscale --mode leaderboard submission.py +wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/refs/heads/main/problems/pmpp_v2/grayscale_py/submission.py +popcorn-cli submit --gpu A100 --leaderboard grayscale_v2 --mode leaderboard submission.py ``` We regularly run competitions with clear due dates but for beginners we will always keep open the PMPP_v2 problem set https://github.com/gpu-mode/reference-kernels/tree/main/problems/pmpp_v2 @@ -79,13 +79,13 @@ Submit a solution to a leaderboard. Supports both TUI (interactive) and plain mo popcorn submit solution.py # Direct submission with all options -popcorn submit --leaderboard grayscale --gpu A100 --mode leaderboard solution.py +popcorn submit --leaderboard grayscale_v2 --gpu A100 --mode leaderboard solution.py # Plain output mode (no TUI, good for CI/scripts) -popcorn submit --no-tui --leaderboard grayscale --gpu A100 --mode test solution.py +popcorn submit --no-tui --leaderboard grayscale_v2 --gpu A100 --mode test solution.py # Save results to a file -popcorn submit --output results.json --leaderboard grayscale --gpu A100 --mode benchmark solution.py +popcorn submit --output results.json --leaderboard grayscale_v2 --gpu A100 --mode benchmark solution.py ``` **Submission modes:** @@ -100,10 +100,10 @@ Manage your past submissions. ```bash # List your submissions for a leaderboard -popcorn submissions list --leaderboard grayscale +popcorn submissions list --leaderboard grayscale_v2 # Limit number of results -popcorn submissions list --leaderboard grayscale --limit 10 +popcorn submissions list --leaderboard grayscale_v2 --limit 10 # View a specific submission with full code popcorn submissions show @@ -134,7 +134,7 @@ popcorn reregister github You can embed default settings directly in your solution files: ```python -#!POPCORN leaderboard grayscale +#!POPCORN leaderboard grayscale_v2 #!POPCORN gpu A100 def solution(): From 12017829766381dac6d76f19c85dd174ed3bb749 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Tue, 17 Feb 2026 14:44:22 -0800 Subject: [PATCH 082/111] add skills files (#37) * add skills files * implement a setup command to add skills and symlink * update readme with new command * Make `popcorn setup` pull templates from reference-kernels Instead of writing a hardcoded submission template, setup now fetches the competition index YAMLs from gpu-mode/reference-kernels, lets the user interactively pick a competition, problem, and GPU, then downloads the real submission.py with the correct #!POPCORN directives injected. Setup always overwrites its own files on re-run (removed --force flag). Also adds a load-inline-native-code skill with CUDA and HIP templates for writing kernels via torch.utils.cpp_extension.load_inline(). --------- Co-authored-by: burtenshaw --- .gitignore | 3 + AGENTS.md | 10 + README.md | 11 + src/cmd/mod.rs | 4 + src/cmd/setup.rs | 489 ++++++++++++++++++ templates/setup/AGENTS.md | 11 + .../skills/load-inline-native-code/SKILL.md | 143 +++++ .../popcorn-submission-workflow/SKILL.md | 32 ++ 8 files changed, 703 insertions(+) create mode 100644 AGENTS.md create mode 100644 src/cmd/setup.rs create mode 100644 templates/setup/AGENTS.md create mode 100644 templates/setup/skills/load-inline-native-code/SKILL.md create mode 100644 templates/setup/skills/popcorn-submission-workflow/SKILL.md diff --git a/.gitignore b/.gitignore index 300478e..47e7497 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ target/ scratch.md *claude *.zip +.codex/ +.popcorn/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1ab8e15 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +## Skills +A skill is a local instruction bundle stored in `SKILL.md`. + +### Available skills +- popcorn-submission-workflow: Helps with Popcorn CLI registration, submission setup, submission modes, and file directives. (file: /Users/ben/code/popcorn-cli/.popcorn/skills/popcorn-submission-workflow/SKILL.md) + +### How to use skills +- Load the skill by reading its `SKILL.md` file when user requests match the description. +- Follow progressive disclosure: read only relevant referenced files/scripts as needed. +- Keep the workspace setup aligned with `popcorn setup`. diff --git a/README.md b/README.md index 888b958..2ea85a6 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,17 @@ We regularly run competitions with clear due dates but for beginners we will alw ## Commands +### Setup + +Bootstrap a project with Popcorn skill scaffolding and a submission template. You can overwrite existing files with `--force`. + +```bash +# Create project skill scaffolding + submission.py +popcorn setup +``` + +This will create a new agent skill based on the [templates](templates/setup) and add it to your `.claude/skills` or `.codex/skills` directory. + ### Submit Submit a solution to a leaderboard. Supports both TUI (interactive) and plain modes. diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 148e74f..35c3e2b 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -6,6 +6,7 @@ use std::path::PathBuf; mod admin; mod auth; +mod setup; mod submissions; mod submit; @@ -103,6 +104,8 @@ enum SubmissionsAction { #[derive(Subcommand, Debug)] enum Commands { + /// Bootstrap this project with Popcorn agent skills and a submission template + Setup, Reregister { #[command(subcommand)] provider: AuthProvider, @@ -149,6 +152,7 @@ enum Commands { pub async fn execute(cli: Cli) -> Result<()> { match cli.command { + Some(Commands::Setup) => setup::run_setup().await, Some(Commands::Reregister { provider }) => { let provider_str = match provider { AuthProvider::Discord => "discord", diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs new file mode 100644 index 0000000..2368a78 --- /dev/null +++ b/src/cmd/setup.rs @@ -0,0 +1,489 @@ +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; +use serde_json::json; +use std::env; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; + +const SKILL_NAME: &str = "popcorn-submission-workflow"; +const NATIVE_SKILL_NAME: &str = "load-inline-native-code"; +const SUBMISSION_FILENAME: &str = "submission.py"; +const SKILL_TEMPLATE: &str = + include_str!("../../templates/setup/skills/popcorn-submission-workflow/SKILL.md"); +const NATIVE_SKILL_TEMPLATE: &str = + include_str!("../../templates/setup/skills/load-inline-native-code/SKILL.md"); +const AGENTS_TEMPLATE: &str = include_str!("../../templates/setup/AGENTS.md"); + +const COMPETITION_YAMLS: &[&str] = &[ + "pmpp_v2.yaml", + "nvidia.yaml", + "amd.yaml", + "amd_distributed.yaml", + "bioml.yaml", +]; + +const RAW_GITHUB_BASE: &str = + "https://raw.githubusercontent.com/gpu-mode/reference-kernels/main/problems"; + +#[derive(Deserialize)] +struct CompetitionIndex { + name: String, + problems: Vec, +} + +#[derive(Deserialize)] +struct ProblemEntry { + directory: String, + name: String, + gpus: Vec, +} + +#[derive(Clone, Copy)] +enum ActionStatus { + Created, + Updated, + Skipped, +} + +impl ActionStatus { + fn label(self) -> &'static str { + match self { + Self::Created => "created", + Self::Updated => "updated", + Self::Skipped => "skipped", + } + } +} + +async fn fetch_competition_index(client: &reqwest::Client) -> Result> { + let mut entries = Vec::new(); + for filename in COMPETITION_YAMLS { + let url = format!("{}/{}", RAW_GITHUB_BASE, filename); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to fetch {}", url))?; + if !resp.status().is_success() { + eprintln!( + "Warning: could not fetch {} (status {})", + filename, + resp.status() + ); + continue; + } + let text = resp.text().await?; + let index: CompetitionIndex = + serde_yaml::from_str(&text).with_context(|| format!("Failed to parse {}", filename))?; + let comp_name = index.name.clone(); + for problem in index.problems { + entries.push((comp_name.clone(), problem)); + } + } + if entries.is_empty() { + return Err(anyhow!( + "No competitions found. Check your network connection." + )); + } + Ok(entries) +} + +async fn download_submission( + client: &reqwest::Client, + directory: &str, + leaderboard_name: &str, + gpu: &str, +) -> Result { + let url = format!("{}/{}/submission.py", RAW_GITHUB_BASE, directory); + let resp = client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to fetch {}", url))?; + if !resp.status().is_success() { + return Err(anyhow!( + "Failed to download submission.py from {} (status {})", + url, + resp.status() + )); + } + let body = resp.text().await?; + + // Strip existing #!POPCORN directives and leading blank lines + let content: String = body + .lines() + .skip_while(|line| line.starts_with("#!POPCORN") || line.trim().is_empty()) + .collect::>() + .join("\n"); + + Ok(format!( + "#!POPCORN leaderboard {}\n#!POPCORN gpu {}\n\n{}\n", + leaderboard_name, gpu, content + )) +} + +fn prompt_choice(prompt_text: &str, max: usize) -> Result { + loop { + print!("{}", prompt_text); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + match input.trim().parse::() { + Ok(n) if n >= 1 && n <= max => return Ok(n - 1), + _ => println!("Please enter a number between 1 and {}", max), + } + } +} + +pub async fn run_setup() -> Result<()> { + let cwd = env::current_dir().context("Failed to determine current directory")?; + + // Fetch competitions from GitHub + println!("Fetching competitions from gpu-mode/reference-kernels..."); + let client = reqwest::Client::new(); + let entries = fetch_competition_index(&client).await?; + + // Build unique competition list preserving order + let mut comp_names: Vec = Vec::new(); + for (name, _) in &entries { + if !comp_names.contains(name) { + comp_names.push(name.clone()); + } + } + + // Select competition + println!("\nAvailable competitions:"); + for (i, name) in comp_names.iter().enumerate() { + println!(" {}. {}", i + 1, name); + } + let comp_idx = prompt_choice( + &format!("\nSelect a competition [1-{}]: ", comp_names.len()), + comp_names.len(), + )?; + let chosen_comp = &comp_names[comp_idx]; + + // Filter problems for chosen competition + let problems: Vec<&ProblemEntry> = entries + .iter() + .filter(|(name, _)| name == chosen_comp) + .map(|(_, p)| p) + .collect(); + + // Select problem + println!("\nProblems in \"{}\":", chosen_comp); + for (i, p) in problems.iter().enumerate() { + println!(" {}. {}", i + 1, p.name); + } + let prob_idx = prompt_choice( + &format!("\nSelect a problem [1-{}]: ", problems.len()), + problems.len(), + )?; + let chosen_problem = problems[prob_idx]; + + // Select GPU + println!("\nAvailable GPUs for \"{}\":", chosen_problem.name); + for (i, gpu) in chosen_problem.gpus.iter().enumerate() { + println!(" {}. {}", i + 1, gpu); + } + let gpu_idx = prompt_choice( + &format!("\nSelect a GPU [1-{}]: ", chosen_problem.gpus.len()), + chosen_problem.gpus.len(), + )?; + let chosen_gpu = &chosen_problem.gpus[gpu_idx]; + + // Download submission template + println!( + "\nDownloading submission template for {} on {}...", + chosen_problem.name, chosen_gpu + ); + let submission_content = download_submission( + &client, + &chosen_problem.directory, + &chosen_problem.name, + chosen_gpu, + ) + .await?; + + // Write scaffolding files + let popcorn_dir = cwd.join(".popcorn"); + let skill_dir = popcorn_dir.join("skills").join(SKILL_NAME); + let skill_path = skill_dir.join("SKILL.md"); + let native_skill_dir = popcorn_dir.join("skills").join(NATIVE_SKILL_NAME); + let native_skill_path = native_skill_dir.join("SKILL.md"); + let manifest_path = popcorn_dir.join("setup.json"); + let submission_path = cwd.join(SUBMISSION_FILENAME); + let agents_path = cwd.join("AGENTS.md"); + + fs::create_dir_all(&skill_dir).with_context(|| { + format!( + "Failed to create skill directory at {}", + skill_dir.to_string_lossy() + ) + })?; + fs::create_dir_all(&native_skill_dir).with_context(|| { + format!( + "Failed to create skill directory at {}", + native_skill_dir.to_string_lossy() + ) + })?; + + let readme_path = cwd.join("README.md"); + let readme_content = fs::read_to_string(&readme_path).unwrap_or_default(); + let skill_markdown = build_skill_markdown(&readme_content); + let skill_status = write_text_file(&skill_path, &skill_markdown, true)?; + + let native_skill_status = write_text_file(&native_skill_path, NATIVE_SKILL_TEMPLATE, true)?; + + let manifest = json!({ + "schema_version": 1, + "setup_source": "popcorn setup", + "skills": [ + { + "name": SKILL_NAME, + "path": format!(".popcorn/skills/{SKILL_NAME}") + }, + { + "name": NATIVE_SKILL_NAME, + "path": format!(".popcorn/skills/{NATIVE_SKILL_NAME}") + } + ], + "agents": ["codex", "claude"] + }); + let manifest_text = serde_json::to_string_pretty(&manifest)?; + let manifest_status = write_text_file(&manifest_path, &manifest_text, true)?; + + let agents_md = build_agents_markdown(&skill_path, &native_skill_path); + let agents_status = write_text_file(&agents_path, &agents_md, true)?; + + let codex_link_status = create_agent_skill_view(&cwd, "codex", &skill_dir, true)?; + let claude_link_status = create_agent_skill_view(&cwd, "claude", &skill_dir, true)?; + let codex_native_link_status = create_agent_skill_view(&cwd, "codex", &native_skill_dir, true)?; + let claude_native_link_status = + create_agent_skill_view(&cwd, "claude", &native_skill_dir, true)?; + + let submission_status = write_text_file(&submission_path, &submission_content, true)?; + + println!( + "{} {}", + skill_status.label(), + relative_display(&cwd, &skill_path) + ); + println!( + "{} {}", + native_skill_status.label(), + relative_display(&cwd, &native_skill_path) + ); + println!( + "{} {}", + manifest_status.label(), + relative_display(&cwd, &manifest_path) + ); + println!( + "{} {}", + agents_status.label(), + relative_display(&cwd, &agents_path) + ); + println!( + "{} {}", + codex_link_status.label(), + relative_display(&cwd, &cwd.join(".codex").join("skills").join(SKILL_NAME)) + ); + println!( + "{} {}", + codex_native_link_status.label(), + relative_display( + &cwd, + &cwd.join(".codex").join("skills").join(NATIVE_SKILL_NAME) + ) + ); + println!( + "{} {}", + claude_link_status.label(), + relative_display(&cwd, &cwd.join(".claude").join("skills").join(SKILL_NAME)) + ); + println!( + "{} {}", + claude_native_link_status.label(), + relative_display( + &cwd, + &cwd.join(".claude").join("skills").join(NATIVE_SKILL_NAME) + ) + ); + println!( + "{} {}", + submission_status.label(), + relative_display(&cwd, &submission_path) + ); + + Ok(()) +} + +fn relative_display(cwd: &Path, target: &Path) -> String { + match target.strip_prefix(cwd) { + Ok(relative) => relative.to_string_lossy().to_string(), + Err(_) => target.to_string_lossy().to_string(), + } +} + +fn write_text_file(path: &Path, content: &str, force: bool) -> Result { + let existed_before = path_exists(path); + if existed_before && !force { + return Ok(ActionStatus::Skipped); + } + + if existed_before { + remove_existing_path(path)?; + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(path, content)?; + if existed_before { + Ok(ActionStatus::Updated) + } else { + Ok(ActionStatus::Created) + } +} + +fn create_agent_skill_view( + cwd: &Path, + agent_name: &str, + skill_source_dir: &Path, + force: bool, +) -> Result { + let skill_dir_name = skill_source_dir + .file_name() + .ok_or_else(|| anyhow!("skill source dir has no file name"))?; + let agent_skills_dir = cwd.join(format!(".{}", agent_name)).join("skills"); + fs::create_dir_all(&agent_skills_dir)?; + + let link_path = agent_skills_dir.join(skill_dir_name); + let existed_before = path_exists(&link_path); + if existed_before && !force { + return Ok(ActionStatus::Skipped); + } + + if existed_before { + remove_existing_path(&link_path)?; + } + + let relative_target = PathBuf::from("../../.popcorn/skills").join(skill_dir_name); + let symlink_result = create_symlink_dir(&relative_target, &link_path); + if symlink_result.is_err() { + copy_dir_all(skill_source_dir, &link_path)?; + } + + if existed_before { + Ok(ActionStatus::Updated) + } else { + Ok(ActionStatus::Created) + } +} + +fn path_exists(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn remove_existing_path(path: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(path)?; + let file_type = metadata.file_type(); + if file_type.is_symlink() || file_type.is_file() { + fs::remove_file(path)?; + } else if file_type.is_dir() { + fs::remove_dir_all(path)?; + } + Ok(()) +} + +fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let from = entry.path(); + let to = dst.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&from, &to)?; + } else { + fs::copy(from, to)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn create_symlink_dir(target: &Path, link_path: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(target, link_path) +} + +#[cfg(windows)] +fn create_symlink_dir(target: &Path, link_path: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_dir(target, link_path) +} + +fn extract_top_level_section(content: &str, heading: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + let start = lines + .iter() + .position(|line| line.trim() == heading) + .map(|idx| idx + 1)?; + + let mut end = lines.len(); + for (idx, line) in lines.iter().enumerate().skip(start) { + if line.trim_start().starts_with("## ") { + end = idx; + break; + } + } + + let section = lines[start..end].join("\n").trim().to_string(); + if section.is_empty() { + None + } else { + Some(section) + } +} + +fn build_skill_markdown(readme_content: &str) -> String { + let authentication = extract_top_level_section(readme_content, "## Authentication") + .unwrap_or_else(|| "See project README for authentication details.".to_string()); + let commands = extract_top_level_section(readme_content, "## Commands") + .unwrap_or_else(|| "See project README for command usage.".to_string()); + let submission_format = extract_top_level_section(readme_content, "## Submission Format") + .unwrap_or_else(|| "Submissions are expected as a single Python file.".to_string()); + + render_template( + SKILL_TEMPLATE, + &[ + ("{{SKILL_NAME}}", SKILL_NAME), + ("{{AUTHENTICATION_SECTION}}", &authentication), + ("{{COMMANDS_SECTION}}", &commands), + ("{{SUBMISSION_FORMAT_SECTION}}", &submission_format), + ], + ) +} + +fn build_agents_markdown(skill_path: &Path, native_skill_path: &Path) -> String { + let skill_path_text = skill_path.to_string_lossy().to_string(); + let native_skill_path_text = native_skill_path.to_string_lossy().to_string(); + render_template( + AGENTS_TEMPLATE, + &[ + ("{{SKILL_NAME}}", SKILL_NAME), + ("{{SKILL_PATH}}", &skill_path_text), + ("{{NATIVE_SKILL_NAME}}", NATIVE_SKILL_NAME), + ("{{NATIVE_SKILL_PATH}}", &native_skill_path_text), + ], + ) +} + +fn render_template(template: &str, replacements: &[(&str, &str)]) -> String { + let mut output = template.to_string(); + for (needle, value) in replacements { + output = output.replace(needle, value); + } + output +} diff --git a/templates/setup/AGENTS.md b/templates/setup/AGENTS.md new file mode 100644 index 0000000..353790d --- /dev/null +++ b/templates/setup/AGENTS.md @@ -0,0 +1,11 @@ +## Skills +A skill is a local instruction bundle stored in `SKILL.md`. + +### Available skills +- {{SKILL_NAME}}: Helps with Popcorn CLI registration, submission setup, submission modes, and file directives. (file: {{SKILL_PATH}}) +- {{NATIVE_SKILL_NAME}}: Helps write CUDA and HIP kernels using torch.utils.cpp_extension.load_inline(). Use when writing native GPU code inside a Python submission. (file: {{NATIVE_SKILL_PATH}}) + +### How to use skills +- Load the skill by reading its `SKILL.md` file when user requests match the description. +- Follow progressive disclosure: read only relevant referenced files/scripts as needed. +- Keep the workspace setup aligned with `popcorn setup`. diff --git a/templates/setup/skills/load-inline-native-code/SKILL.md b/templates/setup/skills/load-inline-native-code/SKILL.md new file mode 100644 index 0000000..ee23351 --- /dev/null +++ b/templates/setup/skills/load-inline-native-code/SKILL.md @@ -0,0 +1,143 @@ +--- +name: load-inline-native-code +description: Helps write CUDA and HIP kernels using torch.utils.cpp_extension.load_inline(). Use when users want to write native GPU code (CUDA/HIP) inside a Python submission file. +compatibility: Intended for popcorn-cli submissions targeting NVIDIA or AMD GPUs with native kernel code. +--- + +# Writing Native GPU Kernels with load_inline() + +Use this skill when the user wants to write a custom CUDA or HIP kernel inside their Python submission file using `torch.utils.cpp_extension.load_inline()`. + +## Overview + +`load_inline()` compiles C++/CUDA/HIP source code at runtime and loads it as a Python module. This lets you write raw GPU kernels directly in your `submission.py` without a separate build system. + +## CUDA Template (NVIDIA GPUs) + +```python +import torch +from torch.utils.cpp_extension import load_inline +from task import input_t, output_t + +CUDA_SRC = """ +template +__global__ void my_kernel(const scalar_t* __restrict__ input, + scalar_t* __restrict__ output, + int N) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx < N) { + output[idx] = input[idx]; + } +} + +torch::Tensor my_op(torch::Tensor input, torch::Tensor output) { + int N = input.numel(); + const int threads = 256; + const int blocks = (N + threads - 1) / threads; + + AT_DISPATCH_FLOATING_TYPES_AND_HALF(input.scalar_type(), "my_kernel", ([&] { + my_kernel<<>>( + input.data_ptr(), + output.data_ptr(), + N + ); + })); + + cudaError_t err = cudaGetLastError(); + if (err != cudaSuccess) { + throw std::runtime_error(cudaGetErrorString(err)); + } + return output; +} +""" + +CPP_SRC = """ +torch::Tensor my_op(torch::Tensor input, torch::Tensor output); +""" + +module = load_inline( + name='my_module', + cpp_sources=[CPP_SRC], + cuda_sources=[CUDA_SRC], + functions=['my_op'], + verbose=True, +) + +def custom_kernel(data: input_t) -> output_t: + input, output = data + return module.my_op(input, output) +``` + +## HIP Template (AMD GPUs) + +```python +import os +os.environ['PYTORCH_ROCM_ARCH'] = 'gfx942' +os.environ['CXX'] = 'clang++' + +import torch +from torch.utils.cpp_extension import load_inline +from task import input_t, output_t + +CUDA_SRC = """ +#include + +__global__ void my_kernel(const float* input, float* output, int N) { + int idx = blockIdx.x * blockDim.x + threadIdx.x; + if (idx < N) { + output[idx] = input[idx]; + } +} + +void my_op(torch::Tensor input, torch::Tensor output) { + int N = input.numel(); + const int threads = 256; + const int blocks = (N + threads - 1) / threads; + my_kernel<<>>( + input.data_ptr(), + output.data_ptr(), + N + ); +} +""" + +CPP_SRC = """ +void my_op(torch::Tensor input, torch::Tensor output); +""" + +module = load_inline( + name='my_module', + cpp_sources=[CPP_SRC], + cuda_sources=[CUDA_SRC], + functions=['my_op'], + verbose=True, + extra_cuda_cflags=["--offload-arch=gfx942", "-std=c++20"], +) + +def custom_kernel(data: input_t) -> output_t: + input, output = data + module.my_op(input, output) + return output +``` + +## Key Points + +- **cpp_sources**: C++ header declaring the functions you want to call from Python. These are the bindings. +- **cuda_sources**: The actual CUDA/HIP kernel code and the C++ wrapper that launches it. +- **functions**: List of function names to expose to Python. Must match the C++ function signatures exactly. +- **verbose=True**: Prints compilation output so you can debug build errors. +- **extra_cuda_cflags**: Pass extra compiler flags. Needed for AMD HIP (`--offload-arch=gfx942`) or C++ standard selection. + +## Common Patterns + +- Use `AT_DISPATCH_FLOATING_TYPES_AND_HALF` to handle multiple dtypes in CUDA kernels. +- For AMD/HIP, set `PYTORCH_ROCM_ARCH` and `CXX` env vars **before** importing torch. +- Always check `cudaGetLastError()` after kernel launches for NVIDIA targets. +- The `load_inline` call compiles on first run and caches the result. Subsequent runs reuse the cache unless the source changes. +- Keep the module-level `load_inline()` call **outside** `custom_kernel()` so compilation happens once at import time, not on every call. + +## Guardrails +- The `custom_kernel` function signature must match `def custom_kernel(data: input_t) -> output_t:`. +- The module is compiled at import time. Do not call `load_inline()` inside `custom_kernel()`. +- For AMD GPUs, always set `PYTORCH_ROCM_ARCH` before any torch import. +- Use `torch::Tensor` in C++ signatures for seamless Python-C++ tensor passing. diff --git a/templates/setup/skills/popcorn-submission-workflow/SKILL.md b/templates/setup/skills/popcorn-submission-workflow/SKILL.md new file mode 100644 index 0000000..2b794df --- /dev/null +++ b/templates/setup/skills/popcorn-submission-workflow/SKILL.md @@ -0,0 +1,32 @@ +--- +name: {{SKILL_NAME}} +description: Helps prepare and submit popcorn-cli GPU Mode solutions. Use when users ask to set up a project, create a submission template, or run/register submissions. +compatibility: Intended for popcorn-cli repositories with README.md and shell access. +--- + +# Popcorn Submission Workflow + +Use this skill when the user is working on Popcorn CLI submissions and needs a reliable flow from setup to submit. + +## Recommended workflow +1. Ensure the project has a `submission.py` file with POPCORN directives. +2. Register once with `popcorn register discord` (or `github`) if `.popcorn.yaml` is missing. +3. Use `popcorn submit submission.py` for interactive mode, or `popcorn submit --no-tui ...` for scripts/CI. +4. Use `popcorn submissions list/show/delete` to inspect previous runs. + +## Reference: Authentication (from README) + +{{AUTHENTICATION_SECTION}} + +## Reference: Commands (from README) + +{{COMMANDS_SECTION}} + +## Reference: Submission Format (from README) + +{{SUBMISSION_FORMAT_SECTION}} + +## Guardrails +- Keep submissions as a single Python file. +- Prefer POPCORN directives (`#!POPCORN leaderboard ...`, `#!POPCORN gpu ...`) so defaults are embedded. +- Use `test` or `benchmark` mode before `leaderboard` submissions when iterating. From 377a679d91c9ba05fa5ec39f9cf359dde0f26298 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:53:52 -0800 Subject: [PATCH 083/111] Build(deps): bump bytes from 1.10.1 to 1.11.1 (#38) Bumps [bytes](https://github.com/tokio-rs/bytes) from 1.10.1 to 1.11.1. - [Release notes](https://github.com/tokio-rs/bytes/releases) - [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md) - [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.1) --- updated-dependencies: - dependency-name: bytes dependency-version: 1.11.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0830437..bf83f27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,9 +150,9 @@ checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cassowary" diff --git a/Cargo.toml b/Cargo.toml index 735ae80..d8d6c05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ base64-url = "3.0.0" base64 = "0.22" chrono = "0.4" urlencoding = "2.1.3" -bytes = "1.10.1" +bytes = "1.11.1" futures-util = "0.3.31" [dev-dependencies] From 1ce7a45a8eb69518a250920aa9d07bab63f00c78 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Tue, 24 Feb 2026 21:27:59 +0100 Subject: [PATCH 084/111] Update deploy --- CONTRIBUTING.md | 2 +- README.md | 2 +- docs/AMD_workshop/README.md | 4 ++-- src/cmd/auth.rs | 4 ++-- src/main.rs | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c06787a..9a0c897 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ Full end-to-end testing requires a running kernelbot API server. You can test ag #### Option A: Test Against Production ```bash -export POPCORN_API_URL=https://discord-cluster-manager-1f6c4782e60a.herokuapp.com +export POPCORN_API_URL=https://site--bot--dxfjds728w5v.code.run cargo run -- submissions list --leaderboard grayscale ``` diff --git a/README.md b/README.md index 2ea85a6..43999e9 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ After installation, restart your terminal (or run `source ~/.bashrc` / `source ~ - Check if the install directory is in your PATH: - Linux/macOS: `echo $PATH` - Windows: `echo $env:PATH` -- Check if POPCORN_API_URL is set to https://discord-cluster-manager-1f6c4782e60a.herokuapp.com +- Check if POPCORN_API_URL is set to https://site--bot--dxfjds728w5v.code.run - Linux/macOS: `echo $POPCORN_API_URL` - Windows: `echo $env:POPCORN_API_URL` diff --git a/docs/AMD_workshop/README.md b/docs/AMD_workshop/README.md index 85629e8..82aaab2 100644 --- a/docs/AMD_workshop/README.md +++ b/docs/AMD_workshop/README.md @@ -80,7 +80,7 @@ If the scripts don't work, you can manually install: - Check if the install directory is in your PATH: - Linux/macOS: `echo $PATH` - Windows: `echo $env:PATH` -- Check if POPCORN_API_URL is set to https://discord-cluster-manager-1f6c4782e60a.herokuapp.com +- Check if POPCORN_API_URL is set to https://site--bot--dxfjds728w5v.code.run - Linux/macOS: `echo $POPCORN_API_URL` - Windows: `echo $env:POPCORN_API_URL` @@ -96,4 +96,4 @@ If the scripts don't work, you can manually install: * https://seb-v.github.io/optimization/update/2025/01/20/Fast-GPU-Matrix-multiplication.html * https://akashkarnatak.github.io/amd-challenge/ * https://www.bilibili.com/read/cv41954307/?opus_fallback=1 -* https://github.com/Snektron/gpumode-amd-fp8-mm \ No newline at end of file +* https://github.com/Snektron/gpumode-amd-fp8-mm diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index 483c3b1..9dec853 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -86,13 +86,13 @@ pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { let auth_url = match auth_provider { "discord" => { - let base_auth_url = "https://discord.com/oauth2/authorize?client_id=1361364685491802243&response_type=code&redirect_uri=https%3A%2F%2Fdiscord-cluster-manager-1f6c4782e60a.herokuapp.com%2Fauth%2Fcli%2Fdiscord&scope=identify"; + let base_auth_url = "https://discord.com/oauth2/authorize?client_id=1361364685491802243&response_type=code&redirect_uri=https%3A%2F%2Fsite--bot--dxfjds728w5v.code.run%2Fauth%2Fcli%2Fdiscord&scope=identify"; format!("{}&state={}", base_auth_url, state_b64) } "github" => { let client_id = "Ov23lieFd2onYk4OnKIR"; let redirect_uri = - "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com/auth/cli/github"; + "https://site--bot--dxfjds728w5v.code.run/auth/cli/github"; let encoded_redirect_uri = urlencoding::encode(redirect_uri); format!( "https://github.com/login/oauth/authorize?client_id={}&state={}&redirect_uri={}", diff --git a/src/main.rs b/src/main.rs index 5b4e5a4..03e1c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ async fn main() { if env::var("POPCORN_API_URL").is_err() { env::set_var( "POPCORN_API_URL", - "https://discord-cluster-manager-1f6c4782e60a.herokuapp.com", + "https://site--bot--dxfjds728w5v.code.run", ); } // Parse command line arguments From b544c36679b895acf8336e2a5907123d26a1e6f6 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Tue, 24 Feb 2026 22:14:00 -0800 Subject: [PATCH 085/111] fix lint --- src/cmd/auth.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index 9dec853..daf00e8 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -91,8 +91,7 @@ pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { } "github" => { let client_id = "Ov23lieFd2onYk4OnKIR"; - let redirect_uri = - "https://site--bot--dxfjds728w5v.code.run/auth/cli/github"; + let redirect_uri = "https://site--bot--dxfjds728w5v.code.run/auth/cli/github"; let encoded_redirect_uri = urlencoding::encode(redirect_uri); format!( "https://github.com/login/oauth/authorize?client_id={}&state={}&redirect_uri={}", From da1d782fb7f0bdde80b72ef229f50f024d6d78e4 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Sat, 7 Mar 2026 12:43:50 -0800 Subject: [PATCH 086/111] setup command should be aware of new and expired competitions --- src/cmd/setup.rs | 75 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index 2368a78..eb6c892 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -1,4 +1,5 @@ use anyhow::{anyhow, Context, Result}; +use chrono::{NaiveDate, NaiveDateTime, Utc}; use serde::Deserialize; use serde_json::json; use std::env; @@ -15,23 +16,35 @@ const NATIVE_SKILL_TEMPLATE: &str = include_str!("../../templates/setup/skills/load-inline-native-code/SKILL.md"); const AGENTS_TEMPLATE: &str = include_str!("../../templates/setup/AGENTS.md"); -const COMPETITION_YAMLS: &[&str] = &[ - "pmpp_v2.yaml", - "nvidia.yaml", - "amd.yaml", - "amd_distributed.yaml", - "bioml.yaml", -]; - const RAW_GITHUB_BASE: &str = "https://raw.githubusercontent.com/gpu-mode/reference-kernels/main/problems"; +const GITHUB_API_CONTENTS: &str = + "https://api.github.com/repos/gpu-mode/reference-kernels/contents/problems"; + #[derive(Deserialize)] struct CompetitionIndex { name: String, + #[serde(default)] + deadline: String, problems: Vec, } +fn is_active(deadline: &str) -> bool { + let deadline = deadline.trim(); + if deadline.is_empty() { + return true; // No deadline means always open + } + let now = Utc::now().naive_utc(); + if let Ok(dt) = NaiveDateTime::parse_from_str(deadline, "%Y-%m-%d %H:%M") { + return now < dt; + } + if let Ok(d) = NaiveDate::parse_from_str(deadline, "%Y-%m-%d") { + return now.date() <= d; + } + true // Can't parse — show it rather than hide it +} + #[derive(Deserialize)] struct ProblemEntry { directory: String, @@ -56,9 +69,50 @@ impl ActionStatus { } } +async fn discover_competition_yamls(client: &reqwest::Client) -> Result> { + let resp = client + .get(GITHUB_API_CONTENTS) + .header("User-Agent", "popcorn-cli") + .header("Accept", "application/vnd.github.v3+json") + .send() + .await + .context("Failed to list problems directory from GitHub API")?; + + if !resp.status().is_success() { + return Err(anyhow!( + "GitHub API returned status {} when listing problems directory", + resp.status() + )); + } + + let items: Vec = resp + .json() + .await + .context("Failed to parse GitHub API response")?; + let yamls: Vec = items + .iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?; + if name.ends_with(".yaml") || name.ends_with(".yml") { + Some(name.to_string()) + } else { + None + } + }) + .collect(); + + if yamls.is_empty() { + return Err(anyhow!( + "No competition YAML files found in problems directory" + )); + } + Ok(yamls) +} + async fn fetch_competition_index(client: &reqwest::Client) -> Result> { + let yaml_files = discover_competition_yamls(client).await?; let mut entries = Vec::new(); - for filename in COMPETITION_YAMLS { + for filename in &yaml_files { let url = format!("{}/{}", RAW_GITHUB_BASE, filename); let resp = client .get(&url) @@ -76,6 +130,9 @@ async fn fetch_competition_index(client: &reqwest::Client) -> Result Date: Mon, 9 Mar 2026 11:14:19 -0700 Subject: [PATCH 087/111] docs: simplify auth flow and remove get-api-url step (#40) --- README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 43999e9..ceb114f 100644 --- a/README.md +++ b/README.md @@ -52,12 +52,10 @@ After installation, restart your terminal (or run `source ~/.bashrc` / `source ~ Since we're effectively giving out GPUs for free we rely on either github or discord authentication to prove that you're a real human before you access our service. -1. Go to the [GPU Mode Discord server](https://discord.gg/gpumode) and type in `/get-api-url` -2. Copy paste that url out `export POPCORN_API_URL="result_of_get_api_url"` -3. We recommend you authenticate via your Discord as this will guarantee that your name will show up correctly on the leaderboard, you can do this via `popcorn-cli register discord`. However in case this doesn't work for you we also support Github based authentication with `popcorn-cli register github` -4. To ensure the above worked you can run `cat $HOME/.popcorn.yaml` which should print your client ID which is what will be sent to us on every request +1. Register the CLI (Discord recommended): `popcorn register discord` (or `popcorn register github`) +2. To ensure the above worked you can run `cat $HOME/.popcorn.yaml` which should print your client ID which is what will be sent to us on every request -Sometimes you'll get an error that you're already authenticated despite being unable to submit in which case you can run `popcorn-cli reregister [discord|github]`. +Sometimes you'll get an error that you're already authenticated despite being unable to submit in which case you can run `popcorn reregister [discord|github]`. ## Make your first submission From 3d5fed850d869ec7195513310954aa67300d1ee9 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Thu, 12 Mar 2026 17:50:58 -0700 Subject: [PATCH 088/111] Create helion-hackathon.md --- docs/helion-hackathon.md | 181 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/helion-hackathon.md diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md new file mode 100644 index 0000000..31ffc46 --- /dev/null +++ b/docs/helion-hackathon.md @@ -0,0 +1,181 @@ +# Helion Kernel Challenge + +Submit [Helion](https://github.com/pytorch/helion) kernels to the GPU MODE leaderboard on B200 GPUs. The challenge has 5 problems based on production LLM kernel patterns. + +**Deadline:** March 14, 2026 + +**GPU:** B200_Nebius + +## Problems + +| # | Leaderboard Name | Description | +|---|-----------------|-------------| +| 1 | `causal_conv1d` | Causal depthwise 1D convolution (Mamba/Mamba-2) | +| 2 | `fp8_quant` | Per-token-group FP8 E4M3 quantization (DeepSeek-V3, Llama 3, Qwen3) | +| 3 | `gated_deltanet_chunk_fwd_h` | Inter-chunk state recurrence for Gated DeltaNet | +| 4 | `gated_deltanet_chunk_fwd_o` | Output computation for Gated DeltaNet | +| 5 | `gated_deltanet_recompute_w_u` | WY-transform forward kernel for Gated DeltaNet | + +## Quick Start + +```bash +# 1. Install popcorn-cli +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash + +# 2. Register +popcorn register discord + +# 3. Setup a project (downloads the submission template for you) +popcorn setup +# Select "Helion Kernel Challenge", then pick a problem and GPU +``` + +`popcorn setup` fetches the latest problems from reference-kernels, downloads the submission template with `#!POPCORN` directives pre-filled, and scaffolds agent skills for Codex/Claude Code. + +Alternatively, you can clone the full reference-kernels repo to browse all problems locally: + +```bash +git clone https://github.com/gpu-mode/reference-kernels.git +cd reference-kernels/problems/helion +``` + +Each problem directory (e.g. `causal_conv1d_py/`) contains: +- `reference.py` -- the reference implementation to beat +- `submission.py` -- your starting point +- `task.py` -- type definitions (`input_t`, `output_t`) +- `task.yml` -- input shapes, test cases, and benchmark configs + +## Writing a Helion Submission + +Your submission must be a single Python file that defines `custom_kernel(data: input_t) -> output_t`. To use Helion, write a `@helion.kernel` decorated function and call it from `custom_kernel`. + +Here's an example structure for `causal_conv1d`: + +```python +from task import input_t, output_t +import torch +import helion +import helion.language as hl + +@helion.kernel(config=helion.Config( + block_sizes=[64, 64], + num_warps=4, + num_stages=3, + # ... your tuned config here +)) +def causal_conv1d_kernel(x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor) -> torch.Tensor: + # Your Helion kernel implementation + ... + +def custom_kernel(data: input_t) -> output_t: + x, weight, bias = data + return causal_conv1d_kernel(x, weight, bias) +``` + +## Do NOT Autotune on KernelBot + +When submitting to KernelBot, you must hardcode a single config in your `@helion.kernel` decorator. Do **not** rely on Helion's autotuner at submission time. + +KernelBot runs your submission on shared infrastructure with timeouts. If your kernel triggers autotuning (which can take 10+ minutes and hundreds of trial runs), your submission will time out and fail. + +### The correct workflow + +1. **Autotune locally on your Nebius-provided B200 compute.** Run your Helion kernel without a fixed config (or with `autotune_effort="quick"`) to find the best configuration for the benchmark shapes. + +2. **Copy the best config** from the autotuner output. Helion prints something like: + ``` + One can hardcode the best config and skip autotuning with: + @helion.kernel(config=helion.Config(block_sizes=[64, 64, 64], ...)) + ``` + +3. **Hardcode the config in your submission.** Pass it via `config=` in the `@helion.kernel` decorator: + ```python + @helion.kernel(config=helion.Config( + block_sizes=[64, 64, 64], + loop_orders=[[0, 1]], + num_warps=8, + num_stages=6, + indexing='block_ptr', + pid_type='flat', + # ... rest of your tuned config + )) + def my_kernel(...): + ... + ``` + +4. **Submit the file** with the hardcoded config to KernelBot. + +You can also use `autotune_effort="none"` during development to skip autotuning entirely and use the default config, but this will give worse performance. + +## Submitting All 5 Problems + +### Test first, then submit to leaderboard + +```bash +# Test each problem (quick correctness check) +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode test --no-tui +popcorn submit fp8_quant_py/submission.py --gpu B200_Nebius --leaderboard fp8_quant --mode test --no-tui +popcorn submit gated_deltanet_chunk_fwd_h_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_h --mode test --no-tui +popcorn submit gated_deltanet_chunk_fwd_o_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_o --mode test --no-tui +popcorn submit gated_deltanet_recompute_w_u_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_recompute_w_u --mode test --no-tui + +# Benchmark (see your perf without affecting the leaderboard) +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode benchmark --no-tui + +# Official leaderboard submission +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode leaderboard --no-tui +popcorn submit fp8_quant_py/submission.py --gpu B200_Nebius --leaderboard fp8_quant --mode leaderboard --no-tui +popcorn submit gated_deltanet_chunk_fwd_h_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_h --mode leaderboard --no-tui +popcorn submit gated_deltanet_chunk_fwd_o_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_chunk_fwd_o --mode leaderboard --no-tui +popcorn submit gated_deltanet_recompute_w_u_py/submission.py --gpu B200_Nebius --leaderboard gated_deltanet_recompute_w_u --mode leaderboard --no-tui +``` + +### Using file directives + +You can also embed the leaderboard and GPU in your submission file so you don't need CLI flags: + +```python +#!POPCORN leaderboard causal_conv1d +#!POPCORN gpu B200_Nebius + +from task import input_t, output_t +import torch +import helion +import helion.language as hl + +@helion.kernel(config=helion.Config(...)) +def causal_conv1d_kernel(...): + ... + +def custom_kernel(data: input_t) -> output_t: + ... +``` + +Then submit with just: +```bash +popcorn submit causal_conv1d_py/submission.py +``` + +## Profiling + +Nsight Compute profiling is available for Helion problems. Use `--mode profile` to get detailed GPU metrics: + +```bash +popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard causal_conv1d --mode profile --no-tui +``` + +This returns GPU throughput, pipe utilization, and warp stall metrics, plus a downloadable `.ncu-rep` trace file you can open in the Nsight Compute GUI. See [profiling.md](profiling.md) for details on interpreting the output. + +## Tips + +- **Iterate locally first.** Use your Nebius B200 to develop and autotune. Only submit to KernelBot once you have a hardcoded config that works. +- **Check the reference.** Each `reference.py` shows the baseline implementation you're trying to beat. Understanding it helps you write a better kernel. +- **Use `--mode test` first.** Verify correctness before submitting to the leaderboard. This saves time and leaderboard quota. +- **Profile your kernels.** Use `--mode profile` to get Nsight Compute metrics and identify bottlenecks. +- **One config per submission.** If Helion found different best configs for different benchmark shapes, pick the one that works best across all of them -- the leaderboard uses geometric mean across benchmarks. +## Resources + +- [Helion Documentation](https://helionlang.com) +- [Helion GitHub](https://github.com/pytorch/helion) +- [Reference Kernels](https://github.com/gpu-mode/reference-kernels/tree/main/problems/helion) +- [GPU MODE Discord](https://discord.gg/gpumode) From 4bb2441c55e9e28b033c2665d4c5d95e11082107 Mon Sep 17 00:00:00 2001 From: Will Feng Date: Fri, 13 Mar 2026 08:12:11 -0700 Subject: [PATCH 089/111] Add `popcorn` alias to install.sh (#41) * Create symlink for popcorn-cli after installation I found that after installation, the default binary name is actually `popcorn-cli`. If we want to use `popcorn` as binary name in the subsequent steps, I believe we need to create a symlink. * Add `popcorn` alias to install.sh and remove manual symlink step from docs Move the symlink creation into install.sh so users get the `popcorn` command automatically. Uses symlink on Linux/macOS and copy on Windows. --- install.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/install.sh b/install.sh index 80e1dec..dc2d94f 100755 --- a/install.sh +++ b/install.sh @@ -24,20 +24,24 @@ fi OS="" ARCH="" BINARY_NAME="" +SYMLINK_NAME="" EXTENSION="" if [[ "$OSTYPE" == "linux-gnu"* ]]; then OS="linux" EXTENSION=".tar.gz" BINARY_NAME="popcorn-cli" + SYMLINK_NAME="popcorn" elif [[ "$OSTYPE" == "darwin"* ]]; then OS="macos" EXTENSION=".tar.gz" BINARY_NAME="popcorn-cli" + SYMLINK_NAME="popcorn" elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]] || [[ "$OSTYPE" == "cygwin" ]]; then OS="windows" EXTENSION=".zip" BINARY_NAME="popcorn-cli.exe" + SYMLINK_NAME="popcorn.exe" else echo "❌ Unsupported operating system: $OSTYPE" exit 1 @@ -81,6 +85,15 @@ if [[ -f "$BINARY_NAME" ]]; then chmod +x "$BINARY_NAME" mv "$BINARY_NAME" "$INSTALL_DIR/" echo "✅ Binary installed to $INSTALL_DIR/$BINARY_NAME" + # Create 'popcorn' alias so users can run 'popcorn' instead of 'popcorn-cli' + if [[ -n "$SYMLINK_NAME" ]]; then + if [[ "$OS" == "windows" ]]; then + cp "$INSTALL_DIR/$BINARY_NAME" "$INSTALL_DIR/$SYMLINK_NAME" + else + ln -sf "$INSTALL_DIR/$BINARY_NAME" "$INSTALL_DIR/$SYMLINK_NAME" + fi + echo "✅ Created alias: $SYMLINK_NAME -> $BINARY_NAME" + fi else echo "❌ Binary not found after extraction" exit 1 From 6ab2e4a1b371479c4c2b13db5b02fb1be92afe43 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Fri, 13 Mar 2026 14:40:38 -0700 Subject: [PATCH 090/111] Enable closed workflow --- CONTRIBUTING.md | 5 ++ docs/helion-hackathon.md | 5 +- src/cmd/admin.rs | 110 ++++++++++++++++++++++++++++++++++++++- src/cmd/mod.rs | 30 +++++++++++ src/service/mod.rs | 92 ++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a0c897..d83b746 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -154,6 +154,11 @@ popcorn admin create-leaderboard # Create leaderboard from problem directo popcorn admin delete-leaderboard # Delete a leaderboard popcorn admin delete-leaderboard --force # Force delete with submissions +# Invite management +popcorn admin generate-invites --leaderboards lb1 lb2 --count 5 # Generate invite codes +popcorn admin list-invites # List invites for a leaderboard +popcorn admin revoke-invite # Revoke an invite code + # Update problems from GitHub popcorn admin update-problems popcorn admin update-problems --problem-set nvidia --force diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index 31ffc46..2dfa344 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -25,7 +25,10 @@ curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.s # 2. Register popcorn register discord -# 3. Setup a project (downloads the submission template for you) +# 3. Join the challenge with your invite code +popcorn join + +# 4. Setup a project (downloads the submission template for you) popcorn setup # Select "Helion Kernel Challenge", then pick a problem and GPU ``` diff --git a/src/cmd/admin.rs b/src/cmd/admin.rs index f34ef0e..f2c2457 100644 --- a/src/cmd/admin.rs +++ b/src/cmd/admin.rs @@ -39,6 +39,26 @@ pub enum AdminAction { #[arg(long)] force: bool, }, + /// Generate invite codes for leaderboard(s) + GenerateInvites { + /// Leaderboard names to grant access to + #[arg(long, required = true, num_args = 1..)] + leaderboards: Vec, + + /// Number of invite codes to generate (1-10000) + #[arg(long, default_value = "1")] + count: u32, + }, + /// List invite codes for a leaderboard + ListInvites { + /// Leaderboard name + leaderboard: String, + }, + /// Revoke an invite code + RevokeInvite { + /// The invite code to revoke + code: String, + }, /// Update problems from a GitHub repository (mirrors Discord /admin update-problems) UpdateProblems { /// Problem set name (e.g., "nvidia", "pmpp_v2"). If not specified, updates all. @@ -56,6 +76,10 @@ pub enum AdminAction { /// Force update even if task definition changed significantly #[arg(long)] force: bool, + + /// Set leaderboard visibility to closed (requires invite to access) + #[arg(long)] + closed: bool, }, } @@ -108,20 +132,101 @@ pub async fn handle_admin(action: AdminAction) -> Result<()> { println!("Deleted leaderboard '{}'", name); println!("{}", serde_json::to_string_pretty(&result)?); } + AdminAction::GenerateInvites { + leaderboards, + count, + } => { + let result = service::admin_generate_invites(&client, &leaderboards, count).await?; + let codes = result["codes"].as_array().map(|arr| arr.len()).unwrap_or(0); + let lbs = result["leaderboards"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!("Generated {} invite code(s) for: {}", codes, lbs); + if let Some(arr) = result["codes"].as_array() { + for code in arr { + println!(" {}", code.as_str().unwrap_or("???")); + } + } + } + AdminAction::ListInvites { leaderboard } => { + let result = service::admin_list_invites(&client, &leaderboard).await?; + let invites = result["invites"].as_array(); + match invites { + Some(arr) if arr.is_empty() => { + println!("No invites for '{}'", leaderboard); + } + Some(arr) => { + let claimed = arr + .iter() + .filter(|i| i["user_id"].as_str().is_some()) + .count(); + println!( + "Invites for '{}': {} total, {} claimed, {} unclaimed\n", + leaderboard, + arr.len(), + claimed, + arr.len() - claimed, + ); + let header = format!( + "{:<26} {:<16} {:<20} {}", + "CODE", "STATUS", "CLAIMED BY", "CREATED" + ); + println!("{header}"); + println!("{}", "-".repeat(82)); + for invite in arr { + let code = invite["code"].as_str().unwrap_or("???"); + let user = invite["user_name"] + .as_str() + .or_else(|| invite["user_id"].as_str()); + let status = if user.is_some() { + "claimed" + } else { + "unclaimed" + }; + let user_display = user.unwrap_or("-"); + let created = invite["created_at"].as_str().unwrap_or("-"); + println!( + "{:<26} {:<16} {:<20} {}", + code, status, user_display, created, + ); + } + } + None => { + println!("{}", serde_json::to_string_pretty(&result)?); + } + } + } + AdminAction::RevokeInvite { code } => { + let result = service::admin_revoke_invite(&client, &code).await?; + let was_claimed = result["was_claimed"].as_bool().unwrap_or(false); + println!( + "Revoked invite code '{}' (was {})", + code, + if was_claimed { "claimed" } else { "unclaimed" } + ); + } AdminAction::UpdateProblems { problem_set, repository, branch, force, + closed, } => { println!( - "Updating problems from {}/tree/{}{}...", + "Updating problems from {}/tree/{}{}{}...", repository, branch, problem_set .as_ref() .map(|ps| format!(" (problem set: {})", ps)) - .unwrap_or_default() + .unwrap_or_default(), + if closed { " (closed)" } else { "" }, ); let result = service::admin_update_problems( &client, @@ -129,6 +234,7 @@ pub async fn handle_admin(action: AdminAction) -> Result<()> { &repository, &branch, force, + closed, ) .await?; diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 35c3e2b..71595b2 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -10,6 +10,8 @@ mod setup; mod submissions; mod submit; +use crate::service; + pub use admin::AdminAction; #[derive(Serialize, Deserialize, Debug, Default)] @@ -138,6 +140,11 @@ enum Commands { #[arg(long)] no_tui: bool, }, + /// Join a closed leaderboard using an invite code + Join { + /// The invite code + code: String, + }, /// Admin commands (requires POPCORN_ADMIN_TOKEN env var) Admin { #[command(subcommand)] @@ -209,6 +216,29 @@ pub async fn execute(cli: Cli) -> Result<()> { .await } } + Some(Commands::Join { code }) => { + let config = load_config()?; + let cli_id = config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run `popcorn register` first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + })?; + let client = service::create_client(Some(cli_id))?; + let result = service::join_with_invite(&client, &code).await?; + let leaderboards = result["leaderboards"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!("Joined leaderboard(s): {}", leaderboards); + Ok(()) + } Some(Commands::Admin { action }) => admin::handle_admin(action).await, Some(Commands::Submissions { action }) => { let config = load_config()?; diff --git a/src/service/mod.rs b/src/service/mod.rs index 906877a..1aee5f6 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -188,6 +188,7 @@ pub async fn admin_update_problems( repository: &str, branch: &str, force: bool, + closed: bool, ) -> Result { let base_url = env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; @@ -202,6 +203,10 @@ pub async fn admin_update_problems( payload["problem_set"] = serde_json::Value::String(ps.to_string()); } + if closed { + payload["visibility"] = serde_json::Value::String("closed".to_string()); + } + let resp = client .post(format!("{}/admin/update-problems", base_url)) .json(&payload) @@ -212,6 +217,61 @@ pub async fn admin_update_problems( handle_admin_response(resp).await } +/// Generate invite codes for one or more leaderboards +pub async fn admin_generate_invites( + client: &Client, + leaderboards: &[String], + count: u32, +) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let payload = serde_json::json!({ + "leaderboards": leaderboards, + "count": count, + }); + + let resp = client + .post(format!("{}/admin/invites", base_url)) + .json(&payload) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// List invite codes for a leaderboard +pub async fn admin_list_invites(client: &Client, leaderboard_name: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .get(format!( + "{}/admin/leaderboards/{}/invites", + base_url, leaderboard_name + )) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + +/// Revoke an invite code +pub async fn admin_revoke_invite(client: &Client, code: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let resp = client + .delete(format!("{}/admin/invites/{}", base_url, code)) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + handle_admin_response(resp).await +} + /// Helper to handle admin API responses async fn handle_admin_response(resp: reqwest::Response) -> Result { let status = resp.status(); @@ -447,6 +507,38 @@ pub async fn delete_user_submission(client: &Client, submission_id: i64) -> Resu .map_err(|e| anyhow!("Failed to parse response: {}", e)) } +/// Claim an invite code to join closed leaderboard(s) +pub async fn join_with_invite(client: &Client, code: &str) -> Result { + let base_url = + env::var("POPCORN_API_URL").map_err(|_| anyhow!("POPCORN_API_URL is not set"))?; + + let payload = serde_json::json!({ "code": code }); + + let resp = client + .post(format!("{}/user/join", base_url)) + .json(&payload) + .timeout(Duration::from_secs(30)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let error_text = resp.text().await?; + let detail = serde_json::from_str::(&error_text) + .ok() + .and_then(|v| v.get("detail").and_then(|d| d.as_str()).map(str::to_string)); + return Err(anyhow!( + "Server returned status {}: {}", + status, + detail.unwrap_or(error_text) + )); + } + + resp.json() + .await + .map_err(|e| anyhow!("Failed to parse response: {}", e)) +} + pub async fn submit_solution>( client: &Client, filepath: P, From d3f81608cc2a11720a104a496c96a17f02128ebf Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Fri, 13 Mar 2026 14:46:21 -0700 Subject: [PATCH 091/111] Update helion-hackathon.md --- docs/helion-hackathon.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index 2dfa344..0aa7a15 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -1,5 +1,7 @@ # Helion Kernel Challenge +Run all the below instructions on your local laptop, you don't need to login to a GPU to make submissions to KernelBot. + Submit [Helion](https://github.com/pytorch/helion) kernels to the GPU MODE leaderboard on B200 GPUs. The challenge has 5 problems based on production LLM kernel patterns. **Deadline:** March 14, 2026 From f141be60869806005afce95450faa159c602e5b1 Mon Sep 17 00:00:00 2001 From: Will Feng Date: Fri, 13 Mar 2026 15:22:32 -0700 Subject: [PATCH 092/111] docs: add local testing section to helion-hackathon.md (#42) --- docs/helion-hackathon.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index 0aa7a15..b605d43 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -50,6 +50,28 @@ Each problem directory (e.g. `causal_conv1d_py/`) contains: - `task.py` -- type definitions (`input_t`, `output_t`) - `task.yml` -- input shapes, test cases, and benchmark configs +## Testing Locally + +You can test and benchmark your submissions locally on your own GPU without submitting to KernelBot. This is useful for fast iteration during development. + +From the `reference-kernels/problems/helion` directory, run: + +```bash +# Correctness test (validates your kernel via CUDA graph capture) +python eval.py test causal_conv1d_py/ + +# Benchmark (measures kernel performance with L2 cache flushing) +python eval.py benchmark causal_conv1d_py/ + +# Both test and benchmark in one go +python eval.py both causal_conv1d_py/ + +# Profile (generates PyTorch profiler trace) +python eval.py profile causal_conv1d_py/ +``` + +Replace `causal_conv1d_py/` with any problem directory. + ## Writing a Helion Submission Your submission must be a single Python file that defines `custom_kernel(data: input_t) -> output_t`. To use Helion, write a `@helion.kernel` decorated function and call it from `custom_kernel`. From 7d015d3741c46ce754f535e3be757367530de9bf Mon Sep 17 00:00:00 2001 From: Will Feng Date: Fri, 13 Mar 2026 15:30:56 -0700 Subject: [PATCH 093/111] docs: add ACF (booster pack) usage guide (#43) * docs: add ACF (booster pack) usage guide to helion-hackathon.md Documents how to use PTXAS Advanced Controls Files from /opt/booster_pack/ during autotuning (autotune_search_acf) and in hardcoded submissions (advanced_controls_file). Includes the important caveat that ACF search only works when the autotuner actually runs. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: remove "How ACFs work" subsection Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- docs/helion-hackathon.md | 57 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index b605d43..5986ad0 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -193,6 +193,63 @@ popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard ca This returns GPU throughput, pipe utilization, and warp stall metrics, plus a downloadable `.ncu-rep` trace file you can open in the Nsight Compute GUI. See [profiling.md](profiling.md) for details on interpreting the output. +## Using ACF Files (Booster Pack) + +Each B200 instance comes with pre-tuned **PTXAS Advanced Controls Files (ACFs)** at `/opt/booster_pack/`. These are low-level NVIDIA PTX assembler configurations that can improve kernel performance beyond what Helion's standard autotuner finds. + +``` +/opt/booster_pack/ +├── causal_conv_0.acf ... causal_conv_2.acf +├── chunk_fwd_h_0.acf ... chunk_fwd_h_1.acf +├── chunk_fwd_o_0.acf ... chunk_fwd_o_6.acf +├── fp8_group_quant_0.acf ... fp8_group_quant_6.acf +└── recompute_w_u_fwd_0.acf ... recompute_w_u_fwd_4.acf +``` + +### Using ACFs during autotuning + +Pass `autotune_search_acf` to the `@helion.kernel` decorator. Helion treats each ACF as another tunable parameter — every config candidate gets tried with each ACF file (plus the default `-O3` baseline): + +```python +from pathlib import Path + +acf_files = sorted(str(p) for p in Path("/opt/booster_pack").glob("causal_conv_*.acf")) + +@helion.kernel( + static_shapes=True, + autotune_search_acf=acf_files, +) +def my_kernel(...): + ... +``` + +> **Important:** `autotune_search_acf` only takes effect when the autotuner actually runs. If you set `autotune_effort="none"` or provide a fixed `config=`, the ACF list is ignored. + +### Hardcoding an ACF in your submission + +After autotuning finds the best ACF, include it in your hardcoded config via `advanced_controls_file`. The autotuner prints the winning ACF path — copy it into your `Config`: + +```python +@helion.kernel(config=helion.Config( + advanced_controls_file="/opt/booster_pack/causal_conv_0.acf", + block_sizes=[1, 512], + num_warps=4, + num_stages=3, + # ... rest of your tuned config +)) +def my_kernel(...): + ... +``` + +This is the approach you should use for KernelBot submissions — a fixed config with a fixed ACF, no autotuning at runtime. + +### Recommended workflow + +1. **Autotune with ACFs locally** on your B200, using the matching `*_*.acf` files for your problem +2. **Check the best config output** — look for the `advanced_controls_file` field +3. **Hardcode both the config and ACF path** in your submission file +4. **Verify** the ACF path (`/opt/booster_pack/...`) exists on B200 — it does on all hackathon instances + ## Tips - **Iterate locally first.** Use your Nebius B200 to develop and autotune. Only submit to KernelBot once you have a hardcoded config that works. From 44a8b351194aa2d8fb2e5b5f1568d8151eb47b1a Mon Sep 17 00:00:00 2001 From: Will Feng Date: Fri, 13 Mar 2026 21:11:43 -0700 Subject: [PATCH 094/111] docs: add TileIR backend (ENABLE_TILE) usage guide (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add TileIR backend usage guide to helion-hackathon.md Documents ENABLE_TILE=0 vs ENABLE_TILE=1 and the TileIR compilation pipeline available via nvtriton on B200 instances. Covers how to enable TileIR with Helion (ENABLE_TILE=1 + HELION_BACKEND=tileir), the different tunables (num_ctas/occupancy vs num_warps/maxnreg), and how to hardcode TileIR configs in submissions. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: restructure ACF + TileIR as optional performance knobs Group both sections under a single "Optional: Extra Performance Knobs" heading to emphasize neither is required. Streamline both into step 1 (autotune) / step 2 (hardcode) format. Add a "Which combination" section showing all 4 options to try. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: remove "(Booster Pack)" from ACF heading Co-Authored-By: Claude Opus 4.6 (1M context) * docs: consolidate TileIR env var instructions Remove duplicate bash export block — the Python os.environ in the code example is sufficient for both local autotuning and submissions. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: clarify TileIR tunables come from autotuner output Co-Authored-By: Claude Opus 4.6 (1M context) * docs: shorten "Which should I use?" section Co-Authored-By: Claude Opus 4.6 (1M context) * docs: be explicit about ENABLE_TILE=0 vs ENABLE_TILE=1 Co-Authored-By: Claude Opus 4.6 (1M context) * docs: simplify TileIR comparison table to just backend names Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- docs/helion-hackathon.md | 66 ++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index 5986ad0..c71230b 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -193,22 +193,24 @@ popcorn submit causal_conv1d_py/submission.py --gpu B200_Nebius --leaderboard ca This returns GPU throughput, pipe utilization, and warp stall metrics, plus a downloadable `.ncu-rep` trace file you can open in the Nsight Compute GUI. See [profiling.md](profiling.md) for details on interpreting the output. -## Using ACF Files (Booster Pack) +## Optional: Extra Performance Knobs -Each B200 instance comes with pre-tuned **PTXAS Advanced Controls Files (ACFs)** at `/opt/booster_pack/`. These are low-level NVIDIA PTX assembler configurations that can improve kernel performance beyond what Helion's standard autotuner finds. +The sections below describe two **optional** techniques that can squeeze extra performance out of your kernels. Neither is required — you can place on the leaderboard without them. Try them after you have a working kernel with a tuned config. + +### ACF Files + +Each B200 instance has pre-tuned **PTXAS Advanced Controls Files (ACFs)** at `/opt/booster_pack/`. ACFs are low-level NVIDIA PTX assembler configurations that can improve performance beyond what Helion's standard autotuner finds. Available files: ``` /opt/booster_pack/ -├── causal_conv_0.acf ... causal_conv_2.acf -├── chunk_fwd_h_0.acf ... chunk_fwd_h_1.acf -├── chunk_fwd_o_0.acf ... chunk_fwd_o_6.acf -├── fp8_group_quant_0.acf ... fp8_group_quant_6.acf -└── recompute_w_u_fwd_0.acf ... recompute_w_u_fwd_4.acf +├── causal_conv_*.acf (3 files) +├── chunk_fwd_h_*.acf (2 files) +├── chunk_fwd_o_*.acf (7 files) +├── fp8_group_quant_*.acf (7 files) +└── recompute_w_u_fwd_*.acf (5 files) ``` -### Using ACFs during autotuning - -Pass `autotune_search_acf` to the `@helion.kernel` decorator. Helion treats each ACF as another tunable parameter — every config candidate gets tried with each ACF file (plus the default `-O3` baseline): +**Step 1: Autotune with ACFs.** Pass `autotune_search_acf` to include ACFs in the search space. Helion tries each ACF file (plus the default `-O3` baseline) as another tunable parameter: ```python from pathlib import Path @@ -223,11 +225,9 @@ def my_kernel(...): ... ``` -> **Important:** `autotune_search_acf` only takes effect when the autotuner actually runs. If you set `autotune_effort="none"` or provide a fixed `config=`, the ACF list is ignored. +> **Note:** `autotune_search_acf` only takes effect when the autotuner actually runs. It is ignored with `autotune_effort="none"` or a fixed `config=`. -### Hardcoding an ACF in your submission - -After autotuning finds the best ACF, include it in your hardcoded config via `advanced_controls_file`. The autotuner prints the winning ACF path — copy it into your `Config`: +**Step 2: Hardcode the best ACF in your submission.** After autotuning, look for the `advanced_controls_file` field in the best config and copy it: ```python @helion.kernel(config=helion.Config( @@ -241,14 +241,40 @@ def my_kernel(...): ... ``` -This is the approach you should use for KernelBot submissions — a fixed config with a fixed ACF, no autotuning at runtime. +### TileIR Backend + +The B200 instances also ship with **nvtriton**, NVIDIA's extended Triton compiler that includes a **TileIR** backend — an alternative compilation pipeline that bypasses LLVM and compiles directly to CUBIN via NVIDIA's `tileiras` compiler. + +| | `ENABLE_TILE=0` (default) | `ENABLE_TILE=1` | +|---|---|---| +| **Helion backend** | `triton` | `tileir` | + +**Step 1: Enable TileIR and autotune.** Set the env vars before importing Helion, then autotune as usual. Helion automatically adjusts the search space for the TileIR backend. + +**Step 2: Hardcode the TileIR config in your submission.** Copy the best config from the autotuner output (it will include TileIR-specific fields like `num_ctas` and `occupancy`). The env vars must be set before imports: + +```python +import os +os.environ["ENABLE_TILE"] = "1" +os.environ["HELION_BACKEND"] = "tileir" + +import helion # must be imported after setting env vars +import helion.language as hl + +@helion.kernel(config=helion.Config( + block_sizes=[64, 64], + num_ctas=1, + num_stages=5, + occupancy=4, + # ... rest of your tuned config +)) +def my_kernel(...): + ... +``` -### Recommended workflow +### Which should I use? -1. **Autotune with ACFs locally** on your B200, using the matching `*_*.acf` files for your problem -2. **Check the best config output** — look for the `advanced_controls_file` field -3. **Hardcode both the config and ACF path** in your submission file -4. **Verify** the ACF path (`/opt/booster_pack/...`) exists on B200 — it does on all hackathon instances +Try both `ENABLE_TILE=0` and `ENABLE_TILE=1`, with and without ACFs, then submit whichever gives the best benchmark numbers. ## Tips From 506fbd45e69c354c6936fa6bf18083a2087e96e4 Mon Sep 17 00:00:00 2001 From: Will Feng Date: Fri, 13 Mar 2026 21:13:09 -0700 Subject: [PATCH 095/111] docs: add scoring system, rules, and contribution track (#45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add TileIR backend usage guide to helion-hackathon.md Documents ENABLE_TILE=0 vs ENABLE_TILE=1 and the TileIR compilation pipeline available via nvtriton on B200 instances. Covers how to enable TileIR with Helion (ENABLE_TILE=1 + HELION_BACKEND=tileir), the different tunables (num_ctas/occupancy vs num_warps/maxnreg), and how to hardcode TileIR configs in submissions. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: restructure ACF + TileIR as optional performance knobs Group both sections under a single "Optional: Extra Performance Knobs" heading to emphasize neither is required. Streamline both into step 1 (autotune) / step 2 (hardcode) format. Add a "Which combination" section showing all 4 options to try. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: remove "(Booster Pack)" from ACF heading Co-Authored-By: Claude Opus 4.6 (1M context) * docs: consolidate TileIR env var instructions Remove duplicate bash export block — the Python os.environ in the code example is sufficient for both local autotuning and submissions. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: clarify TileIR tunables come from autotuner output Co-Authored-By: Claude Opus 4.6 (1M context) * docs: shorten "Which should I use?" section Co-Authored-By: Claude Opus 4.6 (1M context) * docs: be explicit about ENABLE_TILE=0 vs ENABLE_TILE=1 Co-Authored-By: Claude Opus 4.6 (1M context) * docs: simplify TileIR comparison table to just backend names Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add scoring system, rules, and open-ended contribution track Add point allocation table, scoring formula (correctness + performance ranking), rules & requirements, and the separate open-ended contribution track for non-kernel Helion contributions. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: allow unlimited submissions, best one counts Co-Authored-By: Claude Opus 4.6 (1M context) * docs: clarify rules to match actual submission format Each submission uses one static helion.Config for all shapes, not per-shape configs. Simplified rules to reflect this. Co-Authored-By: Claude Opus 4.6 (1M context) * Revert "docs: clarify rules to match actual submission format" This reverts commit 4fac3a8445c66c3a4e7f1994652d19e4c7fe76bf. * Add per-shape config dispatch pattern to all submissions Use a factory function (_make_kernel) to create kernel variants with different helion.Config objects, and dispatch in custom_kernel() based on input tensor shapes. This lets participants optimize each benchmark shape independently. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: update example to show all shapes, remove DEFAULT_CONFIG Co-Authored-By: Claude Opus 4.6 (1M context) * docs: use Config(...) placeholders with distinct TODO comments for test vs benchmark shapes Test shapes: TODO to replace with default config or any config that passes correctness. Benchmark shapes: TODO to replace with autotuned config. Also add instructions on getting default config via autotune_effort="none". Co-Authored-By: Claude Opus 4.6 (1M context) * docs: remove references to single-config-for-all-shapes pattern Per-shape configs are the recommended approach. Remove mentions of using a single config across all shapes. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: remove references to default config in rules section Configs are always participant-provided. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: add tips for version control, tmux, and machine reboots Co-Authored-By: Claude Opus 4.6 (1M context) * docs: move GPU machine tips to standalone section Co-Authored-By: Claude Opus 4.6 (1M context) * docs: fix performance metric description to match actual eval method The previous description incorrectly stated geometric mean of 100 runs. The actual helion eval uses CUDA graphs with L2 cache clearing, 10 measurements, and arithmetic mean. Co-Authored-By: Claude Opus 4.6 (1M context) * Replace hard 30% LOC limit with judges' discretion for inline triton/asm The LOC-based rule was gameable (denominator inflation with padding code), so switch to a qualitative rule: inline triton/asm is allowed as escape hatches, but predominantly inline submissions may be disqualified. Co-Authored-By: Claude Opus 4.6 (1M context) * Add spawn mode tip for autotuning in GPU machine section Spawn mode isolates each autotuner trial in a subprocess with timeout protection, preventing hangs or crashes from killing the entire run. Co-Authored-By: Claude Opus 4.6 (1M context) * Clarify that spawn mode is slower than fork mode Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- docs/helion-hackathon.md | 145 ++++++++++++++++++++++++++++++--------- 1 file changed, 111 insertions(+), 34 deletions(-) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index c71230b..0fa3abc 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -18,6 +18,38 @@ Submit [Helion](https://github.com/pytorch/helion) kernels to the GPU MODE leade | 4 | `gated_deltanet_chunk_fwd_o` | Output computation for Gated DeltaNet | | 5 | `gated_deltanet_recompute_w_u` | WY-transform forward kernel for Gated DeltaNet | +## Scoring + +### Point Allocation + +| Kernel | Correctness Points | Performance Points | +|---|---|---| +| **FP8 Quantization** | 100 | 0 (unscored) | +| **Causal Depthwise 1D Convolution** | 100 | 1000 | +| **Gated DeltaNet chunk\_fwd\_h** | 100 | 1000 | +| **Gated DeltaNet chunk\_fwd\_o** | 100 | 1000 | +| **Gated DeltaNet recompute\_w\_u** | 100 | 1000 | + +### Scoring Rules + +- **Performance Metric**: For each benchmark shape, the kernel is captured in a CUDA graph and replayed with L2 cache clearing before each invocation. The graph unrolls enough calls to fill ~100ms of GPU time, and this is repeated 10 times. The runtime is the arithmetic mean of those 10 measurements. +- **Ranking**: Participants are ranked per kernel by runtime (fastest = rank 1). +- **Formula**: Score = CorrectnessPoints + (PerformancePoints × [1 − (rank - 1) / 10]) + - CorrectnessPoints are earned if the submission passes all test input shapes. + - Only the top 10 performers per kernel (who pass all tests) can earn PerformancePoints. + - Rank 1 → 100% of PerformancePoints, Rank 2 → 90%, …, Rank 10 → 10%. +- **Tiebreaker**: If two participants have the same metric value, the earlier submission wins. +- **Test case shapes**: Provided in `task.yml`; input data sampled from a random distribution. + +**Total score** = Sum of points for all kernels. + +## Rules & Requirements + +- Kernel must pass all test input shapes (numerical accuracy within tolerance) with participant-provided config +- All benchmark shapes must have their best configs submitted for that kernel to be scored +- Implementations must use Helion DSL. `hl.inline_triton()`, `hl.triton_kernel()`, and `hl.inline_asm_elementwise()` are allowed as escape hatches, but the majority of your kernel should be written in Helion. Submissions that are predominantly inline Triton/ASM with a thin Helion wrapper may be disqualified at judges' discretion +- Unlimited submissions per participant per kernel. Only your best submission counts. Each submission should include: your Helion kernel implementation, one config per test input shape, and one best autotuned config per benchmark input shape + ## Quick Start ```bash @@ -76,7 +108,7 @@ Replace `causal_conv1d_py/` with any problem directory. Your submission must be a single Python file that defines `custom_kernel(data: input_t) -> output_t`. To use Helion, write a `@helion.kernel` decorated function and call it from `custom_kernel`. -Here's an example structure for `causal_conv1d`: +Use **per-shape configs** to optimize for each benchmark shape independently. The per-shape config pattern uses a factory function to create kernel variants with different configs, and dispatches based on input tensor shapes: ```python from task import input_t, output_t @@ -84,55 +116,69 @@ import torch import helion import helion.language as hl -@helion.kernel(config=helion.Config( - block_sizes=[64, 64], - num_warps=4, - num_stages=3, - # ... your tuned config here -)) -def causal_conv1d_kernel(x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor) -> torch.Tensor: - # Your Helion kernel implementation - ... +# Map input shapes to optimized configs (autotune each shape locally). +# Include all test and benchmark shapes from task.yml. +SHAPE_CONFIGS: dict[tuple, helion.Config] = { + # Test shapes + (1, 64, 64, 4): helion.Config(...), # TODO: replace with default config or any config that passes correctness check + (2, 128, 128, 4): helion.Config(...), # TODO: replace with default config or any config that passes correctness check + # ... one entry per test shape + # Benchmark shapes + (1, 768, 512, 4): helion.Config(...), # TODO: replace with your autotuned config + (1, 768, 2048, 4): helion.Config(...), # TODO: replace with your autotuned config + # ... one entry per benchmark shape +} + + +def _make_kernel(config: helion.Config): + @helion.kernel(static_shapes=True, config=config) + def causal_conv1d_kernel(x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor) -> torch.Tensor: + # Your Helion kernel implementation + ... + + return causal_conv1d_kernel + + +_KERNELS = {shape: _make_kernel(cfg) for shape, cfg in SHAPE_CONFIGS.items()} + def custom_kernel(data: input_t) -> output_t: x, weight, bias = data - return causal_conv1d_kernel(x, weight, bias) + B, D, S = x.shape + W = weight.shape[1] + kernel = _KERNELS[(B, D, S, W)] + return kernel(x, weight, bias) ``` ## Do NOT Autotune on KernelBot -When submitting to KernelBot, you must hardcode a single config in your `@helion.kernel` decorator. Do **not** rely on Helion's autotuner at submission time. +When submitting to KernelBot, you must hardcode configs in your `@helion.kernel` decorator. Do **not** rely on Helion's autotuner at submission time. KernelBot runs your submission on shared infrastructure with timeouts. If your kernel triggers autotuning (which can take 10+ minutes and hundreds of trial runs), your submission will time out and fail. -### The correct workflow +### Getting a default config (no autotuning) -1. **Autotune locally on your Nebius-provided B200 compute.** Run your Helion kernel without a fixed config (or with `autotune_effort="quick"`) to find the best configuration for the benchmark shapes. +During early development, you can use `autotune_effort="none"` to skip autotuning and use Helion's default config. When you run the kernel, Helion prints the default config to stderr: -2. **Copy the best config** from the autotuner output. Helion prints something like: +``` +Using default config: @helion.kernel(config=helion.Config(block_sizes=[64, 64], num_warps=4, num_stages=1), static_shapes=True) +``` + +Copy the `helion.Config(...)` portion into your `SHAPE_CONFIGS` dict. The default config is usually good enough for test input shapes to pass correctness checks, but won't be competitive for benchmark shapes on the leaderboard. + +### Autotuning for benchmark shapes + +1. **Autotune locally on your Nebius-provided B200 compute.** Run your Helion kernel without a fixed config (or with `autotune_effort="quick"`) to find the best configuration for each benchmark shape. + +2. **Copy the best config** from the autotuner output. When autotuning completes, Helion prints: ``` One can hardcode the best config and skip autotuning with: - @helion.kernel(config=helion.Config(block_sizes=[64, 64, 64], ...)) + @helion.kernel(config=helion.Config(block_sizes=[64, 64, 64], num_warps=8, num_stages=3)) ``` -3. **Hardcode the config in your submission.** Pass it via `config=` in the `@helion.kernel` decorator: - ```python - @helion.kernel(config=helion.Config( - block_sizes=[64, 64, 64], - loop_orders=[[0, 1]], - num_warps=8, - num_stages=6, - indexing='block_ptr', - pid_type='flat', - # ... rest of your tuned config - )) - def my_kernel(...): - ... - ``` - -4. **Submit the file** with the hardcoded config to KernelBot. +3. **Hardcode the config in your submission.** Copy the `helion.Config(...)` from step 2 into the corresponding benchmark shape entry in `SHAPE_CONFIGS`. Repeat steps 1-3 for each benchmark shape in `task.yml`. -You can also use `autotune_effort="none"` during development to skip autotuning entirely and use the default config, but this will give worse performance. +4. **Submit the file** with the hardcoded configs to KernelBot. ## Submitting All 5 Problems @@ -282,7 +328,38 @@ Try both `ENABLE_TILE=0` and `ENABLE_TILE=1`, with and without ACFs, then submit - **Check the reference.** Each `reference.py` shows the baseline implementation you're trying to beat. Understanding it helps you write a better kernel. - **Use `--mode test` first.** Verify correctness before submitting to the leaderboard. This saves time and leaderboard quota. - **Profile your kernels.** Use `--mode profile` to get Nsight Compute metrics and identify bottlenecks. -- **One config per submission.** If Helion found different best configs for different benchmark shapes, pick the one that works best across all of them -- the leaderboard uses geometric mean across benchmarks. +- **One config per shape.** Use the per-shape config pattern to provide an optimized config for each benchmark shape in `task.yml`. + +## Working on Your GPU Machine + +- **Use a GitHub repo for your kernels.** Push your work to a private GitHub repo so you don't lose progress if the GPU machine goes offline or loses data. +- **Use tmux for autotuning.** Autotuning can take a long time. Run it inside a `tmux` session so it survives SSH disconnections. +- **Use spawn mode for autotuning if you hit issues.** By default, Helion's autotuner uses `fork` mode for precompilation, which is faster but can hang or crash if a bad config corrupts process state. If that happens, switch to `spawn` mode, which runs each trial in an isolated subprocess with timeout protection — slower due to subprocess overhead, but one bad config can't take down your entire autotuning run. Enable it via environment variable or decorator: + ```bash + export HELION_AUTOTUNE_PRECOMPILE=spawn + ``` + ```python + @helion.kernel(autotune_precompile="spawn") + def my_kernel(...): + ... + ``` + You can also control parallelism with `HELION_AUTOTUNE_PRECOMPILE_JOBS` (defaults to CPU count). +- **Machine frozen or crashed?** If your GPU machine becomes unresponsive and needs a reboot, let us know and we can reboot it for you. + +## Open-Ended Contribution Track + +In addition to the kernel competition, there is a separate open-ended contribution track. Participants can earn recognition and prizes for contributions to Helion beyond kernel implementations. This track is scored independently and does not affect kernel competition standings. Examples: + +| Contribution Type | Description | +|---|---| +| Autotuner Improvements | Enhancements to Helion's autotuning system | +| Bug Fixes | Bug fixes in Helion | +| Tooling/Infrastructure | Improvements to debugging, profiling, or developer experience | +| Documentation | Significant documentation contributions | +| Other Novel Contributions | Other impactful contributions at judges' discretion | + +Contributions are uncapped and evaluated by a panel of judges based on impact and quality. Prizes for this track are awarded separately from the kernel competition. + ## Resources - [Helion Documentation](https://helionlang.com) From d87db4d424f300f62ed54a5c676741846a1f8bba Mon Sep 17 00:00:00 2001 From: Will Feng Date: Fri, 13 Mar 2026 21:13:26 -0700 Subject: [PATCH 096/111] feat: create dedicated kernel folder on `popcorn setup` (#46) * feat: create dedicated kernel folder on `popcorn setup` Instead of writing files directly into the current directory (which overwrites existing files), `popcorn setup` now creates a subfolder named after the problem directory (e.g. `softmax/`). If a folder with that name already exists, a `-N` suffix is appended (`softmax-1/`, `softmax-2/`, etc.) to avoid collisions. * docs: update setup docs to reflect new project folder behavior * style: fix rustfmt formatting in setup.rs --- README.md | 6 ++-- docs/helion-hackathon.md | 2 +- src/cmd/setup.rs | 65 ++++++++++++++++++++++++++++++++-------- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ceb114f..fc4cf66 100644 --- a/README.md +++ b/README.md @@ -70,14 +70,14 @@ We regularly run competitions with clear due dates but for beginners we will alw ### Setup -Bootstrap a project with Popcorn skill scaffolding and a submission template. You can overwrite existing files with `--force`. +Bootstrap a project with Popcorn skill scaffolding and a submission template. ```bash -# Create project skill scaffolding + submission.py +# Create a project folder with skill scaffolding + submission.py popcorn setup ``` -This will create a new agent skill based on the [templates](templates/setup) and add it to your `.claude/skills` or `.codex/skills` directory. +This will create a new folder named after the selected problem (e.g. `softmax/`), containing a `submission.py` template and agent skills in `.claude/skills` and `.codex/skills`. If a folder with that name already exists, a `-N` suffix is appended (e.g. `softmax-1/`). ### Submit diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index 0fa3abc..73c91ec 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -67,7 +67,7 @@ popcorn setup # Select "Helion Kernel Challenge", then pick a problem and GPU ``` -`popcorn setup` fetches the latest problems from reference-kernels, downloads the submission template with `#!POPCORN` directives pre-filled, and scaffolds agent skills for Codex/Claude Code. +`popcorn setup` fetches the latest problems from reference-kernels, creates a project folder named after the selected problem (e.g. `causal_conv1d_py/`), downloads the submission template with `#!POPCORN` directives pre-filled, and scaffolds agent skills for Codex/Claude Code. If a folder with that name already exists, a `-N` suffix is appended (e.g. `causal_conv1d_py-1/`). Alternatively, you can clone the full reference-kernels repo to browse all problems locally: diff --git a/src/cmd/setup.rs b/src/cmd/setup.rs index eb6c892..06e490b 100644 --- a/src/cmd/setup.rs +++ b/src/cmd/setup.rs @@ -262,15 +262,28 @@ pub async fn run_setup() -> Result<()> { ) .await?; - // Write scaffolding files - let popcorn_dir = cwd.join(".popcorn"); + // Create a unique project folder for this kernel + let project_dir = unique_folder_name(&cwd, &chosen_problem.directory); + fs::create_dir_all(&project_dir).with_context(|| { + format!( + "Failed to create project directory at {}", + project_dir.to_string_lossy() + ) + })?; + println!( + "\nCreated project folder: {}", + relative_display(&cwd, &project_dir) + ); + + // Write scaffolding files into the project folder + let popcorn_dir = project_dir.join(".popcorn"); let skill_dir = popcorn_dir.join("skills").join(SKILL_NAME); let skill_path = skill_dir.join("SKILL.md"); let native_skill_dir = popcorn_dir.join("skills").join(NATIVE_SKILL_NAME); let native_skill_path = native_skill_dir.join("SKILL.md"); let manifest_path = popcorn_dir.join("setup.json"); - let submission_path = cwd.join(SUBMISSION_FILENAME); - let agents_path = cwd.join("AGENTS.md"); + let submission_path = project_dir.join(SUBMISSION_FILENAME); + let agents_path = project_dir.join("AGENTS.md"); fs::create_dir_all(&skill_dir).with_context(|| { format!( @@ -313,11 +326,12 @@ pub async fn run_setup() -> Result<()> { let agents_md = build_agents_markdown(&skill_path, &native_skill_path); let agents_status = write_text_file(&agents_path, &agents_md, true)?; - let codex_link_status = create_agent_skill_view(&cwd, "codex", &skill_dir, true)?; - let claude_link_status = create_agent_skill_view(&cwd, "claude", &skill_dir, true)?; - let codex_native_link_status = create_agent_skill_view(&cwd, "codex", &native_skill_dir, true)?; + let codex_link_status = create_agent_skill_view(&project_dir, "codex", &skill_dir, true)?; + let claude_link_status = create_agent_skill_view(&project_dir, "claude", &skill_dir, true)?; + let codex_native_link_status = + create_agent_skill_view(&project_dir, "codex", &native_skill_dir, true)?; let claude_native_link_status = - create_agent_skill_view(&cwd, "claude", &native_skill_dir, true)?; + create_agent_skill_view(&project_dir, "claude", &native_skill_dir, true)?; let submission_status = write_text_file(&submission_path, &submission_content, true)?; @@ -344,27 +358,39 @@ pub async fn run_setup() -> Result<()> { println!( "{} {}", codex_link_status.label(), - relative_display(&cwd, &cwd.join(".codex").join("skills").join(SKILL_NAME)) + relative_display( + &cwd, + &project_dir.join(".codex").join("skills").join(SKILL_NAME) + ) ); println!( "{} {}", codex_native_link_status.label(), relative_display( &cwd, - &cwd.join(".codex").join("skills").join(NATIVE_SKILL_NAME) + &project_dir + .join(".codex") + .join("skills") + .join(NATIVE_SKILL_NAME) ) ); println!( "{} {}", claude_link_status.label(), - relative_display(&cwd, &cwd.join(".claude").join("skills").join(SKILL_NAME)) + relative_display( + &cwd, + &project_dir.join(".claude").join("skills").join(SKILL_NAME) + ) ); println!( "{} {}", claude_native_link_status.label(), relative_display( &cwd, - &cwd.join(".claude").join("skills").join(NATIVE_SKILL_NAME) + &project_dir + .join(".claude") + .join("skills") + .join(NATIVE_SKILL_NAME) ) ); println!( @@ -376,6 +402,21 @@ pub async fn run_setup() -> Result<()> { Ok(()) } +fn unique_folder_name(parent: &Path, base_name: &str) -> PathBuf { + let candidate = parent.join(base_name); + if !candidate.exists() { + return candidate; + } + let mut n = 1; + loop { + let candidate = parent.join(format!("{}-{}", base_name, n)); + if !candidate.exists() { + return candidate; + } + n += 1; + } +} + fn relative_display(cwd: &Path, target: &Path) -> String { match target.strip_prefix(cwd) { Ok(relative) => relative.to_string_lossy().to_string(), From 5b9f29d0c58863e0fb086018008b15444926a10a Mon Sep 17 00:00:00 2001 From: Will Feng Date: Sat, 14 Mar 2026 09:16:55 -0700 Subject: [PATCH 097/111] docs: mention HELION_BACKEND=tileir alongside ENABLE_TILE=1 (#47) The TileIR backend requires both env vars to be set. Update the table, step-by-step instructions, and "Which should I use?" section to consistently mention both ENABLE_TILE=1 and HELION_BACKEND=tileir. --- docs/helion-hackathon.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index 73c91ec..d48ca83 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -291,11 +291,11 @@ def my_kernel(...): The B200 instances also ship with **nvtriton**, NVIDIA's extended Triton compiler that includes a **TileIR** backend — an alternative compilation pipeline that bypasses LLVM and compiles directly to CUBIN via NVIDIA's `tileiras` compiler. -| | `ENABLE_TILE=0` (default) | `ENABLE_TILE=1` | +| | `ENABLE_TILE=0` (default) | `ENABLE_TILE=1` + `HELION_BACKEND=tileir` | |---|---|---| | **Helion backend** | `triton` | `tileir` | -**Step 1: Enable TileIR and autotune.** Set the env vars before importing Helion, then autotune as usual. Helion automatically adjusts the search space for the TileIR backend. +**Step 1: Enable TileIR and autotune.** Set both `ENABLE_TILE=1` and `HELION_BACKEND=tileir` env vars before importing Helion, then autotune as usual. Helion automatically adjusts the search space for the TileIR backend. **Step 2: Hardcode the TileIR config in your submission.** Copy the best config from the autotuner output (it will include TileIR-specific fields like `num_ctas` and `occupancy`). The env vars must be set before imports: @@ -320,7 +320,7 @@ def my_kernel(...): ### Which should I use? -Try both `ENABLE_TILE=0` and `ENABLE_TILE=1`, with and without ACFs, then submit whichever gives the best benchmark numbers. +Try both the default backend (`ENABLE_TILE=0`) and the TileIR backend (`ENABLE_TILE=1` + `HELION_BACKEND=tileir`), with and without ACFs, then submit whichever gives the best benchmark numbers. ## Tips From 68fd44adfe2d2741adfe9a322647291923162d23 Mon Sep 17 00:00:00 2001 From: Will Feng Date: Sat, 14 Mar 2026 10:17:05 -0700 Subject: [PATCH 098/111] docs: simplify hackathon scoring to top-3 points system (#49) Replace the rank-based correctness/performance formula with a simpler top-3 system: 5 pts (1st), 3 pts (2nd), 1 pt (3rd) per scored problem. Mark fp8_quant as an unscored warm-up. Ties decided by kernel quality. --- docs/helion-hackathon.md | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index d48ca83..4a48790 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -20,28 +20,22 @@ Submit [Helion](https://github.com/pytorch/helion) kernels to the GPU MODE leade ## Scoring -### Point Allocation +Each scored problem awards points to the **top 3** fastest correct submissions: -| Kernel | Correctness Points | Performance Points | -|---|---|---| -| **FP8 Quantization** | 100 | 0 (unscored) | -| **Causal Depthwise 1D Convolution** | 100 | 1000 | -| **Gated DeltaNet chunk\_fwd\_h** | 100 | 1000 | -| **Gated DeltaNet chunk\_fwd\_o** | 100 | 1000 | -| **Gated DeltaNet recompute\_w\_u** | 100 | 1000 | +| Place | Points | +|---|---| +| 1st | 5 | +| 2nd | 3 | +| 3rd | 1 | -### Scoring Rules +> **Note:** Problem 1 (`fp8_quant`) is **not scored** — it is a warm-up problem only. Points are awarded for problems 2–5. - **Performance Metric**: For each benchmark shape, the kernel is captured in a CUDA graph and replayed with L2 cache clearing before each invocation. The graph unrolls enough calls to fill ~100ms of GPU time, and this is repeated 10 times. The runtime is the arithmetic mean of those 10 measurements. -- **Ranking**: Participants are ranked per kernel by runtime (fastest = rank 1). -- **Formula**: Score = CorrectnessPoints + (PerformancePoints × [1 − (rank - 1) / 10]) - - CorrectnessPoints are earned if the submission passes all test input shapes. - - Only the top 10 performers per kernel (who pass all tests) can earn PerformancePoints. - - Rank 1 → 100% of PerformancePoints, Rank 2 → 90%, …, Rank 10 → 10%. -- **Tiebreaker**: If two participants have the same metric value, the earlier submission wins. +- **Correctness**: Submissions must pass all test input shapes to be eligible for points. +- **Tiebreaker**: If two participants have the same metric value, judges will decide based on the quality of the kernel. - **Test case shapes**: Provided in `task.yml`; input data sampled from a random distribution. -**Total score** = Sum of points for all kernels. +**Total score** = Sum of points across problems 2–5 (max 20). ## Rules & Requirements From 66daac59bd092251500aad610f0c40c2e7749895 Mon Sep 17 00:00:00 2001 From: Will Feng Date: Sat, 14 Mar 2026 10:30:06 -0700 Subject: [PATCH 099/111] docs: add Discord auth hint after register step (#50) Co-authored-by: Claude Opus 4.6 (1M context) --- docs/helion-hackathon.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index 4a48790..ed0b4a4 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -52,6 +52,7 @@ curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.s # 2. Register popcorn register discord +# Please click on the link to authenticate on Discord # 3. Join the challenge with your invite code popcorn join From 3fd2da95a3ddad4780a7c5ef01cf2e4a5f6dcbd4 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 14 Mar 2026 10:30:13 -0700 Subject: [PATCH 100/111] More ui friendly register flow --- src/cmd/auth.rs | 81 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 56 insertions(+), 25 deletions(-) diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index daf00e8..1279315 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -1,9 +1,10 @@ use anyhow::{anyhow, Result}; +use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; use std::fs::{File, OpenOptions}; use std::path::PathBuf; -use crate::service; // Assuming service::create_client is needed +use crate::service; // Configuration structure #[derive(Serialize, Deserialize, Debug, Default)] @@ -50,32 +51,46 @@ struct AuthInitResponse { // Function to handle the login logic pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { - println!("Attempting authentication via {}...", auth_provider); + println!( + "{} Authenticating via {}...", + "●".cyan(), + auth_provider.bold() + ); - let popcorn_api_url = std::env::var("POPCORN_API_URL") - .map_err(|_| anyhow!("POPCORN_API_URL environment variable not set"))?; + let popcorn_api_url = std::env::var("POPCORN_API_URL").map_err(|_| { + anyhow!( + "{} POPCORN_API_URL environment variable not set", + "error:".red().bold() + ) + })?; let client = service::create_client(None)?; let init_url = format!("{}/auth/init?provider={}", popcorn_api_url, auth_provider); - println!("Requesting CLI ID from {}", init_url); - let init_resp = client.get(&init_url).send().await?; + let init_resp = client.get(&init_url).send().await.map_err(|e| { + anyhow!( + "{} Could not reach auth server: {}", + "error:".red().bold(), + e + ) + })?; let status = init_resp.status(); if !status.is_success() { let error_text = init_resp.text().await?; - return Err(anyhow!( - "Failed to initialize auth ({}): {}", - status, + eprintln!( + "{} Failed to initialize auth ({}): {}", + "error:".red().bold(), + status.to_string().red(), error_text - )); + ); + return Err(anyhow!("Authentication initialization failed")); } let auth_init_data: AuthInitResponse = init_resp.json().await?; let cli_id = auth_init_data.state; - println!("Received CLI ID: {}", cli_id); let state_json = serde_json::json!({ "cli_id": cli_id, @@ -99,41 +114,57 @@ pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { ) } _ => { + eprintln!( + "{} Unsupported authentication provider: {}", + "error:".red().bold(), + auth_provider.yellow() + ); return Err(anyhow!( "Unsupported authentication provider: {}", auth_provider - )) + )); } }; println!( - "\n>>> Please open the following URL in your browser to log in via {}:", - auth_provider - ); - println!("{}", auth_url); - println!("\nWaiting for you to complete the authentication in your browser..."); - println!( - "After successful authentication with {}, the CLI ID will be saved.", - auth_provider + "\n {} Open this URL to log in via {}:\n", + "▸".bold(), + auth_provider.bold() ); + println!(" {}\n", auth_url.as_str().underlined().cyan()); if webbrowser::open(&auth_url).is_err() { println!( - "Could not automatically open the browser. Please copy the URL above and paste it manually." + " {} Could not open browser automatically — please copy the link above.", + "!".yellow().bold() + ); + } else { + println!( + " {} Browser opened. Complete the login there.", + "✓".green().bold() ); } + println!( + " {} Waiting for authentication to complete...\n", + "…".dark_grey() + ); + // Save the cli_id to config file optimistically let mut config = load_config().unwrap_or_default(); config.cli_id = Some(cli_id.clone()); save_config(&config)?; + let config_path = get_config_path()?.display().to_string(); + println!( + " {} Authentication initiated! CLI ID saved to {}", + "✓".green().bold(), + config_path.underlined() + ); println!( - "\nSuccessfully initiated authentication. Your CLI ID ({}) has been saved to {}. To use the CLI on different machines, you can copy the config file.", - cli_id, - get_config_path()?.display() + " {} You can now use commands that require authentication.\n", + "●".cyan() ); - println!("You can now use other commands that require authentication."); Ok(()) } From fa1291f2067a2c5abd9b0b051c339d5c756315d8 Mon Sep 17 00:00:00 2001 From: S1ro1 Date: Sat, 14 Mar 2026 10:35:33 -0700 Subject: [PATCH 101/111] More ui friendly register flow2 --- src/cmd/auth.rs | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index 1279315..6ad65ec 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -133,7 +133,10 @@ pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { ); println!(" {}\n", auth_url.as_str().underlined().cyan()); + let mut browser_failed = false; + if webbrowser::open(&auth_url).is_err() { + browser_failed = true; println!( " {} Could not open browser automatically — please copy the link above.", "!".yellow().bold() @@ -156,15 +159,24 @@ pub async fn run_auth(reset: bool, auth_provider: &str) -> Result<()> { save_config(&config)?; let config_path = get_config_path()?.display().to_string(); - println!( - " {} Authentication initiated! CLI ID saved to {}", - "✓".green().bold(), - config_path.underlined() - ); - println!( - " {} You can now use commands that require authentication.\n", - "●".cyan() - ); + + if !browser_failed { + println!( + " {} Authentication initiated! CLI ID saved to {}", + "✓".green().bold(), + config_path.underlined() + ); + + println!( + " {} You can now use commands that require authentication.\n", + "●".cyan() + ); + } else { + println!( + "{} You need to open the browser URL above to complete the authentication. After that, you can use the CLI as normal.", + "?".yellow().bold(), + ); + } Ok(()) } From 81720ae0102bbfd32dc4b101cc98623e367b6bd2 Mon Sep 17 00:00:00 2001 From: Will Feng Date: Sat, 14 Mar 2026 13:53:25 -0700 Subject: [PATCH 102/111] docs: move fp8_quant to problem 1 in hackathon problems table Reorder so the unscored warm-up problem (fp8_quant) is listed first, matching the scoring note that says "Problem 1 is not scored". --- docs/helion-hackathon.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/helion-hackathon.md b/docs/helion-hackathon.md index ed0b4a4..1aaaedc 100644 --- a/docs/helion-hackathon.md +++ b/docs/helion-hackathon.md @@ -12,8 +12,8 @@ Submit [Helion](https://github.com/pytorch/helion) kernels to the GPU MODE leade | # | Leaderboard Name | Description | |---|-----------------|-------------| -| 1 | `causal_conv1d` | Causal depthwise 1D convolution (Mamba/Mamba-2) | -| 2 | `fp8_quant` | Per-token-group FP8 E4M3 quantization (DeepSeek-V3, Llama 3, Qwen3) | +| 1 | `fp8_quant` | Per-token-group FP8 E4M3 quantization (DeepSeek-V3, Llama 3, Qwen3) | +| 2 | `causal_conv1d` | Causal depthwise 1D convolution (Mamba/Mamba-2) | | 3 | `gated_deltanet_chunk_fwd_h` | Inter-chunk state recurrence for Gated DeltaNet | | 4 | `gated_deltanet_chunk_fwd_o` | Output computation for Gated DeltaNet | | 5 | `gated_deltanet_recompute_w_u` | WY-transform forward kernel for Gated DeltaNet | From 4ee4737c2d580f49f9f29790992f7d22ee844a49 Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Fri, 3 Apr 2026 14:18:37 -0700 Subject: [PATCH 103/111] add princeton 2026 quickstart doc (#54) --- docs/princeton2026.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/princeton2026.md diff --git a/docs/princeton2026.md b/docs/princeton2026.md new file mode 100644 index 0000000..d14260f --- /dev/null +++ b/docs/princeton2026.md @@ -0,0 +1,29 @@ +# Princeton 2026 Quick Start + +Use `A100` and this leaderboard: `princeton_cross_entropy`. + +```bash +# 1. Install the CLI +curl -fsSL https://raw.githubusercontent.com/gpu-mode/popcorn-cli/main/install.sh | bash + +# 2. Register once with GitHub +popcorn register github + +# 3. Join the closed leaderboard with your invite code +popcorn join + +# 4. Get the starter file +wget https://raw.githubusercontent.com/gpu-mode/reference-kernels/main/problems/princeton/cross_entropy_py/submission.py + +# 5. Run a correctness check +popcorn submit --leaderboard princeton_cross_entropy --gpu A100 --mode test submission.py + +# 6. Submit an official ranked run +popcorn submit --leaderboard princeton_cross_entropy --gpu A100 --mode leaderboard submission.py +``` + +Notes: + +- `test` checks correctness only. +- `leaderboard` is the official ranked submission. +- If registration gets stuck, run `popcorn reregister github`. From 45e4ae54303fcbf49331c6aa1461f061801a511f Mon Sep 17 00:00:00 2001 From: John Law <8260377+Wal8800@users.noreply.github.com> Date: Sat, 4 Apr 2026 10:32:25 +1300 Subject: [PATCH 104/111] fix: writing multiple profile zip (#53) * fix: writing multiple profile zip * test: cover profile trace helpers * ci: fix PR validation workflows --------- Co-authored-by: Mark Saroufim --- .github/workflows/build.yml | 3 - docs/profiling.md | 4 +- src/service/mod.rs | 125 +++++++++++++++++++++++++++++++++--- 3 files changed, 119 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1640c6a..6a28a05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,9 +6,6 @@ on: - main tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 - - # Keep pull request builds for testing - pull_request: workflow_dispatch: permissions: diff --git a/docs/profiling.md b/docs/profiling.md index 5aba6d6..45601c6 100644 --- a/docs/profiling.md +++ b/docs/profiling.md @@ -48,12 +48,12 @@ Stall Barrier inst 0.75 After profiling, a zip file is saved to your current directory: ``` -profile_20260113_031052_run0.zip +profile_20260113_031052_result0_profile0.zip ``` This contains a `.ncu-rep` file (the full Nsight Compute report): ``` -$ unzip -l profile_20260113_031052_run0.zip +$ unzip -l profile_20260113_031052_result0_profile0.zip Length Date Time Name --------- ---------- ----- ---- 2178383 01-13-2026 03:10 profile.ncu-rep diff --git a/src/service/mod.rs b/src/service/mod.rs index 1aee5f6..bcdfa80 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; use base64::Engine; -use chrono::Utc; +use chrono::{DateTime, Utc}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::multipart::{Form, Part}; use reqwest::Client; @@ -650,7 +650,7 @@ pub async fn submit_solution>( { for (key, run_data) in runs.iter() { if key.starts_with("profile") { - handle_profile_result(cb, run_data, i); + handle_profile_result(cb, run_data, i, key); } } } @@ -754,7 +754,12 @@ pub async fn submit_solution>( /// Handle profile mode results by decoding and displaying profile data, /// and saving trace files to the current directory. -fn handle_profile_result(cb: &(dyn Fn(String) + Send + Sync), run_data: &Value, run_idx: usize) { +fn handle_profile_result( + cb: &(dyn Fn(String) + Send + Sync), + run_data: &Value, + result_idx: usize, + run_key: &str, +) { // 1. Get profiler type and display it if let Some(profile) = run_data.get("profile") { let profiler = profile @@ -814,11 +819,9 @@ fn handle_profile_result(cb: &(dyn Fn(String) + Send + Sync), run_data: &Value, if !trace_b64.is_empty() { match base64::engine::general_purpose::STANDARD.decode(trace_b64) { Ok(trace_data) => { - // Generate unique filename with timestamp and run index - let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); - let filename = format!("profile_{}_run{}.zip", timestamp, run_idx); - match std::fs::write(&filename, &trace_data) { - Ok(_) => cb(format!("\nSaved profile trace to: {}", filename)), + match write_profile_trace_file(&trace_data, Utc::now(), result_idx, run_key) + { + Ok(filename) => cb(format!("\nSaved profile trace to: {}", filename)), Err(e) => cb(format!("Failed to save trace file: {}", e)), } } @@ -836,9 +839,55 @@ fn handle_profile_result(cb: &(dyn Fn(String) + Send + Sync), run_data: &Value, } } +fn sanitize_profile_run_key(run_key: &str) -> String { + let sanitized: String = run_key + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' { + ch + } else { + '_' + } + }) + .collect(); + + if sanitized.is_empty() { + "profile".to_string() + } else { + sanitized + } +} + +fn build_profile_trace_filename( + timestamp: DateTime, + result_idx: usize, + run_key: &str, +) -> String { + let run_key = sanitize_profile_run_key(run_key); + format!( + "profile_{}_result{}_{}.zip", + timestamp.format("%Y%m%d_%H%M%S"), + result_idx, + run_key + ) +} + +fn write_profile_trace_file( + trace_data: &[u8], + timestamp: DateTime, + result_idx: usize, + run_key: &str, +) -> std::io::Result { + let filename = build_profile_trace_filename(timestamp, result_idx, run_key); + std::fs::write(&filename, trace_data)?; + Ok(filename) +} + #[cfg(test)] mod tests { use super::*; + use chrono::TimeZone; + use tempfile::tempdir; #[test] fn test_create_client_without_cli_id() { @@ -932,4 +981,64 @@ mod tests { std::env::set_var("POPCORN_API_URL", val); } } + + #[test] + fn test_build_profile_trace_filename_uses_result_index_and_run_key() { + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + + let filename = build_profile_trace_filename(timestamp, 0, "profile3"); + + assert_eq!(filename, "profile_20260327_093846_result0_profile3.zip"); + } + + #[test] + fn test_build_profile_trace_filename_sanitizes_run_key() { + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + + let filename = build_profile_trace_filename(timestamp, 1, "profile:1/a b"); + + assert_eq!( + filename, + "profile_20260327_093846_result1_profile_1_a_b.zip" + ); + } + + #[test] + fn test_build_profile_trace_filename_uses_default_run_key_when_empty() { + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + + let filename = build_profile_trace_filename(timestamp, 2, ""); + + assert_eq!(filename, "profile_20260327_093846_result2_profile.zip"); + } + + #[test] + fn test_write_profile_trace_file_writes_expected_contents() { + let temp_dir = tempdir().unwrap(); + let original_dir = std::env::current_dir().unwrap(); + let timestamp = Utc + .with_ymd_and_hms(2026, 3, 27, 9, 38, 46) + .single() + .unwrap(); + let trace_data = b"trace-bytes"; + + std::env::set_current_dir(temp_dir.path()).unwrap(); + + let filename = write_profile_trace_file(trace_data, timestamp, 3, "profile/3").unwrap(); + let written_path = temp_dir.path().join(&filename); + + assert_eq!(filename, "profile_20260327_093846_result3_profile_3.zip"); + assert_eq!(std::fs::read(&written_path).unwrap(), trace_data); + + std::env::set_current_dir(original_dir).unwrap(); + } } From 79e8cd211434a32a3b8c8fb1cde03f47af2720df Mon Sep 17 00:00:00 2001 From: Mark Saroufim Date: Fri, 3 Apr 2026 18:44:26 -0700 Subject: [PATCH 105/111] [codex] document submission inspection and deletion flow (#55) * document submission inspection and deletion flow * reframe README note as reward hack section --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index fc4cf66..ef6bc6e 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,23 @@ popcorn submissions delete popcorn submissions delete --force ``` +#### Avoid Reward Hacks + +If you want to avoid reward hacks, inspect your past submissions and delete any bad ones. + +```bash +# 1. List your submissions and note the ID +popcorn submissions list --leaderboard grayscale_v2 + +# 2. Inspect the exact code stored for that submission +popcorn submissions show 1234 + +# 3. Delete the submission if you do not want it kept +popcorn submissions delete 1234 +``` + +`list` shows the submission `ID`, `show` prints the full submitted code for that `ID`, and `delete` previews the submission before removing it. + ### Authentication Register or re-register your CLI with Discord or GitHub. From b94cc1ab1b195d946409e1068461114110f2935e Mon Sep 17 00:00:00 2001 From: burtenshaw Date: Sat, 4 Apr 2026 07:09:03 +0200 Subject: [PATCH 106/111] Mention and add details in AGENTS.md (#39) * add to cli docstring * details in AGENTS.md * Apply suggestion from @burtenshaw Co-authored-by: burtenshaw --------- Co-authored-by: Mark Saroufim --- src/cmd/mod.rs | 3 ++- templates/setup/AGENTS.md | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 71595b2..e1b1033 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -42,6 +42,7 @@ fn load_config() -> Result { #[derive(Parser, Debug)] #[command(author, version = env!("CLI_VERSION"), about, long_about = None)] +/// Popcorn CLI for GPU Mode competitions. Run `popcorn setup` first in each project so agents use the correct workflow and templates. pub struct Cli { #[command(subcommand)] command: Option, @@ -106,7 +107,7 @@ enum SubmissionsAction { #[derive(Subcommand, Debug)] enum Commands { - /// Bootstrap this project with Popcorn agent skills and a submission template + /// Run this first: bootstrap the project with Popcorn agent skills and a submission template Setup, Reregister { #[command(subcommand)] diff --git a/templates/setup/AGENTS.md b/templates/setup/AGENTS.md index 353790d..15177cb 100644 --- a/templates/setup/AGENTS.md +++ b/templates/setup/AGENTS.md @@ -1,3 +1,13 @@ +# Kernel Bot Submissions + +The Popcorn CLI is intended to submit kernels to leaderboards on GPU Mode's Kernel Bot. + +## Objective + +Agents must write CUDA C and C++ kernels and integrate them into the single-file `submission.py` workflow. + +Do not submit pure PyTorch based optimization. The objective of the task is to use any kernel DSL like CUDA or Triton to improved *beyond* the performance of native PyTorch. + ## Skills A skill is a local instruction bundle stored in `SKILL.md`. From 78d93adfb9ffc37514bd9be57fa03a9910143365 Mon Sep 17 00:00:00 2001 From: brandon in Date: Fri, 3 Apr 2026 22:34:08 -0700 Subject: [PATCH 107/111] Add aarch64 Linux support (DGX Spark / GB10) (#51) * Add aarch64 Linux support (DGX Spark / GB10) - Add aarch64-unknown-linux-gnu build target to the release workflow - Add .cargo/config.toml to configure the cross-linker for aarch64 - Update install.sh to detect arm64/aarch64 and download the correct binary (popcorn-cli-linux-aarch64.tar.gz) instead of the x86-64 build * validate arm64 installer in CI * move arm64 validation into test workflow * fold arm64 into test matrix * build arm64 release on native runner --------- Co-authored-by: brandonin Co-authored-by: Mark Saroufim --- .github/workflows/build.yml | 14 ++++++++------ .github/workflows/test.yml | 2 +- install.sh | 11 ++++++++--- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a28a05..a1f6dd8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,6 +58,13 @@ jobs: compress_cmd: tar -czf compress_ext: .tar.gz + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + artifact_name: popcorn-cli + asset_name: popcorn-cli-linux-aarch64.tar.gz + compress_cmd: tar -czf + compress_ext: .tar.gz + steps: - uses: actions/checkout@v4 @@ -72,12 +79,6 @@ jobs: with: key: ${{ matrix.target }} - - name: Install cross-compilation dependencies (Linux ARM) - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - - name: Install musl tools (Linux musl) if: matrix.target == 'x86_64-unknown-linux-musl' run: | @@ -126,6 +127,7 @@ jobs: name: Release ${{ needs.version.outputs.new_tag }} files: | popcorn-cli-linux.tar.gz/popcorn-cli-linux.tar.gz + popcorn-cli-linux-aarch64.tar.gz/popcorn-cli-linux-aarch64.tar.gz popcorn-cli-windows.zip/popcorn-cli-windows.zip popcorn-cli-macos.tar.gz/popcorn-cli-macos.tar.gz env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7b1a74..be183c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] rust: [stable] steps: diff --git a/install.sh b/install.sh index dc2d94f..0d61d61 100755 --- a/install.sh +++ b/install.sh @@ -28,7 +28,12 @@ SYMLINK_NAME="" EXTENSION="" if [[ "$OSTYPE" == "linux-gnu"* ]]; then - OS="linux" + ARCH=$(uname -m) + if [[ "$ARCH" == "aarch64" || "$ARCH" == "arm64" ]]; then + OS="linux-aarch64" + else + OS="linux" + fi EXTENSION=".tar.gz" BINARY_NAME="popcorn-cli" SYMLINK_NAME="popcorn" @@ -47,7 +52,7 @@ else exit 1 fi -echo "✅ Detected OS: $OS" +echo "✅ Detected OS: $OS ($(uname -m))" # Download URL DOWNLOAD_URL="https://github.com/gpu-mode/popcorn-cli/releases/latest/download/popcorn-cli-${OS}${EXTENSION}" @@ -149,4 +154,4 @@ echo " - ✅ All modes available: test, benchmark, leaderboard, profile" echo " - ✅ Clean user identification" echo "" echo "💡 Need help? Run: popcorn-cli --help" -echo "🔗 Example: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py" \ No newline at end of file +echo "🔗 Example: popcorn-cli submit --gpu MI300 --leaderboard amd-fp8-mm --mode test example.py" From d2a55b44b246af10877dd487bb31cfe82a5894b8 Mon Sep 17 00:00:00 2001 From: Jack-Khuu Date: Mon, 20 Apr 2026 18:06:47 -0700 Subject: [PATCH 108/111] Override popcorn id with env arg --- README.md | 1 + src/cmd/mod.rs | 111 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 80 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ef6bc6e..7a6069d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Since we're effectively giving out GPUs for free we rely on either github or dis 1. Register the CLI (Discord recommended): `popcorn register discord` (or `popcorn register github`) 2. To ensure the above worked you can run `cat $HOME/.popcorn.yaml` which should print your client ID which is what will be sent to us on every request +3. To override the submitter ID for local proxy/testing flows, set `POPCORN_SUBMITTER_ID`. Commands that require `cli_id` use this value first, then fall back to `~/.popcorn.yaml`. Sometimes you'll get an error that you're already authenticated despite being unable to submit in which case you can run `popcorn reregister [discord|github]`. diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index e1b1033..004b2a8 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -40,6 +40,27 @@ fn load_config() -> Result { serde_yaml::from_reader(file).map_err(|e| anyhow!("Failed to parse config file: {}", e)) } +fn submit_cli_id_from_env() -> Option { + std::env::var("POPCORN_SUBMITTER_ID") + .ok() + .filter(|v| !v.trim().is_empty()) +} + +fn resolve_cli_id() -> Result { + if let Some(cli_id) = submit_cli_id_from_env() { + return Ok(cli_id); + } + + let config = load_config()?; + config.cli_id.ok_or_else(|| { + anyhow!( + "cli_id not found in config file ({}). Please run 'popcorn-cli register' first.", + get_config_path() + .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) + ) + }) +} + #[derive(Parser, Debug)] #[command(author, version = env!("CLI_VERSION"), about, long_about = None)] /// Popcorn CLI for GPU Mode competitions. Run `popcorn setup` first in each project so agents use the correct workflow and templates. @@ -183,14 +204,7 @@ pub async fn execute(cli: Cli) -> Result<()> { output, no_tui, }) => { - let config = load_config()?; - let cli_id = config.cli_id.ok_or_else(|| { - anyhow!( - "cli_id not found in config file ({}). Please run 'popcorn-cli register' first.", - get_config_path() - .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) - ) - })?; + let cli_id = resolve_cli_id()?; // Use filepath from Submit command first, fallback to top-level filepath let final_filepath = filepath.or(cli.filepath); @@ -218,14 +232,7 @@ pub async fn execute(cli: Cli) -> Result<()> { } } Some(Commands::Join { code }) => { - let config = load_config()?; - let cli_id = config.cli_id.ok_or_else(|| { - anyhow!( - "cli_id not found in config file ({}). Please run `popcorn register` first.", - get_config_path() - .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) - ) - })?; + let cli_id = resolve_cli_id()?; let client = service::create_client(Some(cli_id))?; let result = service::join_with_invite(&client, &code).await?; let leaderboards = result["leaderboards"] @@ -242,14 +249,7 @@ pub async fn execute(cli: Cli) -> Result<()> { } Some(Commands::Admin { action }) => admin::handle_admin(action).await, Some(Commands::Submissions { action }) => { - let config = load_config()?; - let cli_id = config.cli_id.ok_or_else(|| { - anyhow!( - "cli_id not found in config file ({}). Please run `popcorn register` first.", - get_config_path() - .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) - ) - })?; + let cli_id = resolve_cli_id()?; match action { SubmissionsAction::List { leaderboard, limit } => { @@ -272,14 +272,7 @@ pub async fn execute(cli: Cli) -> Result<()> { // Handle the case where only a filepath is provided (for backward compatibility) if let Some(top_level_filepath) = cli.filepath { - let config = load_config()?; - let cli_id = config.cli_id.ok_or_else(|| { - anyhow!( - "cli_id not found in config file ({}). Please run `popcorn register` first.", - get_config_path() - .map_or_else(|_| "unknown path".to_string(), |p| p.display().to_string()) - ) - })?; + let cli_id = resolve_cli_id()?; // Run TUI with only filepath, no other options submit::run_submit_tui( @@ -299,3 +292,57 @@ pub async fn execute(cli: Cli) -> Result<()> { } } } + +#[cfg(test)] +mod tests { + use super::resolve_cli_id; + use std::env; + use std::fs; + use std::sync::Mutex; + use tempfile::tempdir; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvGuard { + old_home: Option, + old_submitter: Option, + } + + impl EnvGuard { + fn new() -> Self { + Self { + old_home: env::var("HOME").ok(), + old_submitter: env::var("POPCORN_SUBMITTER_ID").ok(), + } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.old_home { + Some(v) => env::set_var("HOME", v), + None => env::remove_var("HOME"), + } + match &self.old_submitter { + Some(v) => env::set_var("POPCORN_SUBMITTER_ID", v), + None => env::remove_var("POPCORN_SUBMITTER_ID"), + } + } + } + + #[test] + fn test_resolve_cli_id_prefers_env_over_config() { + let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _guard = EnvGuard::new(); + + let temp_home = tempdir().expect("Failed to create temp home dir"); + let config_path = temp_home.path().join(".popcorn.yaml"); + fs::write(config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + + env::set_var("HOME", temp_home.path()); + env::set_var("POPCORN_SUBMITTER_ID", "env-cli-id"); + + let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); + assert_eq!(cli_id, "env-cli-id"); + } +} From 40f94927a0caff444a03f6e2f724c78b15099d08 Mon Sep 17 00:00:00 2001 From: Jack-Khuu Date: Wed, 29 Apr 2026 10:11:28 -0700 Subject: [PATCH 109/111] More tests --- src/cmd/mod.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 004b2a8..e2394d5 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -345,4 +345,52 @@ mod tests { let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); assert_eq!(cli_id, "env-cli-id"); } + + #[test] + fn test_resolve_cli_id_falls_back_to_config() { + let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _guard = EnvGuard::new(); + + let temp_home = tempdir().expect("Failed to create temp home dir"); + let config_path = temp_home.path().join(".popcorn.yaml"); + fs::write(config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + + env::set_var("HOME", temp_home.path()); + env::remove_var("POPCORN_SUBMITTER_ID"); + + let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); + assert_eq!(cli_id, "config-cli-id"); + } + + #[test] + fn test_resolve_cli_id_ignores_empty_env() { + let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _guard = EnvGuard::new(); + + let temp_home = tempdir().expect("Failed to create temp home dir"); + let config_path = temp_home.path().join(".popcorn.yaml"); + fs::write(config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + + env::set_var("HOME", temp_home.path()); + env::set_var("POPCORN_SUBMITTER_ID", " "); + + let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); + assert_eq!(cli_id, "config-cli-id"); + } + + #[test] + fn test_resolve_cli_id_errors_when_no_cli_id() { + let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _guard = EnvGuard::new(); + + let temp_home = tempdir().expect("Failed to create temp home dir"); + let config_path = temp_home.path().join(".popcorn.yaml"); + fs::write(config_path, "{}\n").expect("Failed to write config"); + + env::set_var("HOME", temp_home.path()); + env::remove_var("POPCORN_SUBMITTER_ID"); + + let err = resolve_cli_id().unwrap_err(); + assert!(err.to_string().contains("cli_id not found")); + } } From 88c50f57cae2dd1e37648a080cb3afeee4c56e6e Mon Sep 17 00:00:00 2001 From: Jack-Khuu Date: Wed, 29 Apr 2026 11:54:46 -0700 Subject: [PATCH 110/111] Fix Windows test failures: skip config-fallback tests on Windows dirs::home_dir() on Windows uses the shell API (SHGetKnownFolderPath), not the HOME env var, so we cannot redirect config lookup in tests. Gate config-fallback tests with #[cfg(not(windows))]. The env var override path is still tested cross-platform. Also recover from poisoned mutex so one test failure doesn't cascade. --- src/cmd/mod.rs | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index e2394d5..e0d88f1 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -332,62 +332,62 @@ mod tests { #[test] fn test_resolve_cli_id_prefers_env_over_config() { - let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = EnvGuard::new(); - let temp_home = tempdir().expect("Failed to create temp home dir"); - let config_path = temp_home.path().join(".popcorn.yaml"); - fs::write(config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); - - env::set_var("HOME", temp_home.path()); env::set_var("POPCORN_SUBMITTER_ID", "env-cli-id"); let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); assert_eq!(cli_id, "env-cli-id"); } + // dirs::home_dir() on Windows uses a shell API, not HOME, so config + // redirection via HOME only works on Unix. + #[cfg(not(windows))] #[test] fn test_resolve_cli_id_falls_back_to_config() { - let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = EnvGuard::new(); - let temp_home = tempdir().expect("Failed to create temp home dir"); - let config_path = temp_home.path().join(".popcorn.yaml"); - fs::write(config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + let tmp = tempdir().expect("Failed to create temp dir"); + let config_path = tmp.path().join(".popcorn.yaml"); + fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); - env::set_var("HOME", temp_home.path()); + env::set_var("HOME", tmp.path()); env::remove_var("POPCORN_SUBMITTER_ID"); let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); assert_eq!(cli_id, "config-cli-id"); } + #[cfg(not(windows))] #[test] fn test_resolve_cli_id_ignores_empty_env() { - let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = EnvGuard::new(); - let temp_home = tempdir().expect("Failed to create temp home dir"); - let config_path = temp_home.path().join(".popcorn.yaml"); - fs::write(config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + let tmp = tempdir().expect("Failed to create temp dir"); + let config_path = tmp.path().join(".popcorn.yaml"); + fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); - env::set_var("HOME", temp_home.path()); + env::set_var("HOME", tmp.path()); env::set_var("POPCORN_SUBMITTER_ID", " "); let cli_id = resolve_cli_id().expect("Expected cli_id resolution to succeed"); assert_eq!(cli_id, "config-cli-id"); } + #[cfg(not(windows))] #[test] fn test_resolve_cli_id_errors_when_no_cli_id() { - let _lock = ENV_LOCK.lock().expect("Failed to lock env mutex"); + let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = EnvGuard::new(); - let temp_home = tempdir().expect("Failed to create temp home dir"); - let config_path = temp_home.path().join(".popcorn.yaml"); - fs::write(config_path, "{}\n").expect("Failed to write config"); + let tmp = tempdir().expect("Failed to create temp dir"); + let config_path = tmp.path().join(".popcorn.yaml"); + fs::write(&config_path, "{}\n").expect("Failed to write config"); - env::set_var("HOME", temp_home.path()); + env::set_var("HOME", tmp.path()); env::remove_var("POPCORN_SUBMITTER_ID"); let err = resolve_cli_id().unwrap_err(); From a1b51d55455a33a791c92015809c4af68374a978 Mon Sep 17 00:00:00 2001 From: Jack-Khuu Date: Wed, 29 Apr 2026 12:56:27 -0700 Subject: [PATCH 111/111] Scope imports --- src/cmd/mod.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index e0d88f1..9f8b9d3 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -297,9 +297,7 @@ pub async fn execute(cli: Cli) -> Result<()> { mod tests { use super::resolve_cli_id; use std::env; - use std::fs; use std::sync::Mutex; - use tempfile::tempdir; static ENV_LOCK: Mutex<()> = Mutex::new(()); @@ -349,9 +347,9 @@ mod tests { let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = EnvGuard::new(); - let tmp = tempdir().expect("Failed to create temp dir"); + let tmp = tempfile::tempdir().expect("Failed to create temp dir"); let config_path = tmp.path().join(".popcorn.yaml"); - fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + std::fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); env::set_var("HOME", tmp.path()); env::remove_var("POPCORN_SUBMITTER_ID"); @@ -366,9 +364,9 @@ mod tests { let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = EnvGuard::new(); - let tmp = tempdir().expect("Failed to create temp dir"); + let tmp = tempfile::tempdir().expect("Failed to create temp dir"); let config_path = tmp.path().join(".popcorn.yaml"); - fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); + std::fs::write(&config_path, "cli_id: config-cli-id\n").expect("Failed to write config"); env::set_var("HOME", tmp.path()); env::set_var("POPCORN_SUBMITTER_ID", " "); @@ -383,9 +381,9 @@ mod tests { let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _guard = EnvGuard::new(); - let tmp = tempdir().expect("Failed to create temp dir"); + let tmp = tempfile::tempdir().expect("Failed to create temp dir"); let config_path = tmp.path().join(".popcorn.yaml"); - fs::write(&config_path, "{}\n").expect("Failed to write config"); + std::fs::write(&config_path, "{}\n").expect("Failed to write config"); env::set_var("HOME", tmp.path()); env::remove_var("POPCORN_SUBMITTER_ID");