diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..7da1f96 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 100 diff --git a/.github/workflows/codestyle-python-flake8.yml b/.github/workflows/codestyle-python-flake8.yml new file mode 100644 index 0000000..86f6ae0 --- /dev/null +++ b/.github/workflows/codestyle-python-flake8.yml @@ -0,0 +1,18 @@ +name: codestyle-python-flake8 + +on: + push: + pull_request: + +jobs: + flake8: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install Flake8 + run: pip install flake8 + - name: Run Flake8 + run: flake8 python tests/python tests/fixtures diff --git a/.github/workflows/codestyle-vimscript-vint.yml b/.github/workflows/codestyle-vimscript-vint.yml new file mode 100644 index 0000000..e7b4b57 --- /dev/null +++ b/.github/workflows/codestyle-vimscript-vint.yml @@ -0,0 +1,18 @@ +name: codestyle-vimscript-vint + +on: + push: + pull_request: + +jobs: + vint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install vint + run: pip install vim-vint + - name: Run vint + run: vint plugin diff --git a/.github/workflows/unittests-python.yml b/.github/workflows/unittests-python.yml new file mode 100644 index 0000000..795746f --- /dev/null +++ b/.github/workflows/unittests-python.yml @@ -0,0 +1,18 @@ +name: unittests-python + +on: + push: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: pip install pytest + - name: Run pytest + run: pytest tests/python diff --git a/.github/workflows/unittests-vim.yml b/.github/workflows/unittests-vim.yml new file mode 100644 index 0000000..ed60658 --- /dev/null +++ b/.github/workflows/unittests-vim.yml @@ -0,0 +1,47 @@ +name: unittests-vimscript + +on: + push: + pull_request: + +jobs: + vim-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Vim and Neovim + run: | + sudo apt-get update + sudo apt-get install -y vim neovim + - name: Run Vimscript tests (Neovim then Vim) + run: | + set +e + rm -f nvim-test.log vim-test.log + + echo + echo "Starting Neovim unittest" + nvim --headless -u NONE -i NONE -V1nvim-test.log \ + -c "source tests/vim/run_tests.vim" \ + -c "qa" + nvim_status=$? + if [ $nvim_status -ne 0 ]; then + echo "Neovim test failed:" + cat nvim-test.log + fi + + echo + echo "Starting Vim unittest" + vim -E -u NONE -i NONE -V1vim-test.log \ + -c "source tests/vim/run_tests.vim" \ + -c "qa" + vim_status=$? + if [ $vim_status -ne 0 ]; then + echo "Vim test failed:" + cat vim-test.log + fi + + rm -f nvim-test.log vim-test.log + + if [ $nvim_status -ne 0 ] || [ $vim_status -ne 0 ]; then + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b298dbf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,88 @@ +# Contributing + +Thank you for your interest in contributing. Please read this document +carefully before opening an issue or pull request. + + +## Scope and expectations + +This project is a Vim plugin implemented in Vim script with a Python backend. +It is maintained on a best-effort basis by a single maintainer alongside other +commitments. The primary goals are correctness, stability, and low +maintenance overhead (but some feature expansion and keeping up with version +updates of MLflow/Vim/NVim are still of interest!). + +The following contributions are most welcome: + +* Bug fixes with clear reproduction steps +* Documentation improvements +* Small, focused enhancements that align with the existing design + +The following are less likely to be accepted: + +* Large or architectural changes +* Features that significantly increase configuration surface area +* Changes that introduce new heavy dependencies + +One goal of this plugin is breadth of support, ie ensuring it works on both +Vim and NVim, on Linux/MacOS/Windows, and on older versions of the supporting +tools. So contributions are expected to support the minimum versions of: +Vim 8.2, NVim v0.11.5, Python 3.10, and MLflow 2.12.0. (Ok that NVim version +is not old but the rest are - point being to accommodate older versions in +addition to the newer ones.) Of course MLflow in particular has features in +v3.x that are not available in x2.x, but the point is to enable it to still +work with v2.x within the limits of v2.x's capabilities. + +Vim-specific contribution requirements: + +* `:help` documentation: User-facing changes must update the appropriate + `doc/*.txt` help files with proper help tags. README-only documentation is + not sufficient for end-user behavior. +* Backward compatibility: Behavior changes require clear justification and + corresponding documentation updates. Preserving existing workflows is + strongly preferred. + +In addition to unittests and codestyle workflows passing, contributors are +expected to thoroughly test their updates end-to-end themselves within vim/nvim +before submitting a PR. + + +## Issues + +Before opening an issue: + +* Check existing issues to avoid duplicates +* For bugs, include Vim/NVim version, Python version, OS version, and minimal + repro steps +* For feature requests, clearly describe the use case and why it fits the + project’s scope + +Issues that are vague, unreproducible, or out of scope may be closed without +further discussion. + + +## Pull requests + +Guidelines: + +* Keep PRs small and narrowly scoped +* One logical change per PR +* Follow existing code style in both Vim script and Python +* Add or update tests when applicable +* Update documentation if behavior changes +* Write commit messages per [commit.style](https://commit.style). + +All PRs must pass CI before they will be reviewed. Review may take time, and +not all PRs will be merged. + + +## Code of conduct + +Contributors are expected to be respectful and professional in their +communication and behavior at all times. + + +## License + +By contributing, you agree that your contributions will be licensed under the +project’s license. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9434be5 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.PHONY: codestyle-python-flake8 codestyle-vimscript-vint unittests unittests-vim unittests-vim-vim unittests-vim-nvim unittests-python env + +dev-env: + @python3 -m pip install -r dev-requirements.txt + +codestyle: codestyle-vimscript-vint codestyle-python-flake8 + +codestyle-vimscript-vint: + @command -v vint >/dev/null 2>&1 || { echo "vint not available; install it (pip install vim-vint)."; exit 1; } + vint plugin + +codestyle-python-flake8: + @python3 -m flake8 python tests/python tests/fixtures + +unittests: unittests-vim unittests-python + +unittests-vim: unittests-vim-nvim unittests-vim-vim + +unittests-vim-vim: + @echo + @echo -- + @echo Starting Vim unittest... + @command -v vim >/dev/null 2>&1 || { echo "vim not available on PATH."; exit 1; } + @rm -f vim-test.log + @vim -E -u NONE -i NONE -V1vim-test.log -c "source tests/vim/run_tests.vim" -c "qa" || { cat vim-test.log; exit 1; } + @rm -f vim-test.log + +unittests-vim-nvim: + @echo + @echo -- + @echo Starting NVim unittest... + @command -v nvim >/dev/null 2>&1 || { echo "nvim not available on PATH."; exit 1; } + @rm -f nvim-test.log + @nvim --headless -u NONE -i NONE -V1nvim-test.log -c "source tests/vim/run_tests.vim" -c "qa" || { cat nvim-test.log; exit 1; } + @rm -f nvim-test.log + +unittests-python: + @echo -- + @echo Starting Python unittest... + @command -v pytest >/dev/null 2>&1 || { echo "pytest not available; install it in your virtualenv."; exit 1; } + @pytest tests/python diff --git a/README.md b/README.md index e758520..bc0d5de 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,95 @@ # vim-mlflow -![version](https://img.shields.io/badge/version-1.0.0-green.svg) -![License](https://img.shields.io/badge/license-MIT-green.svg) +[![unittests-python](https://github.com/aganse/vim-mlflow/workflows/unittests-python/badge.svg)](https://github.com/aganse/vim-mlflow/actions/workflows/unittests-python.yml) +[![unittests-vimscript](https://github.com/aganse/vim-mlflow/workflows/unittests-vimscript/badge.svg)](https://github.com/aganse/vim-mlflow/actions/workflows/unittests-vim.yml) +[![codestyle-python-flake8](https://github.com/aganse/vim-mlflow/workflows/codestyle-python-flake8/badge.svg)](https://github.com/aganse/vim-mlflow/actions/workflows/codestyle-python-flake8.yml) +[![codestyle-vimscript-vint](https://github.com/aganse/vim-mlflow/workflows/codestyle-vimscript-vint/badge.svg)](https://github.com/aganse/vim-mlflow/actions/workflows/codestyle-vimscript-vint.yml) +![licence](https://img.shields.io/badge/license-MIT-blue.svg) +![version](https://img.shields.io/badge/version-1.0.1-blue.svg) -`vim‑mlflow` is a lightweight Vim plugin that lets you browse and interact with -MLflow experiments, runs, metrics, parameters, tags, and artifacts directly + +`vim‑mlflow` is a lightweight Vim/NVim plugin that lets you browse and interact +with MLflow experiments, runs, metrics, parameters, tags, and artifacts directly in your Vim editor. It opens a dedicated sidebar and a detail pane so you can explore data without leaving the terminal, even allowing you to plot metric histories and browse non-graphical artifacts. The plugin is written in Vimscript with embedded Python and talks to MLflow through its Python API. -It works with both MLflow3.x and MLflow2.x ML tracking servers (but not GenAI -traces/etc currently). - -> -> :bulb: Note this repo is part of a group that you might find useful together -> (but all are separate tools that can be used independently): -> -> * [aganse/docker_mlflow_db](https://github.com/aganse/docker_mlflow_db): -> ready-to-run MLflow server with PostgreSQL, AWS S3, Nginx -> -> * [aganse/py_tf2_gpu_dock_mlflow](https://github.com/aganse/py_tf2_gpu_dock_mlflow): -> ready-to-run Python/Tensorflow2/MLflow-Projects setup to train models on GPU -> -> * [aganse/vim_mlflow](https://github.com/aganse/vim-mlflow): -> a Vim plugin to browse the MLflow parameters and metrics instead of GUI -> -

 

+It works with both MLflow3.x and MLflow2.x ML tracking servers (but not the +GenAI traces/etc in MLflow3.x currently; feedback/demand can guide such future +steps). [![example vim-mlflow screenshot](doc/demo_1.0.0_light.gif)](doc/demo_1.0.0_light.gif) -## Summary -* Open a sidebar (`__MLflow__`) that lists all experiments on the connected - MLflow server. -* Expand experiments to see individual runs. -* Drill into a run to view metrics, parameters, tags, and artifacts. -* Open a run comparison pane (`__MLflowRuns__`) to compare metrics - across multiple selected runs. -* View ASCII plots of metric histories, and text artifacts inline. -* Completely configurable via Vim variables (e.g. in your ~/.vimrc file). - -> [!IMPORTANT] -> `vim‑mlflow` requires a Python3‑enabled Vim and the `mlflow` Python package -> installed in the same environment that Vim is launched from. You'll also want -> to make sure that the version of Python3 with which you created your Python -> environment matches the version of Python3 in your vim installation (e.g. you -> can check this in vim with `:py3 import sys; print(sys.version)`). If you -> just use the latest version of everything you probably won't have any issue. +## TL;DR +* Must run Vim/NVim in a python environment with `mlflow` installed. +* Vim must be a compiled-with-python version (check `vim --verison` for `+python3`); + or for NVim just install the `pynvim` package in that python environment as well. +* Put `Plugin 'aganse/vim-mlflow'` or your package manager equivalent in your + resource file to load plugin. +* At minimum set `let g:mlflow_tracking_uri = "http://:"` + in your resource file to your MLflow tracking server. Other options listed below. +* Press `\m` (leader-key and `m`) to start the plugin, and press `?` in there to + check the help listing for other keys. Navigate with the standard vim movement + keys, and "open" various items via `o` or ``. ## Installation -`vim‑mlflow` works in Vim versions compiled with python3 support. #### 1. Check that your Vim supports python3: -- `vim --version | grep +python3` -- (If no +python3 line is found, install a Vim build that was compiled with Python3.) +- In Vim: `vim --version | grep +python3` (if no +python3 line is found, you + must install a Vim build compiled with Python3.) +- In NVim you're good to go as long as you install pynvim in your python env + down in #3. +- Tested successfully on Vim 8.2+ and NVim v0.11.5, with Python3.10+. -#### 2. Highly recommended to create/use a virtual environment: +#### 2. Highly recommended to create/use a python virtual environment: - `python3 -m venv .venv` - `source .venv/bin/activate # syntax for linux/mac` -- (Technically this step is optional if you really insist, but it's recommended.) -#### 3. Install the `mlflow` Python package: -- `pip install mlflow` -- The plugin imports and uses this mlflow package to connect to your MLflow - tracking server. For the relatively basic functions in this plugin, the - version of the MLflow package doesn't need to exactly match that of your - tracking server. +#### 3. Install the `mlflow` Python package (and also `pynvim` for Nvim): +- `pip install mlflow` (in both Vim and NVim) +- In NVim you also need this package in your env to support the python: + `pip install pynvim` -#### 4. Add the plugin to your plugin manager: -- *Vundle* add `Plugin 'aganse/vim-mlflow'` to .vimrc, run `:PluginInstall`. -- *Plug* add `Plug 'aganse/vim-mlflow'` to .vimrc, run `:PlugInstall`. -- *Or just path‑based* `cp -r . ~/.vim/plugin/vim-mlflow` +#### 4. Load Vim-mlflow in your Vim/NVim resource file: +- Add the plugin to your plugin manager, e.g. using Vundle add + `Plugin 'aganse/vim-mlflow'` to your resource file and run `:PluginInstall`. +- Or could do manually, e.g. in NVim's `~/.config/nvim/init.vim` could load + via: `set runtimepath+=/Users/aganse/Documents/src/python/vim-mlflow` -#### 5. Set your MLflow tracking URI (and any other config settings you like) in your ~/.vimrc: -- `let g:mlflow_tracking_uri = "http://localhost:5000"` +#### 5. Set your config settings in your Vim/NVim resource file: +- Set your MLflow tracking URI. Fyi the default is `http://localhost:5000`, + which may be relevant for a simple local test setup, but often you'll have + some other host and port to set: + `let g:mlflow_tracking_uri = "http://:"` +- The Configuration section has quite a list of settings (colors, characters, + sizing, etc) that can be customized. + +- For NVim you may need to set `setlocal nowrap` in your resource file - see + last Troubleshooting tip below regarding line-wrap default in NVim affecting + content layout. ## Usage -Ensure you're in your python environment with MLflow before starting Vim. -Then start the plugin using `m` or `:call RunMLflow()` (e.g. on my -system the `` key is `\` so I do `\m`). -You can also update that mapping in your ~/.vimrc file to set a new leader/key -to start vim-mlflow in your Vim session, for example -`nnoremap m :call RunMLflow()`. - -Starting the plugin opens the `__MLflow__` sidebar. Navigate the cursor around -with the standard vim movement keys. A few of the more important plugin-specific -key bindings inside the sidebar are: - -| Key | Action | -|-----|--------| -| `o`, `` | Open experiment/run/plot/artifact/section under cursor | -| `r` | Requery the MLflow display | -| `` | Mark runs in the runs list | -| `R` | Open the `__MLflowRuns__` window to show and compare more details for the marked runs | - -Pressing `o` or `` while your cursor is on an experiment will open that -experiment, updating the Runs list. Pressing it on a run will open that run and -update the Metrics, Parameters, Tags, and Artifacts shown from the run. Pressing -it on a metric with a history will open an ASCII plot of that metric's time -series. Press `?` in the sidebar for a full help listing of the keys map. - +* Ensure you're in your python environment with MLflow before starting Vim. +* Press `\m` to start vim-mlflow (default setting, ie leader-key and m. or can + use `:call RunMLflow()`). You can update that leader/key mapping via + `nnoremap m :call RunMLflow()`. +* Vim-mlflow opens a sidebar (`__MLflow__`) that lists all experiments on the + connected MLflow server. +* Navigate the cursor around with the standard vim movement keys, and "open" + various items via `o` or `` key. +* Select experiments to show their respective lists of runs; drill into runs to + view metrics, parameters, tags, and artifacts. View ASCII plots of metric + histories, and text artifacts inline. +* Open a run comparison pane (`__MLflowRuns__`) to compare metrics across + multiple selected runs in multiple experiments. +* Press `?` in the sidebar for a full help listing of the keys map. ## Configuration - -Only `g:mlflow_tracking_uri` is required to be set by user (e.g. in .vimrc). +Only `g:mlflow_tracking_uri` is required to be set by user (e.g. in resource file). But a typical small set of vim-mlflow config variables that one might set is: ```vim " Vim-mlflow settings @@ -117,88 +100,96 @@ let g:vim_mlflow_expts_length = 10 " experiments to show at a time let g:vim_mlflow_runs_length = 15 " runs to show at a time ``` -Full list of vim-mlflow config variables that may be of interest to set in .vimrc: -| variable | description | -| -------------------------------- | --------------------------------------- | -| `g:mlflow_tracking_uri` _(required)_ | The MLFLOW_TRACKING_URI of the MLflow tracking server to connect to (default is `"http://localhost:5000"`)| -| `g:vim_mlflow_timeout` | Timeout in float seconds if cannot access MLflow tracking server (default is 0.5)| -| `g:vim_mlflow_buffername` | Buffername of the MLflow side pane (default is `__MLflow__`)| -| `g:vim_mlflow_runs_buffername` | Buffername of the MLflowRuns side pane (default is `__MLflow__`)| -| `g:vim_mlflow_vside` | Which side to open the MLflow pane on: 'left' or 'right' (default is `right`)| -| `g:vim_mlflow_hside` | Whether to open the MLflowRuns pane 'below' or 'above' (default is `below`)| -| `g:vim_mlflow_width` | Width of the vim-mlflow window in chars (default is 70)| -| `g:vim_mlflow_height` | Width of the vim-mlflow window in chars (default is 10)| -| `g:vim_mlflow_expts_length` | Number of expts to show in list (default is 8)| -| `g:vim_mlflow_runs_length` | Number of runs to show in list (default is 8)| -| `g:vim_mlflow_viewtype` | Show 1:activeonly, 2:deletedonly, or 3:all expts and runs (default is 1)| -| `g:vim_mlflow_show_scrollicons` | Show the little up/down scroll arrows on expt/run lists, 1 or 0 (default is 1, ie yes show them)| -| `g:vim_mlflow_icon_useunicode` | Allow unicode vs just ascii chars in UI, 1 or 0 (default is 0, ascii)| -| `g:vim_mlflow_icon_vdivider` | Default is `'─'` if `vim_mlflow_icon_useunicode` else `'-'`| -| `g:vim_mlflow_icon_scrollstop` | Default is `'▰'` if `vim_mlflow_icon_useunicode` else `''`| -| `g:vim_mlflow_icon_scrollup` | Default is `'▲'` if `vim_mlflow_icon_useunicode` else `'^'`| -| `g:vim_mlflow_icon_scrolldown` | Default is `'▼'` if `vim_mlflow_icon_useunicode` else `'v'`| -| `g:vim_mlflow_icon_markrun` | Default is `'▶'` if `vim_mlflow_icon_useunicode` else `'>'`| -| `g:vim_mlflow_icon_hdivider` | Default is `'│'` if `vim_mlflow_icon_useunicode` else `'|'`| -| `g:vim_mlflow_icon_plotpts` | Default is `'●'` if `vim_mlflow_icon_useunicode` else `'*'`| -| `g:vim_mlflow_icon_between_plotpts` | Default is `'•'` if `vim_mlflow_icon_useunicode` else `'.'`| -| `g:vim_mlflow_color_titles` | Element highlight color label (default is `'Statement'`)| -| `g:vim_mlflow_color_divlines` | Element highlight color label (default is `'vimParenSep'`)| -| `g:vim_mlflow_color_scrollicons `| Element highlight color label (default is `'vimParenSep'`)| -| `g:vim_mlflow_color_selectedexpt`| Element highlight color label (default is `'String'`)| -| `g:vim_mlflow_color_selectedrun` | Element highlight color label (default is `'Number'`)| -| `g:vim_mlflow_color_help` | Element highlight color label (default is `'Comment'`)| -| `g:vim_mlflow_color_markrun` | Element highlight color label (default is `'vimParenSep'`)| -| `g:vim_mlflow_color_hiddencol` | Element highlight color label (default is `'Comment'`)| -| `g:vim_mlflow_color_plot_title` | Highlight group for plot titles (default `'Statement'`)| -| `g:vim_mlflow_color_plot_axes` | Highlight group for plot axes text (default `'vimParenSep'`)| -| `g:vim_mlflow_color_plotpts` | Highlight group for plot point glyphs (default `'Constant'`)| -| `g:vim_mlflow_color_between_plotpts` | Highlight group for line segments between points (default `'Comment'`)| -| `g:vim_mlflow_plot_height` | ASCII plot height in rows when graphing metric history (default `25`)| -| `g:vim_mlflow_plot_width` | ASCII plot width in columns (default `70`)| -| `g:vim_mlflow_plot_xaxis` | `'step'` or `'timestamp'` for metric plot x-axis (default `'step'`)| -| `g:vim_mlflow_plot_reuse_buffer` | If `1`, reuse a single `__MLflowMetricPlot__` buffer; if `0`, create sequential plot buffers (default `1`)| -| `g:vim_mlflow_artifacts_max_depth` | Maximum artifact directory depth shown when expanding folders (default `3`)| +By default Vim-mlflow uses standard color groups like "Comment" and "Statement" +to color its components so that whatever your colorscheme is it should "just +work" in vim-mlflow. (E.g. the animated GIF above used +[PaperColor](https://github.com/vim-scripts/PaperColor.vim) colorscheme; +see also its [dark-mode equivalent animated GIF](doc/demo_1.0.0_dark.gif)). +But all details can be changed, per listing below. +With no configuration parameters set, ascii characters with no color are used. +See the [full listing of vim-mlflow config variables](doc/configuration_params.md) +that may be of interest to set in your resource file. ## Troubleshooting - -- If the plugin fails to load, double-check that `mlflow` is importable from - the Python environment embedded in Vim (`:py3 import mlflow` should succeed). -- The sidebar can be slow on high-latency connections to MLflow, because as - currently implemented, each refresh spins up a short-lived Python process and - re-queries MLflow anew. Running Vim close to the tracking server seems to - work fine (e.g. on same machine, and fine even if the database MLflow is - using is in AWS RDS while MLflow is in a tiny EC2 instance). For slower - connections, increasing `g:vim_mlflow_timeout` might help avoid access - timeouts. If there's demand, a future plugin version could keep a persistent - python process that keeps state in memory and requeries the database much - less often, but this hasn't seemed to be of much concern so far. +- The sidebar may be slow on high-latency MLflow connections because each + refresh starts a short-lived Python process and re-queries MLflow. + Performance seems fine when Vim runs close to the tracking server; running + all components in AWS within same region, it has worked well for our team. + On slower links, increasing `g:vim_mlflow_timeout` may help. A future version + could use a persistent Python process to reduce queries if necessary, but so + far this has not been a common enough concern. - Unicode icons require a font that includes box-drawing characters. Set `g:vim_mlflow_icon_useunicode = 0` if glyphs look broken as the simple quick fix, and also note there are config vars to change individual icon characters. -- Can I view non‑text artifacts? – Text files (`*.txt`, `*.json`, `*.yaml`, - `MLmodel`) open directly in the plugin. Binary artifacts' filenames are - shown but cannot be opened in terminal. - +- Text artifacts (`*.txt`, `*.json`, `*.yaml`, `MLmodel`) open directly in the + plugin. Binary artifacts are listed but cannot be opened in the plugin. +- If the plugin fails to load in classic Vim, verify that Vim supports Python + (vim --version) and that mlflow is importable in Vim’s Python environment + (:py3 import mlflow). In Neovim, also ensure pynvim is installed. +- In NVim if the layout seems screwy, check step 5 above in Installation + regarding `nowrap`. +- Neovim enables line wrapping by default, which can break table layouts in + this plugin. Adding `setlocal nowrap` fixes this globally. The issue is most + noticeable in the MLflowRuns window (opened with R), which displays many + columns. + + +## Contributing +Contributions are welcome; just note this project is maintained on a best-effort +basis by a single maintainer (Andy Ganse) alongside other commitments, and so +focused on long-term maintainability more than rapid feature growth. Bug fixes, +documentation improvements, and small, well-scoped enhancements are the most +likely to be accepted. Feature requests may be declined if they significantly +increase complexity, maintenance burden, or diverge from the project’s stated +scope. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening issues or +pull requests, and note that response and review times may not be fast. + +#### Dev tools to be aware of when contributing +This repo has unittests and codestyle checks, implemented in both CI workflows +and also available locally via the following Makefile calls. To run these you +need a few more packages in your Python environment than when just using the +plugin as above. So for dev purposes after entering your Python environment +(e.g. `source .venv/bin/activate`) run `make dev-env` which will install the +list of packages in dev-requirements.txt. Then you can run the following to +confirm they pass before submitting a PR for review (and to ease passing the +CI workflows): +- `make unittests` runs Vimscript unittests in both Vim and Neovim, and also + the Python unittests. +- `make codestyle` lints the Vimscript in `plugin/` with `vint` and the + Python in `python/` with `flake8`. ## Legacy/older versions - - Legacy/older versions of this plugin can be accessed by git checking out an - earlier version locally, and then referencing it in your .vimrc like - (e.g. for Vundle) `Plugin 'file:///my/path/to/python/vim-mlflow'`. - - | vim-mlflow git tag | tested with mlflow version | - | ---------------------| -------------------------- | - | v0.8 | 1.26.1 | - | v0.9 | 1.30.0, 2.7.1 | - | v1.0.0 (this version)| 2.12.0, 2.19.0, 3.6.0 | - +Legacy/older versions of this plugin can be accessed by git checking out an +earlier version locally, and then referencing it in your .vimrc (for classic +Vim like `Plugin 'file:///my/path/to/python/vim-mlflow'`. +Or similarly you can set that path in your runtimepath in NVim (without the +`file://`). +That said, it's recommended to use >= v1.0.0 - that's the first "official" +release (we'll just keep adding to this table as more releases come out). + +| vim-mlflow git tag | tested with mlflow version | tested with vim version | +| ---------------------| -------------------------- | ----------------------- | +| v0.8 | 1.26.1 | vim 8.2 | +| v0.9 | 1.30.0, 2.7.1 | vim 8.2 | +| v1.0.0 (this version)| 2.12.0, 2.19.0, 3.6.0 | vim 9.1, nvim v0.11.5 | + + +## Related repos by aganse +Vim-mlflow is part of a group of tools that you might find useful together (but +all are separate tools that can be used independently). In particular the +following two tools allow to populate test contents into a temporary MLflow +tracking server for dev/test purposes - they're the contents seen in screencast +above: +* [aganse/docker_mlflow_db](https://github.com/aganse/docker_mlflow_db): + ready-to-run MLflow server with PostgreSQL, AWS S3, Nginx +* [aganse/py_torch_gpu_dock_mlflow](https://github.com/aganse/py_torch_gpu_dock_mlflow): + ready-to-run Python/PyTorch/MLflow-Projects setup to train models on GPU ## Making the animated screen-shot gif - * Install [rust](https://rust-lang.org/tools/install) (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`) * Install [asciinema](https://github.com/asciinema/asciinema) (`cargo install --locked --git https://github.com/asciinema/asciinema`) * `~/.cargo/bin/asciinema rec demo.cast # start recording terminal screen to file` @@ -207,21 +198,13 @@ Full list of vim-mlflow config variables that may be of interest to set in .vimr * `~/.cargo/bin/agg --speed 2 demo.cast demo.gif # convert the asciinema cast to animated gif` - ## Acknowledgements - With many thanks to: -* The Writing Vim plugin in Python article by Timur Rubeko, 2017 Aug 11, at +* "Writing a Vim plugin in Python" article by Timur Rubeko, 2017 Aug 11, at http://candidtim.github.io/vim/2017/08/11/write-vim-plugin-in-python.html -* Analyzing Your MLflow Data with DataFrames by Max Allen, 2019 Oct 3, at - https://slacker.ro/2019/10/03/analyzing-your-mlflow-data-with-dataframes +* "Analyzing Your MLflow Data with DataFrames" by Max Allen, 2019 Oct 3, at + https://www.databricks.com/blog/2019/10/03/analyzing-your-mlflow-data-with-dataframes.html * The Python Interface to Vim, 2019 Dec 07, at https://vimhelp.org/if_pyth.txt.html#python-vim -* Alternative to execfile in Python 3, Stack Overflow, 2011 Jun 15 at - https://stackoverflow.com/questions/6357361/alternative-to-execfile-in-python-3/6357418#6357418 * MLFlow Python API at https://www.mlflow.org/docs/latest/python_api/mlflow.html -* MLFlow REST API at - https://www.mlflow.org/docs/latest/rest-api.html -* MLFlow Projects page at - https://www.mlflow.org/docs/latest/projects.html diff --git a/TODO b/TODO deleted file mode 100644 index 7ccb028..0000000 --- a/TODO +++ /dev/null @@ -1,3 +0,0 @@ -Make graceful handling for when no VIRTUAL_ENV exists (currently barfs on vim startup). -Reduce frequency of accessing the MLflow API (database) and store results more in memory to speed up UI. -Resolve remaining intermittant bugs in MLflowRuns pane column hiding. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..7dea76e --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.1 diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..e5adeec --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,6 @@ +mlflow +pynvim +pytest +flake8 +vim-vint +setuptools diff --git a/doc/configuration_params.md b/doc/configuration_params.md new file mode 100644 index 0000000..9f99d9c --- /dev/null +++ b/doc/configuration_params.md @@ -0,0 +1,43 @@ +Full list of vim-mlflow config variables that may be of interest to set in resource file: + +| variable | description | +| -------------------------------- | --------------------------------------- | +| `g:mlflow_tracking_uri` _(required)_ | The MLFLOW_TRACKING_URI of the MLflow tracking server to connect to (default is `"http://localhost:5000"`)| +| `g:vim_mlflow_timeout` | Timeout in float seconds if cannot access MLflow tracking server (default is 0.5)| +| `g:vim_mlflow_buffername` | Buffername of the MLflow side pane (default is `__MLflow__`)| +| `g:vim_mlflow_runs_buffername` | Buffername of the MLflowRuns side pane (default is `__MLflow__`)| +| `g:vim_mlflow_vside` | Which side to open the MLflow pane on: 'left' or 'right' (default is `right`)| +| `g:vim_mlflow_hside` | Whether to open the MLflowRuns pane 'below' or 'above' (default is `below`)| +| `g:vim_mlflow_width` | Width of the vim-mlflow window in chars (default is 70)| +| `g:vim_mlflow_height` | Width of the vim-mlflow window in chars (default is 10)| +| `g:vim_mlflow_expts_length` | Number of expts to show in list (default is 8)| +| `g:vim_mlflow_runs_length` | Number of runs to show in list (default is 8)| +| `g:vim_mlflow_viewtype` | Show 1:activeonly, 2:deletedonly, or 3:all expts and runs (default is 1)| +| `g:vim_mlflow_show_scrollicons` | Show the little up/down scroll arrows on expt/run lists, 1 or 0 (default is 1, ie yes show them)| +| `g:vim_mlflow_icon_useunicode` | Allow unicode vs just ascii chars in UI, 1 or 0 (default is 0, ascii)| +| `g:vim_mlflow_icon_vdivider` | Default is `'─'` if `vim_mlflow_icon_useunicode` else `'-'`| +| `g:vim_mlflow_icon_scrollstop` | Default is `'▰'` if `vim_mlflow_icon_useunicode` else `''`| +| `g:vim_mlflow_icon_scrollup` | Default is `'▲'` if `vim_mlflow_icon_useunicode` else `'^'`| +| `g:vim_mlflow_icon_scrolldown` | Default is `'▼'` if `vim_mlflow_icon_useunicode` else `'v'`| +| `g:vim_mlflow_icon_markrun` | Default is `'▶'` if `vim_mlflow_icon_useunicode` else `'>'`| +| `g:vim_mlflow_icon_hdivider` | Default is `'│'` if `vim_mlflow_icon_useunicode` else `'|'`| +| `g:vim_mlflow_icon_plotpts` | Default is `'●'` if `vim_mlflow_icon_useunicode` else `'*'`| +| `g:vim_mlflow_icon_between_plotpts` | Default is `'•'` if `vim_mlflow_icon_useunicode` else `'.'`| +| `g:vim_mlflow_color_titles` | Element highlight color label (default is `'Statement'`)| +| `g:vim_mlflow_color_divlines` | Element highlight color label (default is `'vimParenSep'`)| +| `g:vim_mlflow_color_scrollicons `| Element highlight color label (default is `'vimParenSep'`)| +| `g:vim_mlflow_color_selectedexpt`| Element highlight color label (default is `'String'`)| +| `g:vim_mlflow_color_selectedrun` | Element highlight color label (default is `'Number'`)| +| `g:vim_mlflow_color_help` | Element highlight color label (default is `'Comment'`)| +| `g:vim_mlflow_color_markrun` | Element highlight color label (default is `'vimParenSep'`)| +| `g:vim_mlflow_color_hiddencol` | Element highlight color label (default is `'Comment'`)| +| `g:vim_mlflow_color_plot_title` | Highlight group for plot titles (default `'Statement'`)| +| `g:vim_mlflow_color_plot_axes` | Highlight group for plot axes text (default `'vimParenSep'`)| +| `g:vim_mlflow_color_plotpts` | Highlight group for plot point glyphs (default `'Constant'`)| +| `g:vim_mlflow_color_between_plotpts` | Highlight group for line segments between points (default `'Comment'`)| +| `g:vim_mlflow_plot_height` | ASCII plot height in rows when graphing metric history (default `25`)| +| `g:vim_mlflow_plot_width` | ASCII plot width in columns (default `70`)| +| `g:vim_mlflow_plot_xaxis` | `'step'` or `'timestamp'` for metric plot x-axis (default `'step'`)| +| `g:vim_mlflow_plot_reuse_buffer` | If `1`, reuse a single `__MLflowMetricPlot__` buffer; if `0`, create sequential plot buffers (default `1`)| +| `g:vim_mlflow_artifacts_max_depth` | Maximum artifact directory depth shown when expanding folders (default `3`)| + diff --git a/doc/demo_0.9.0.gif b/doc/demo_0.9.0.gif deleted file mode 100644 index 8d5a733..0000000 Binary files a/doc/demo_0.9.0.gif and /dev/null differ diff --git a/doc/example_screen_shot_0.9.0.png b/doc/example_screen_shot_0.9.0.png deleted file mode 100644 index 6c5465d..0000000 Binary files a/doc/example_screen_shot_0.9.0.png and /dev/null differ diff --git a/doc/tags b/doc/tags new file mode 100644 index 0000000..12a1981 --- /dev/null +++ b/doc/tags @@ -0,0 +1,11 @@ +vim-mlflow vim-mlflow.txt /*vim-mlflow* +vim-mlflow-config vim-mlflow.txt /*vim-mlflow-config* +vim-mlflow-contents vim-mlflow.txt /*vim-mlflow-contents* +vim-mlflow-install vim-mlflow.txt /*vim-mlflow-install* +vim-mlflow-links vim-mlflow.txt /*vim-mlflow-links* +vim-mlflow-mappings vim-mlflow.txt /*vim-mlflow-mappings* +vim-mlflow-overview vim-mlflow.txt /*vim-mlflow-overview* +vim-mlflow-start vim-mlflow.txt /*vim-mlflow-start* +vim-mlflow-troubleshooting vim-mlflow.txt /*vim-mlflow-troubleshooting* +vim-mlflow-windows vim-mlflow.txt /*vim-mlflow-windows* +vim-mlflow.txt vim-mlflow.txt /*vim-mlflow.txt* diff --git a/doc/vim-mlflow.txt b/doc/vim-mlflow.txt index 7acc6a7..98f74fc 100644 --- a/doc/vim-mlflow.txt +++ b/doc/vim-mlflow.txt @@ -1,17 +1,185 @@ -help_vim_mlflow.txt For Vim version 8.2 Last change: 2020 Sep 15 +*vim-mlflow.txt* For Vim version 8.2+ and Neovim v0.11.5+ Last change: 2025 Dec 16 -A Vim plugin to view in Vim the tables and info one sees in an MLFlow website + *vim-mlflow* +vim-mlflow is a Vim/Neovim plugin for browsing MLflow experiments, runs, +metrics, parameters, tags, and artifacts without leaving your editor. It +provides a sidebar for navigation and an optional comparison window for marked +runs. +============================================================================== +CONTENTS *vim-mlflow-contents* -With many thanks to the Writing Vim plugin in Python article by Timur Rubeko at -http://candidtim.github.io/vim/2017/08/11/write-vim-plugin-in-python.html +1. Overview |vim-mlflow-overview| +2. Requirements & install |vim-mlflow-install| +3. Getting started |vim-mlflow-start| +4. Windows & navigation |vim-mlflow-windows| +5. Key mappings |vim-mlflow-mappings| +6. Configuration |vim-mlflow-config| +7. Troubleshooting & tips |vim-mlflow-troubleshooting| +8. Further reading |vim-mlflow-links| +============================================================================== +1. Overview *vim-mlflow-overview* -Referring to this plugin in your .vimrc file: +vim-mlflow surfaces MLflow tracking data directly inside Vim. The plugin: + - Lists experiments and runs in a dedicated sidebar buffer (`__MLflow__`). + - Shows run details (parameters, metrics, tags, artifacts) inline. + - Plots metric histories as ASCII graphs inside Vim splits. + - Opens a secondary window (`__MLflowRuns__`) to compare multiple runs. -* For normal users not developing/tweaking the plugin: - `Plugin 'aganse/vim-mlflow'` +The project focuses on correctness and low maintenance overhead. Features that +require extensive configuration or background services are intentionally kept +limited. -* For those who cloned the repo locally and want to tweak/dev further, use - the file:// protocol with absolute path, like this for example: - `Plugin 'file:///Users/aganse/Documents/src/python/vim-mlflow'` +============================================================================== +2. Requirements & install *vim-mlflow-install* + +vim-mlflow needs a Python-enabled Vim or Neovim and the MLflow Python package. + +Minimum supported versions + - Vim 8.2 compiled with `+python3` + - Neovim v0.11.5 with the `pynvim` package installed + +Python environment + 1. Create or activate a virtual environment (recommended): + `python3 -m venv .venv && source .venv/bin/activate` + 2. Install MLflow (and pynvim for Neovim users): + `pip install mlflow` + `pip install pynvim` + +Plugin install + - Plugin managers: `Plugin 'aganse/vim-mlflow'` + - Manual runtimepath example (Neovim): `set runtimepath+=/path/to/vim-mlflow` + +After installation, restart Vim so the plugin is sourced. + +============================================================================== +3. Getting started *vim-mlflow-start* + +Basic setup in your `vimrc`/`init.vim`: +> + let g:mlflow_tracking_uri = 'http://localhost:5000' + let g:vim_mlflow_width = 70 +< +Launch the UI with either the default mapping or a command: + - Default mapping: `m` (typically `\m`) + - Command form: `:call RunMLflow()` + +Make sure your Vim session runs inside a Python environment where `mlflow` (and +`pynvim`, if needed) is installed; otherwise the plugin cannot connect. + +============================================================================== +4. Windows & navigation *vim-mlflow-windows* + +Sidebar (`__MLflow__`) + - Lists experiments, runs, and run details. + - Sections for parameters, metrics, tags, and artifacts can be toggled. + - Uses scroll hints and highlighting to show the active experiment/run. + +Runs window (`__MLflowRuns__`) + - Optional comparison buffer opened with `R` inside the sidebar. + - Displays a tabular view of marked runs, including tags/params/metrics based + on the toggles you choose. + +Metric plots + - Press `o` or `` on metrics marked with `[final value; o to plot]` to + open an ASCII graph in a vertical split. Plots reuse a dedicated buffer by + default but can be configured to open new windows for each metric. + +Artifacts + - Expand directories and open supported artifacts (text, JSON, YAML, MLmodel) + directly inside Vim. Binary artifacts are listed but stay unopened. + +============================================================================== +5. Key mappings *vim-mlflow-mappings* + +Default mappings in the sidebar buffer (`__MLflow__`): + + `?` Toggle the inline help banner. + `` Open the experiment/run/section under the cursor. + `o` Same as ``; also plots metrics where available. + `` Mark or unmark the highlighted run (visible in the runs window). + `r` Refresh the sidebar and reset expanded artifacts. + `R` Open or refresh the marked runs window (`__MLflowRuns__`). + `` Toggle the Parameters section visibility. + `` Toggle the Metrics section visibility. + `` Toggle the Tags section visibility. + `` Toggle the Artifacts section visibility. + `@` Rotate the order of detail sections (params/metrics/tags/artifacts). + `A` Cycle Active → Deleted → All experiments/runs (MLflow view type). + `n` / `p` Scroll the experiment or run list down / up. + `N` / `P` Jump to the bottom / top of the current list. + +Default mappings in the runs window (`__MLflowRuns__`): + + `?` Toggle the window-specific help banner. + `R` Refresh the listing of marked runs. + `.` Collapse or expand the column under the cursor. + `x` Remove the run under the cursor from the marked list. + `` Undo all column layout tweaks (collapse/hide/move). + `` Include or omit run parameters in the table. + `` Include or omit run metrics in the table. + `` Include or omit run tags in the table. + +All mappings are buffer-local; redefining them globally is unnecessary. You can +remap the entry point by adjusting your own `` mapping or calling +`RunMLflow()` explicitly. + +============================================================================== +6. Configuration *vim-mlflow-config* + +Only `g:mlflow_tracking_uri` is required. Common options: + + `g:vim_mlflow_width` Sidebar width in columns (default 70). + `g:vim_mlflow_height` Runs window height in rows (default 10). + `g:vim_mlflow_expts_length` Number of experiments displayed at once (default 8). + `g:vim_mlflow_runs_length` Number of runs displayed at once (default 8). + `g:vim_mlflow_icon_useunicode` Use Unicode box-drawing/icons (default 0 for ASCII). + `g:vim_mlflow_plot_width` / `g:vim_mlflow_plot_height` + Dimensions of metric plots (default 70x25). + `g:vim_mlflow_plot_reuse_buffer` Reuse a single plot buffer (default 1). + `g:vim_mlflow_section_order` List order for run details (default + `['params', 'metrics', 'tags', 'artifacts']`). + `g:vim_mlflow_timeout` HTTP timeout (seconds) when probing the tracking URI + before querying MLflow (default 0.5). + +See `doc/configuration_params.md` in the repository for a complete table of +settings, including highlight groups and custom icon characters. + +============================================================================== +7. Troubleshooting & tips *vim-mlflow-troubleshooting* + +Slow refreshes~ + Each refresh queries MLflow synchronously. Performance is best when Vim runs + close to the tracking server. Increase `g:vim_mlflow_timeout` if connections + are flaky. Long round-trips may still block Vim temporarily. + +Unicode glyph issues~ + Set `let g:vim_mlflow_icon_useunicode = 0` to fall back to ASCII characters if + your terminal font lacks box-drawing support. Individual icons can also be + overridden in your config. + +Plugin does not load~ + - Confirm Vim reports `+python3` (`vim --version | grep +python3`). + - In Neovim ensure `pynvim` is installed in the same environment. + - From Vim run `:py3 import mlflow` to verify MLflow is importable. + +Neovim layout wrapping~ + Neovim enables line wrapping by default. Add `setlocal nowrap` in your config + if columns in vim-mlflow buffers appear misaligned. + +Artifacts~ + Text artifacts (`*.txt`, `*.json`, `*.yaml`, `MLmodel`) open directly. Binary + artifacts are listed but not opened. + +============================================================================== +8. Further reading *vim-mlflow-links* + +Primary documentation (repository): + - Project README for animated demos and deeper background. + - `doc/configuration_params.md` for the full configuration reference. + - CONTRIBUTING.md for contribution guidelines and support expectations. + +============================================================================== + +vim:tw=78:ts=8:ft=help:norl: diff --git a/plugin/vim-mlflow.vim b/plugin/vim-mlflow.vim index dd6649b..84f4c16 100644 --- a/plugin/vim-mlflow.vim +++ b/plugin/vim-mlflow.vim @@ -1,7 +1,18 @@ -let g:vim_mlflow_version = get(g:, 'vim_mlflow_version', '1.0.0') +scriptencoding utf-8 let s:plugin_root_dir = fnamemodify(resolve(expand(':p')), ':h') -if !has('python3') +let s:version_path = fnamemodify(s:plugin_root_dir . '/../VERSION', ':p') +if filereadable(s:version_path) + let s:raw_version = readfile(s:version_path) + let s:file_version = empty(s:raw_version) ? 'dev' : trim(s:raw_version[0]) +else + let s:file_version = 'dev' +endif +let g:vim_mlflow_version = get(g:, 'vim_mlflow_version', s:file_version) + +let s:num_expts = 0 +let s:num_runs = 0 +if !get(g:, 'vim_mlflow_skip_python_check', 0) && !has('python3') echo 'Error: vim must be compiled with +python3 to run the vim-mlflow plugin.' finish endif @@ -65,10 +76,10 @@ function! SetDefaults() let g:vim_mlflow_artifact_expanded = get(g:, 'vim_mlflow_artifact_expanded', {}) let g:vim_mlflow_artifacts_max_depth = get(g:, 'vim_mlflow_artifacts_max_depth', 3) let g:vim_mlflow_section_order = get(g:, 'vim_mlflow_section_order', ['params', 'metrics', 'tags', 'artifacts']) - if type(g:vim_mlflow_section_order) != type([]) + if type(g:vim_mlflow_section_order) !=# type([]) let g:vim_mlflow_section_order = ['params', 'metrics', 'tags', 'artifacts'] else - let g:vim_mlflow_section_order = filter(copy(g:vim_mlflow_section_order), {_, v -> index(['params', 'metrics', 'tags', 'artifacts'], v) != -1}) + let g:vim_mlflow_section_order = filter(copy(g:vim_mlflow_section_order), {_, v -> index(['params', 'metrics', 'tags', 'artifacts'], v) !=# -1}) if empty(g:vim_mlflow_section_order) let g:vim_mlflow_section_order = ['params', 'metrics', 'tags', 'artifacts'] endif @@ -135,17 +146,17 @@ function! s:ColorizePlotBuffer() call matchadd(g:vim_mlflow_color_plot_title, '\%1l.*') call matchadd(g:vim_mlflow_color_selectedexpt, '\%1l\zs#[0-9]\+\ze', 15) call matchadd(g:vim_mlflow_color_selectedrun, '\%1l\zs#[0-9a-zA-Z]\{5}\ze', 15) - if g:vim_mlflow_icon_hdivider != '' + if g:vim_mlflow_icon_hdivider !=# '' call matchadd(g:vim_mlflow_color_plot_axes, '\V' . g:vim_mlflow_icon_hdivider) endif - if g:vim_mlflow_icon_vdivider != '' + if g:vim_mlflow_icon_vdivider !=# '' call matchadd(g:vim_mlflow_color_plot_axes, '\V' . g:vim_mlflow_icon_vdivider) endif call matchadd(g:vim_mlflow_color_plot_axes, '\V+') - if g:vim_mlflow_icon_plotpts != '' + if g:vim_mlflow_icon_plotpts !=# '' call matchadd(g:vim_mlflow_color_plotpts, '\V' . g:vim_mlflow_icon_plotpts) endif - if g:vim_mlflow_icon_between_plotpts != '' + if g:vim_mlflow_icon_between_plotpts !=# '' call matchadd(g:vim_mlflow_color_between_plotpts, '\V' . g:vim_mlflow_icon_between_plotpts) endif endfunction @@ -193,11 +204,11 @@ function! RunMLflow() " Set the defaults for all the user-specifiable options call SetDefaults() - if bufwinnr(g:vim_mlflow_buffername) == -1 + if bufwinnr(g:vim_mlflow_buffername) ==# -1 " Open a new split on specified side - if g:vim_mlflow_vside == 'left' + if g:vim_mlflow_vside ==# 'left' execute 'lefta vsplit ' . g:vim_mlflow_buffername - elseif g:vim_mlflow_vside == 'right' + elseif g:vim_mlflow_vside ==# 'right' execute 'rightb vsplit ' . g:vim_mlflow_buffername else echo 'unrecognized value for g:vim_mlflow_vside = ' . g:vim_mlflow_vside @@ -239,11 +250,11 @@ endfunction function! OpenRunsWindow() - if bufwinnr(g:vim_mlflow_runs_buffername) == -1 + if bufwinnr(g:vim_mlflow_runs_buffername) ==# -1 " Open a new split on specified side - if g:vim_mlflow_hside == 'below' + if g:vim_mlflow_hside ==# 'below' execute 'botright split ' . g:vim_mlflow_runs_buffername - elseif g:vim_mlflow_hside == 'above' + elseif g:vim_mlflow_hside ==# 'above' execute 'topleft split ' . g:vim_mlflow_runs_buffername else echo 'unrecognized value for g:vim_mlflow_hside = ' . g:vim_mlflow_hside @@ -285,14 +296,14 @@ function! RemoveMarkedRunViaCurpos() " Maybe some issue with vim 'magic'; if () could work then substitute is unnecesary. let l:run = substitute(matchstr(l:currentLine, '\m\#[0-9a-fA-F]\{5\} '), '[\# ]', '', 'g') - if l:run != '' + if l:run !=# '' let s:markruns_list = filter(s:markruns_list, 'v:val[:5] !~ "'.l:run[:5].'"') endif call RefreshRunsBuffer() " Also refresh the main pane so marks disappear there too. let l:current_win = win_getid() let l:mlflow_winnr = bufwinnr(g:vim_mlflow_buffername) - if l:mlflow_winnr != -1 + if l:mlflow_winnr !=# -1 execute l:mlflow_winnr . 'wincmd w' call RefreshMLflowBuffer(0) call win_gotoid(l:current_win) @@ -309,13 +320,13 @@ function! AssignExptRunFromCurpos(curpos) let l:expt = substitute(matchstr(l:currentLine, '\m\#[0-9]\{1,4\}\:'), '[\#\:]', '', 'g') let l:run = substitute(matchstr(l:currentLine, '\m\#[0-9a-fA-F]\{5\}\:'), '[\#\:]', '', 'g') - if l:expt != '' + if l:expt !=# '' let s:current_exptid = l:expt let s:current_runid = '' let s:runs_first_idx = 0 let g:vim_mlflow_artifact_expanded = {} endif - if l:run != '' + if l:run !=# '' let s:current_runid = l:run let g:vim_mlflow_artifact_expanded = {} endif @@ -330,7 +341,7 @@ function! RefreshMLflowBuffer(doassign, ...) let l:reset_artifacts = 0 " Allow callers to pass cursor position and/or reset flag via a:000. if len(a:000) >= 1 - if type(a:000[0]) == type([]) + if type(a:000[0]) ==# type([]) let l:curpos = a:000[0] if len(a:000) >= 2 let l:reset_artifacts = a:000[1] @@ -400,12 +411,12 @@ endfunction function! ColorizeRunsBuffer() call matchadd(g:vim_mlflow_color_titles, 'expt_id.*$') - if g:vim_mlflow_icon_vdivider != '' + if g:vim_mlflow_icon_vdivider !=# '' call matchadd(g:vim_mlflow_color_divlines, repeat(g:vim_mlflow_icon_vdivider, 4).'*') endif call matchadd(g:vim_mlflow_color_help, '^".*') call matchadd(g:vim_mlflow_color_hiddencol, ' : ') - if g:vim_mlflow_icon_vdivider != '' + if g:vim_mlflow_icon_vdivider !=# '' call matchadd(g:vim_mlflow_color_hiddencol, ' '.g:vim_mlflow_icon_vdivider.' ') endif endfunction @@ -418,17 +429,17 @@ function! ColorizeMLflowBuffer() call matchadd(g:vim_mlflow_color_titles, 'Metrics in run .*:') call matchadd(g:vim_mlflow_color_titles, 'Artifacts in run .*:') call matchadd(g:vim_mlflow_color_titles, 'Tags in run .*:') - if g:vim_mlflow_icon_vdivider != '' + if g:vim_mlflow_icon_vdivider !=# '' call matchadd(g:vim_mlflow_color_divlines, repeat(g:vim_mlflow_icon_vdivider, 4).'*') endif - if g:vim_mlflow_icon_scrollstop != '' + if g:vim_mlflow_icon_scrollstop !=# '' call matchadd(g:vim_mlflow_color_scrollicons, '^'.g:vim_mlflow_icon_scrollstop) endif call matchadd(g:vim_mlflow_color_scrollicons, '^'.g:vim_mlflow_icon_scrollup) call matchadd(g:vim_mlflow_color_scrollicons, '^'.g:vim_mlflow_icon_scrolldown) call matchadd(g:vim_mlflow_color_help, '^".*') call matchadd(g:vim_mlflow_color_markrun, '^'.g:vim_mlflow_icon_markrun.'.*$') - if s:expt_hi != '' + if s:expt_hi !=# '' call matchdelete(s:expt_hi) call matchdelete(s:run_hi) endif @@ -456,7 +467,7 @@ function! MarkRun() \ l:curpos[1]<=l:top_to_expts+s:actual_expts_length+l:expts_to_runs+s:actual_runs_length let l:currentLine = getline(l:curpos[1]) let l:runid5 = substitute(matchstr(l:currentLine, '\m\#[0-9a-fA-F]\{5\}\:'), '[\#\:]', '', 'g') - if l:runid5 != '' + if l:runid5 !=# '' if index(s:markruns_list, l:runid5) >= 0 " If item is in the list. call remove(s:markruns_list, index(s:markruns_list, l:runid5)) else @@ -564,7 +575,7 @@ function! HideColumn() let s:numreducedcols = len(l:hiddencols_le_colnum) let l:colnum = len(split(l:line, ' ')) - 1 + s:numreducedcols - if index(s:hiddencols_list, l:colnum) == -1 + if index(s:hiddencols_list, l:colnum) ==# -1 call add(s:hiddencols_list, str2nr(l:colnum)) endif call RefreshRunsBuffer() @@ -675,7 +686,7 @@ function! ListHelpMsg() normal! 1G let s:help_msg_is_showing = 1 else - execute "normal! 1G". len(s:helptext) . "dd" + execute 'normal! 1G' . len(s:helptext) . 'dd' call append(line('^'), '" Press ? for help') call append(line('^'), l:title) let s:help_msg_is_showing = 0 @@ -709,7 +720,7 @@ function! RunsListHelpMsg() normal! 1G let s:runhelp_msg_is_showing = 1 else - execute "normal! 1G". len(s:helptext) . "dd" + execute 'normal! 1G' . len(s:helptext) . 'dd' call append(line('^'), '" Press ? for help') call append(line('^'), 'Vim-MLflow Marked Runs') let s:runhelp_msg_is_showing = 0 @@ -832,13 +843,13 @@ function! HandleMetricPlotUnderCursor() let l:line = line('.') let l:curline = getline('.') let l:metric_lines = get(g:, 'vim_mlflow_metric_lines', []) - if type(l:metric_lines) != type([]) + if type(l:metric_lines) !=# type([]) let l:metric_lines = [] endif if empty(l:metric_lines) return 0 endif - if index(l:metric_lines, l:line) == -1 + if index(l:metric_lines, l:line) ==# -1 return 0 endif if l:curline !~# '^\s\{2,}\S\+:' @@ -849,30 +860,30 @@ function! HandleMetricPlotUnderCursor() return 0 endif let l:metric = l:match[1] - if ! exists("s:current_runid") || s:current_runid == "" - echo "vim-mlflow: no run selected." + if ! exists('s:current_runid') || s:current_runid ==# '' + echo 'vim-mlflow: no run selected.' return 1 endif let l:all_histories = get(g:, 'vim_mlflow_metric_histories', {}) - if type(l:all_histories) != type({}) + if type(l:all_histories) !=# type({}) let l:all_histories = {} endif if ! has_key(l:all_histories, s:current_runid) - echo "vim-mlflow: metric history unavailable; try refreshing." + echo 'vim-mlflow: metric history unavailable; try refreshing.' return 1 endif let l:histories = l:all_histories[s:current_runid] if ! has_key(l:histories, l:metric) - echo "vim-mlflow: metric history not found." + echo 'vim-mlflow: metric history not found.' return 1 endif let l:history = l:histories[l:metric] if len(l:history) <= 1 - echo "vim-mlflow: metric has no series to plot." + echo 'vim-mlflow: metric has no series to plot.' return 1 endif if ! exists('*json_encode') - echo "vim-mlflow: json support is required for plotting." + echo 'vim-mlflow: json support is required for plotting.' return 1 endif let l:history_json = json_encode(l:history) @@ -939,12 +950,12 @@ vim.vars['vim_mlflow_plot_lines'] = lines vim.vars['vim_mlflow_plot_title'] = title EOF if ! exists('g:vim_mlflow_plot_lines') - echo "vim-mlflow: failed to generate plot." + echo 'vim-mlflow: failed to generate plot.' return 1 endif let l:plot_lines = get(g:, 'vim_mlflow_plot_lines', []) if empty(l:plot_lines) - echo "vim-mlflow: no plot data available." + echo 'vim-mlflow: no plot data available.' return 1 endif let l:plot_title = get(g:, 'vim_mlflow_plot_title', '') @@ -963,7 +974,7 @@ function! MLflowActionUnderCursor() let l:line = line('.') let l:key = string(l:line) for l:entry in get(g:, 'vim_mlflow_section_headers', []) - if get(l:entry, 'line', -1) == l:line + if get(l:entry, 'line', -1) ==# l:line let l:section = get(l:entry, 'section', '') if !empty(l:section) call ToggleSection(l:section) @@ -979,7 +990,7 @@ function! MLflowActionUnderCursor() if l:info.openable call OpenArtifactFile(l:info.path) else - echo "vim-mlflow: artifact not opened (unsupported type)." + echo 'vim-mlflow: artifact not opened (unsupported type).' endif endif return 1 @@ -1006,7 +1017,7 @@ endfunction function! RotateMLflowSections() - let l:order = filter(copy(g:vim_mlflow_section_order), {_, v -> index(['params', 'metrics', 'tags', 'artifacts'], v) != -1}) + let l:order = filter(copy(g:vim_mlflow_section_order), {_, v -> index(['params', 'metrics', 'tags', 'artifacts'], v) !=# -1}) if empty(l:order) let l:order = ['params', 'metrics', 'tags', 'artifacts'] endif @@ -1033,7 +1044,7 @@ endfunction function! ToggleSection(section) - if index(['params', 'metrics', 'tags', 'artifacts'], a:section) == -1 + if index(['params', 'metrics', 'tags', 'artifacts'], a:section) ==# -1 return endif call s:ToggleSectionInternal(a:section) @@ -1064,7 +1075,7 @@ endfunction function! OpenArtifactFile(path) - if a:path == '' + if a:path ==# '' return endif python3 << EOF @@ -1123,7 +1134,7 @@ except Exception as exc: vim.vars['vim_mlflow_artifact_error'] = str(exc) EOF if !exists('g:vim_mlflow_artifact_local') - echo "vim-mlflow: failed to download artifact." + echo 'vim-mlflow: failed to download artifact.' return endif let l:local = g:vim_mlflow_artifact_local @@ -1142,7 +1153,7 @@ EOF if filereadable(l:local) call s:ShowArtifactBuffer(a:path, l:local) else - echo "vim-mlflow: artifact not readable." + echo 'vim-mlflow: artifact not readable.' endif endfunction @@ -1151,9 +1162,9 @@ function! s:ShowArtifactBuffer(path, localpath) let l:current_win = win_getid() let l:bufname = 'artifact://' . a:path let l:winnr = bufwinnr(l:bufname) - if l:winnr == -1 + if l:winnr ==# -1 let l:scratch = s:FindScratchWindow() - if l:scratch != -1 + if l:scratch !=# -1 call win_gotoid(l:scratch) execute 'enew' else @@ -1187,13 +1198,13 @@ endfunction function! s:SetBufferFiletype(path) let l:lower = tolower(a:path) - if l:lower =~ '\v\.json$' + if l:lower =~# '\v\.json$' setfiletype json - elseif l:lower =~ '\v\.(yaml|yml)$' + elseif l:lower =~# '\v\.(yaml|yml)$' setfiletype yaml - elseif l:lower =~ '\v\.txt$' + elseif l:lower =~# '\v\.txt$' setfiletype text - elseif l:lower =~ '\vmlmodel$' + elseif l:lower =~# '\vmlmodel$' setfiletype yaml else setfiletype text @@ -1208,7 +1219,7 @@ function! s:FindScratchWindow() continue endif let l:name = bufname(l:buf) - if (empty(l:name) || l:name =~? '^artifact://') && getbufvar(l:buf, '&buftype') == '' + if (empty(l:name) || l:name =~? '^artifact://') && getbufvar(l:buf, '&buftype') ==# '' return win_getid(l:w) endif endfor diff --git a/python/vim_mlflow.py b/python/vim_mlflow.py index 51fd902..77bba08 100644 --- a/python/vim_mlflow.py +++ b/python/vim_mlflow.py @@ -1,20 +1,15 @@ -from datetime import datetime -from urllib.request import urlopen -import math -import os -import json -import tempfile import contextlib import io +import json +import math +import os +from datetime import datetime +from urllib.request import urlopen import mlflow +import vim from mlflow.entities import ViewType from mlflow.tracking import MlflowClient -# import pandas as pd -import vim -import warnings - -#warnings.simplefilter(action='ignore', category=FutureWarning) from vim_mlflow_utils import format_run_duration @@ -42,7 +37,10 @@ def getMLflowExpts(mlflow_tracking_uri): output_lines = [] num_expts_viewtype = len(expts) vim.command("let s:num_expts='" + str(num_expts_viewtype) + "'") - output_lines.append(f"{vim.eval('s:num_expts')} {VIEWTYPE_LABELS.get(view_idx, VIEWTYPE_LABELS[1])} Experiments:") + output_lines.append( + f"{vim.eval('s:num_expts')} {VIEWTYPE_LABELS.get(view_idx, VIEWTYPE_LABELS[1])} " + "Experiments:" + ) if vim.eval("g:vim_mlflow_show_scrollicons"): if int(vim.eval("s:expts_first_idx")) == 0: scrollicon = vim.eval("g:vim_mlflow_icon_scrollstop") @@ -50,19 +48,26 @@ def getMLflowExpts(mlflow_tracking_uri): scrollicon = vim.eval("g:vim_mlflow_icon_scrollup") else: scrollicon = "" - output_lines.append(scrollicon + vim.eval("g:vim_mlflow_icon_vdivider")*30) + output_lines.append(scrollicon + vim.eval("g:vim_mlflow_icon_vdivider") * 30) expts = sorted(expts, key=lambda e: int(e.experiment_id), reverse=True) beginexpt_idx = int(vim.eval("s:expts_first_idx")) - endexpt_idx = int(vim.eval("s:expts_first_idx"))+int(vim.eval("g:vim_mlflow_expts_length")) - for expt in expts[beginexpt_idx: endexpt_idx]: + endexpt_idx = int(vim.eval("s:expts_first_idx")) + int( + vim.eval("g:vim_mlflow_expts_length") + ) + for expt in expts[beginexpt_idx:endexpt_idx]: if view_type == ViewType.ALL: - stage_letter = lifecycles.get(expt.lifecycle_stage, expt.lifecycle_stage[:1].upper()) - output_lines.append(f"#{expt.experiment_id}: {stage_letter} {expt.name}") + stage_letter = lifecycles.get( + expt.lifecycle_stage, expt.lifecycle_stage[:1].upper() + ) + output_lines.append( + f"#{expt.experiment_id}: {stage_letter} {expt.name}" + ) else: output_lines.append(f"#{expt.experiment_id}: {expt.name}") if vim.eval("g:vim_mlflow_show_scrollicons"): - if int(vim.eval("s:expts_first_idx")) == \ - int(vim.eval("s:num_expts-min([g:vim_mlflow_expts_length, s:num_expts])")): + if int(vim.eval("s:expts_first_idx")) == int( + vim.eval("s:num_expts-min([g:vim_mlflow_expts_length, s:num_expts])") + ): scrollicon = vim.eval("g:vim_mlflow_icon_scrollstop") else: scrollicon = vim.eval("g:vim_mlflow_icon_scrolldown") @@ -72,7 +77,9 @@ def getMLflowExpts(mlflow_tracking_uri): return output_lines, [expt.experiment_id for expt in expts] except ModuleNotFoundError: - print('Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup.') + print( + "Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup." + ) def getRunsListForExpt(mlflow_tracking_uri, current_exptid): @@ -86,7 +93,10 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): output_lines = [] num_runs_viewtype = len(runs) vim.command("let s:num_runs='" + str(num_runs_viewtype) + "'") - output_lines.append(f"{vim.eval('s:num_runs')} {VIEWTYPE_LABELS.get(view_idx, VIEWTYPE_LABELS[1])} Runs in expt #{current_exptid}:") + output_lines.append( + f"{vim.eval('s:num_runs')} {VIEWTYPE_LABELS.get(view_idx, VIEWTYPE_LABELS[1])} " + f"Runs in expt #{current_exptid}:" + ) if vim.eval("g:vim_mlflow_show_scrollicons"): if int(vim.eval("s:runs_first_idx")) == 0: scrollicon = vim.eval("g:vim_mlflow_icon_scrollstop") @@ -94,14 +104,18 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): scrollicon = vim.eval("g:vim_mlflow_icon_scrollup") else: scrollicon = "" - output_lines.append(scrollicon + vim.eval("g:vim_mlflow_icon_vdivider")*30) + output_lines.append(scrollicon + vim.eval("g:vim_mlflow_icon_vdivider") * 30) runs = sorted(runs, key=lambda r: r.info.start_time, reverse=True) beginrun_idx = int(vim.eval("s:runs_first_idx")) - endrun_idx = int(vim.eval("s:runs_first_idx"))+int(vim.eval("g:vim_mlflow_runs_length")) + endrun_idx = int(vim.eval("s:runs_first_idx")) + int( + vim.eval("g:vim_mlflow_runs_length") + ) visible_rows = [] - for run in runs[beginrun_idx: endrun_idx]: + for run in runs[beginrun_idx:endrun_idx]: if run.info.start_time: - st = datetime.utcfromtimestamp(run.info.start_time/1e3).strftime("%Y-%m-%d %H:%M:%S") + st = datetime.utcfromtimestamp(run.info.start_time / 1e3).strftime( + "%Y-%m-%d %H:%M:%S" + ) else: st = "N/A" mark = " " @@ -113,7 +127,9 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): user = runtags.get("mlflow.user") or run.info.user_id or "-" stage_letter = "" if view_type == ViewType.ALL: - stage_letter = lifecycles.get(run.info.lifecycle_stage, run.info.lifecycle_stage[:1].upper()) + stage_letter = lifecycles.get( + run.info.lifecycle_stage, run.info.lifecycle_stage[:1].upper() + ) if run.info.start_time and run.info.end_time: duration_seconds = (run.info.end_time - run.info.start_time) / 1e3 else: @@ -144,11 +160,13 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): duration_col = row["duration"].rjust(duration_width) user_col = row["user"].ljust(user_width) output_lines.append( - f"{row['mark']}#{row['run_id']}: {stage_prefix}{row['start']} {status_col} {duration_col} {user_col} {row['name']}" + f"{row['mark']}#{row['run_id']}: {stage_prefix}{row['start']} {status_col} " + f"{duration_col} {user_col} {row['name']}" ) if vim.eval("g:vim_mlflow_show_scrollicons"): - if int(vim.eval("s:runs_first_idx")) == \ - int(vim.eval("s:num_runs-min([g:vim_mlflow_runs_length, s:num_runs])")): + if int(vim.eval("s:runs_first_idx")) == int( + vim.eval("s:num_runs-min([g:vim_mlflow_runs_length, s:num_runs])") + ): scrollicon = vim.eval("g:vim_mlflow_icon_scrollstop") else: scrollicon = vim.eval("g:vim_mlflow_icon_scrolldown") @@ -158,7 +176,9 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): return output_lines, [run.info.run_id for run in runs] except ModuleNotFoundError: - print('Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup.') + print( + "Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup." + ) def getMetricsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon=""): @@ -194,8 +214,8 @@ def getMetricsListForRun(mlflow_tracking_uri, current_runid, show=True, header_i output_lines.append("") # Cache histories in a global dict so Vimscript can access them. - vim.vars['vim_mlflow_metric_histories'] = {current_runid: metric_histories} - vim.vars['vim_mlflow_current_runinfo'] = { + vim.vars["vim_mlflow_metric_histories"] = {current_runid: metric_histories} + vim.vars["vim_mlflow_current_runinfo"] = { "run_id": run.info.run_id, "run_name": run.info.run_name or "", "experiment_id": run.info.experiment_id, @@ -203,7 +223,9 @@ def getMetricsListForRun(mlflow_tracking_uri, current_runid, show=True, header_i return output_lines, metric_offsets except ModuleNotFoundError: - print('Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup.') + print( + "Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup." + ) def getParamsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon=""): @@ -223,7 +245,9 @@ def getParamsListForRun(mlflow_tracking_uri, current_runid, show=True, header_ic return output_lines except ModuleNotFoundError: - print('Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup.') + print( + "Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup." + ) def getTagsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon=""): @@ -243,7 +267,9 @@ def getTagsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon return output_lines except ModuleNotFoundError: - print('Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup.') + print( + "Sorry, `mlflow` is not installed. See :h vim-mlflow for more details on setup." + ) def _clean_metric_history(history): @@ -281,7 +307,7 @@ def _downsample_points(points, target_len): end = start + 1 slice_points = points[start:end] if not slice_points: - slice_points = points[start:start + 1] + slice_points = points[start: start + 1] avg_x = sum(p[0] for p in slice_points) / len(slice_points) avg_y = sum(p[1] for p in slice_points) / len(slice_points) downsampled.append((avg_x, avg_y)) @@ -306,7 +332,9 @@ def _collect_artifacts(client, run_id, path="", depth=0, max_depth=50): "children": [], } if item.is_dir and depth < max_depth: - node["children"] = _collect_artifacts(client, run_id, item.path, depth + 1, max_depth) + node["children"] = _collect_artifacts( + client, run_id, item.path, depth + 1, max_depth + ) nodes.append(node) return nodes @@ -380,14 +408,20 @@ def download_artifact_file(tracking_uri, run_id, artifact_path, target_dir): mlflow.set_tracking_uri(tracking_uri) client = MlflowClient() os.makedirs(target_dir, exist_ok=True) - with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): - local_path = client.download_artifacts(run_id, artifact_path, dst_path=target_dir) + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr( + io.StringIO() + ): + local_path = client.download_artifacts( + run_id, artifact_path, dst_path=target_dir + ) if os.path.isdir(local_path): raise IsADirectoryError(f"{artifact_path} is a directory") return local_path -def render_metric_plot(run_id, metric_name, history, width, height, xaxis_mode, experiment_id, run_name): +def render_metric_plot( + run_id, metric_name, history, width, height, xaxis_mode, experiment_id, run_name +): experiment_id = str(experiment_id) if experiment_id else "-" run_name = str(run_name) if run_name else "" cleaned = _clean_metric_history(history) @@ -406,7 +440,9 @@ def render_metric_plot(run_id, metric_name, history, width, height, xaxis_mode, if xaxis_mode not in {"step", "timestamp"}: xaxis_mode = "step" - if xaxis_mode == "timestamp" and all(pt.get("timestamp") is not None for pt in cleaned): + if xaxis_mode == "timestamp" and all( + pt.get("timestamp") is not None for pt in cleaned + ): baseline = cleaned[0]["timestamp"] xs = [(pt["timestamp"] - baseline) / 1000.0 for pt in cleaned] x_label = "seconds (relative)" @@ -495,30 +531,38 @@ def getMainPageMLflow(mlflow_tracking_uri): out = [] version = vim.eval("get(g:, 'vim_mlflow_version', 'dev')") out.append(f"Vim-MLflow v{version}") - out.append("\" Press ? for help") + out.append('" Press ? for help') out.append("") out.append("") - vim.vars['vim_mlflow_artifact_lineinfo'] = {} - vim.vars['vim_mlflow_metric_lines'] = [] - vim.vars['vim_mlflow_section_headers'] = [] - if verifyTrackingUrl(mlflow_tracking_uri, timeout=float(vim.eval("g:vim_mlflow_timeout"))): + vim.vars["vim_mlflow_artifact_lineinfo"] = {} + vim.vars["vim_mlflow_metric_lines"] = [] + vim.vars["vim_mlflow_section_headers"] = [] + if verifyTrackingUrl( + mlflow_tracking_uri, timeout=float(vim.eval("g:vim_mlflow_timeout")) + ): text, exptids = getMLflowExpts(mlflow_tracking_uri) out.extend(text) out.append("") if vim.eval("s:current_exptid") == "": vim.command("let s:current_exptid='" + exptids[0] + "'") - text, runids = getRunsListForExpt(mlflow_tracking_uri, vim.eval("s:current_exptid")) + text, runids = getRunsListForExpt( + mlflow_tracking_uri, vim.eval("s:current_exptid") + ) out.extend(text) out.append("") if runids: if vim.eval("s:current_runid") == "": vim.command("let s:current_runid='" + runids[0] + "'") - elif len(vim.eval("s:current_runid"))==5: - fullrunid = [runid for runid in runids if runid[:5]==vim.eval("s:current_runid")][0] + elif len(vim.eval("s:current_runid")) == 5: + fullrunid = [ + runid + for runid in runids + if runid[:5] == vim.eval("s:current_runid") + ][0] vim.command("let s:current_runid='" + fullrunid + "'") section_order = vim.eval("g:vim_mlflow_section_order") if not section_order: - section_order = ['params', 'metrics', 'tags', 'artifacts'] + section_order = ["params", "metrics", "tags", "artifacts"] states = { "params": vim.eval("s:params_are_showing") == "1", "metrics": vim.eval("s:metrics_are_showing") == "1", @@ -532,14 +576,21 @@ def getMainPageMLflow(mlflow_tracking_uri): section_header_entries = [] metric_line_numbers = [] for section in section_order: - if section not in ('params', 'metrics', 'tags', 'artifacts'): + if section not in ("params", "metrics", "tags", "artifacts"): continue show = states.get(section, False) header_icon = open_icon if show else closed_icon header_line = len(out) + 1 - if section == 'params': - out.extend(getParamsListForRun(mlflow_tracking_uri, current_run, show=show, header_icon=header_icon)) - elif section == 'metrics': + if section == "params": + out.extend( + getParamsListForRun( + mlflow_tracking_uri, + current_run, + show=show, + header_icon=header_icon, + ) + ) + elif section == "metrics": metrics_output, offsets = getMetricsListForRun( mlflow_tracking_uri, current_run, @@ -548,19 +599,32 @@ def getMainPageMLflow(mlflow_tracking_uri): ) out.extend(metrics_output) if show: - metric_line_numbers.extend([header_line + offset for offset in offsets]) - elif section == 'tags': - out.extend(getTagsListForRun(mlflow_tracking_uri, current_run, show=show, header_icon=header_icon)) - elif section == 'artifacts': + metric_line_numbers.extend( + [header_line + offset for offset in offsets] + ) + elif section == "tags": + out.extend( + getTagsListForRun( + mlflow_tracking_uri, + current_run, + show=show, + header_icon=header_icon, + ) + ) + elif section == "artifacts": divider_char = vim.eval("g:vim_mlflow_icon_vdivider") or "-" max_depth = int(vim.eval("g:vim_mlflow_artifacts_max_depth")) mark_icon = closed_icon open_dir_icon = open_icon if show: - expanded_json = vim.eval("json_encode(get(g:, 'vim_mlflow_artifact_expanded', {}))") + expanded_json = vim.eval( + "json_encode(get(g:, 'vim_mlflow_artifact_expanded', {}))" + ) expanded = json.loads(expanded_json) client = MlflowClient(tracking_uri=mlflow_tracking_uri) - tree = _collect_artifacts(client, current_run, max_depth=max_depth) + tree = _collect_artifacts( + client, current_run, max_depth=max_depth + ) artifact_lines, artifact_info = _render_artifact_section( short_run, tree, @@ -579,26 +643,28 @@ def getMainPageMLflow(mlflow_tracking_uri): target_line = start_line + offset entry["line"] = target_line lineinfo_map[str(target_line)] = entry - vim.vars['vim_mlflow_artifact_lineinfo'] = lineinfo_map + vim.vars["vim_mlflow_artifact_lineinfo"] = lineinfo_map out.extend(artifact_lines) else: - vim.vars['vim_mlflow_artifact_lineinfo'] = {} + vim.vars["vim_mlflow_artifact_lineinfo"] = {} header = f"{header_icon} Artifacts in run #{short_run}:" out.extend([header, divider_char * 30, ""]) section_header_entries.append({"line": header_line, "section": section}) - vim.vars['vim_mlflow_section_headers'] = section_header_entries - vim.vars['vim_mlflow_metric_lines'] = metric_line_numbers + vim.vars["vim_mlflow_section_headers"] = section_header_entries + vim.vars["vim_mlflow_metric_lines"] = metric_line_numbers else: - vim.vars['vim_mlflow_artifact_lineinfo'] = {} - vim.vars['vim_mlflow_section_headers'] = [] - vim.vars['vim_mlflow_metric_lines'] = [] + vim.vars["vim_mlflow_artifact_lineinfo"] = {} + vim.vars["vim_mlflow_section_headers"] = [] + vim.vars["vim_mlflow_metric_lines"] = [] else: out.append("Could not connect to mlflow_tracking_uri") out.append(mlflow_tracking_uri) - out.append(f"within the g:vim_mlflow_timeout={float(vim.eval('g:vim_mlflow_timeout')):.2f}") + out.append( + f"within the g:vim_mlflow_timeout={float(vim.eval('g:vim_mlflow_timeout')):.2f}" + ) out.append("Are you sure that's the right URI?") - vim.vars['vim_mlflow_section_headers'] = [] - vim.vars['vim_mlflow_metric_lines'] = [] + vim.vars["vim_mlflow_section_headers"] = [] + vim.vars["vim_mlflow_metric_lines"] = [] return out @@ -615,9 +681,10 @@ def verifyTrackingUrl(url, timeout=1.0): raise RuntimeError("Incorrect and possibly insecure protocol in url") try: - if urlopen(url, timeout=timeout).getcode()==200: + if urlopen(url, timeout=timeout).getcode() == 200: out = True - except: + except Exception as exc: # fallback if you tr + print("Unexpected failure: %s", exc) out = False return out diff --git a/python/vim_mlflow_runs.py b/python/vim_mlflow_runs.py index ceb9e85..4b41c38 100644 --- a/python/vim_mlflow_runs.py +++ b/python/vim_mlflow_runs.py @@ -1,15 +1,11 @@ -from datetime import datetime import re -from urllib.request import urlopen, Request +from urllib.request import urlopen -import mlflow from mlflow.entities import ViewType from mlflow.tracking import MlflowClient import pandas as pd import vim -import warnings -#warnings.simplefilter(action='ignore', category=FutureWarning) from vim_mlflow_utils import format_run_duration @@ -33,7 +29,6 @@ def getRunsPageMLflow(mlflow_tracking_uri): out.append("No marked runs.") return out - if verifyTrackingUrl(mlflow_tracking_uri, timeout=float(vim.eval("g:vim_mlflow_timeout"))): # Find full runids for the short-runids in s:markruns_list @@ -42,7 +37,10 @@ def getRunsPageMLflow(mlflow_tracking_uri): view_type = VIEWTYPE_MAP.get(view_idx, ViewType.ACTIVE_ONLY) runinfos = [] if vim.eval("s:current_exptid") != "": - runinfos = client.search_runs([str(vim.eval("s:current_exptid"))], run_view_type=view_type) + runinfos = client.search_runs( + [str(vim.eval("s:current_exptid"))], + run_view_type=view_type + ) fullmarkrunids = [] for run in runinfos: if run.info.run_id[:5] in vim.eval("s:markruns_list"): @@ -60,11 +58,11 @@ def getRunsPageMLflow(mlflow_tracking_uri): for runid in fullmarkrunids: mldict = client.get_run(runid).to_dictionary() rundict = mldict["info"] - if vim.eval("s:runs_tags_are_showing")=='1': + if vim.eval("s:runs_tags_are_showing") == '1': rundict.update(mldict["data"]["tags"]) - if vim.eval("s:runs_params_are_showing")=='1': + if vim.eval("s:runs_params_are_showing") == '1': rundict.update(mldict["data"]["params"]) - if vim.eval("s:runs_metrics_are_showing")=='1': + if vim.eval("s:runs_metrics_are_showing") == '1': rundict.update(mldict["data"]["metrics"]) runsforpd.append(rundict) runsdf = pd.DataFrame(runsforpd) @@ -86,8 +84,11 @@ def getRunsPageMLflow(mlflow_tracking_uri): "mlflow.project.env": "env", } ) - runsdf = runsdf.drop(columns=["run_uuid", "user_id", "mlflow.gitRepoURL"], errors="ignore") # duplicated cols - runsdf = runsdf.drop(columns=["mlflow.log-model.history", "artifact_uri"], errors="ignore") # huge columns + # duplicated cols: + runsdf = runsdf.drop(columns=["run_uuid", "user_id", "mlflow.gitRepoURL"], errors="ignore") + # huge columns: + runsdf = runsdf.drop(columns=["mlflow.log-model.history", "artifact_uri"], errors="ignore") + if "start_time" in runsdf.columns: runsdf.insert(0, "start_time", runsdf.pop("start_time")) if "end_time" in runsdf.columns: @@ -96,7 +97,7 @@ def getRunsPageMLflow(mlflow_tracking_uri): runsdf.insert(0, "expt_id", runsdf.pop("expt_id")) # Shorten specified columns - runsdf["run_id"] = runsdf["run_id"].apply(lambda x: x[:5]) # run_id is always in run results! + runsdf["run_id"] = runsdf["run_id"].apply(lambda x: x[:5]) # run_id always in run results! if "source.name" in runsdf.columns: runsdf["source.name"] = runsdf["source.name"].apply(lambda x: x.split("/")[-1]) if "git.commit" in runsdf.columns: @@ -137,14 +138,18 @@ def getRunsPageMLflow(mlflow_tracking_uri): runsdf.columns = colnames # Hide (remove) specified columns - cols2keep = [int(col) for col in range(runsdf.shape[1]) if str(col) not in vim.eval("s:hiddencols_list")] + cols2keep = [ + int(col) + for col in range(runsdf.shape[1]) + if str(col) not in vim.eval("s:hiddencols_list") + ] runsdf = runsdf.iloc[:, cols2keep] # Output dataframe lines = runsdf.to_string(index=False, justify="center").split('\n') for i, line in enumerate(lines): out.append(line) - if i==0: + if i == 0: out.append(make_headerline(lines, vim.eval("g:vim_mlflow_icon_vdivider"))) # Retaining these lines while still debugging occasional column-hiding bug: @@ -166,7 +171,8 @@ def make_headerline(lines, divchar): a = re.sub('[^ ]', divchar, lines[0]) for i in range(len(lines)-1): b = re.sub('[^ ]', divchar, lines[i+1]) - a = ''.join(map(lambda x: divchar if x[0]==divchar or x[1]==divchar else ' ', zip(a, b))) + a = ''.join(map(lambda x: divchar if x[0] == divchar or x[1] == divchar else ' ', + zip(a, b))) return a @@ -183,9 +189,10 @@ def verifyTrackingUrl(url, timeout=1.0): raise RuntimeError("Incorrect and possibly insecure protocol in url") try: - if urlopen(url, timeout=timeout).getcode()==200: + if urlopen(url, timeout=timeout).getcode() == 200: out = True - except: + except Exception as exc: # fallback if you tr + print("Unexpected failure: %s", exc) out = False return out diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..11d225c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,22 @@ +# Test Suite + +This directory contains unit tests for both the Python helper modules and the +Vimscript plugin logic. + +## Layout + +- `tests/python/`: Pytest-based unit tests that exercise `python/vim_mlflow*.py`. +- `tests/vim/`: Headless Vimscript assertions run through `vim` or `nvim`. +- `tests/fixtures/`: Lightweight helpers that build fake MLflow objects for tests. + +## Running Locally + +```bash +# Python tests +pytest tests/python + +# Vimscript tests (requires vim or nvim) +nvim --headless -u NONE -i NONE -c "source tests/vim/run_tests.vim" -c "qa" +# or +vim -Es -u NONE -i NONE -c "source tests/vim/run_tests.vim" -c "qa" +``` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/mlflow.py b/tests/fixtures/mlflow.py new file mode 100644 index 0000000..1f25fcb --- /dev/null +++ b/tests/fixtures/mlflow.py @@ -0,0 +1,65 @@ +"""Lightweight helpers to build fake MLflow objects for tests.""" + +from types import SimpleNamespace +from typing import Dict, Iterable, Optional + + +def make_experiment(exp_id: int, name: str, lifecycle: str = "active") -> SimpleNamespace: + """Return a minimal stand-in for mlflow.entities.Experiment.""" + return SimpleNamespace( + experiment_id=str(exp_id), + name=name, + lifecycle_stage=lifecycle, + ) + + +def make_run( + run_id: str, + start_time_ms: Optional[int], + end_time_ms: Optional[int], + *, + status: str = "FINISHED", + lifecycle_stage: str = "active", + user_id: str = "user", + run_name: str = "", + tags: Optional[Dict[str, str]] = None, + metrics: Optional[Dict[str, float]] = None, +) -> SimpleNamespace: + """Return a minimal stand-in for mlflow.entities.Run.""" + merged_tags = {"mlflow.user": user_id} + if run_name: + merged_tags["mlflow.runName"] = run_name + if tags: + merged_tags.update(tags) + + run_metrics = metrics or {} + + info = SimpleNamespace( + run_id=run_id, + start_time=start_time_ms, + end_time=end_time_ms, + status=status, + lifecycle_stage=lifecycle_stage, + user_id=user_id, + run_name=run_name, + ) + data = SimpleNamespace( + tags=merged_tags, + metrics=run_metrics, + ) + return SimpleNamespace(info=info, data=data) + + +def make_metric_history(values: Iterable[float], *, start_step: int = 0) -> list: + """Build a sequence that mimics mlflow.entities.Metric.""" + history = [] + timestamp = 1_700_000_000_000 + for idx, value in enumerate(values, start=start_step): + history.append( + SimpleNamespace( + step=idx, + timestamp=timestamp + idx * 1000, + value=value, + ) + ) + return history diff --git a/tests/python/__init__.py b/tests/python/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/python/conftest.py b/tests/python/conftest.py new file mode 100644 index 0000000..0ea068e --- /dev/null +++ b/tests/python/conftest.py @@ -0,0 +1,123 @@ +import ast +import importlib +import re +import sys +import types +from pathlib import Path +from typing import Dict, List + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +PYTHON_SRC = ROOT / "python" +if str(PYTHON_SRC) not in sys.path: + sys.path.insert(0, str(PYTHON_SRC)) + + +class _FakeVim: + """A very small subset of the `vim` module needed for unit tests.""" + + def __init__(self) -> None: + self.vars: Dict[str, object] = {} + self.g: Dict[str, object] = {} + self.s: Dict[str, object] = {} + + def eval(self, expression: str): + if expression.startswith("g:"): + return self.g.get(expression[2:], 0) + if expression.startswith("s:"): + return self.s.get(expression[2:], 0) + + def _replace(match: re.Match) -> str: + scope = match.group(1) + name = match.group(2) + mapping = "_g" if scope == "g" else "_s" + return f"{mapping}['{name}']" + + translated = re.sub(r"([gs]):([A-Za-z0-9_]+)", _replace, expression) + return eval(translated, {"min": min}, {"_g": self.g, "_s": self.s}) # noqa: S307 + + def command(self, command: str) -> None: + if not command.startswith("let "): + raise NotImplementedError("Only :let commands are supported in tests.") + rest = command[4:].strip() + name, raw_value = rest.split("=", 1) + name = name.strip() + raw_value = raw_value.strip() + value = self._parse_value(raw_value) + if name.startswith("g:"): + self.g[name[2:]] = value + elif name.startswith("s:"): + self.s[name[2:]] = value + else: + raise NotImplementedError("Only g: and s: scopes are supported.") + + @staticmethod + def _parse_value(raw: str): + try: + value = ast.literal_eval(raw) + except (ValueError, SyntaxError): + return raw + if isinstance(value, str) and value.isdigit(): + return int(value) + return value + + +@pytest.fixture +def vim_mlflow_env(monkeypatch): + """Prepare stubbed mlflow and vim modules, then import vim_mlflow.""" + + fake_vim = _FakeVim() + vim_module = types.ModuleType("vim") + vim_module.vars = fake_vim.vars + vim_module.eval = fake_vim.eval + vim_module.command = fake_vim.command + monkeypatch.setitem(sys.modules, "vim", vim_module) + + fake_state = { + "experiments": [], + "runs": [], + "runs_by_id": {}, + "metric_history": {}, + } + + class FakeMlflowClient: + def __init__(self, tracking_uri: str): + self.tracking_uri = tracking_uri + + def search_experiments(self, view_type): + return list(fake_state["experiments"]) + + def search_runs(self, experiment_ids: List[str], run_view_type): + return list(fake_state["runs"]) + + def get_run(self, run_id: str): + return fake_state["runs_by_id"][run_id] + + def get_metric_history(self, run_id: str, key: str): + return list(fake_state["metric_history"].get((run_id, key), [])) + + class FakeViewType: + ACTIVE_ONLY = object() + DELETED_ONLY = object() + ALL = object() + + mlflow_module = types.ModuleType("mlflow") + entities_module = types.ModuleType("mlflow.entities") + tracking_module = types.ModuleType("mlflow.tracking") + + entities_module.ViewType = FakeViewType + tracking_module.MlflowClient = FakeMlflowClient + + monkeypatch.setitem(sys.modules, "mlflow", mlflow_module) + monkeypatch.setitem(sys.modules, "mlflow.entities", entities_module) + monkeypatch.setitem(sys.modules, "mlflow.tracking", tracking_module) + + if "vim_mlflow" in sys.modules: + del sys.modules["vim_mlflow"] + import vim_mlflow # noqa: F401 + + module = importlib.import_module("vim_mlflow") + + return module, fake_state, fake_vim diff --git a/tests/python/test_vim_mlflow.py b/tests/python/test_vim_mlflow.py new file mode 100644 index 0000000..bdbf686 --- /dev/null +++ b/tests/python/test_vim_mlflow.py @@ -0,0 +1,103 @@ +from tests.fixtures.mlflow import make_experiment, make_metric_history, make_run +from vim_mlflow_utils import format_run_duration + + +def test_format_run_duration_handles_edge_cases(): + assert format_run_duration(None) == "-" + assert format_run_duration(float("inf")) == "infh" + assert format_run_duration(float("nan")) == "-" + assert format_run_duration(-1) == "-" + assert format_run_duration(42) == "42s" + assert format_run_duration(600) == "10m" + assert format_run_duration(3 * 3600) == "3.0h" + + +def test_get_mlflow_experiments_formats_output(vim_mlflow_env): + module, state, fake_vim = vim_mlflow_env + + state["experiments"] = [ + make_experiment(1, "Alpha"), + make_experiment(2, "Beta"), + ] + + fake_vim.g.update( + { + "vim_mlflow_viewtype": 1, + "vim_mlflow_expts_length": 8, + "vim_mlflow_show_scrollicons": 1, + "vim_mlflow_icon_vdivider": "|", + "vim_mlflow_icon_scrollstop": "X", + "vim_mlflow_icon_scrollup": "^", + "vim_mlflow_icon_scrolldown": "v", + } + ) + fake_vim.s.update( + { + "expts_first_idx": 0, + } + ) + + lines, experiment_ids = module.getMLflowExpts("http://example.com") + + assert lines[0] == "2 Active Experiments:" + assert lines[1] == "X" + "|" * 30 + assert lines[2] == "#2: Beta" + assert lines[3] == "#1: Alpha" + assert lines[-1] == "X" + assert experiment_ids == ["2", "1"] + + +def test_get_runs_list_formats_columns(vim_mlflow_env): + module, state, fake_vim = vim_mlflow_env + + start_time = 1_700_000_000_000 + run_1 = make_run( + "run-aaa111", + start_time_ms=start_time + 5_000, + end_time_ms=start_time + 65_000, + user_id="alice", + run_name="Warmup", + ) + run_2 = make_run( + "run-bbb222", + start_time_ms=start_time, + end_time_ms=None, + status="RUNNING", + user_id="bob", + run_name="Long job", + ) + + state["runs"] = [run_1, run_2] + state["runs_by_id"] = {run_1.info.run_id: run_1, run_2.info.run_id: run_2} + state["metric_history"][(run_1.info.run_id, "loss")] = make_metric_history([0.4, 0.2]) + + fake_vim.g.update( + { + "vim_mlflow_viewtype": 1, + "vim_mlflow_runs_length": 8, + "vim_mlflow_show_scrollicons": 1, + "vim_mlflow_icon_vdivider": "|", + "vim_mlflow_icon_scrollstop": "X", + "vim_mlflow_icon_scrollup": "^", + "vim_mlflow_icon_scrolldown": "v", + "vim_mlflow_icon_markrun": ">", + } + ) + fake_vim.s.update( + { + "runs_first_idx": 0, + "markruns_list": [run_1.info.run_id[:5]], + } + ) + + lines, run_ids = module.getRunsListForExpt("http://example.com", "99") + + assert lines[0] == "2 Active Runs in expt #99:" + assert lines[1] == "X" + "|" * 30 + assert lines[2].startswith(f">{'#'}{run_1.info.run_id[:5]}") + assert "alice" in lines[2] + assert "Warmup" in lines[2] + assert lines[3].startswith(f" #{run_2.info.run_id[:5]}") + assert "RUNNING" in lines[3] + assert lines[-1] == "X" + assert run_ids == [run_1.info.run_id, run_2.info.run_id] diff --git a/tests/vim/run_tests.vim b/tests/vim/run_tests.vim new file mode 100644 index 0000000..5f38231 --- /dev/null +++ b/tests/vim/run_tests.vim @@ -0,0 +1,24 @@ +let g:vim_mlflow_skip_python_check = 1 +let v:errors = [] + +source plugin/vim-mlflow.vim + +call SetDefaults() +call assert_equal('http://localhost:5000', g:mlflow_tracking_uri) +call assert_equal(8, g:vim_mlflow_expts_length) +call assert_equal('-', g:vim_mlflow_icon_vdivider) + +let g:vim_mlflow_icon_useunicode = 1 +unlet g:vim_mlflow_icon_vdivider +call SetDefaults() +call assert_equal(nr2char(9472), g:vim_mlflow_icon_vdivider) + +let g:vim_mlflow_section_order = 'invalid' +call SetDefaults() +call assert_equal(['params', 'metrics', 'tags', 'artifacts'], g:vim_mlflow_section_order) + +if len(v:errors) > 0 + echoerr join(v:errors, "\n") + " echom string(v:errors) + cquit 1 +endif