diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a43773f..1769792 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,24 +1,72 @@ name: CI on: [push, pull_request] jobs: - test: - runs-on: ${{ matrix.os }} + bsd: + runs-on: ${{ matrix.os.host }} strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy3.10"] - os: ["macos-latest", "ubuntu-latest", "windows-latest"] + os: + - name: freebsd + architecture: x86-64 + version: '14.1' + host: ubuntu-latest + + - name: openbsd + architecture: x86-64 + version: '7.5' + host: ubuntu-latest steps: - - name: 'Disable `autocrlf` in Git' - run: git config --global core.autocrlf false - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + - name: Checkout + uses: actions/checkout@v4 + + - name: Run CI script on ${{ matrix.os.name }} + uses: cross-platform-actions/action@v0.25.0 with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install --upgrade pip - pip install poetry tox - - name: Test with tox - run: | - tox run + operating_system: ${{ matrix.os.name }} + architecture: ${{ matrix.os.architecture }} + version: ${{ matrix.os.version }} + shell: bash + run: | + sudo .github/workflows/install-deps.sh + make test + + linux: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo .github/workflows/install-deps.sh + + - name: Test + run: | + make test + + mac: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + .github/workflows/install-deps.sh + + - name: Build and test + run: | + make test + + windows: + runs-on: windows-latest + steps: + - name: 'Disable `autocrlf` in Git' + run: git config --global core.autocrlf false + + - name: Checkout + uses: actions/checkout@v4 + + - name: Test + run: | + .\test.ps1 diff --git a/.github/workflows/install-deps.sh b/.github/workflows/install-deps.sh new file mode 100755 index 0000000..cb35a5f --- /dev/null +++ b/.github/workflows/install-deps.sh @@ -0,0 +1,27 @@ +#! /bin/sh +set -e + +if [ "$(uname)" = Darwin ]; then + brew install go +fi + +if [ "$(uname)" = Linux ]; then + : +fi + +if [ "$(uname)" = FreeBSD ]; then + pkg install -y go +fi + +if [ "$(uname)" = NetBSD ]; then + pkgin -y install go + + for bin in /usr/pkg/bin/go1*; do + src=$bin + done + ln -s "$src" /usr/pkg/bin/go +fi + +if [ "$(uname)" = OpenBSD ]; then + pkg_add -I go +fi diff --git a/.github/workflows/lint-and-type.yml b/.github/workflows/lint-and-type.yml deleted file mode 100644 index 2c10a70..0000000 --- a/.github/workflows/lint-and-type.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: lint-and-type -on: [push, pull_request] -jobs: - lint-and-type: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install dependencies - run: | - pipx install poethepoet poetry - poetry install - - name: Format - run: poe format - - name: Check spelling - run: poe spell - - name: Lint - run: poe lint - - name: Typecheck - run: poe type diff --git a/.gitignore b/.gitignore index ac9c134..117d748 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ -/*.build -/*.dist +/attic/ +/dist/ + +*.bak +*.exe *.log -__pycache__ *.swp -/.tox + +/help +/memsparkline +/test/sleep diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3d12e7a --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +TEST_BINARIES := test/sleep + +.PHONY: all +all: memsparkline + +.PHONY: clean +clean: + -rm memsparkline $(TEST_BINARIES) + +memsparkline: main.go + CGO_ENABLED=0 go build + +.PHONY: release +release: + go run script/release.go + +.PHONY: test +test: memsparkline $(TEST_BINARIES) + go test + +test/sleep: test/sleep.go + go build -o $@ test/sleep.go diff --git a/README.md b/README.md index 4200f2c..419e72d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ Track the RAM usage ([resident set size](https://en.wikipedia.org/wiki/Resident_set_size)) of a process, its children, its children's children, etc. in real time with a Unicode text [sparkline](https://en.wikipedia.org/wiki/Sparkline). See the average and the maximum usage after the process exits, as well as the run time. - ## Examples ```none @@ -32,150 +31,141 @@ time: 0:00:12.0 time: 0:00:10.1 ``` +## Installation + +### Prebuilt binaries + +Prebuilt binaries for +FreeBSD (amd64), +Linux (aarch64, riscv64, x86_64), +macOS (arm64, x86_64), +OpenBSD (amd64), +and Windows (amd64, x86) +are attached to [releases](https://github.com/dbohdan/memsparkline/releases). + +### Go + +Install Go, then run the following command: + +```shell +go install github.com/dbohdan/memsparkline@latest +``` + +## Build requirements + +- Go 1.21 +- OS supported by [gopsutil](https://github.com/shirou/gopsutil) +- POSIX Make for testing ## Compatibility and limitations -memsparkline works on POSIX systems supported by [psutil](https://github.com/giampaolo/psutil). -It has been tested on Debian, Ubuntu, FreeBSD, NetBSD, and OpenBSD. +memsparkline works on POSIX systems supported by [gopsutil](https://github.com/shirou/gopsutil). +It has been tested on Debian, Ubuntu, FreeBSD, and OpenBSD. +Unfortunately, gopsutil doesn't support NetBSD. +NetBSD users can install the last [Python release](https://pypi.org/project/memsparkline/) of memsparkline. Although memsparkline seems to work on Windows, Windows support has received little testing outside of [CI](https://en.wikipedia.org/wiki/Continuous_integration). The sparkline displays incorrectly in the Command Prompt and [ConEmu](https://conemu.github.io/) on Windows 7 with the stock console fonts. It displays correctly on Windows 10 with the font NSimSun. - ## Operation ### Usage ```none -usage: memsparkline [-h] [-v] [-d path] [-l n] [-m fmt] [-n] [-o path] [-q] - [-r ms] [-s ms] [-t fmt] [-w ms] - command ... +Usage: memsparkline [-h] [-v] [-d path] [-l n] [-m fmt] [-n] [-o path] [-q] [-t +fmt] [-w ms] command [arg ...] Track the RAM usage (resident set size) of a process and its descendants in real time. -positional arguments: - command command to run - args arguments to command - -options: - -h, --help show this help message and exit - -v, --version show program's version number and exit - -d path, --dump path file in which to write full memory usage history when - finished - -l n, --length n sparkline length (default: 20) - -m fmt, --mem-format fmt - format string for memory amounts (default: "%0.1f") - -n, --newlines print new sparkline on new line instead of over - previous - -o path, --output path - output file to append to ("-" for standard error) - -q, --quiet do not print sparklines, only final report - -r ms, --record ms how frequently to record/report memory usage (default: - every 1000 ms) - -s ms, --sample ms how frequently to sample memory usage (default: every - 200 ms) - -t fmt, --time-format fmt - format string for run time (default: "%d:%02d:%04.1f") - -w ms, --wait ms set "--sample" and "--record" time simultaneously - (that both options override) -``` - -### Samples and records - -memsparkline differentiates between _samples_ and _records_. -Samples are measurements of memory usage. -Records are information about memory usage printed to the chosen output (given by `--output`) and added to history (saved using the `--dump` option). - -There is a separate setting for the sample time and the record time. -The sample time determines the interval between when memory usage is measured. -The record time determines the interval between when a record is made (written to the output and added to history). -When sampling is more frequent than recording (as with the default settings), -memsparkline uses the highest sampled value since the last record. - -A short sample time like 5 ms can result in high CPU usage, -up to 100% of one CPU core. -To reduce CPU usage, sample less frequently. -The default sample time of 200 ms results in memsparkline using around 10% of a 2019 x86-64 core on the developer's machine. - -Records are only created after a sample has been taken. -Setting the record time shorter than the sample time is allowed for convenience but equivalent to setting it to the sample time. +Arguments: + command + Command to run + [arg ...] + Arguments to the command -## Installation +Options: + -h, --help + Print this help message and exit -memsparkline requires Python 3.8 or later. + -v, --version + Print the version number and exit -### Installing from PyPI + -d, --dump path + File to append full memory usage history to when finished -The recommended way to install memsparkline is [from PyPI](https://pypi.org/project/memsparkline/) with [pipx](https://github.com/pypa/pipx). + -l, --length n + Sparkline length (default: 20) -```sh -pipx install memsparkline -``` + -m, --mem-format fmt + Format string for memory amounts (default: "%.1f") -You can also use pip: + -n, --newlines + Print new sparkline on new line instead of over previous -```sh -pip install --user memsparkline -``` + -o, --output path + Output file to append to ("-" for standard error) -### Manual installation + -q, --quiet + Do not print sparklines, only final report -1. Install the dependencies from the package repositories for your OS. - You will find instructions for some operating systems below. -2. Download `src/memsparkline/main.py` and copy it to a directory in `PATH` as `memsparkline`. - For example: + -r, --record ms + How frequently to record/report memory usage in ms (default: 1000) -```sh -git clone https://github.com/dbohdan/memsparkline -sudo install memsparkline/src/memsparkline/main.py /usr/local/bin/memsparkline -``` + -s, --sample ms + How frequently to sample memory usage in ms (default: 200) -#### Dependencies + -t, --time-format fmt + Format string for run time (default: "%d:%02d:%04.1f") -##### Debian/Ubuntu - -```sh -sudo apt install python3-psutil -``` - -##### DragonFly BSD 6.6, FreeBSD 13.1 - -```sh -sudo pkg install py39-psutil + -w, --wait ms + Set "--sample" and "--record" time simultaneously (that both options +override) ``` -##### NetBSD 9.3 +### Samples and records -```sh -sudo pkgin in py310-psutil -``` +memsparkline differentiates between _samples_ and _records_. +Samples are measurements of memory usage. +Records are information about memory usage printed to the chosen output (given by `--output`) and added to history (saved using the `--dump` option). -##### OpenBSD +There is a separate setting for the sample time and the record time. +The sample time determines the interval between when memory usage is measured. +The record time determines the interval between when a record is made (written to the output and added to history). +When sampling is more frequent than recording (as with the default settings), +memsparkline uses the highest sampled value since the last record. -```sh -doas pkg_add py3-psutil -``` +A short sample time like 5 ms can result in high CPU usage, +up to 100% of one CPU core. +To reduce CPU usage, sample less frequently. +The default sample time of 200 ms results in memsparkline using around 15% of a 2019 x86-64 core on the developer's machine. +Records are only created when at least one sample has been taken. +Setting the record time shorter than the sample time is allowed for convenience, but no record is added when there are no samples. ## License MIT. - ## See also memusg and spark (both linked below) inspired this project. ### Tracking memory usage -* [DragonFly BSD](https://man.dragonflybsd.org/?command=time§ion=ANY), [FreeBSD](https://man.freebsd.org/cgi/man.cgi?query=time&format=html), [NetBSD](https://man.netbsd.org/time.1), [OpenBSD](https://man.openbsd.org/time), and [macOS](https://ss64.com/osx/time.html) time(1) flag `-l`. +* [DragonFly BSD](https://man.dragonflybsd.org/?command=time§ion=ANY), + [FreeBSD](https://man.freebsd.org/cgi/man.cgi?query=time&format=html), + [macOS](https://ss64.com/osx/time.html), + [NetBSD](https://man.netbsd.org/time.1), + and [OpenBSD](https://man.openbsd.org/time) + time(1) flag `-l`. * [GNU time(1)](https://linux.die.net/man/1/time) flag `-v`. * [memusg](http://gist.github.com/526585) — a Bash script for FreeBSD, Linux, and macOS that measures the peak resident set size of a process. ### Sparklines * [spark](https://github.com/holman/spark) — a Bash script that generates a Unicode text sparkline from a list of numbers. -* [sparkline.tcl](https://wiki.tcl-lang.org/page/Sparkline) — a Tcl script by the developer of this project that does the same. Adds a `--min` and `--max` option for setting the scale. +* [sparkline.tcl](https://wiki.tcl-lang.org/page/Sparkline) — a Tcl script inspired by spark made by the developer of this project. + Adds a `--min` and `--max` option for setting the scale. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..57ca125 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/dbohdan/memsparkline + +go 1.21 + +require ( + github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54 + github.com/mitchellh/go-wordwrap v1.0.1 + github.com/shirou/gopsutil/v4 v4.24.10 +) + +require ( + github.com/ebitengine/purego v0.8.1 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.26.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b5be587 --- /dev/null +++ b/go.sum @@ -0,0 +1,41 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.8.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE= +github.com/ebitengine/purego v0.8.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54 h1:0SMHxjkLKNawqUjjnMlCtEdj6uWZjv0+qDZ3F6GOADI= +github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54/go.mod h1:bm7MVZZvHQBfqHG5X59jrRE/3ak6HvK+/Zb6aZhLR2s= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v4 v4.24.10 h1:7VOzPtfw/5YDU+jLEoBwXwxJbQetULywoSV4RYY7HkM= +github.com/shirou/gopsutil/v4 v4.24.10/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d917f61 --- /dev/null +++ b/main.go @@ -0,0 +1,657 @@ +// Copyright (c) 2020, 2022-2024 D. Bohdan +// +// 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. + +package main + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + tsize "github.com/kopoli/go-terminal-size" + "github.com/mitchellh/go-wordwrap" + "github.com/shirou/gopsutil/v4/process" +) + +const ( + defaultDumpPath = "" + defaultLength = 20 + defaultMemFormat = "%.1f" + defaultNewlines = false + defaultOutputPath = "-" + defaultQuiet = false + defaultRecordTime = 1000 // ms + defaultSampleTime = 200 // ms + defaultTimeFormat = "%d:%02d:%04.1f" + defaultVerbose = false + defaultWait = -1 + sparklineLowMaximum = 10000 + usageDivisor = 1 << 20 // Report memory usage in binary megabytes. + version = "0.7.0" +) + +var sparklineTicks = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} + +type config struct { + arguments []string + command string + dumpPath string + length int + memFormat string + newlines bool + outputPath string + quiet bool + record int + sample int + timeFormat string + wait int +} + +type MemoryTracker struct { + timestamps []int64 + values []int64 + maximum int64 + mu sync.RWMutex +} + +func (mt *MemoryTracker) AddRecord(timestamp int64, value int64) { + mt.mu.Lock() + defer mt.mu.Unlock() + + mt.timestamps = append(mt.timestamps, timestamp) + mt.values = append(mt.values, value) + + if value > mt.maximum { + mt.maximum = value + } +} + +func (mt *MemoryTracker) History(count int) ([]int64, []int64, int64) { + mt.mu.RLock() + defer mt.mu.RUnlock() + + if count < 0 || count > len(mt.values) { + count = len(mt.values) + } + + timestampsCopy := make([]int64, count) + copy(timestampsCopy, mt.timestamps[len(mt.timestamps)-count:]) + valuesCopy := make([]int64, count) + copy(valuesCopy, mt.values[len(mt.values)-count:]) + + return timestampsCopy, valuesCopy, mt.maximum +} + +func wrapForTerm(s string) string { + size, err := tsize.GetSize() + if err != nil { + return s + } + + return wordwrap.WrapString(s, uint(size.Width)) +} + +func usage(w io.Writer) { + s := fmt.Sprintf( + `Usage: %s [-h] [-v] [-d path] [-l n] [-m fmt] [-n] [-o path] [-q] [-t fmt] [-w ms] command [arg ...]`, + filepath.Base(os.Args[0]), + ) + + fmt.Fprintln(w, wrapForTerm(s)) +} + +func help() { + usage(os.Stdout) + + s := fmt.Sprintf(` +Track the RAM usage (resident set size) of a process and its descendants in real time. + +Arguments: + command + Command to run + + [arg ...] + Arguments to the command + +Options: + -h, --help + Print this help message and exit + + -v, --version + Print the version number and exit + + -d, --dump path + File to append full memory usage history to when finished + + -l, --length n + Sparkline length (default: %d) + + -m, --mem-format fmt + Format string for memory amounts (default: %q) + + -n, --newlines + Print new sparkline on new line instead of over previous + + -o, --output path + Output file to append to (%q for standard error) + + -q, --quiet + Do not print sparklines, only final report + + -r, --record ms + How frequently to record/report memory usage in ms (default: %d) + + -s, --sample ms + How frequently to sample memory usage in ms (default: %d) + + -t, --time-format fmt + Format string for run time (default: %q) + + -w, --wait ms + Set "--sample" and "--record" time simultaneously (that both options override) +`, + defaultLength, + defaultMemFormat, + defaultOutputPath, + defaultRecordTime, + defaultSampleTime, + defaultTimeFormat, + ) + + fmt.Print(wrapForTerm(s)) +} + +func parseArgs() (*config, error) { + cfg := &config{ + dumpPath: defaultDumpPath, + length: defaultLength, + memFormat: defaultMemFormat, + outputPath: defaultOutputPath, + record: defaultRecordTime, + sample: defaultSampleTime, + timeFormat: defaultTimeFormat, + wait: defaultWait, + } + + // Check for a help or version flag first. + for _, arg := range os.Args { + switch arg { + + case "-h", "--help": + help() + os.Exit(0) + + case "-v", "--version": + fmt.Println(version) + os.Exit(0) + } + } + + usageError := func(message string, badValue interface{}) error { + usage(os.Stderr) + return fmt.Errorf(message, badValue) + } + + // Parse the command-line flags. + recondTimeSet := false + sampleTimeSet := false + waitTimeSet := false + + var i int + nextArg := func(flag string) (string, error) { + i++ + if i >= len(os.Args) { + return "", fmt.Errorf("no value for option %q", flag) + } + return os.Args[i], nil + } + + for i = 1; i < len(os.Args); i++ { + arg := os.Args[i] + + if arg == "--" { + i++ + } + if arg == "--" || !strings.HasPrefix(arg, "-") { + break + } + + switch arg { + + case "-d", "--dump": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + cfg.dumpPath = value + + case "-l", "--length": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + length, err := strconv.Atoi(value) + if err != nil { + return nil, usageError("invalid length: %v", value) + } + + cfg.length = length + + case "-m", "--mem-format": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + cfg.memFormat = value + + case "-n", "--newlines": + cfg.newlines = true + + case "-o", "--output": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + cfg.outputPath = value + + case "-q", "--quiet": + cfg.quiet = true + + case "-r", "--record": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + record, err := strconv.Atoi(value) + if err != nil { + return nil, usageError("invalid record time: %v", value) + } + + cfg.record = record + recondTimeSet = true + + case "-s", "--sample": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + sample, err := strconv.Atoi(value) + if err != nil { + return nil, usageError("invalid sample time: %v", value) + } + + cfg.sample = sample + sampleTimeSet = true + + case "-t", "--time-format": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + cfg.timeFormat = value + + case "-w", "--wait": + value, err := nextArg(arg) + if err != nil { + return nil, err + } + + wait, err := strconv.Atoi(value) + if err != nil { + return nil, usageError("invalid wait time: %v", value) + } + + cfg.wait = wait + waitTimeSet = true + + default: + return nil, usageError("unknown option: %v", arg) + } + } + + // Ensure we have a command. + if i >= len(os.Args) { + return nil, usageError("command is required%v", "") + } + + // Set the command and arguments. + cfg.command = os.Args[i] + if i+1 < len(os.Args) { + cfg.arguments = os.Args[i+1:] + } else { + cfg.arguments = []string{} + } + + // Handle the wait option. + if waitTimeSet { + if !recondTimeSet { + cfg.record = cfg.wait + } + + if !sampleTimeSet { + cfg.sample = cfg.wait + } + } + + return cfg, nil +} + +func main() { + cfg, err := parseArgs() + if err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(2) + } + + if err := run(cfg); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(cfg *config) error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Set up signal handling. + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + startTime := time.Now().UTC() + + // Prepare stderr or file output. + output, err := getOutput(cfg.outputPath) + if err != nil { + return err + } + if output != os.Stderr { + defer output.Close() + } + + // We use '\r' to print the sparklines on the same line by default. + coreFormat := "%s " + cfg.memFormat + sparklineFormat := "\r" + coreFormat + if cfg.newlines { + sparklineFormat = coreFormat + "\n" + } + + // Start the command. + cmd := exec.CommandContext(ctx, cfg.command, cfg.arguments...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start command: %w", err) + } + + // Ensure we shut down the process. + defer func() { + if cmd.Process != nil { + cmd.Process.Kill() + } + }() + + // Get the process. + proc, err := process.NewProcess(int32(cmd.Process.Pid)) + if err != nil { + return fmt.Errorf("failed to get process: %w", err) + } + + // Create the memory-tracking data structures and closures that append to them. + memTracker := &MemoryTracker{} + sample := []int64{} + + addSample := func() error { + mem, err := getMemoryUsage(proc) + if err != nil { + return err + } + + sample = append(sample, int64(mem)) + + return nil + } + + addRecord := func() { + if len(sample) == 0 { + return + } + + memTracker.AddRecord(time.Now().UnixNano(), slices.Max(sample)) + + if !cfg.quiet { + _, values, maximum := memTracker.History(cfg.length) + line := sparkline(maximum, values) + fmt.Fprintf(output, sparklineFormat, line, float64(maximum)/usageDivisor) + } + + sample = []int64{} + } + + // Start memory tracking by adding an initial record before we wait. + _ = addSample() + addRecord() + + done := make(chan error, 1) + + go func() { + sampleTicker := time.NewTicker(time.Duration(cfg.sample) * time.Millisecond) + defer sampleTicker.Stop() + + recordTicker := time.NewTicker(time.Duration(cfg.record) * time.Millisecond) + defer recordTicker.Stop() + + for { + select { + + case <-ctx.Done(): + return + + case <-sampleTicker.C: + err := addSample() + if err != nil { + continue + } + + case <-recordTicker.C: + addRecord() + } + } + }() + + go func() { + done <- cmd.Wait() + }() + + // Wait for either the command's completion or a signal. + select { + + case err := <-done: + // Stop memory tracking. + cancel() + + if err != nil { + return err + } + + case sig := <-sigChan: + cancel() + + return fmt.Errorf("received signal: %v", sig) + } + + // Get the complete final stats. + // Stop memory tracking. + timestamps, values, maximum := memTracker.History(-1) + endTime := time.Now().UTC() + + if len(values) == 0 { + fmt.Fprintln(output, "no data collected") + } else { + if !cfg.newlines && !cfg.quiet { + fmt.Fprintln(output) + } + + summary := summarize(values, maximum, startTime, endTime, cfg.memFormat, cfg.timeFormat) + fmt.Fprintln(output, summary) + } + + // Dump the memory usage history if required. + if cfg.dumpPath != defaultDumpPath { + if err := dumpHistory(cfg.dumpPath, timestamps, values); err != nil { + return fmt.Errorf("failed to dump history: %w", err) + } + } + + return nil +} + +func getOutput(path string) (*os.File, error) { + if path == defaultOutputPath { + return os.Stderr, nil + } + + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open output file: %w", err) + } + + return file, nil +} + +func getMemoryUsage(proc *process.Process) (int64, error) { + children, err := proc.Children() + if err != nil && err != process.ErrorNoChildren { + return 0, err + } + + var total uint64 + for _, child := range children { + mem, err := child.MemoryInfo() + + // If we can't get memory info for a child, skip it. + if err == nil && mem != nil && mem.RSS > 0 { + total += mem.RSS + } + } + + memInfo, err := proc.MemoryInfo() + if err != nil { + return 0, fmt.Errorf("failed to get process memory info: %w", err) + } + if memInfo == nil { + return 0, fmt.Errorf("no memory info available") + } + + total += memInfo.RSS + return int64(total), nil +} + +func summarize(values []int64, maximum int64, start, end time.Time, memFormat, timeFormat string) string { + avg := average(values) + + result := strings.Builder{} + + result.WriteString(" avg: ") + result.WriteString(fmt.Sprintf(memFormat, float64(avg)/usageDivisor)) + result.WriteString("\n max: ") + result.WriteString(fmt.Sprintf(memFormat, float64(maximum)/usageDivisor)) + result.WriteString("\ntime: ") + hours, minutes, seconds := hmsDelta(start, end) + result.WriteString(fmt.Sprintf(timeFormat, hours, minutes, seconds)) + + return result.String() +} + +func average[T int64](values []T) T { + var sum T + for _, value := range values { + sum += value + } + + if len(values) == 0 { + return T(0) + } + + return sum / T(len(values)) +} + +func dumpHistory(path string, timestamps []int64, values []int64) error { + file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open history dump file: %w", err) + } + defer file.Close() + + writer := bufio.NewWriter(file) + + for i, timestamp := range timestamps { + _, err := fmt.Fprintf(writer, "%d %d\n", timestamp/1_000_000, values[i]) + if err != nil { + return err + } + } + + return writer.Flush() +} + +func hmsDelta(start, end time.Time) (int, int, float64) { + delta := end.Sub(start) + totalMillis := int(delta / time.Millisecond) + + hours := totalMillis / (60 * 60 * 1000) + remaining := totalMillis % (60 * 60 * 1000) + minutes := remaining / (60 * 1000) + remaining = remaining % (60 * 1000) + seconds := float64(remaining) / 1000.0 + + return hours, minutes, seconds +} + +func sparkline(maximum int64, data []int64) string { + tickMax := int64(len(sparklineTicks) - 1) + result := strings.Builder{} + + for _, x := range data { + tickIndex := int(tickMax * x / maximum) + result.WriteRune(sparklineTicks[tickIndex]) + } + + return result.String() +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..8b20f96 --- /dev/null +++ b/main_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "testing" +) + +var ( + command = getCommand() + testPath = getCurrentDir() +) + +func getCommand() []string { + if envCmd := os.Getenv("MEMSPARKLINE_COMMAND"); envCmd != "" { + return strings.Fields(envCmd) + } + + return []string{"./memsparkline"} +} + +func getCurrentDir() string { + _, filename, _, _ := runtime.Caller(0) + return filepath.Dir(filename) +} + +func run_memsparkline(t *testing.T, args ...string) (string, string, error) { + // Start with command args, if any, then add the test-specific args. + allArgs := append([]string{}, command[1:]...) + allArgs = append(allArgs, args...) + + cmd := exec.Command(command[0], allArgs...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +func getSleepCommand(duration float64) []string { + return []string{"test/sleep", strconv.FormatFloat(duration, 'f', -1, 64)} +} + +func TestUsage(t *testing.T) { + _, stderr, _ := run_memsparkline(t) + if matched, _ := regexp.MatchString("^Usage", stderr); !matched { + t.Error("Expected usage information in stderr") + } +} + +func TestVersion(t *testing.T) { + stdout, _, _ := run_memsparkline(t, "-v") + if matched, _ := regexp.MatchString(`\d+\.\d+\.\d+`, stdout); !matched { + t.Error("Expected version number in stdout") + } +} + +func TestBasic(t *testing.T) { + args := getSleepCommand(0.5) + _, stderr, _ := run_memsparkline(t, args...) + if matched, _ := regexp.MatchString(`(?s).*avg:.*max:`, stderr); !matched { + t.Error("Expected 'avg:' and 'max:' in output") + } +} + +func TestLength(t *testing.T) { + args := append([]string{"-l", "5", "-w", "10"}, getSleepCommand(0.5)...) + _, stderr, _ := run_memsparkline(t, args...) + + if matched, _ := regexp.MatchString(`(?m)\r[^ ]{5} \d+\.\d\r?\n avg`, stderr); !matched { + t.Error("Expected sparkline of specific length followed by summary") + } +} + +func TestMemFormat(t *testing.T) { + args := append([]string{"-l", "5", "-w", "10", "-m", "%0.2f"}, getSleepCommand(0.5)...) + _, stderr, _ := run_memsparkline(t, args...) + + if matched, _ := regexp.MatchString(`(?m)\r[^ ]{5} \d+\.\d{2}\r?\n avg`, stderr); !matched { + t.Error("Expected sparkline with memory format with two decimal places") + } +} + +func TestTimeFormat(t *testing.T) { + args := append([]string{"-l", "10", "-t", "%d:%05d:%06.3f"}, getSleepCommand(0.5)...) + _, stderr, _ := run_memsparkline(t, args...) + + if matched, _ := regexp.MatchString(`(?m)time: \d+:\d{5}:\d{2}\.\d{3}\r?\n`, stderr); !matched { + t.Error("Expected specific time format in summary") + } +} + +func TestWait1(t *testing.T) { + args := append([]string{"-w", "2000"}, getSleepCommand(0.5)...) + _, stderr, _ := run_memsparkline(t, args...) + + if lines := strings.Count(stderr, "\n"); lines != 4 { + t.Errorf("Expected 4 lines in output, got %d", lines) + } +} + +func TestWait2(t *testing.T) { + args := append([]string{"-n", "-w", "10"}, getSleepCommand(0.5)...) + _, stderr, _ := run_memsparkline(t, args...) + + if lines := strings.Count(stderr, "\n"); lines < 9 { + t.Errorf("Expected at least 9 lines in output, got %d", lines) + } +} + +func TestSampleAndRecord(t *testing.T) { + args := append([]string{"-r", "500", "-s", "100"}, getSleepCommand(0.5)...) + _, stderr, _ := run_memsparkline(t, args...) + + if lines := strings.Count(stderr, "\n"); lines != 4 { + t.Errorf("Expected 4 lines in output, got %d", lines) + } +} + +func TestQuiet(t *testing.T) { + args := append([]string{"-q"}, getSleepCommand(0.5)...) + _, stderr, _ := run_memsparkline(t, args...) + + if matched, _ := regexp.MatchString("^ avg", stderr); !matched { + t.Error("Expected output to start with 'avg' in quiet mode") + } +} + +func TestMissingBinary(t *testing.T) { + _, stderr, err := run_memsparkline(t, "no-such-binary-exists") + + if err == nil { + t.Error("Expected error for missing binary") + } + + if !strings.Contains(stderr, "failed to start command") { + t.Error("Expected 'failed to start command' in stderr") + } +} + +func TestDoubleDash(t *testing.T) { + stdout, _, _ := run_memsparkline(t, "--", "ls", "-l") + + if !strings.Contains(stdout, "\n") { + t.Error("Expected newline in output") + } +} + +func TestTwoDoubleDashes(t *testing.T) { + args := append(command, "--", "ls", "-l") + stdout, _, _ := run_memsparkline(t, append([]string{"--"}, args...)...) + + if !strings.Contains(stdout, "\n") { + t.Error("Expected newline in output") + } +} + +func TestDump(t *testing.T) { + dumpPath := filepath.Join(testPath, "dump.log") + // Clean up any existing file so we don't append to it. + os.Remove(dumpPath) + + args := append([]string{"-q", "-w", "100", "-d", dumpPath}, getSleepCommand(0.5)...) + run_memsparkline(t, args...) + + content, err := os.ReadFile(dumpPath) + if err != nil { + t.Fatal(err) + } + + lines := strings.Split(strings.TrimSpace(string(content)), "\n") + if len(lines) == 0 { + t.Error("Expected non-empty dump file") + } + + for _, line := range lines { + if matched, _ := regexp.MatchString(`\d+ \d+`, line); !matched { + t.Errorf("Invalid line format: %s", line) + } + } +} + +func TestOutput(t *testing.T) { + outputPath := filepath.Join(testPath, "output.log") + // Clean up any existing file so we don't append to it. + os.Remove(outputPath) + + args := append([]string{"-q", "-o", outputPath}, getSleepCommand(0.5)...) + for i := 0; i < 2; i++ { + run_memsparkline(t, args...) + } + + content, err := os.ReadFile(outputPath) + if err != nil { + t.Fatal(err) + } + + lines := strings.Split(string(content), "\n") + if len(lines) != 7 { + t.Errorf("Expected 7 lines in output file, got %d", len(lines)) + } +} diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index e5f59f7..0000000 --- a/poetry.lock +++ /dev/null @@ -1,336 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "codespell" -version = "2.3.0" -description = "Codespell" -optional = false -python-versions = ">=3.8" -files = [ - {file = "codespell-2.3.0-py3-none-any.whl", hash = "sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1"}, - {file = "codespell-2.3.0.tar.gz", hash = "sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f"}, -] - -[package.extras] -dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] -hard-encoding-detection = ["chardet"] -toml = ["tomli"] -types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.2.7" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "coverage-7.2.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8"}, - {file = "coverage-7.2.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2"}, - {file = "coverage-7.2.7-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353"}, - {file = "coverage-7.2.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495"}, - {file = "coverage-7.2.7-cp310-cp310-win32.whl", hash = "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818"}, - {file = "coverage-7.2.7-cp310-cp310-win_amd64.whl", hash = "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f"}, - {file = "coverage-7.2.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f"}, - {file = "coverage-7.2.7-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97"}, - {file = "coverage-7.2.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a"}, - {file = "coverage-7.2.7-cp311-cp311-win32.whl", hash = "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a"}, - {file = "coverage-7.2.7-cp311-cp311-win_amd64.whl", hash = "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562"}, - {file = "coverage-7.2.7-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01"}, - {file = "coverage-7.2.7-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de"}, - {file = "coverage-7.2.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d"}, - {file = "coverage-7.2.7-cp312-cp312-win32.whl", hash = "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511"}, - {file = "coverage-7.2.7-cp312-cp312-win_amd64.whl", hash = "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3"}, - {file = "coverage-7.2.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9"}, - {file = "coverage-7.2.7-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959"}, - {file = "coverage-7.2.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02"}, - {file = "coverage-7.2.7-cp37-cp37m-win32.whl", hash = "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f"}, - {file = "coverage-7.2.7-cp37-cp37m-win_amd64.whl", hash = "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5"}, - {file = "coverage-7.2.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6"}, - {file = "coverage-7.2.7-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5"}, - {file = "coverage-7.2.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f"}, - {file = "coverage-7.2.7-cp38-cp38-win32.whl", hash = "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e"}, - {file = "coverage-7.2.7-cp38-cp38-win_amd64.whl", hash = "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9"}, - {file = "coverage-7.2.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e"}, - {file = "coverage-7.2.7-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250"}, - {file = "coverage-7.2.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2"}, - {file = "coverage-7.2.7-cp39-cp39-win32.whl", hash = "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb"}, - {file = "coverage-7.2.7-cp39-cp39-win_amd64.whl", hash = "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27"}, - {file = "coverage-7.2.7-pp37.pp38.pp39-none-any.whl", hash = "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d"}, - {file = "coverage-7.2.7.tar.gz", hash = "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "exceptiongroup" -version = "1.1.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.1.2-py3-none-any.whl", hash = "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f"}, - {file = "exceptiongroup-1.1.2.tar.gz", hash = "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "nodeenv" -version = "1.8.0" -description = "Node.js virtual environment builder" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" -files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, -] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "packaging" -version = "23.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "psutil" -version = "5.9.5" -description = "Cross-platform lib for process and system monitoring in Python." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "psutil-5.9.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:be8929ce4313f9f8146caad4272f6abb8bf99fc6cf59344a3167ecd74f4f203f"}, - {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ab8ed1a1d77c95453db1ae00a3f9c50227ebd955437bcf2a574ba8adbf6a74d5"}, - {file = "psutil-5.9.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:4aef137f3345082a3d3232187aeb4ac4ef959ba3d7c10c33dd73763fbc063da4"}, - {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:ea8518d152174e1249c4f2a1c89e3e6065941df2fa13a1ab45327716a23c2b48"}, - {file = "psutil-5.9.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:acf2aef9391710afded549ff602b5887d7a2349831ae4c26be7c807c0a39fac4"}, - {file = "psutil-5.9.5-cp27-none-win32.whl", hash = "sha256:5b9b8cb93f507e8dbaf22af6a2fd0ccbe8244bf30b1baad6b3954e935157ae3f"}, - {file = "psutil-5.9.5-cp27-none-win_amd64.whl", hash = "sha256:8c5f7c5a052d1d567db4ddd231a9d27a74e8e4a9c3f44b1032762bd7b9fdcd42"}, - {file = "psutil-5.9.5-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3c6f686f4225553615612f6d9bc21f1c0e305f75d7d8454f9b46e901778e7217"}, - {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a7dd9997128a0d928ed4fb2c2d57e5102bb6089027939f3b722f3a210f9a8da"}, - {file = "psutil-5.9.5-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89518112647f1276b03ca97b65cc7f64ca587b1eb0278383017c2a0dcc26cbe4"}, - {file = "psutil-5.9.5-cp36-abi3-win32.whl", hash = "sha256:104a5cc0e31baa2bcf67900be36acde157756b9c44017b86b2c049f11957887d"}, - {file = "psutil-5.9.5-cp36-abi3-win_amd64.whl", hash = "sha256:b258c0c1c9d145a1d5ceffab1134441c4c5113b2417fafff7315a917a026c3c9"}, - {file = "psutil-5.9.5-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c607bb3b57dc779d55e1554846352b4e358c10fff3abf3514a7a6601beebdb30"}, - {file = "psutil-5.9.5.tar.gz", hash = "sha256:5410638e4df39c54d957fc51ce03048acd8e6d60abc0f5107af51e5fb566eb3c"}, -] - -[package.extras] -test = ["enum34", "ipaddress", "mock", "pywin32", "wmi"] - -[[package]] -name = "pyright" -version = "1.1.384" -description = "Command line wrapper for pyright" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pyright-1.1.384-py3-none-any.whl", hash = "sha256:f0b6f4db2da38f27aeb7035c26192f034587875f751b847e9ad42ed0c704ac9e"}, - {file = "pyright-1.1.384.tar.gz", hash = "sha256:25e54d61f55cbb45f1195ff89c488832d7a45d59f3e132f178fdf9ef6cafc706"}, -] - -[package.dependencies] -nodeenv = ">=1.6.0" -typing-extensions = ">=4.1" - -[package.extras] -all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] -dev = ["twine (>=3.4.1)"] -nodejs = ["nodejs-wheel-binaries"] - -[[package]] -name = "pytest" -version = "8.3.3" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=1.5,<2" -tomli = {version = ">=1", markers = "python_version < \"3.11\""} - -[package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-cov" -version = "4.1.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] - -[[package]] -name = "ruff" -version = "0.6.9" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, - {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, - {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, - {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, - {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, - {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, - {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, -] - -[[package]] -name = "setuptools" -version = "68.0.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "setuptools-68.0.0-py3-none-any.whl", hash = "sha256:11e52c67415a381d10d6b462ced9cfb97066179f0e871399e006c4ab101fc85f"}, - {file = "setuptools-68.0.0.tar.gz", hash = "sha256:baf1fdb41c6da4cd2eae722e135500da913332ab3f2f5c7d33af9b492acb5235"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "types-psutil" -version = "5.9.5.16" -description = "Typing stubs for psutil" -optional = false -python-versions = "*" -files = [ - {file = "types-psutil-5.9.5.16.tar.gz", hash = "sha256:4e9b219efb625d3d04f6bf106934f87cab49aa41a94b0a3b3089403f47a79228"}, - {file = "types_psutil-5.9.5.16-py3-none-any.whl", hash = "sha256:fec713104d5d143afea7b976cfa691ca1840f5d19e8714a5d02a96ebd061363e"}, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[metadata] -lock-version = "2.0" -python-versions = "^3.8" -content-hash = "0c487d8c939e6332e5f0838c3f7c9bcafac377b0d46d8eb3406f03291d15ad19" diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 21cd7dd..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,169 +0,0 @@ -[tool.poetry] -name = "memsparkline" -version = "0.6.1" -description = "Track the RAM usage (resident set size) of a process and its descendants in real time." -authors = ["D. Bohdan "] -license = "MIT" -readme = "README.md" -repository = "https://github.com/dbohdan/memsparkline" -keywords = [ - "memory", - "monitoring", - "performance", - "RAM", - "resident set size", - "sparklines", -] -classifiers = [ - "Development Status :: 4 - Beta", - "Environment :: Console", - "License :: OSI Approved :: MIT License", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - "Operating System :: POSIX", - "Programming Language :: Python :: 3", - "Topic :: System :: Benchmark", - "Topic :: System :: Monitoring", - "Topic :: Utilities", -] - -include = [ - { path = "tests/", format = "sdist" }, - { path = "tox.ini", format = "sdist" }, -] - -[tool.poetry.dependencies] -python = "^3.8" -psutil = "^5.9.5" - -[tool.poetry.group.dev.dependencies] -types-psutil = "^5.9.5.15" -ruff = "0.6.9" -pytest = "^8.3.3" -pyright = "1.1.384" -pytest-cov = "^4.1.0" -codespell = "^2.3.0" - -[build-system] -requires = ["poetry-core>=1.0"] -build-backend = "poetry.core.masonry.api" - -[tool.poetry.scripts] -memsparkline = 'memsparkline.main:main' - -[tool.codespell] -quiet-level = 3 - -[tool.poe.env] -"PYTHON_SOURCES" = "src tests" - -[tool.poe.tasks.check] -sequence = ["format", "spell", "lint", "type"] -help = "Run the formatter, then all static checks" -ignore_fail = "return_non_zero" - -[tool.poe.tasks.format] -cmd = "ruff format ${PYTHON_SOURCES}" -help = "Run the Ruff formatter" - -[tool.poe.tasks.lint] -cmd = "ruff check ${PYTHON_SOURCES}" -help = "Run Ruff" - -[tool.poe.tasks.memsparkline] -cmd = "python -m memsparkline" -help = "Run memsparkline" - -[tool.poe.tasks.spell] -cmd = "codespell ${PYTHON_SOURCES}" -help = "Run codespell" - -[tool.poe.tasks.test] -cmd = "pytest" -help = "Run Pytest (not through tox)" - -[tool.poe.tasks.type] -cmd = "pyright ${PYTHON_SOURCES}" -help = "Run Pyright" - -[tool.pyright] -pythonVersion = "3.8" - -[tool.ruff] -src = ["src", "tests"] -target-version = "py38" - -[tool.ruff.lint] -select = [ - "A", # flake8-builtins - "AIR", # Airflow - # "ANN", # flake8-annotations - "ARG", # flake8-unused-arguments - "ASYNC", # flake8-async - "B", # flake8-bugbear - "BLE", # flake8-blind-except - "C4", # flake8-comprehensions - "C90", # mccabe - "COM", # flake8-commas - # "CPY", # flake8-copyright - "DJ", # flake8-django - # "D", # pydocstyle - "DTZ", # flake8-datetimez - "EM", # flake8-errmsg - "ERA", # eradicate - "E", # pycodestyle - "EXE", # flake8-executable - "FA", # flake8-future-annotations - "FBT", # flake8-boolean-trap - "FIX", # flake8-fixme - "FLY", # flynt - "F", # Pyflakes - "G", # flake8-logging-format - "ICN", # flake8-import-conventions - "I", # isort - "INP", # flake8-no-pep420 - "INT", # flake8-gettext - "ISC", # flake8-implicit-str-concat - "N", # pep8-naming - "NPY", # NumPy-specific rules - "PD", # pandas-vet - "PERF", # Perflint - "PGH", # pygrep-hooks - "PIE", # flake8-pie - "PL", # Pylint - "PT", # flake8-pytest-style - "PTH", # flake8-use-pathlib - "PYI", # flake8-pyi - "Q", # flake8-quotes - "RET", # flake8-return - "RSE", # flake8-raise - "RUF", # Ruff-specific rules - "S", # flake8-bandit - "SIM", # flake8-simplify - "SLF", # flake8-self - "SLOT", # flake8-slots - "T10", # flake8-debugger - "T20", # flake8-print - "TCH", # flake8-type-checking - "TD", # flake8-todos - "TID", # flake8-tidy-imports - "TRY", # tryceratops - "UP", # pyupgrade - "W", # pycodestyle - "YTT", # flake8-2020 -] -ignore = [ - "COM812", # missing-trailing-comma - "ISC001", # single-line-implicit-string-concatenation -] - -[tool.ruff.lint.per-file-ignores] -"tests/*.py" = [ - "PLR2004", # magic-value-comparison - "PT027", # pytest-unittest-raises-assertion - "S101", # assert - "S603", # subprocess-without-shell-equals-true -] - -[tool.ruff.lint.pylint] -max-args = 8 diff --git a/script/release.go b/script/release.go new file mode 100644 index 0000000..b0a10ff --- /dev/null +++ b/script/release.go @@ -0,0 +1,127 @@ +package main + +import ( + "crypto/sha512" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" +) + +const ( + checksumFilename = "SHA512SUMS.txt" + projectName = "memsparkline" + distDir = "dist" +) + +type BuildTarget struct { + os string + arch string +} + +func main() { + version := os.Getenv("VERSION") + if version == "" { + fmt.Fprintln(os.Stderr, "'VERSION' environment variable must be set") + os.Exit(1) + } + + releaseDir := filepath.Join(distDir, version) + if err := os.MkdirAll(releaseDir, 0755); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create release directory: %v\n", err) + os.Exit(1) + } + + targets := []BuildTarget{ + {"darwin", "amd64"}, + {"darwin", "arm64"}, + {"freebsd", "amd64"}, + {"linux", "amd64"}, + {"linux", "arm64"}, + {"linux", "riscv64"}, + {"openbsd", "amd64"}, + {"windows", "386"}, + {"windows", "amd64"}, + } + + for _, target := range targets { + if err := build(releaseDir, target, version); err != nil { + fmt.Fprintf(os.Stderr, "Build failed for %s/%s: %v\n", target.os, target.arch, err) + os.Exit(1) + } + } +} + +func build(dir string, target BuildTarget, version string) error { + fmt.Printf("Building for %s/%s\n", target.os, target.arch) + + ext := "" + if target.os == "windows" { + ext = ".exe" + } + + // Map `GOARCH` and `GOOS` to user-facing names. + arch := target.arch + system := target.os + + if arch == "386" { + arch = "x86" + } + if system == "darwin" { + system = "macos" + } + if (system == "linux" || system == "macos") && arch == "amd64" { + arch = "x86_64" + } + if system == "linux" && arch == "arm64" { + arch = "aarch64" + } + + filename := fmt.Sprintf("%s-v%s-%s-%s%s", projectName, version, system, arch, ext) + outputPath := filepath.Join(dir, filename) + + cmd := exec.Command("go", "build", "-trimpath", "-o", outputPath, ".") + cmd.Env = append(os.Environ(), + "GOOS="+target.os, + "GOARCH="+target.arch, + "CGO_ENABLED=0", + ) + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("Build command failed: %v\nOutput:\n%s", err, output) + } + + return generateChecksum(outputPath, version) +} + +func generateChecksum(filePath, version string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("Failed to open file for checksumming: %v", err) + } + defer f.Close() + + h := sha512.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("Failed to calculate hash: %v", err) + } + + hash := hex.EncodeToString(h.Sum(nil)) + + checksumLine := fmt.Sprintf("%s %s\n", hash, filepath.Base(filePath)) + + checksumFilePath := filepath.Join(filepath.Dir(filePath), checksumFilename) + f, err = os.OpenFile(checksumFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("Failed to open checksum file: %v", err) + } + defer f.Close() + + if _, err := f.WriteString(checksumLine); err != nil { + return fmt.Errorf("Failed to write checksum: %v", err) + } + + return nil +} diff --git a/src/memsparkline/__init__.py b/src/memsparkline/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/memsparkline/__main__.py b/src/memsparkline/__main__.py deleted file mode 100644 index 209de4c..0000000 --- a/src/memsparkline/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from memsparkline.main import main - -if __name__ == "__main__": - main() diff --git a/src/memsparkline/main.py b/src/memsparkline/main.py deleted file mode 100755 index 217f257..0000000 --- a/src/memsparkline/main.py +++ /dev/null @@ -1,363 +0,0 @@ -#! /usr/bin/env python3 - -# Copyright (c) 2020, 2022-2024 D. Bohdan -# -# 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 __future__ import annotations - -import argparse -import contextlib -import sys -import time -import traceback -from datetime import datetime, timezone -from pathlib import Path -from typing import IO, TYPE_CHECKING - -import psutil - -if TYPE_CHECKING: - from collections.abc import Iterator, Sequence - -__version__ = "0.6.1" - - -DEFAULT_RECORD_TIME = 1000 -DEFAULT_SAMPLE_TIME = 200 -SPARKLINE_LOW_MAXIMUM = 0.01 -SPARKLINE_TICKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] -USAGE_DIVISOR = 1 << 20 # Report memory usage in binary megabytes. - - -def main() -> None: - args = cli(sys.argv) - - with open_output(args.output_path, sys.stderr) as output: - try: - start_dt = datetime.now(tz=timezone.utc) - process = psutil.Popen([args.command, *args.arguments]) - maximum, history, timestamps = track( - process, - output, - newlines=args.newlines, - sparkline_length=args.length, - wait_record=args.record, - wait_sample=args.sample, - mem_format=args.mem_format, - quiet=args.quiet, - ) - process.wait() - - if not history: - print("no data collected", file=output) - else: - if not args.newlines and not args.quiet: - print(file=output) - summary = summarize( - history, - maximum, - start_dt, - datetime.now(tz=timezone.utc), - args.mem_format, - args.time_format, - ) - print("\n".join(summary), file=output) - - if args.dump_path != "": - with Path(args.dump_path).open("w") as dump_file: - for value, timestamp in zip(history, timestamps): - print(timestamp // 1_000_000, value, file=dump_file) - except Exception as err: # noqa: BLE001 - tb = sys.exc_info()[-1] - frame = traceback.extract_tb(tb)[-1] - line = frame.lineno - file_info = ( - f"file {Path(frame.filename).name!r}, " - if "__file__" in globals() and frame.filename != __file__ - else "" - ) - - print( - f"\nerror: {err}\n" - f"(debug info: {file_info}line {line}, " - f"exception {type(err).__name__!r})", - file=output, - ) - sys.exit(1) - - sys.exit(process.returncode) - - -def hms_delta( - start_dt: datetime, - end_dt: datetime, -) -> tuple[int, int, float]: - delta = end_dt - start_dt - total_millis = ( - delta.days * 24 * 60 * 60 * 1000 - + delta.seconds * 1000 - + delta.microseconds // 1000 - ) - - hours, rem = divmod(total_millis, 60 * 60 * 1000) - minutes, rem = divmod(rem, 60 * 1000) - seconds = rem / 1000 - - return hours, minutes, seconds - - -def summarize( - history: Sequence[int], - maximum: int, - start_dt: datetime, - end_dt: datetime, - mem_format: str, - time_format: str, -) -> list[str]: - return [ - (" avg: " + mem_format) % (sum(history) / len(history) / USAGE_DIVISOR), - (" max: " + mem_format) % (maximum / USAGE_DIVISOR), - "time: " + time_format % hms_delta(start_dt, end_dt), - ] - - -def cli(argv: Sequence[str]) -> argparse.Namespace: - argv0 = Path(sys.argv[0]) - prog = ( - f"{Path(sys.executable).name} -m {argv0.parent.name}" - if argv0.name == "__main__.py" - else argv0.name - ) - - parser = argparse.ArgumentParser( - description="Track the RAM usage (resident set size) of a process and " - "its descendants in real time.", - prog=prog, - ) - parser.add_argument( - "command", - default=[], - help="command to run", - ) - parser.add_argument( - "arguments", - default=[], - help="arguments to command", - metavar="args", - nargs=argparse.REMAINDER, - ) - parser.add_argument( - "-v", - "--version", - action="version", - version=__version__, - ) - parser.add_argument( - "-d", - "--dump", - default="", - dest="dump_path", - help="file in which to write full memory usage history when finished", - metavar="path", - ) - parser.add_argument( - "-l", - "--length", - default=20, - dest="length", - help="sparkline length (default: %(default)d)", - metavar="n", - type=int, - ) - parser.add_argument( - "-m", - "--mem-format", - default="%0.1f", - dest="mem_format", - help='format string for memory amounts (default: "%(default)s")', - metavar="fmt", - type=str, - ) - parser.add_argument( - "-n", - "--newlines", - action="store_true", - default=False, - help="print new sparkline on new line instead of over previous", - ) - parser.add_argument( - "-o", - "--output", - default="-", - dest="output_path", - help='output file to append to ("-" for standard error)', - metavar="path", - ) - parser.add_argument( - "-q", - "--quiet", - dest="quiet", - action="store_true", - help="do not print sparklines, only final report", - ) - parser.add_argument( - "-r", - "--record", - default=None, - help=( - "how frequently to record/report memory usage " - f"(default: every {DEFAULT_RECORD_TIME} ms)" - ), - metavar="ms", - type=int, - ) - parser.add_argument( - "-s", - "--sample", - default=None, - help=( - "how frequently to sample memory usage " - f"(default: every {DEFAULT_SAMPLE_TIME} ms)" - ), - metavar="ms", - type=int, - ) - parser.add_argument( - "-t", - "--time-format", - default="%d:%02d:%04.1f", - dest="time_format", - help='format string for run time (default: "%(default)s")', - metavar="fmt", - type=str, - ) - parser.add_argument( - "-w", - "--wait", - default=None, - help=( - 'set "--sample" and "--record" time simultaneously ' - "(that both options override)" - ), - metavar="ms", - type=int, - ) - - args = parser.parse_args(argv[1:]) - - if args.record is None: - args.record = args.wait or DEFAULT_RECORD_TIME - if args.sample is None: - args.sample = args.wait or DEFAULT_SAMPLE_TIME - - return args - - -@contextlib.contextmanager -def open_output(path: str, fallback: IO[str]) -> Iterator[IO[str]]: - handle = fallback - if path != "-": - handle = Path(path).open("a", 1) # noqa: SIM115 - - try: - yield handle - finally: - if handle is not sys.stderr: - handle.close() - - -def track( - parent: psutil.Popen, - output: IO[str], - *, - newlines: bool = False, - sparkline_length: int = 20, - wait_sample: int = 100, - wait_record: int = 1000, - mem_format: str = "0.1f%", - quiet: bool = False, -) -> tuple[int, list[int], list[int]]: - core_fmt = "%s " + mem_format - fmt = core_fmt + "\n" if newlines else "\r" + core_fmt - history = [] - # The time when the last record was added to history - # in nanoseconds since the epoch. - last_record_time = 0 - # The time when the sample was taken in nanoseconds since the epoch. - last_sample_time = 0 - # The maximum total resident set size overall. - maximum = 0 - # The maximum total resident set size since the last record. - record_maximum = 0 - timestamps = [] - - def add_record(current_time: int) -> None: - nonlocal last_record_time - - if current_time - last_record_time < wait_record * 1_000_000: - return - - history.append(record_maximum) - timestamps.append(current_time) - last_record_time = current_time - - if not quiet: - latest = history[-sparkline_length:] - line = sparkline(maximum, latest) - print( - fmt % (line, record_maximum / USAGE_DIVISOR), - end="", - file=output, - ) - - try: - while parent.is_running() and parent.status() != psutil.STATUS_ZOMBIE: - tree = parent.children(recursive=True) - tree.append(parent) - - current_total = sum([x.memory_info().rss for x in tree]) - record_maximum = max(current_total, record_maximum) - maximum = max(record_maximum, maximum) - - current_time = time.time_ns() - add_record(current_time) - - delta = (current_time - last_sample_time) // 1_000_000 - last_sample_time = current_time - time.sleep(max(0, (wait_sample - delta) / 1000)) - - add_record(time.time_ns()) - except (KeyboardInterrupt, psutil.NoSuchProcess): - pass - - return (maximum, history, timestamps) - - -def sparkline(maximum: float, data: Sequence[float]) -> str: - if maximum < SPARKLINE_LOW_MAXIMUM: - return SPARKLINE_TICKS[0] * len(data) - - tick_max = len(SPARKLINE_TICKS) - 1 - - return "".join([SPARKLINE_TICKS[int(tick_max * x / maximum)] for x in data]) - - -if __name__ == "__main__": - main() diff --git a/src/memsparkline/py.typed b/src/memsparkline/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/test.ps1 b/test.ps1 new file mode 100644 index 0000000..71e6479 --- /dev/null +++ b/test.ps1 @@ -0,0 +1,25 @@ +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$env:CGO_ENABLED = "0" + +$extension = "" +if (((Get-Variable 'IsWindows' -Scope 'Global' -ErrorAction 'Ignore') -and + $IsWindows) -or + $env:OS -eq "Windows_NT") { + $extension = ".exe" +} +$build = @{ + "memsparkline" = "main.go" + "test/sleep" = "test/sleep.go" +} + +foreach ($dest in $build.Keys) { + $executable = "$dest$extension" + $source = $build[$dest] + + Remove-Item -Force -ErrorAction Ignore $executable + go build -o $executable $source +} + +go test diff --git a/test/sleep.go b/test/sleep.go new file mode 100644 index 0000000..a4227cc --- /dev/null +++ b/test/sleep.go @@ -0,0 +1,24 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "time" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "usage: %s seconds\n", os.Args[0]) + os.Exit(2) + } + + seconds, err := strconv.ParseFloat(os.Args[1], 64) + if err != nil { + fmt.Println("Invalid number of seconds:", err) + return + } + + time.Sleep(time.Duration(seconds * float64(time.Second))) + fmt.Printf("T\n") +} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/test_memsparkline.py b/tests/test_memsparkline.py deleted file mode 100644 index 3095086..0000000 --- a/tests/test_memsparkline.py +++ /dev/null @@ -1,149 +0,0 @@ -# Copyright (c) 2020, 2022-2024 D. Bohdan -# -# 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 __future__ import annotations - -import os -import re -import shlex -import subprocess -import sys -import unittest -from pathlib import Path - -PYTHON = sys.executable -TEST_PATH = Path(__file__).resolve().parent - -COMMAND = shlex.split(os.environ.get("MEMSPARKLINE_COMMAND", "")) -if COMMAND == []: - COMMAND = [PYTHON, "-m", "memsparkline"] - - -def run( - *args: str, - check: bool = True, - return_stdout: bool = False, - return_stderr: bool = True, -) -> str: - completed = subprocess.run( - COMMAND + list(args), - check=check, - stdin=None, - capture_output=True, - ) - - output = "" - if return_stdout: - output += completed.stdout.decode("utf-8") - if return_stderr: - output += completed.stderr.decode("utf-8") - - return output - - -def sleep_command(duration: float = 0.5) -> list[str]: - return [PYTHON, "-c", f"import time; time.sleep({duration})"] - - -class TestMemsparkline(unittest.TestCase): - def test_usage(self) -> None: - assert re.search("^usage", run(check=False)) - - def test_version(self) -> None: - assert re.search(r"\d+\.\d+\.\d+", run("-v", return_stdout=True)) - - def test_basic(self) -> None: - assert re.search("(?s).*avg:.*max:", run(*sleep_command())) - - def test_length(self) -> None: - stderr = run("-l", "5", "-w", "10", *sleep_command()) - - assert re.search(r"(?m)\r[^ ]{5} \d+\.\d\r?\n avg", stderr) - - def test_mem_format(self) -> None: - stderr = run("-l", "5", "-w", "10", "-m", "%0.2f", *sleep_command()) - - assert re.search(r"(?m)\r[^ ]{5} \d+\.\d{2}\r?\n avg", stderr) - - def test_time_format(self) -> None: - stderr = run("-l", "10", "-t", "%d:%05d:%06.3f", *sleep_command()) - - assert re.search(r"(?m)time: \d+:\d{5}:\d{2}\.\d{3}\r?\n", stderr) - - def test_wait_1(self) -> None: - stderr = run("-w", "2000", *sleep_command()) - - assert len(stderr.split("\n")) == 5 - - def test_wait_2(self) -> None: - stderr = run("-n", "-w", "10", *sleep_command()) - - assert len(stderr.split("\n")) >= 10 - - def test_sample_and_record(self) -> None: - stderr = run("-r", "500", "-s", "100", *sleep_command()) - - assert len(stderr.split("\n")) == 5 - - def test_quiet(self) -> None: - stderr = run("-q", *sleep_command()) - - assert re.search("^ avg", stderr) - - def test_missing_binary(self) -> None: - with self.assertRaises(subprocess.CalledProcessError) as err: - run("no-such-binary-exists") - assert re.search( - r"No such file or directory", - err.exception.output, - ) - - def test_double_dash(self) -> None: - assert "\n" in run("--", "ls", "-l", return_stdout=True) - - def test_two_double_dashes(self) -> None: - assert "\n" in run("--", *COMMAND, "--", "ls", "-l", return_stdout=True) - - def test_dump(self) -> None: - dump_path = Path(TEST_PATH, "dump.log") - if dump_path.exists(): - dump_path.unlink() - - run("-q", "-w", "100", "-d", str(dump_path), *sleep_command()) - - lines = dump_path.read_text().splitlines() - - assert lines - assert all(re.match(r"\d+ \d+", line) for line in lines) - - def test_output(self) -> None: - output_path = Path(TEST_PATH, "output.log") - if output_path.exists(): - output_path.unlink() - - for _ in range(2): - run("-q", "-o", str(output_path), *sleep_command()) - - text = output_path.read_text() - assert len(text.split("\n")) == 7 - - -if __name__ == "__main__": - unittest.main() diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 5566e29..0000000 --- a/tox.ini +++ /dev/null @@ -1,13 +0,0 @@ -[tox] -isolated_build = true -requires = - tox>=4 -env_list = - py{38,39,310,311,312,313},pypy{310} - -[testenv] -description = run tests -deps = - pytest>=8,<9 -commands = - pytest