diff --git a/.gitignore b/.gitignore index f8cc4e7..1d92a09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binaries for programs and plugins bin/ +dist/ *.exe *.exe~ *.dll @@ -19,5 +20,5 @@ bin/ atomic-summary* atomic -*.gif -test* \ No newline at end of file +test* +*.png \ No newline at end of file diff --git a/LICENSE b/LICENSE.txt similarity index 98% rename from LICENSE rename to LICENSE.txt index a30fae1..b08ea42 100644 --- a/LICENSE +++ b/LICENSE.txt @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2021-Present Shravan Asati - -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. +MIT License + +Copyright (c) 2021-Present Shravan Asati + +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. diff --git a/README.md b/README.md index eb5416c..854b9fb 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Continuous integration](https://github.com/shravanasati/atomic/actions/workflows/integrate.yml/badge.svg)](https://github.com/shravanasati/atomic/actions/workflows/integrate.yml) -![bench_demo](assets/demo.png) +![atomic demo](assets/demo.gif) *atomic* is a simple CLI tool for making benchmarking easy. @@ -12,12 +12,14 @@ ## ✨ Features -- Benchmarks programs easily with just one command, no extra code needed -- Export the results in markdown, json and text formats -- Universal support, you can benchmark any shell command -- Choose the number of runs to perform - Detailed benchmark summary at the end -- Fast and reliable +- Export the results in markdown, json, csv format +- Statistical Outlier Detection +- Plot the benchmarking data, comparing the different commands +- Arbitrary command support +- Constant feedback about the benchmark progress and current estimates. +- Warmup runs can be executed before the actual benchmark. +- Cache-clearing commands can be set up before each timing run.
diff --git a/assets/demo.gif b/assets/demo.gif new file mode 100644 index 0000000..c6e748e Binary files /dev/null and b/assets/demo.gif differ diff --git a/assets/demo.png b/assets/demo.png deleted file mode 100644 index 7a87ef4..0000000 Binary files a/assets/demo.png and /dev/null differ diff --git a/barchart.png b/barchart.png deleted file mode 100644 index 76376c8..0000000 Binary files a/barchart.png and /dev/null differ diff --git a/hist.png b/hist.png deleted file mode 100644 index db1372f..0000000 Binary files a/hist.png and /dev/null differ diff --git a/internal/export.go b/internal/export.go index d1cd3db..825bdaa 100644 --- a/internal/export.go +++ b/internal/export.go @@ -142,7 +142,6 @@ func VerifyExportFormats(formats string) ([]string, error) { return formatList, nil } - func Export(formats []string, filename string, results []*SpeedResult, timeUnit time.Duration) { for _, format := range formats { switch format { diff --git a/internal/log.go b/internal/log.go index 8495158..9fd4aed 100644 --- a/internal/log.go +++ b/internal/log.go @@ -7,7 +7,7 @@ const ( RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" - BLUE = "\033[34m" + BLUE = "\033[34m" PURPLE = "\033[35m" CYAN = "\033[36m" RESET = "\033[0m" diff --git a/internal/plotter.go b/internal/plotter.go index 0f4fca9..2d79e97 100644 --- a/internal/plotter.go +++ b/internal/plotter.go @@ -68,7 +68,7 @@ func barPlot(results []*SpeedResult, timeUnit string) { p.Add(bars) p.NominalX(MapFunc[[]*SpeedResult, []string](func(r *SpeedResult) string { return r.Command }, results)...) - + barWidth := max(3, len(results)) if err := p.Save(font.Length(barWidth)*vg.Inch, 3*vg.Inch, "barchart.png"); err != nil { panic(err) diff --git a/main.go b/main.go index b750228..9fca8cf 100644 --- a/main.go +++ b/main.go @@ -194,8 +194,6 @@ func RunCommand(runOpts *RunOptions) *RunResult { return runResult } -// todo add graphing support - var MinRuns = 10 var MaxRuns = math.MaxInt64 var MinDuration = (3 * time.Second).Microseconds() diff --git a/scripts/build.py b/scripts/build.py index a36e68a..c24ba16 100644 --- a/scripts/build.py +++ b/scripts/build.py @@ -1,51 +1,179 @@ -import subprocess +import hashlib +from subprocess import run, CalledProcessError +from multiprocessing import Pool, cpu_count +import shlex +import shutil import os -from multiprocessing import Process -from typing import List +import json +from pathlib import Path -def build(appname:str, platform: str) -> None: - try: - goos = platform.split("/")[0] - goarch = platform.split("/")[1] +# build config, would be altered by init_config() +APP_NAME = "atomic" +STRIP = True # whether to strip binaries +VERBOSE = False # go compiler verbose output +FORMAT = True # format code before building binaries +PLATFORMS: list[str] = [] # list of platforms to build for +PANDOC_CONVERSION = True # whether to convert readme.md to plain text using pandoc in distributions - print(f"==> 🚧 Building executable for `{platform}`...") - os.environ["GOOS"] = goos - os.environ["GOARCH"] = goarch - outpath = f"./bin/{appname}-{goos}-{goarch}" +def hash_file(filename: str): + h = hashlib.sha256() - if goos == "windows": - outpath += ".exe" + with open(filename, "rb") as file: + chunk = 0 + while chunk != b"": + chunk = file.read(1024) + h.update(chunk) - subprocess.check_output(["go", "build", "-v", "-o", outpath]) + return h.hexdigest() - print(f"==> ✅ Built executable for `{platform}` at `{outpath}`.") - except Exception as e: - print(e) - print("==> ❌ An error occured! Aborting script execution.") - os._exit(1) +def init_config(): + try: + global APP_NAME, STRIP, VERBOSE, FORMAT, PLATFORMS, PANDOC_CONVERSION + release_config_file = Path(__file__).parent.resolve() / 'release.config.json' + with open(str(release_config_file)) as f: + config = json.load(f) -if __name__ == "__main__": - # add all platforms to the tuple you want to build - platforms = {"windows/amd64", "linux/amd64", "darwin/amd64"} - appname = "atomic" # name of the executable - multithreaded = True # set to True to enable multithreading + APP_NAME = config["app_name"] + STRIP = config["strip_binaries"] + VERBOSE = config["verbose"] + FORMAT = config["format_code"] + PLATFORMS = config["platforms"] + PANDOC_CONVERSION = config["pandoc_conversion"] + + except Exception as e: + print(f"==> ❌ Some error occured while reading the release config:\n{e}") + exit(1) + + +def init_folders() -> None: + """ + Makes sure that the `temp` and `dist` folders exist. + """ + if not os.path.exists("./dist"): + os.mkdir("./dist") + + if not os.path.exists("./temp"): + os.mkdir("./temp") + + +def pack(dir: str, platform: str) -> None: + """ + Copies README, LICENSE, CHANGELOG and atomic logo to the output directory and creates an archive for the given platform. + """ + project_base = Path(__file__).parent.parent + readme_file = project_base / "temp" / "README.txt" + if not readme_file.exists(): + # if pandoc conversion failed or not enabled + readme_file = project_base / "README.md" + + license_file = project_base / "LICENSE.txt" + icon_file = project_base / "assets" / "icon.png" + # changelog_file = project_base / "CHANGELOG.md" + + shutil.copyfile(str(readme_file), f"{dir}/README.txt") + shutil.copyfile(str(license_file), f"{dir}/LICENSE.txt") + # shutil.copyfile(str(changelog_file), f"{dir}/CHANGELOG.md") + # shutil.copyfile(str(icon_file), f"{dir}/icon.png") + + splitted = platform.split("/") + build_os = splitted[0] + build_arch = splitted[1] + + compression = "zip" if build_os == "windows" else "gztar" + + shutil.make_archive(f"dist/{APP_NAME}_{build_os}_{build_arch}", compression, dir) + + +def build(platform: str) -> None: + """ + Calls the go compiler to build the application for the given platform, and the pack function. + """ + try: + print(f"==> 🚧 Building for {platform}.") + splitted = platform.split("/") + build_os = splitted[0] + build_arch = splitted[1] + + output_dir = f"temp/{APP_NAME}_{build_os}_{build_arch}" + if not os.path.exists(output_dir): + os.makedirs(output_dir, exist_ok=True) - if multithreaded: - threads: List[Process] = [] + executable_path = f"{output_dir}/{APP_NAME}" + if build_os == "windows": + executable_path += ".exe" + + os.environ["GOOS"] = build_os + os.environ["GOARCH"] = build_arch + + run( + shlex.split( + "go build -o {} {} {}".format( + executable_path, + '-ldflags="-s -w"' if STRIP else "", + "-v" if VERBOSE else "", + ) + ), + check=True, + ) + + print(f"==> ✅ Packing for {platform}.") + pack(output_dir, platform) + + except CalledProcessError: + print(f"==> ❌ Failed to build for {platform}: The Go compiler returned an error.") + + except Exception as e: + print(f"==> ❌ Failed to build for {platform}.") + print(e) + + +def generate_checksums() -> None: + project_base = Path(__file__).parent.parent + dist_folder = project_base / "dist" + checksum = "" + + for item in dist_folder.iterdir(): + checksum += f"{hash_file(str(item.absolute()))} {item.name}\n" + + checksum_file = dist_folder / "checksums.txt" + with open(str(checksum_file), 'w') as f: + f.write(checksum) + + +def cleanup() -> None: + """ + Removes the `temp` folder. + """ + print("==> 👍 Cleaning up.") + shutil.rmtree("./temp") + + +if __name__ == "__main__": + print("==> ⌛ Initialising folders, executing prebuild commands.") + init_config() + init_folders() + if FORMAT: + run(shlex.split("go fmt ./..."), check=True) - for p in platforms: - threads.append(Process(target=build, args=(appname, p))) + try: + if PANDOC_CONVERSION: + readme_file = Path(__file__).parent.parent / "README.md" + run( + f"pandoc -s {str(readme_file)} -o ./temp/README.txt --to plain", + check=True + ) + except CalledProcessError: + print("==> ⚠ Unable to convert README.md to README.txt using pandoc in distributions, make sure you've pandoc installed on your system.") - for t in threads: - t.start() + max_procs = cpu_count() + print(f"==> 🔥 Starting builds with {max_procs} parallel processes.") - for t in threads: - t.join() + with Pool(processes=max_procs) as pool: + pool.map(build, PLATFORMS) - else: - for p in platforms: - build(appname, p) + print("==> #️⃣ Generating checksums.") + generate_checksums() - print(f"==> 👍 Executables for {len(platforms)} platforms built successfully!") + cleanup() diff --git a/scripts/demo.tape b/scripts/demo.tape index af7cb2d..a25c77d 100644 --- a/scripts/demo.tape +++ b/scripts/demo.tape @@ -69,4 +69,4 @@ Set Height 1200 Type "atomic 'scc' 'tokei'" Sleep 500ms Enter -Sleep 8s +Sleep 15s diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..ce3cc57 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,114 @@ +#!/bin/sh + +# This script installs atomic. +# +# Quick install: `curl https://raw.githubusercontent.com/shravanasati/atomic/main/scripts/install.sh | bash` +# +# Acknowledgments: +# - https://github.com/zyedidia/eget +# - https://github.com/burntsushi/ripgrep + +set -e -u + +githubLatestTag() { + finalUrl=$(curl "https://github.com/$1/releases/latest" -s -L -I -o /dev/null -w '%{url_effective}') + printf "%s\n" "${finalUrl##*v}" +} + +ensure() { + if ! "$@"; then err "command failed: $*"; fi +} + +platform='' +machine=$(uname -m) + +if [ "${GETatomic_PLATFORM:-x}" != "x" ]; then + platform="$GETatomic_PLATFORM" +else + case "$(uname -s | tr '[:upper:]' '[:lower:]')" in + "linux") + case "$machine" in + "arm64"* | "aarch64"* ) platform='linux_arm64' ;; + *"86") platform='linux_386' ;; + *"64") platform='linux_amd64' ;; + esac + ;; + "darwin") + case "$machine" in + "arm64"* | "aarch64"* ) platform='darwin_arm64' ;; + *"64") platform='darwin_amd64' ;; + esac + ;; + "msys"*|"cygwin"*|"mingw"*|*"_nt"*|"win"*) + case "$machine" in + *"86") platform='windows_386' ;; + *"64") platform='windows_amd64' ;; + "arm64"* | "aarch64"* ) platform='windows_arm64' ;; + esac + ;; + esac +fi + +if [ "x$platform" = "x" ]; then + cat << 'EOM' +/=====================================\\ +| COULD NOT DETECT PLATFORM | +\\=====================================/ +Uh oh! We couldn't automatically detect your operating system. +To continue with installation, please choose from one of the following values: +- linux_arm64 +- linux_386 +- linux_amd64 +- darwin_amd64 +- darwin_arm64 +- windows_386 +- windows_arm64 +- windows_amd64 +Export your selection as the GETatomic_PLATFORM environment variable, and then +re-run this script. +For example: + $ export GETatomic_PLATFORM=linux_amd64 + $ curl https://raw.githubusercontent.com/shravanasati/atomic/main/scripts/install.sh | bash +EOM + exit 1 +else + printf "Detected platform: %s\n" "$platform" +fi + +TAG=$(githubLatestTag shravanasati/atomic) + +if [ "x$platform" = "xwindows_amd64" ] || [ "x$platform" = "xwindows_386" ] || [ "x$platform" = "xwindows_arm64" ]; then + extension='zip' +else + extension='tar.gz' +fi + +printf "Latest Version: %s\n" "$TAG" +printf "Downloading https://github.com/shravanasati/atomic/releases/download/v%s/atomic_%s.%s\n" "$TAG" "$platform" "$extension" + +ensure curl -L "https://github.com/shravanasati/atomic/releases/download/v$TAG/atomic_$platform.$extension" > "atomic.$extension" + +case "$extension" in + "zip") ensure unzip -j "atomic.$extension" -d "./atomic" ;; + "tar.gz") ensure tar -xvzf "atomic.$extension" "./atomic" ;; +esac + +bin_dir="${HOME}/.local/bin" +ensure mkdir -p "${bin_dir}" + +if [ -e "$bin_dir/atomic" ]; then + echo "Existing atomic binary found at ${bin_dir}, removing it..." + ensure rm "$bin_dir/atomic" +fi + +ensure mv "./atomic" "${bin_dir}" +ensure chmod +x "${bin_dir}/atomic" + +ensure rm "atomic.$extension" +ensure rm -rf "$platform" + +echo 'atomic has been installed at' ${bin_dir} + +if ! echo ":${PATH}:" | grep -Fq ":${bin_dir}:"; then + echo "NOTE: ${bin_dir} is not on your \$PATH. atomic will not work unless it is added to \$PATH." +fi diff --git a/scripts/linux_install.sh b/scripts/linux_install.sh deleted file mode 100755 index 84870a4..0000000 --- a/scripts/linux_install.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -echo "Downloading atomic..." -curl -L "https://github.com/shravanasati/atomic/releases/latest/download/atomic-linux-amd64" -o atomic - -echo "Adding atomic into PATH..." - -mkdir -p ~/.atomic - -chmod u+x ./atomic - -mv ./atomic ~/.atomic -echo "export PATH=$PATH:~/.atomic" >> ~/.bashrc - -echo "atomic installation is completed!" -echo "You need to restart the shell to use atomic." diff --git a/scripts/macos_install.sh b/scripts/macos_install.sh deleted file mode 100644 index a9dc2ea..0000000 --- a/scripts/macos_install.sh +++ /dev/null @@ -1,14 +0,0 @@ - -#!/bin/bash - -echo "Downloading atomic..." -curl -L "https://github.com/shravanasati/atomic/releases/latest/download/atomic-darwin-amd64" -o atomic - -echo "Adding atomic into PATH..." - -mkdir -p ~/.atomic; -mv ./atomic ~/.atomic -echo "export PATH=$PATH:~/.atomic" >> ~/.bashrc - -echo "atomic installation is completed!" -echo "You need to restart the shell to use atomic." diff --git a/scripts/release.config.json b/scripts/release.config.json new file mode 100644 index 0000000..9559a79 --- /dev/null +++ b/scripts/release.config.json @@ -0,0 +1,16 @@ +{ + "app_name": "atomic", + "version": "0.4.0", + "platforms": [ + "linux/amd64", + "linux/arm64", + "windows/amd64", + "windows/arm64", + "darwin/amd64", + "darwin/arm64" + ], + "format_code": true, + "strip_binaries": true, + "verbose": false, + "pandoc_conversion": true +} \ No newline at end of file diff --git a/scripts/scoop_gen.py b/scripts/scoop_gen.py new file mode 100644 index 0000000..73b1642 --- /dev/null +++ b/scripts/scoop_gen.py @@ -0,0 +1,80 @@ +# python script to generate scoop schema +# must be ran after build.py + +import json +from pathlib import Path +import hashlib + +ATOMIC_BASE_URL = "https://github.com/shravanasati/atomic" + + + +def hash_file(filename): + h = hashlib.sha256() + + with open(filename, "rb") as file: + chunk = 0 + while chunk != b"": + chunk = file.read(1024) + h.update(chunk) + + return h.hexdigest() + + +if __name__ == "__main__": + schema = { + "homepage": ATOMIC_BASE_URL, + "version": "", + "architecture": {"64bit": {}, "32bit": {}, "arm64": {}}, + "license": "MIT", + "bin": "atomic.exe", + "checkver": "github", + "post_install": "", + "post_uninstall": "" + } + + # read release config to obtain version and platforms + project_base = Path(__file__).parent.parent + release_config_file = project_base / "scripts" / "release.config.json" + with open(str(release_config_file)) as f: + release_config = json.load(f) + + # set version in schema + schema["version"] = release_config["version"] + + # read the post_install and post_uninstall scripts (and set them) + post_install_path = project_base / "scripts" / "post_install.ps1" + with open(str(post_install_path)) as script: + schema["post_install"] = script.read() + + post_uninstall_path = project_base / "scripts" / "post_uninstall.ps1" + with open(str(post_uninstall_path)) as script: + schema["post_uninstall"] = script.read() + + # set architecture data in the manifest file + for entry in schema["architecture"]: + match entry: + case "64bit": + arch = "amd64" + case "32bit": + arch = "386" + case "arm64": + arch = "arm64" + case _: + raise Exception(f"Unkown architecture: {entry}") + + filename = f"atomic_windows_{arch}.zip" + dist_file = project_base / "dist" / filename + arch_data = { + "url": f"{ATOMIC_BASE_URL}/releases/latest/download/{filename}", + "hash": hash_file(str(dist_file)) + } + + schema["architecture"][entry] = arch_data + + # write the manifest file + jsonfile_path = project_base / "scripts" / "atomic.json" + with open(str(jsonfile_path), 'w') as f: + f.write(json.dumps(schema, indent=2)) + + print("Scoop app manifest file for atomic generated.") diff --git a/scripts/windows_install.ps1 b/scripts/windows_install.ps1 deleted file mode 100644 index d26457c..0000000 --- a/scripts/windows_install.ps1 +++ /dev/null @@ -1,18 +0,0 @@ -Write-Host "Downloading atomic..." - -$url = "https://github.com/shravanasati/atomic/releases/latest/download/atomic-windows-amd64.exe" - -$dir = $env:USERPROFILE + "\.atomic" -$filepath = $env:USERPROFILE + "\.atomic\atomic.exe" - -[System.IO.Directory]::CreateDirectory($dir) -(Invoke-WebRequest -Uri $url -OutFile $filepath) - -Write-Host "Adding atomic to PATH..." -[Environment]::SetEnvironmentVariable( - "Path", - [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::Machine) + ";"+$dir, - [EnvironmentVariableTarget]::Machine) - -Write-Host 'atomic installation is successfull!' -Write-Host "You need to restart your shell to use atomic."