diff --git a/.github/workflows/codestyle-python-flake8.yml b/.github/workflows/codestyle-python-flake8.yml index 86f6ae0..f9a2dd8 100644 --- a/.github/workflows/codestyle-python-flake8.yml +++ b/.github/workflows/codestyle-python-flake8.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/setup-python@v4 with: python-version: "3.11" - - name: Install Flake8 - run: pip install flake8 + - name: Install dependencies + run: pip install -r dev-requirements.txt - name: Run Flake8 run: flake8 python tests/python tests/fixtures diff --git a/.github/workflows/unittests-python.yml b/.github/workflows/unittests-python.yml index 795746f..069f077 100644 --- a/.github/workflows/unittests-python.yml +++ b/.github/workflows/unittests-python.yml @@ -13,6 +13,6 @@ jobs: with: python-version: "3.11" - name: Install dependencies - run: pip install pytest + run: pip install -r dev-requirements.txt - name: Run pytest run: pytest tests/python diff --git a/.github/workflows/unittests-vim.yml b/.github/workflows/unittests-vim.yml index ed60658..f3bf87b 100644 --- a/.github/workflows/unittests-vim.yml +++ b/.github/workflows/unittests-vim.yml @@ -12,10 +12,16 @@ jobs: - name: Install Vim and Neovim run: | sudo apt-get update - sudo apt-get install -y vim neovim + sudo apt-get install -y vim neovim python3-venv + - name: Install Python dependencies + run: | + python3 -m venv .venv + .venv/bin/pip install -r dev-requirements.txt - name: Run Vimscript tests (Neovim then Vim) run: | set +e + export VIRTUAL_ENV="$PWD/.venv" + export PATH="$VIRTUAL_ENV/bin:$PATH" rm -f nvim-test.log vim-test.log echo diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..220033e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,41 @@ +# Changelog + +## [1.1.0] - 2026-06-20 + +### Added +- Added a Python session cache for experiments, run summaries, run details, + metric histories, and artifact trees to avoid re-querying MLflow on every + redraw. +- Added `g:vim_mlflow_runs_cache_mode` with `selected_expt` and `all_expts` + modes for controlling run-summary cache scope. +- Added `g:vim_mlflow_plotpane_pct` to control the plot-versus-artifact height + split when both panes are visible. +- Added `pandas` as an explicit dependency and expanded automated coverage for + cache behavior, viewer layout, and artifact rendering. +- Added pretty-printed display for valid `.json` artifacts in the scratch + viewer buffer, with raw-text fallback for invalid JSON. + +### Changed +- Refactored the main `__MLflow__` sidebar and `__MLflowRuns__` comparison view + to render from cached in-memory data instead of repeatedly rebuilding from + fresh MLflow API calls. +- Changed the viewer layout to a shared opposite-side viewer column with plots + stacked above artifacts. +- Changed the runs-window undo-layout mapping from `` to `u`, with a + confirmation prompt before clearing all column layout tweaks. +- Made vim-mlflow-managed MLflow, runs, plot, and artifact buffers explicitly + use `nowrap` and `noswapfile` instead of depending on editor defaults. +- Updated README and Vim help documentation for caching, viewer layout, JSON + display, and new configuration settings. + +### Fixed +- Fixed repeated redraws so section toggles, artifact directory expansion, and + experiment/run selection preserve the visible scroll position much more + reliably. +- Fixed artifact buffer identity so similarly named artifacts from different + runs no longer collide. +- Fixed artifact and plot viewer placement so the main `__MLflow__` buffer stays + pinned while viewer panes are reused predictably. +- Fixed noisy artifact-open status messages that previously triggered hit-enter + prompts in Vim. +- Fixed Neovim wrapping issues that broke table and artifact display layouts. diff --git a/README.md b/README.md index 3e0669b..1f93e9f 100644 --- a/README.md +++ b/README.md @@ -4,24 +4,23 @@ [![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.2-blue.svg) +![version](https://img.shields.io/badge/version-1.1.0-blue.svg) -`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 the -GenAI traces/etc in MLflow3.x currently; feedback/demand can guide such future -steps). +`Vim‑mlflow` is a 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 the GenAI traces/etc in +MLflow3.x currently). -[![example vim-mlflow screenshot](doc/demo_1.0.0_light.gif)](doc/demo_1.0.0_light.gif) +[![example vim-mlflow screenshot](doc/demo_1.1.0_light.gif)](doc/demo_1.1.0_light.gif) ## TL;DR -* Must run Vim/NVim in a python environment with `mlflow` installed. +* Must run Vim/NVim in a python environment with `mlflow` and `pandas` 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 @@ -46,8 +45,8 @@ steps). - `python3 -m venv .venv` - `source .venv/bin/activate # syntax for linux/mac` -#### 3. Install the `mlflow` Python package (and also `pynvim` for Nvim): -- `pip install mlflow` (in both Vim and NVim) +#### 3. Install the required Python packages (`mlflow`, `pandas`, and `pynvim` for Nvim): +- `pip install mlflow pandas` (in both Vim and NVim) - In NVim you also need this package in your env to support the python: `pip install pynvim` @@ -67,13 +66,12 @@ steps). - 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. +- vim-mlflow manages `nowrap` itself for its scratch buffers, so no extra NVim + line-wrap setting should normally be needed. ## Usage -* Ensure you're in your python environment with MLflow before starting Vim. +* Ensure you're in your python environment with `mlflow` and `pandas` 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()`. @@ -91,7 +89,7 @@ steps). ## Configuration 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: +But a typical small set of vim-mlflow config variables that one would usually set is: ```vim " Vim-mlflow settings let g:mlflow_tracking_uri = "http://localhost:5000" " running locally or via ssh-tunnel @@ -99,6 +97,8 @@ let g:vim_mlflow_icon_useunicode = 1 " default 0 value uses ascii chars instead let g:vim_mlflow_width = get(g:, 'vim_mlflow_width', 50) " width of mlflow window 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 +let g:vim_mlflow_plotpane_pct = 66 " plot pane height % when artifact pane is also open +let g:vim_mlflow_runs_cache_mode = 'selected_expt' " (or 'all_expts') how much to query at once ``` By default Vim-mlflow uses standard color groups like "Comment" and "Statement" @@ -107,47 +107,18 @@ 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. +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 -- 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. -- 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 +Contributions are welcome. This project is maintained on a best-effort basis, +so bug fixes, documentation improvements, and small, well-scoped enhancements +are the most likely to be accepted. Please read [CONTRIBUTING.md](CONTRIBUTING.md) +before opening issues or pull requests. + 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 @@ -161,41 +132,16 @@ CI workflows): - `make codestyle` lints the Vimscript in `plugin/` with `vint` and the Python in `python/` with `flake8`. -#### Related repos you may find useful to populate an MLflow installation with test data -The following tools are useful in their own right to get an MLflow instance up -and running quickly and to get some modeling up and running quickly. -But in this case you may find them convenient to populate test contents into -a temporary MLflow tracking server for dev/test purposes - they created 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 - - -## 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 (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 | - ## Making the animated screen-shot gif +Just so I remember for next time: * 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` * Manually conduct the usage sequence to record, which gets saved to file; ctrl-D to exit/end when done. * Install [agg](https://github.com/asciinema/agg) (`cargo install --git https://github.com/asciinema/agg`) -* `~/.cargo/bin/agg --speed 2 demo.cast demo.gif # convert the asciinema cast to animated gif` +* `~/.cargo/bin/agg --font-size 24 --speed 2 --theme github-light --font-family "SauceCodePro Nerd Font" demo.cast demo.gif # convert the asciinema cast to animated gif` + (the "SauceCodePro Nerd Font" is what my system uses but that arg can be left out) ## Acknowledgements diff --git a/VERSION b/VERSION index 6d7de6e..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.2 +1.1.0 diff --git a/dev-requirements.txt b/dev-requirements.txt index e5adeec..9b8d949 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,5 @@ mlflow +pandas pynvim pytest flake8 diff --git a/doc/configuration_params.md b/doc/configuration_params.md index 9f99d9c..0d6606a 100644 --- a/doc/configuration_params.md +++ b/doc/configuration_params.md @@ -5,13 +5,14 @@ Full list of vim-mlflow config variables that may be of interest to set in resou | `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_runs_buffername` | Buffername of the MLflowRuns side pane (default is `__MLflowRuns__`)| +| `g:vim_mlflow_vside` | Which side to open the MLflow pane on: 'left' or 'right' (default is `left`)| | `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_height` | Height of the MLflowRuns window in rows (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_runs_cache_mode` | Cache run summaries for `'selected_expt'` (default) or eagerly for `'all_expts'`| | `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)| @@ -37,7 +38,7 @@ Full list of vim-mlflow config variables that may be of interest to set in resou | `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_plotpane_pct` | Plot pane height percent when both plot and artifact panes are visible (default `66`)| | `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_1.1.0_dark.gif b/doc/demo_1.1.0_dark.gif new file mode 100644 index 0000000..6fed9d2 Binary files /dev/null and b/doc/demo_1.1.0_dark.gif differ diff --git a/doc/demo_1.1.0_light.gif b/doc/demo_1.1.0_light.gif new file mode 100644 index 0000000..ab457d8 Binary files /dev/null and b/doc/demo_1.1.0_light.gif differ diff --git a/doc/description.txt b/doc/description.txt index ef87e6d..9ed84bf 100644 --- a/doc/description.txt +++ b/doc/description.txt @@ -1,16 +1,15 @@ -`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 the -GenAI traces/etc in MLflow3.x currently; feedback/demand can guide such future -steps). +`Vim‑mlflow` is a 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 the GenAI traces/etc in +MLflow3.x currently). ## TL;DR -* Must run Vim/NVim in a python environment with `mlflow` installed. +* Must run Vim/NVim in a python environment with `mlflow` and `pandas` 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 @@ -23,7 +22,7 @@ steps). ## Usage -* Ensure you're in your python environment with MLflow before starting Vim. +* Ensure you're in your python environment with `mlflow` and `pandas` 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()`. diff --git a/doc/installdetails.txt b/doc/installdetails.txt index 19fe84f..08bd66d 100644 --- a/doc/installdetails.txt +++ b/doc/installdetails.txt @@ -11,8 +11,8 @@ - `python3 -m venv .venv` - `source .venv/bin/activate # syntax for linux/mac` -#### 3. Install the `mlflow` Python package (and also `pynvim` for Nvim): -- `pip install mlflow` (in both Vim and NVim) +#### 3. Install the required Python packages (`mlflow`, `pandas`, and `pynvim` for Nvim): +- `pip install mlflow pandas` (in both Vim and NVim) - In NVim you also need this package in your env to support the python: `pip install pynvim` @@ -32,6 +32,5 @@ - 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. +- vim-mlflow manages `nowrap` itself for its scratch buffers, so no extra NVim + line-wrap setting should normally be needed. diff --git a/doc/troubleshooting.md b/doc/troubleshooting.md new file mode 100644 index 0000000..626a103 --- /dev/null +++ b/doc/troubleshooting.md @@ -0,0 +1,24 @@ +# Troubleshooting + +- The sidebar now caches MLflow data in the embedded Python session, but the + first load and explicit `r` refreshes still query MLflow synchronously. + Performance is best when Vim runs close to the tracking server. On slower + links, increasing `g:vim_mlflow_timeout` may help; for especially large + tracking servers, keep `g:vim_mlflow_runs_cache_mode = 'selected_expt'` so + run summaries are loaded lazily per experiment instead of eagerly across all + experiments. +- Unicode icons require a font that includes box-drawing characters. Set + `g:vim_mlflow_icon_useunicode = 0` if glyphs look broken, and note that there + are config vars to change individual icon characters. +- Text artifacts (`*.txt`, `*.json`, `*.yaml`, `MLmodel`) open directly in the + plugin. `.json` artifacts are pretty-printed in the scratch viewer when they + contain valid JSON. 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 both `mlflow` and `pandas` are importable in Vim's + Python environment (`:py3 import mlflow, pandas`). In Neovim, also ensure + `pynvim` is installed. +- Neovim enables line wrapping by default in many setups, but vim-mlflow now + sets `nowrap` on its managed buffers. If wrapping still appears in a plugin + buffer, check for custom autocommands or filetype hooks overriding window- + local options. diff --git a/doc/vim-mlflow.txt b/doc/vim-mlflow.txt index 98f74fc..ed61c57 100644 --- a/doc/vim-mlflow.txt +++ b/doc/vim-mlflow.txt @@ -1,4 +1,4 @@ -*vim-mlflow.txt* For Vim version 8.2+ and Neovim v0.11.5+ Last change: 2025 Dec 16 +*vim-mlflow.txt* For Vim version 8.2+ and Neovim v0.11.5+ Last change: 2026 Jun 20 *vim-mlflow* vim-mlflow is a Vim/Neovim plugin for browsing MLflow experiments, runs, @@ -34,7 +34,8 @@ limited. ============================================================================== 2. Requirements & install *vim-mlflow-install* -vim-mlflow needs a Python-enabled Vim or Neovim and the MLflow Python package. +vim-mlflow needs a Python-enabled Vim or Neovim plus the `mlflow` and `pandas` +Python packages. Minimum supported versions - Vim 8.2 compiled with `+python3` @@ -43,8 +44,8 @@ Minimum supported versions 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` + 2. Install MLflow, pandas, and pynvim for Neovim users: + `pip install mlflow pandas` `pip install pynvim` Plugin install @@ -65,8 +66,9 @@ 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. +Make sure your Vim session runs inside a Python environment where `mlflow`, +`pandas`, and `pynvim` (if needed) are installed; otherwise the plugin cannot +connect. ============================================================================== 4. Windows & navigation *vim-mlflow-windows* @@ -99,7 +101,8 @@ Default mappings in the sidebar buffer (`__MLflow__`): `` 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` Refresh cached MLflow data for the current view and reset expanded + artifacts. `R` Open or refresh the marked runs window (`__MLflowRuns__`). `` Toggle the Parameters section visibility. `` Toggle the Metrics section visibility. @@ -116,7 +119,8 @@ Default mappings in the runs window (`__MLflowRuns__`): `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). + `u` Undo all column layout tweaks (collapse/hide/move) after + confirmation. `` Include or omit run parameters in the table. `` Include or omit run metrics in the table. `` Include or omit run tags in the table. @@ -137,7 +141,11 @@ Only `g:mlflow_tracking_uri` is required. Common options: `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_plotpane_pct` Height percent for the plot pane when both plot + and artifact panes are visible (default 66). `g:vim_mlflow_plot_reuse_buffer` Reuse a single plot buffer (default 1). + `g:vim_mlflow_runs_cache_mode` Run-summary cache scope: `'selected_expt'` + (default) or `'all_expts'`. `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 @@ -150,9 +158,12 @@ 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. + vim-mlflow caches MLflow data inside the embedded Python session, but the + first load and explicit refreshes still query MLflow synchronously. + Performance is best when Vim runs close to the tracking server. Increase + `g:vim_mlflow_timeout` if connections are flaky, and keep + `g:vim_mlflow_runs_cache_mode = 'selected_expt'` for large tracking servers so + run summaries are loaded lazily per experiment. Unicode glyph issues~ Set `let g:vim_mlflow_icon_useunicode = 0` to fall back to ASCII characters if @@ -162,15 +173,17 @@ Unicode glyph issues~ 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. + - From Vim run `:py3 import mlflow, pandas` to verify both are importable. Neovim layout wrapping~ - Neovim enables line wrapping by default. Add `setlocal nowrap` in your config - if columns in vim-mlflow buffers appear misaligned. + vim-mlflow now sets `nowrap` on its managed buffers. If columns still appear + misaligned in Neovim, check for custom autocommands or filetype hooks that may + be overriding window-local options. Artifacts~ - Text artifacts (`*.txt`, `*.json`, `*.yaml`, `MLmodel`) open directly. Binary - artifacts are listed but not opened. + Text artifacts (`*.txt`, `*.json`, `*.yaml`, `MLmodel`) open directly. Valid + `.json` artifacts are pretty-printed in the scratch viewer for readability. + Binary artifacts are listed but not opened. ============================================================================== 8. Further reading *vim-mlflow-links* diff --git a/plugin/vim-mlflow.vim b/plugin/vim-mlflow.vim index 84f4c16..9b9e460 100644 --- a/plugin/vim-mlflow.vim +++ b/plugin/vim-mlflow.vim @@ -68,6 +68,8 @@ function! SetDefaults() let g:vim_mlflow_plot_height = get(g:, 'vim_mlflow_plot_height', 25) let g:vim_mlflow_plot_width = get(g:, 'vim_mlflow_plot_width', 70) let g:vim_mlflow_plot_xaxis = get(g:, 'vim_mlflow_plot_xaxis', 'step') + let g:vim_mlflow_plotpane_pct = str2nr(get(g:, 'vim_mlflow_plotpane_pct', 66)) + let g:vim_mlflow_plotpane_pct = max([20, min([80, g:vim_mlflow_plotpane_pct])]) let g:vim_mlflow_plot_reuse_buffer = get(g:, 'vim_mlflow_plot_reuse_buffer', 1) let g:vim_mlflow_color_plot_title = get(g:, 'vim_mlflow_color_plot_title', 'Statement') let g:vim_mlflow_color_plot_axes = get(g:, 'vim_mlflow_color_plot_axes', 'vimParenSep') @@ -75,6 +77,10 @@ function! SetDefaults() let g:vim_mlflow_color_between_plotpts = get(g:, 'vim_mlflow_color_between_plotpts', 'Comment') 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_runs_cache_mode = get(g:, 'vim_mlflow_runs_cache_mode', 'selected_expt') + if index(['selected_expt', 'all_expts'], g:vim_mlflow_runs_cache_mode) ==# -1 + let g:vim_mlflow_runs_cache_mode = 'selected_expt' + endif let g:vim_mlflow_section_order = get(g:, 'vim_mlflow_section_order', ['params', 'metrics', 'tags', 'artifacts']) if type(g:vim_mlflow_section_order) !=# type([]) let g:vim_mlflow_section_order = ['params', 'metrics', 'tags', 'artifacts'] @@ -105,22 +111,204 @@ function! s:GetPlotBufferName() endfunction +" Return the split command for the shared viewer column opposite the sidebar. +function! s:GetViewerSplitCmd() + return g:vim_mlflow_vside ==# 'left' ? 'vert botright' : 'vert topleft' +endfunction + + +" Match managed viewer buffer names for plot and artifact panes. +function! s:GetManagedViewerPattern(kind) + if a:kind ==# 'plot' + return '^__MLflowMetricPlot' + endif + return '^artifact://' +endfunction + + +" Check whether a window id still points at a live window. +function! s:IsLiveWindow(winid) + return a:winid > 0 && win_id2win(a:winid) !=# 0 +endfunction + + +" Check whether a window id points at the main MLflow sidebar. +function! s:IsMainWindow(winid) + return exists('s:mlflow_winid') && a:winid ==# s:mlflow_winid +endfunction + + +" Track the current plot and artifact viewer window ids. +function! s:GetViewerWinid(kind) + if a:kind ==# 'plot' + return get(s:, 'plot_winid', -1) + endif + return get(s:, 'artifact_winid', -1) +endfunction + + +" Persist the plot or artifact viewer window id. +function! s:SetViewerWinid(kind, winid) + if a:kind ==# 'plot' + let s:plot_winid = a:winid + else + let s:artifact_winid = a:winid + endif +endfunction + + +function! s:GetSiblingViewerWinid(kind) + if a:kind ==# 'plot' + return get(s:, 'artifact_winid', -1) + endif + return get(s:, 'plot_winid', -1) +endfunction + + +" Detect whether a window currently hosts the requested managed viewer type. +function! s:IsManagedViewerWindow(kind, winid) + if ! s:IsLiveWindow(a:winid) || s:IsMainWindow(a:winid) + return 0 + endif + let l:bufname = bufname(winbufnr(win_id2win(a:winid))) + return l:bufname =~# s:GetManagedViewerPattern(a:kind) +endfunction + + +" Detect whether a window can serve as the shared viewer column. +function! s:IsReusableViewerColumnWindow(winid) + if ! s:IsLiveWindow(a:winid) || s:IsMainWindow(a:winid) + return 0 + endif + let l:bufname = bufname(winbufnr(win_id2win(a:winid))) + if empty(l:bufname) + return 1 + endif + if l:bufname =~# s:GetManagedViewerPattern('plot') || l:bufname =~# s:GetManagedViewerPattern('artifact') + return 1 + endif + return getbufvar(winbufnr(win_id2win(a:winid)), '&modified') ==# 0 && getbufvar(winbufnr(win_id2win(a:winid)), '&buftype') ==# 'nofile' +endfunction + + +" Resolve the active plot or artifact viewer window from live windows. +function! s:ResolveViewerWinid(kind) + let l:stored_winid = s:GetViewerWinid(a:kind) + if s:IsManagedViewerWindow(a:kind, l:stored_winid) + return l:stored_winid + endif + for l:w in range(1, winnr('$')) + let l:winid = win_getid(l:w) + if s:IsManagedViewerWindow(a:kind, l:winid) + call s:SetViewerWinid(a:kind, l:winid) + return l:winid + endif + endfor + return -1 +endfunction + + +" Open or reuse a scratch viewer buffer without echoing rename messages. +function! s:OpenViewerBuffer(bufname) + if bufexists(a:bufname) + silent execute 'buffer ' . fnameescape(a:bufname) + else + silent execute 'enew' + silent execute 'file ' . fnameescape(a:bufname) + endif +endfunction + + +" Ensure the shared viewer column exists opposite the MLflow sidebar. +function! s:EnsureViewerColumnWindow() + let l:plot_winid = s:ResolveViewerWinid('plot') + if l:plot_winid > 0 + return l:plot_winid + endif + let l:artifact_winid = s:ResolveViewerWinid('artifact') + if l:artifact_winid > 0 + return l:artifact_winid + endif + let l:stored_winid = get(s:, 'viewer_column_winid', -1) + if s:IsReusableViewerColumnWindow(l:stored_winid) + return l:stored_winid + endif + for l:w in range(1, winnr('$')) + let l:winid = win_getid(l:w) + if s:IsReusableViewerColumnWindow(l:winid) + let s:viewer_column_winid = l:winid + return l:winid + endif + endfor + let l:main_winid = exists('s:mlflow_winid') ? s:mlflow_winid : win_getid() + call win_gotoid(l:main_winid) + execute s:GetViewerSplitCmd() . ' new' + let s:viewer_column_winid = win_getid() + return s:viewer_column_winid +endfunction + + +" Resize stacked plot/artifact panes according to the configured split percent. +function! s:ResizeViewerPanes() + let l:plot_winid = s:ResolveViewerWinid('plot') + let l:artifact_winid = s:ResolveViewerWinid('artifact') + if l:plot_winid > 0 && l:artifact_winid > 0 + let l:total_height = winheight(win_id2win(l:plot_winid)) + winheight(win_id2win(l:artifact_winid)) + let l:plot_height = max([1, float2nr(l:total_height * g:vim_mlflow_plotpane_pct / 100.0)]) + call win_gotoid(l:plot_winid) + execute 'resize ' . l:plot_height + let s:viewer_column_winid = l:plot_winid + elseif l:plot_winid > 0 + let s:viewer_column_winid = l:plot_winid + elseif l:artifact_winid > 0 + let s:viewer_column_winid = l:artifact_winid + endif +endfunction + + +" Ensure the requested plot or artifact viewer pane exists and is focused. +function! s:EnsureViewerWindow(kind, bufname) + let l:target_winid = s:ResolveViewerWinid(a:kind) + if l:target_winid > 0 && win_gotoid(l:target_winid) + call s:OpenViewerBuffer(a:bufname) + call s:ResizeViewerPanes() + return l:target_winid + endif + + let l:sibling_kind = a:kind ==# 'plot' ? 'artifact' : 'plot' + let l:sibling_winid = s:ResolveViewerWinid(l:sibling_kind) + if l:sibling_winid > 0 && win_gotoid(l:sibling_winid) + if a:kind ==# 'plot' + leftabove split + else + rightbelow split + endif + let l:new_winid = win_getid() + call s:SetViewerWinid(a:kind, l:new_winid) + call s:OpenViewerBuffer(a:bufname) + call s:ResizeViewerPanes() + return l:new_winid + endif + + let l:column_winid = s:EnsureViewerColumnWindow() + call win_gotoid(l:column_winid) + call s:SetViewerWinid(a:kind, l:column_winid) + call s:OpenViewerBuffer(a:bufname) + let s:viewer_column_winid = l:column_winid + call s:ResizeViewerPanes() + return l:column_winid +endfunction + + function! s:EnsurePlotWindow(bufname) - if exists('s:plot_winid') && win_gotoid(s:plot_winid) + let l:target_winid = s:EnsureViewerWindow('plot', a:bufname) + if l:target_winid > 0 && win_gotoid(l:target_winid) if bufexists(a:bufname) - execute 'buffer ' . fnameescape(a:bufname) - else - execute 'enew' - execute 'file ' . fnameescape(a:bufname) + silent execute 'buffer ' . fnameescape(a:bufname) endif - return s:plot_winid + return l:target_winid endif - - let l:split_cmd = g:vim_mlflow_vside ==# 'left' ? 'vert botright' : 'vert topleft' - execute l:split_cmd . ' new' - execute 'file ' . fnameescape(a:bufname) - let s:plot_winid = win_getid() - return s:plot_winid + return -1 endfunction @@ -165,7 +353,7 @@ endfunction function! s:OpenMetricPlotBuffer(title, lines) let l:bufname = s:GetPlotBufferName() let l:current_winid = win_getid() - let l:winid = s:EnsurePlotWindow(l:bufname) + let l:winid = s:EnsureViewerWindow('plot', l:bufname) call win_gotoid(l:winid) call s:PopulatePlotBuffer(a:title, a:lines) " widen window if needed for plot width + margins @@ -176,6 +364,7 @@ endfunction function! RunMLflow() + let l:origin_winid = win_getid() let s:debuglines = [] let s:current_exptid = '' " empty string means show '1st expt' let s:current_runid = '' " empty string means show '1st run' @@ -218,13 +407,19 @@ function! RunMLflow() " Focus the existing window execute bufwinnr(g:vim_mlflow_buffername) . 'wincmd w' endif + let s:mlflow_winid = win_getid() + if s:IsLiveWindow(l:origin_winid) && l:origin_winid !=# s:mlflow_winid + let s:viewer_column_winid = l:origin_winid + endif " Set buffer properties: no line#s, don't prompt to save set nonumber set buftype=nofile + setlocal noswapfile + setlocal nowrap " Initial query/draw of MLflow content - call RefreshMLflowBuffer(1) + call RefreshMLflowBuffer(1, 0, 1) normal! 1G " Map certain key input to vim-mlflow features within buffer @@ -232,7 +427,7 @@ function! RunMLflow() nmap :call MLflowSelect() nmap :call MarkRun() nmap o :call MLflowSelect() - nmap r :call RefreshMLflowBuffer(0, 1) + nmap r :call RefreshMLflowBuffer(0, 1, 1) nmap R :call OpenRunsWindow() nmap :call ToggleMLParamsDisplay() nmap :call ToggleMLMetricsDisplay() @@ -268,6 +463,8 @@ function! OpenRunsWindow() " Set buffer properties: no line#s, don't prompt to save set nonumber set buftype=nofile + setlocal noswapfile + setlocal nowrap " Initial query/draw of MLflow content call RefreshRunsBuffer() @@ -279,7 +476,7 @@ function! OpenRunsWindow() nmap . :call CollapseColumn() nmap x :call RemoveMarkedRunViaCurpos() " nmap :call HideColumn() - nmap :call UnhideAll() + nmap u :call ConfirmUnhideAll() nmap :call ToggleRunsParamsDisplay() nmap :call ToggleRunsMetricsDisplay() nmap :call ToggleRunsTagsDisplay() @@ -336,9 +533,11 @@ endfunction " Requery MLflow content and update buffer function! RefreshMLflowBuffer(doassign, ...) - " Optional args: [cursor_position], [reset_artifacts_flag] + " Optional args: [cursor_position], [reset_artifacts_flag], [force_refresh_flag] let l:curpos = getpos('.') + let l:view = winsaveview() let l:reset_artifacts = 0 + let l:force_refresh = 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([]) @@ -346,8 +545,14 @@ function! RefreshMLflowBuffer(doassign, ...) if len(a:000) >= 2 let l:reset_artifacts = a:000[1] endif + if len(a:000) >= 3 + let l:force_refresh = a:000[2] + endif else let l:reset_artifacts = a:000[0] + if len(a:000) >= 2 + let l:force_refresh = a:000[1] + endif endif endif if l:reset_artifacts @@ -363,10 +568,10 @@ function! RefreshMLflowBuffer(doassign, ...) normal! gg"_dG " Insert the results. - let l:view = winsaveview() + let g:vim_mlflow_force_refresh = l:force_refresh let l:results = MainPageMLflow() + unlet g:vim_mlflow_force_refresh call append(0, l:results) - call winrestview(l:view) " Colorize the contents call ColorizeMLflowBuffer() @@ -376,8 +581,7 @@ function! RefreshMLflowBuffer(doassign, ...) let s:artifact_lineinfo = {} endif - " Replace the cursor position - call setpos('.', l:curpos) + call winrestview(l:view) redraw endfunction @@ -389,21 +593,19 @@ function! RefreshRunsBuffer() if ! exists(l:curpos) let l:curpos = getpos('.') endif + let l:view = winsaveview() " Clear out existing content normal! gg"_dG " Insert the results. - let l:view = winsaveview() let l:results = RunsPageMLflow() call append(0, l:results) - call winrestview(l:view) " Colorize the contents call ColorizeRunsBuffer() - " Replace the cursor position - call setpos('.', l:curpos) + call winrestview(l:view) redraw endfunction @@ -453,7 +655,7 @@ function! CycleActiveDeletedAll() " which correspond to states Active, Deleted, and All " for both Experiments and Runs simultaneously: let g:vim_mlflow_viewtype = (g:vim_mlflow_viewtype%3)+1 - call RefreshMLflowBuffer(1) + call RefreshMLflowBuffer(1, 0, 1) endfunction @@ -565,6 +767,26 @@ function! UnhideAll() endfunction +" Wrap UnhideAll() with a confirmation prompt in the runs buffer. +function! ConfirmUnhideAll() + if empty(s:collapsedcols_list) && empty(s:hiddencols_list) && empty(s:movedcols_list) + echo 'vim-mlflow: no column layout changes to undo.' + return + endif + if exists('*confirm') + let l:choice = confirm( + \ 'Undo all column layout changes?', + \ "&Yes\n&No", + \ 2 + \ ) + if l:choice !=# 1 + return + endif + endif + call UnhideAll() +endfunction + + function! HideColumn() let l:curcol = col('.') " Convert cursor position into dataframe column # to hide: @@ -704,7 +926,7 @@ function! RunsListHelpMsg() \'" R : requery marked-runs display', \'" x : remove run under cursor from list', \'" . : collapse/open current column', - \'" ^u : unhide/undo all column changes', + \'" u : undo all column changes', \'" ^p : toggle display of parameters', \'" ^e : toggle display of metrics', \'" ^t : toggle display of tags', @@ -780,7 +1002,11 @@ except ModuleNotFoundError as exc: print("Please see vim-mlflow's readme file for more details.") print("Underlying import error:", exc) raise -mlflowmain = vim_mlflow.getMainPageMLflow(vim.eval('g:mlflow_tracking_uri')) +force_refresh = vim.eval("get(g:, 'vim_mlflow_force_refresh', 0)") == '1' +mlflowmain = vim_mlflow.getMainPageMLflow( + vim.eval('g:mlflow_tracking_uri'), + force_refresh=force_refresh, +) EOF let g:vim_mlflow_plugin_loaded = 1 @@ -1160,33 +1386,17 @@ endfunction 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 - let l:scratch = s:FindScratchWindow() - if l:scratch !=# -1 - call win_gotoid(l:scratch) - execute 'enew' - else - if g:vim_mlflow_vside ==# 'left' - execute 'vert botright split' - else - execute 'vert topleft split' - endif - endif - else - execute l:winnr . 'wincmd w' - setlocal modifiable - endif - execute 'file ' . fnameescape(l:bufname) + let l:runid = exists('s:current_runid') ? s:current_runid : 'unknown' + let l:bufname = 'artifact://' . l:runid . '/' . a:path + let l:winid = s:EnsureViewerWindow('artifact', l:bufname) + call win_gotoid(l:winid) + setlocal modifiable setlocal buftype=nofile setlocal bufhidden=wipe setlocal noswapfile + setlocal nowrap setlocal modifiable - let l:content = readfile(a:localpath) - if empty(l:content) - let l:content = [''] - endif + let l:content = s:GetArtifactDisplayLines(a:path, a:localpath) silent keepjumps %d call setline(1, l:content) call s:SetBufferFiletype(a:path) @@ -1196,6 +1406,69 @@ function! s:ShowArtifactBuffer(path, localpath) endfunction +" Build display lines for an artifact buffer, pretty-printing JSON when possible. +function! s:GetArtifactDisplayLines(path, localpath) +python3 << EOF +import os, sys, site +from os.path import normpath, join +import vim + +def _augment_sys_path(): + def _add_site_dir(path): + if not os.path.isdir(path): + return + before = list(sys.path) + site.addsitedir(path) + new_entries = [p for p in sys.path if p not in before] + for entry in reversed(new_entries): + idx = sys.path.index(entry) + sys.path.insert(0, sys.path.pop(idx)) + + env_roots = [] + for key in ('VIRTUAL_ENV', 'CONDA_PREFIX', 'PYENV_VIRTUAL_ENV'): + value = os.environ.get(key) + if value and value not in env_roots: + env_roots.append(value) + for root in env_roots: + for lib_name in ('lib', 'Lib', 'lib64'): + lib_dir = os.path.join(root, lib_name) + if not os.path.isdir(lib_dir): + continue + py_version_dir = 'python{}.{}'.format(sys.version_info.major, sys.version_info.minor) + candidate_dirs = [os.path.join(lib_dir, py_version_dir, 'site-packages')] + for entry in os.listdir(lib_dir): + if entry.startswith('python') and entry != py_version_dir: + candidate_dirs.append(os.path.join(lib_dir, entry, 'site-packages')) + for path in candidate_dirs: + _add_site_dir(path) + +_augment_sys_path() + +plugin_root_dir = vim.eval('s:plugin_root_dir') +python_root_dir = normpath(join(plugin_root_dir, '..', 'python')) +if python_root_dir not in sys.path: + sys.path.insert(0, python_root_dir) + +import vim_mlflow + +artifact_path = vim.eval('a:path') +local_path = vim.eval('a:localpath') +vim.vars['vim_mlflow_artifact_lines'] = vim_mlflow.read_artifact_display_lines( + artifact_path, + local_path, +) +EOF + let l:content = get(g:, 'vim_mlflow_artifact_lines', []) + if exists('g:vim_mlflow_artifact_lines') + unlet g:vim_mlflow_artifact_lines + endif + if empty(l:content) + return [''] + endif + return l:content +endfunction + + function! s:SetBufferFiletype(path) let l:lower = tolower(a:path) if l:lower =~# '\v\.json$' @@ -1210,18 +1483,3 @@ function! s:SetBufferFiletype(path) setfiletype text endif endfunction - - -function! s:FindScratchWindow() - for l:w in range(1, winnr('$')) - let l:buf = winbufnr(l:w) - if l:buf <= 0 - continue - endif - let l:name = bufname(l:buf) - if (empty(l:name) || l:name =~? '^artifact://') && getbufvar(l:buf, '&buftype') ==# '' - return win_getid(l:w) - endif - endfor - return -1 -endfunction diff --git a/python/vim_mlflow.py b/python/vim_mlflow.py index bb2c074..5ababbc 100644 --- a/python/vim_mlflow.py +++ b/python/vim_mlflow.py @@ -3,7 +3,6 @@ import json import math import os -from datetime import datetime, timezone from urllib.request import urlopen import mlflow @@ -11,7 +10,7 @@ from mlflow.entities import ViewType from mlflow.tracking import MlflowClient -from vim_mlflow_utils import format_run_duration +from vim_mlflow_cache import get_session VIEWTYPE_MAP = { 1: ViewType.ACTIVE_ONLY, @@ -26,16 +25,36 @@ } -def getMLflowExpts(mlflow_tracking_uri): +def _get_cache_mode(): + """Return the configured run-summary cache mode.""" + cache_mode = vim.eval("get(g:, 'vim_mlflow_runs_cache_mode', 'selected_expt')") + if cache_mode not in {"selected_expt", "all_expts"}: + return "selected_expt" + return cache_mode + + +def _get_session(mlflow_tracking_uri): + """Return the active cache session for the current Vim settings.""" + view_idx = int(vim.eval("g:vim_mlflow_viewtype")) + view_type = VIEWTYPE_MAP.get(view_idx, ViewType.ACTIVE_ONLY) + timeout = float(vim.eval("g:vim_mlflow_timeout")) + return get_session(mlflow_tracking_uri, view_type, _get_cache_mode(), timeout) + + +def _vim_flag(expression): + """Interpret a Vimscript boolean-like value as a Python bool.""" + return str(vim.eval(expression)) == "1" + + +def getMLflowExpts(session, force_refresh=False): + """Render the experiment list from cached MLflow data.""" try: lifecycles = {"active": "A", "deleted": "D"} - client = MlflowClient(tracking_uri=mlflow_tracking_uri) view_idx = int(vim.eval("g:vim_mlflow_viewtype")) - view_type = VIEWTYPE_MAP.get(view_idx, ViewType.ACTIVE_ONLY) - expts = client.search_experiments(view_type=view_type) + expts_df = session.get_experiments_df(force_refresh=force_refresh) output_lines = [] - num_expts_viewtype = len(expts) + num_expts_viewtype = len(expts_df.index) 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])} " @@ -49,21 +68,20 @@ def getMLflowExpts(mlflow_tracking_uri): else: scrollicon = "" 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]: + visible_expts = expts_df.iloc[beginexpt_idx:endexpt_idx] + view_type = session.view_type + for _, expt in visible_expts.iterrows(): 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}" + 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}") + 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])") @@ -74,7 +92,7 @@ def getMLflowExpts(mlflow_tracking_uri): else: scrollicon = "" output_lines.append(scrollicon) - return output_lines, [expt.experiment_id for expt in expts] + return output_lines, expts_df["experiment_id"].tolist() except ModuleNotFoundError: print( @@ -82,16 +100,15 @@ def getMLflowExpts(mlflow_tracking_uri): ) -def getRunsListForExpt(mlflow_tracking_uri, current_exptid): +def getRunsListForExpt(session, current_exptid, force_refresh=False): + """Render the run list for one experiment from cached summaries.""" try: lifecycles = {"active": "A", "deleted": "D"} - client = MlflowClient(tracking_uri=mlflow_tracking_uri) view_idx = int(vim.eval("g:vim_mlflow_viewtype")) - view_type = VIEWTYPE_MAP.get(view_idx, ViewType.ACTIVE_ONLY) - runs = client.search_runs([str(current_exptid)], run_view_type=view_type) + runs_df = session.get_runs_df(str(current_exptid), force_refresh=force_refresh) output_lines = [] - num_runs_viewtype = len(runs) + num_runs_viewtype = len(runs_df.index) 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])} " @@ -105,47 +122,32 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): else: scrollicon = "" 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") ) visible_rows = [] - for run in runs[beginrun_idx:endrun_idx]: - if run.info.start_time: - st = datetime.fromtimestamp( - run.info.start_time / 1e3, tz=timezone.utc - ).strftime( - "%Y-%m-%d %H:%M:%S" - ) - else: - st = "N/A" + visible_runs = runs_df.iloc[beginrun_idx:endrun_idx] + view_type = session.view_type + for _, run in visible_runs.iterrows(): mark = " " - if run.info.run_id[:5] in vim.eval("s:markruns_list"): + if run["run_id_short"] in vim.eval("s:markruns_list"): mark = vim.eval("g:vim_mlflow_icon_markrun") - runtags = run.data.tags - runname = run.info.run_name if "mlflow.runName" in runtags else "" - status = run.info.status or "-" - 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() + run["lifecycle_stage"], run["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: - duration_seconds = None visible_rows.append( { "mark": mark, - "run_id": run.info.run_id[:5], + "run_id": run["run_id_short"], "stage": stage_letter, - "start": st, - "status": status, - "duration": format_run_duration(duration_seconds), - "user": user, - "name": runname, + "start": run["start_time"], + "status": run["status"], + "duration": run["duration"], + "user": run["user"], + "name": run["run_name"], } ) @@ -175,7 +177,7 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): else: scrollicon = "" output_lines.append(scrollicon) - return output_lines, [run.info.run_id for run in runs] + return output_lines, runs_df["run_id"].tolist() except ModuleNotFoundError: print( @@ -183,10 +185,11 @@ def getRunsListForExpt(mlflow_tracking_uri, current_exptid): ) -def getMetricsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon=""): +def getMetricsListForRun(session, current_runid, show=True, header_icon="", force_refresh=False): + """Render the metrics section for one run and cache metric histories.""" try: - client = MlflowClient(tracking_uri=mlflow_tracking_uri) - run = client.get_run(current_runid) + run = session.get_run_detail(current_runid, force_refresh=force_refresh) + metrics = run["data"]["metrics"] metric_histories = {} output_lines = [] @@ -196,16 +199,11 @@ def getMetricsListForRun(mlflow_tracking_uri, current_runid, show=True, header_i output_lines.append(f"{prefix}Metrics in run #{current_runid[:5]}:") output_lines.append(divider) if show: - for k, v in run.data.metrics.items(): - history = client.get_metric_history(current_runid, k) - metric_histories[k] = [ - { - "step": m.step, - "timestamp": m.timestamp, - "value": m.value, - } - for m in history - ] + for k, v in metrics.items(): + history = session.get_metric_history( + current_runid, k, force_refresh=force_refresh + ) + metric_histories[k] = history suffix = "" if len(history) > 1: suffix = " [final value; o to plot]" @@ -218,9 +216,9 @@ def getMetricsListForRun(mlflow_tracking_uri, current_runid, show=True, header_i # 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"] = { - "run_id": run.info.run_id, - "run_name": run.info.run_name or "", - "experiment_id": run.info.experiment_id, + "run_id": run["info"]["run_id"], + "run_name": run["info"]["run_name"] or "", + "experiment_id": run["info"]["experiment_id"], } return output_lines, metric_offsets @@ -230,10 +228,10 @@ def getMetricsListForRun(mlflow_tracking_uri, current_runid, show=True, header_i ) -def getParamsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon=""): +def getParamsListForRun(session, current_runid, show=True, header_icon="", force_refresh=False): + """Render the params section for one run.""" try: - client = MlflowClient(tracking_uri=mlflow_tracking_uri) - run = client.get_run(current_runid) + run = session.get_run_detail(current_runid, force_refresh=force_refresh) output_lines = [] prefix = f"{header_icon} " if header_icon else "" @@ -241,7 +239,7 @@ def getParamsListForRun(mlflow_tracking_uri, current_runid, show=True, header_ic output_lines.append(f"{prefix}Params in run #{current_runid[:5]}:") output_lines.append(divider) if show: - for k, v in run.data.params.items(): + for k, v in run["data"]["params"].items(): output_lines.append(f" {k}: {v}") output_lines.append("") return output_lines @@ -252,10 +250,10 @@ def getParamsListForRun(mlflow_tracking_uri, current_runid, show=True, header_ic ) -def getTagsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon=""): +def getTagsListForRun(session, current_runid, show=True, header_icon="", force_refresh=False): + """Render the tags section for one run.""" try: - client = MlflowClient(tracking_uri=mlflow_tracking_uri) - run = client.get_run(current_runid) + run = session.get_run_detail(current_runid, force_refresh=force_refresh) output_lines = [] prefix = f"{header_icon} " if header_icon else "" @@ -263,7 +261,7 @@ def getTagsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon output_lines.append(f"{prefix}Tags in run #{current_runid[:5]}:") output_lines.append(divider) if show: - for k, v in run.data.tags.items(): + for k, v in run["data"]["tags"].items(): output_lines.append(f" {k}: {v}") output_lines.append("") return output_lines @@ -275,6 +273,7 @@ def getTagsListForRun(mlflow_tracking_uri, current_runid, show=True, header_icon def _clean_metric_history(history): + """Normalize metric history points into plottable numeric entries.""" cleaned = [] for idx, point in enumerate(history): value = point.get("value") @@ -298,6 +297,7 @@ def _clean_metric_history(history): def _downsample_points(points, target_len): + """Reduce plotted points to fit the target display width.""" if len(points) <= target_len: return points ratio = len(points) / float(target_len) @@ -317,6 +317,7 @@ def _downsample_points(points, target_len): def _collect_artifacts(client, run_id, path="", depth=0, max_depth=50): + """Collect an artifact tree for one run up to the requested depth.""" nodes = [] try: actual_path = path or None @@ -342,6 +343,7 @@ def _collect_artifacts(client, run_id, path="", depth=0, max_depth=50): def _is_text_artifact(name): + """Return whether an artifact should be opened as inline text.""" lowered = name.lower() if lowered == "mlmodel": return True @@ -362,6 +364,7 @@ def _render_artifact_section( max_depth=3, header_icon=None, ): + """Render artifact tree lines plus metadata for clickable rows.""" indent_unit = " " prefix = f"{header_icon} " if header_icon else "" lines = [f"{prefix}Artifacts in run #{short_run_id}:", divider_char * 30] @@ -407,6 +410,7 @@ def walk(nodes, depth): def download_artifact_file(tracking_uri, run_id, artifact_path, target_dir): + """Download one artifact file for local viewing.""" mlflow.set_tracking_uri(tracking_uri) client = MlflowClient() os.makedirs(target_dir, exist_ok=True) @@ -421,9 +425,26 @@ def download_artifact_file(tracking_uri, run_id, artifact_path, target_dir): return local_path +def read_artifact_display_lines(artifact_path, local_path): + """Return artifact lines for display, pretty-printing JSON when possible.""" + if artifact_path.lower().endswith(".json"): + try: + with open(local_path, encoding="utf-8") as infile: + payload = json.load(infile) + rendered = json.dumps(payload, indent=2, ensure_ascii=False) + return rendered.splitlines() or [""] + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + pass + + with open(local_path, encoding="utf-8", errors="replace") as infile: + content = infile.read().splitlines() + return content or [""] + + def render_metric_plot( run_id, metric_name, history, width, height, xaxis_mode, experiment_id, run_name ): + """Render an ASCII plot for one metric history.""" experiment_id = str(experiment_id) if experiment_id else "-" run_name = str(run_name) if run_name else "" cleaned = _clean_metric_history(history) @@ -528,7 +549,8 @@ def render_metric_plot( return lines, title -def getMainPageMLflow(mlflow_tracking_uri): +def getMainPageMLflow(mlflow_tracking_uri, force_refresh=False): + """Render the main MLflow sidebar from cached and lazy-loaded data.""" out = [] version = vim.eval("get(g:, 'vim_mlflow_version', 'dev')") @@ -539,41 +561,50 @@ def getMainPageMLflow(mlflow_tracking_uri): 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") - ) + session = _get_session(mlflow_tracking_uri) + if session.ensure_available(force_refresh=force_refresh): + text, exptids = getMLflowExpts(session, force_refresh=force_refresh) out.extend(text) out.append("") + current_exptid = vim.eval("s:current_exptid") + if exptids and current_exptid not in exptids: + current_exptid = exptids[0] + vim.command("let s:current_exptid='" + current_exptid + "'") + elif current_exptid == "" and exptids: + current_exptid = exptids[0] + vim.command("let s:current_exptid='" + current_exptid + "'") + + if current_exptid: + session.prime_runs_cache(current_exptid, force_refresh=force_refresh) + text, runids = getRunsListForExpt(session, current_exptid) + out.extend(text) + out.append("") + else: + runids = [] + 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] - vim.command("let s:current_runid='" + fullrunid + "'") + current_run = vim.eval("s:current_runid") + if current_run == "": + current_run = runids[0] + elif len(current_run) == 5: + current_run = session.find_run_id(current_exptid, current_run) + elif current_run not in runids: + current_run = runids[0] + vim.command("let s:current_runid='" + current_run + "'") + if force_refresh: + session.get_run_detail(current_run, force_refresh=True) + section_order = vim.eval("g:vim_mlflow_section_order") if not section_order: section_order = ["params", "metrics", "tags", "artifacts"] states = { - "params": vim.eval("s:params_are_showing") == "1", - "metrics": vim.eval("s:metrics_are_showing") == "1", - "tags": vim.eval("s:tags_are_showing") == "1", - "artifacts": vim.eval("s:artifacts_are_showing") == "1", + "params": _vim_flag("s:params_are_showing"), + "metrics": _vim_flag("s:metrics_are_showing"), + "tags": _vim_flag("s:tags_are_showing"), + "artifacts": _vim_flag("s:artifacts_are_showing"), } open_icon = vim.eval("g:vim_mlflow_icon_scrolldown") or "v" closed_icon = vim.eval("g:vim_mlflow_icon_markrun") or ">" - current_run = vim.eval("s:current_runid") short_run = current_run[:5] section_header_entries = [] metric_line_numbers = [] @@ -586,7 +617,7 @@ def getMainPageMLflow(mlflow_tracking_uri): if section == "params": out.extend( getParamsListForRun( - mlflow_tracking_uri, + session, current_run, show=show, header_icon=header_icon, @@ -594,7 +625,7 @@ def getMainPageMLflow(mlflow_tracking_uri): ) elif section == "metrics": metrics_output, offsets = getMetricsListForRun( - mlflow_tracking_uri, + session, current_run, show=show, header_icon=header_icon, @@ -607,7 +638,7 @@ def getMainPageMLflow(mlflow_tracking_uri): elif section == "tags": out.extend( getTagsListForRun( - mlflow_tracking_uri, + session, current_run, show=show, header_icon=header_icon, @@ -623,9 +654,10 @@ def getMainPageMLflow(mlflow_tracking_uri): "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 = session.get_artifact_tree( + current_run, + max_depth, + _collect_artifacts, ) artifact_lines, artifact_info = _render_artifact_section( short_run, @@ -655,6 +687,7 @@ def getMainPageMLflow(mlflow_tracking_uri): vim.vars["vim_mlflow_section_headers"] = section_header_entries vim.vars["vim_mlflow_metric_lines"] = metric_line_numbers else: + vim.command("let s:current_runid=''") vim.vars["vim_mlflow_artifact_lineinfo"] = {} vim.vars["vim_mlflow_section_headers"] = [] vim.vars["vim_mlflow_metric_lines"] = [] @@ -683,10 +716,6 @@ def verifyTrackingUrl(url, timeout=1.0): raise RuntimeError("Incorrect and possibly insecure protocol in url") try: - if urlopen(url, timeout=timeout).getcode() == 200: - out = True - except Exception as exc: # fallback if you tr - print("Unexpected failure: %s", exc) - out = False - - return out + return urlopen(url, timeout=timeout).getcode() == 200 + except Exception: + return False diff --git a/python/vim_mlflow_cache.py b/python/vim_mlflow_cache.py new file mode 100644 index 0000000..e206916 --- /dev/null +++ b/python/vim_mlflow_cache.py @@ -0,0 +1,314 @@ +"""Session cache for MLflow data used by vim-mlflow.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple +from urllib.request import urlopen + +import pandas as pd +from mlflow.tracking import MlflowClient + +from vim_mlflow_utils import format_run_duration + + +def _verify_tracking_url(url: str, timeout: float = 1.0) -> bool: + """Check that the MLflow URL is running and accessible.""" + if not url.startswith("http"): + raise RuntimeError("Incorrect and possibly insecure protocol in url") + + try: + return urlopen(url, timeout=timeout).getcode() == 200 + except Exception: + return False + + +def _format_timestamp(timestamp_ms: Optional[int]) -> str: + """Format an MLflow timestamp in UTC for sidebar display.""" + if not timestamp_ms: + return "N/A" + return datetime.fromtimestamp(timestamp_ms / 1e3, tz=timezone.utc).strftime( + "%Y-%m-%d %H:%M:%S" + ) + + +def _normalize_experiment(experiment: Any) -> Dict[str, str]: + """Normalize an MLflow experiment entity into one dataframe row.""" + return { + "experiment_id": str(experiment.experiment_id), + "name": experiment.name, + "lifecycle_stage": getattr(experiment, "lifecycle_stage", "active") or "active", + } + + +def _normalize_run_summary(run: Any) -> Dict[str, Any]: + """Normalize a run entity into a cached summary row.""" + run_tags = getattr(run.data, "tags", {}) or {} + info = run.info + duration_seconds = None + if info.start_time and info.end_time: + duration_seconds = (info.end_time - info.start_time) / 1e3 + + return { + "run_id": info.run_id, + "run_id_short": info.run_id[:5], + "experiment_id": str(info.experiment_id), + "lifecycle_stage": getattr(info, "lifecycle_stage", "active") or "active", + "start_time_ms": info.start_time, + "end_time_ms": info.end_time, + "start_time": _format_timestamp(info.start_time), + "status": info.status or "-", + "user": run_tags.get("mlflow.user") or getattr(info, "user_id", None) or "-", + "run_name": getattr(info, "run_name", "") or run_tags.get("mlflow.runName", ""), + "duration_seconds": duration_seconds, + "duration": format_run_duration(duration_seconds), + } + + +def _run_detail_to_dict(run: Any) -> Dict[str, Any]: + """Normalize a detailed run entity into cached dict form.""" + info = run.info + run_data = run.data + return { + "info": { + "run_id": info.run_id, + "experiment_id": str(info.experiment_id), + "run_name": getattr(info, "run_name", "") or "", + "status": info.status or "-", + "lifecycle_stage": getattr(info, "lifecycle_stage", "active") or "active", + "start_time": info.start_time, + "end_time": info.end_time, + "user_id": getattr(info, "user_id", None), + }, + "data": { + "params": dict(getattr(run_data, "params", {}) or {}), + "metrics": dict(getattr(run_data, "metrics", {}) or {}), + "tags": dict(getattr(run_data, "tags", {}) or {}), + }, + } + + +@dataclass +class MlflowSessionCache: + """Hold cached MLflow data for one tracking-uri/view/mode combination.""" + + tracking_uri: str + view_type: Any + cache_mode: str + timeout: float + client: MlflowClient = field(init=False) + experiments_df: pd.DataFrame = field(default_factory=pd.DataFrame) + runs_by_experiment: Dict[str, pd.DataFrame] = field(default_factory=dict) + run_details_by_id: Dict[str, Dict[str, Any]] = field(default_factory=dict) + metric_history_by_run: Dict[str, Dict[str, List[Dict[str, Any]]]] = field( + default_factory=dict + ) + artifacts_by_run: Dict[Tuple[str, int], List[Dict[str, Any]]] = field( + default_factory=dict + ) + available: Optional[bool] = None + + def __post_init__(self) -> None: + self.client = MlflowClient(tracking_uri=self.tracking_uri) + + def invalidate(self) -> None: + """Drop all cached data for this session.""" + self.experiments_df = pd.DataFrame() + self.runs_by_experiment = {} + self.run_details_by_id = {} + self.metric_history_by_run = {} + self.artifacts_by_run = {} + self.available = None + + def ensure_available(self, force_refresh: bool = False) -> bool: + """Verify the tracking server once per cache lifetime.""" + if force_refresh: + self.available = None + if self.available is None: + self.available = _verify_tracking_url(self.tracking_uri, timeout=self.timeout) + return bool(self.available) + + def get_experiments_df(self, force_refresh: bool = False) -> pd.DataFrame: + """Return cached experiments as a normalized dataframe.""" + if force_refresh: + self.experiments_df = pd.DataFrame() + if self.experiments_df.empty: + experiments = self.client.search_experiments(view_type=self.view_type) + rows = [_normalize_experiment(experiment) for experiment in experiments] + experiments_df = pd.DataFrame(rows) + if experiments_df.empty: + experiments_df = pd.DataFrame( + columns=["experiment_id", "name", "lifecycle_stage", "experiment_sort"] + ) + else: + experiments_df["experiment_sort"] = pd.to_numeric( + experiments_df["experiment_id"], errors="coerce" + ) + experiments_df = experiments_df.sort_values( + ["experiment_sort", "experiment_id"], ascending=False, na_position="last" + ) + self.experiments_df = experiments_df.reset_index(drop=True) + return self.experiments_df + + def get_runs_df(self, experiment_id: str, force_refresh: bool = False) -> pd.DataFrame: + """Return cached run summaries for one experiment.""" + experiment_id = str(experiment_id) + if force_refresh: + self.runs_by_experiment.pop(experiment_id, None) + if experiment_id not in self.runs_by_experiment: + runs = self.client.search_runs([experiment_id], run_view_type=self.view_type) + rows = [_normalize_run_summary(run) for run in runs] + runs_df = pd.DataFrame(rows) + if runs_df.empty: + runs_df = pd.DataFrame( + columns=[ + "run_id", + "run_id_short", + "experiment_id", + "lifecycle_stage", + "start_time_ms", + "end_time_ms", + "start_time", + "status", + "user", + "run_name", + "duration_seconds", + "duration", + ] + ) + else: + runs_df = runs_df.sort_values( + ["start_time_ms", "run_id"], ascending=[False, True], na_position="last" + ) + self.runs_by_experiment[experiment_id] = runs_df.reset_index(drop=True) + return self.runs_by_experiment[experiment_id] + + def get_all_runs_df(self, force_refresh: bool = False) -> pd.DataFrame: + """Return cached run summaries for all experiments combined.""" + experiment_ids = self.get_experiments_df(force_refresh=force_refresh)[ + "experiment_id" + ].tolist() + run_frames = [ + self.get_runs_df(experiment_id, force_refresh=force_refresh) + for experiment_id in experiment_ids + ] + if not run_frames: + return pd.DataFrame() + non_empty_frames = [frame for frame in run_frames if not frame.empty] + if not non_empty_frames: + return run_frames[0].copy() + return pd.concat(non_empty_frames, ignore_index=True) + + def prime_runs_cache(self, current_experiment_id: str, force_refresh: bool = False) -> None: + """Load run summaries according to the configured cache mode.""" + current_experiment_id = str(current_experiment_id) + if self.cache_mode == "all_expts": + experiments_df = self.get_experiments_df() + for experiment_id in experiments_df["experiment_id"].tolist(): + self.get_runs_df(experiment_id, force_refresh=force_refresh) + return + if current_experiment_id: + self.get_runs_df(current_experiment_id, force_refresh=force_refresh) + + def find_run_id(self, experiment_id: str, run_id_or_short: str) -> str: + """Resolve a short run id within an experiment to its full run id.""" + run_id_or_short = str(run_id_or_short) + if len(run_id_or_short) != 5: + return run_id_or_short + runs_df = self.get_runs_df(str(experiment_id)) + matches = runs_df[runs_df["run_id_short"] == run_id_or_short] + if matches.empty: + return run_id_or_short + return str(matches.iloc[0]["run_id"]) + + def get_run_detail(self, run_id: str, force_refresh: bool = False) -> Dict[str, Any]: + """Return cached detailed run data.""" + run_id = str(run_id) + if force_refresh: + self.run_details_by_id.pop(run_id, None) + self.metric_history_by_run.pop(run_id, None) + stale_keys = [key for key in self.artifacts_by_run if key[0] == run_id] + for key in stale_keys: + self.artifacts_by_run.pop(key, None) + if run_id not in self.run_details_by_id: + run = self.client.get_run(run_id) + self.run_details_by_id[run_id] = _run_detail_to_dict(run) + return self.run_details_by_id[run_id] + + def get_metric_history( + self, run_id: str, metric_name: str, force_refresh: bool = False + ) -> List[Dict[str, Any]]: + """Return cached metric history for one run metric.""" + run_id = str(run_id) + metric_name = str(metric_name) + if force_refresh: + self.metric_history_by_run.setdefault(run_id, {}).pop(metric_name, None) + run_histories = self.metric_history_by_run.setdefault(run_id, {}) + if metric_name not in run_histories: + history = self.client.get_metric_history(run_id, metric_name) + run_histories[metric_name] = [ + { + "step": point.step, + "timestamp": point.timestamp, + "value": point.value, + } + for point in history + ] + return run_histories[metric_name] + + def get_artifact_tree( + self, run_id: str, max_depth: int, collector, force_refresh: bool = False + ) -> List[Dict[str, Any]]: + """Return cached artifact tree for one run.""" + key = (str(run_id), int(max_depth)) + if force_refresh: + self.artifacts_by_run.pop(key, None) + if key not in self.artifacts_by_run: + self.artifacts_by_run[key] = collector( + self.client, str(run_id), max_depth=int(max_depth) + ) + return self.artifacts_by_run[key] + + +_CURRENT_SESSION: Optional[MlflowSessionCache] = None + + +def get_session(tracking_uri: str, view_type: Any, cache_mode: str, timeout: float): + """Return the active MLflow cache session for the current Vim settings.""" + global _CURRENT_SESSION + + normalized_mode = ( + cache_mode if cache_mode in {"selected_expt", "all_expts"} else "selected_expt" + ) + normalized_timeout = float(timeout) + if _CURRENT_SESSION is None: + _CURRENT_SESSION = MlflowSessionCache( + tracking_uri=str(tracking_uri), + view_type=view_type, + cache_mode=normalized_mode, + timeout=normalized_timeout, + ) + return _CURRENT_SESSION + + if ( + _CURRENT_SESSION.tracking_uri != str(tracking_uri) + or _CURRENT_SESSION.view_type != view_type + or _CURRENT_SESSION.cache_mode != normalized_mode + ): + _CURRENT_SESSION = MlflowSessionCache( + tracking_uri=str(tracking_uri), + view_type=view_type, + cache_mode=normalized_mode, + timeout=normalized_timeout, + ) + return _CURRENT_SESSION + + _CURRENT_SESSION.timeout = normalized_timeout + return _CURRENT_SESSION + + +def reset_session() -> None: + """Clear the active cache session.""" + global _CURRENT_SESSION + _CURRENT_SESSION = None diff --git a/python/vim_mlflow_runs.py b/python/vim_mlflow_runs.py index 4b41c38..35f6576 100644 --- a/python/vim_mlflow_runs.py +++ b/python/vim_mlflow_runs.py @@ -2,11 +2,11 @@ from urllib.request import urlopen from mlflow.entities import ViewType -from mlflow.tracking import MlflowClient import pandas as pd import vim +from vim_mlflow_cache import get_session from vim_mlflow_utils import format_run_duration VIEWTYPE_MAP = { @@ -16,7 +16,13 @@ } +def _vim_flag(expression): + """Interpret a Vimscript boolean-like value as a Python bool.""" + return str(vim.eval(expression)) == "1" + + def getRunsPageMLflow(mlflow_tracking_uri): + """Render the marked-runs comparison buffer from cached run data.""" out = [] out.append("Vim-MLflow Marked Runs") out.append("\" Press ? for help") @@ -29,43 +35,81 @@ 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 - client = MlflowClient(tracking_uri=mlflow_tracking_uri) - view_idx = int(vim.eval("g:vim_mlflow_viewtype")) - 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 - ) + view_idx = int(vim.eval("g:vim_mlflow_viewtype")) + view_type = VIEWTYPE_MAP.get(view_idx, ViewType.ACTIVE_ONLY) + cache_mode = vim.eval("get(g:, 'vim_mlflow_runs_cache_mode', 'selected_expt')") + if cache_mode not in {"selected_expt", "all_expts"}: + cache_mode = "selected_expt" + session = get_session( + mlflow_tracking_uri, + view_type, + cache_mode, + float(vim.eval("g:vim_mlflow_timeout")), + ) + + if session.ensure_available(): + + # Find full runids for the short-runids in s:markruns_list using cached summaries. + exptids_to_scan = [] + current_exptid = str(vim.eval("s:current_exptid")) + if current_exptid != "": + exptids_to_scan.append(current_exptid) + for exptid in vim.eval("s:markruns_exptids"): + if str(exptid) not in exptids_to_scan: + exptids_to_scan.append(str(exptid)) + + runs_by_short = {} + for exptid in exptids_to_scan: + if exptid == "": + continue + runs_df = session.get_runs_df(exptid) + for _, run in runs_df.iterrows(): + runs_by_short[(exptid, run["run_id_short"])] = { + "run_id": run["run_id"], + "summary": run.to_dict(), + } + fullmarkrunids = [] - for run in runinfos: - if run.info.run_id[:5] in vim.eval("s:markruns_list"): - fullmarkrunids.append(run.info.run_id) - if len(fullmarkrunids) < len(vim.eval("s:markruns_list")): - for exptid in set(vim.eval("s:markruns_exptids")): - if exptid != vim.eval("s:current_exptid"): - runinfos = client.search_runs([str(exptid)], run_view_type=view_type) - for run in runinfos: - if run.info.run_id[:5] in vim.eval("s:markruns_list"): - fullmarkrunids.append(run.info.run_id) - - # Loop over marked full-runids to get their complete info for display: + summaries_by_run = {} + markruns_exptids = vim.eval("s:markruns_exptids") or [] + for idx, runid5 in enumerate(vim.eval("s:markruns_list")): + exptid = "" + if idx < len(markruns_exptids): + exptid = str(markruns_exptids[idx]) + key = (exptid, runid5) + if key not in runs_by_short and current_exptid: + key = (current_exptid, runid5) + if key in runs_by_short: + full_run_id = runs_by_short[key]["run_id"] + fullmarkrunids.append(full_run_id) + summaries_by_run[full_run_id] = runs_by_short[key]["summary"] + + # Loop over marked full-runids to get their complete info for display. runsforpd = [] for runid in fullmarkrunids: - mldict = client.get_run(runid).to_dictionary() - rundict = mldict["info"] - if vim.eval("s:runs_tags_are_showing") == '1': - rundict.update(mldict["data"]["tags"]) - if vim.eval("s:runs_params_are_showing") == '1': - rundict.update(mldict["data"]["params"]) - if vim.eval("s:runs_metrics_are_showing") == '1': - rundict.update(mldict["data"]["metrics"]) + detail = session.get_run_detail(runid) + rundict = dict(detail["info"]) + summary = summaries_by_run.get(runid, {}) + if summary: + rundict.setdefault("start_time", summary.get("start_time_ms")) + rundict.setdefault("end_time", summary.get("end_time_ms")) + rundict.setdefault("status", summary.get("status")) + rundict.setdefault("lifecycle_stage", summary.get("lifecycle_stage")) + rundict.setdefault("experiment_id", summary.get("experiment_id")) + rundict.setdefault("run_id", summary.get("run_id")) + rundict.setdefault("user_id", summary.get("user")) + if _vim_flag("s:runs_tags_are_showing"): + rundict.update(detail["data"]["tags"]) + if _vim_flag("s:runs_params_are_showing"): + rundict.update(detail["data"]["params"]) + if _vim_flag("s:runs_metrics_are_showing"): + rundict.update(detail["data"]["metrics"]) runsforpd.append(rundict) runsdf = pd.DataFrame(runsforpd) + if runsdf.empty: + out.append("") + out.append("No marked runs.") + return out # Process dataframe regarding collapsed/hidden/shortened columns @@ -129,7 +173,10 @@ def getRunsPageMLflow(mlflow_tracking_uri): # Collapse specified columns colnames = runsdf.columns.values - for colidstr in vim.eval("s:collapsedcols_list"): + collapsedcols_list = vim.eval("s:collapsedcols_list") + if not isinstance(collapsedcols_list, list): + collapsedcols_list = [] + for colidstr in collapsedcols_list: col_idx = int(colidstr) colname = runsdf.columns[col_idx] runsdf[colname] = runsdf[colname].astype(str) @@ -138,10 +185,13 @@ def getRunsPageMLflow(mlflow_tracking_uri): runsdf.columns = colnames # Hide (remove) specified columns + hiddencols_list = vim.eval("s:hiddencols_list") + if not isinstance(hiddencols_list, list): + hiddencols_list = [] cols2keep = [ int(col) for col in range(runsdf.shape[1]) - if str(col) not in vim.eval("s:hiddencols_list") + if str(col) not in hiddencols_list ] runsdf = runsdf.iloc[:, cols2keep] diff --git a/tests/fixtures/mlflow.py b/tests/fixtures/mlflow.py index 1f25fcb..135c7d2 100644 --- a/tests/fixtures/mlflow.py +++ b/tests/fixtures/mlflow.py @@ -18,11 +18,13 @@ def make_run( start_time_ms: Optional[int], end_time_ms: Optional[int], *, + experiment_id: str = "99", status: str = "FINISHED", lifecycle_stage: str = "active", user_id: str = "user", run_name: str = "", tags: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, str]] = None, metrics: Optional[Dict[str, float]] = None, ) -> SimpleNamespace: """Return a minimal stand-in for mlflow.entities.Run.""" @@ -36,6 +38,7 @@ def make_run( info = SimpleNamespace( run_id=run_id, + experiment_id=str(experiment_id), start_time=start_time_ms, end_time=end_time_ms, status=status, @@ -45,6 +48,7 @@ def make_run( ) data = SimpleNamespace( tags=merged_tags, + params=params or {}, metrics=run_metrics, ) return SimpleNamespace(info=info, data=data) diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 0ea068e..a405994 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -28,6 +28,16 @@ def eval(self, expression: str): return self.g.get(expression[2:], 0) if expression.startswith("s:"): return self.s.get(expression[2:], 0) + get_match = re.fullmatch( + r"get\(([gs]):,\s*'([^']+)'(?:,\s*([^\)]+))?\)", expression + ) + if get_match: + scope = get_match.group(1) + name = get_match.group(2) + raw_default = get_match.group(3) + default = 0 if raw_default is None else self._parse_value(raw_default.strip()) + mapping = self.g if scope == "g" else self.s + return mapping.get(name, default) def _replace(match: re.Match) -> str: scope = match.group(1) @@ -80,6 +90,14 @@ def vim_mlflow_env(monkeypatch): "runs": [], "runs_by_id": {}, "metric_history": {}, + "artifacts": {}, + "calls": { + "search_experiments": 0, + "search_runs": 0, + "get_run": 0, + "get_metric_history": 0, + "list_artifacts": 0, + }, } class FakeMlflowClient: @@ -87,17 +105,30 @@ def __init__(self, tracking_uri: str): self.tracking_uri = tracking_uri def search_experiments(self, view_type): + fake_state["calls"]["search_experiments"] += 1 return list(fake_state["experiments"]) def search_runs(self, experiment_ids: List[str], run_view_type): - return list(fake_state["runs"]) + fake_state["calls"]["search_runs"] += 1 + experiment_id_set = {str(experiment_id) for experiment_id in experiment_ids} + return [ + run + for run in fake_state["runs"] + if str(run.info.experiment_id) in experiment_id_set + ] def get_run(self, run_id: str): + fake_state["calls"]["get_run"] += 1 return fake_state["runs_by_id"][run_id] def get_metric_history(self, run_id: str, key: str): + fake_state["calls"]["get_metric_history"] += 1 return list(fake_state["metric_history"].get((run_id, key), [])) + def list_artifacts(self, run_id: str, path=None): + fake_state["calls"]["list_artifacts"] += 1 + return list(fake_state["artifacts"].get((run_id, path), [])) + class FakeViewType: ACTIVE_ONLY = object() DELETED_ONLY = object() @@ -114,8 +145,9 @@ class FakeViewType: 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"] + for module_name in ["vim_mlflow", "vim_mlflow_runs", "vim_mlflow_cache"]: + if module_name in sys.modules: + del sys.modules[module_name] import vim_mlflow # noqa: F401 module = importlib.import_module("vim_mlflow") diff --git a/tests/python/test_vim_mlflow.py b/tests/python/test_vim_mlflow.py index bdbf686..541e9ae 100644 --- a/tests/python/test_vim_mlflow.py +++ b/tests/python/test_vim_mlflow.py @@ -1,7 +1,18 @@ +import importlib + from tests.fixtures.mlflow import make_experiment, make_metric_history, make_run from vim_mlflow_utils import format_run_duration +def _allow_tracking_probe(monkeypatch): + class _Response: + def getcode(self): + return 200 + + cache_module = importlib.import_module("vim_mlflow_cache") + monkeypatch.setattr(cache_module, "urlopen", lambda url, timeout=1.0: _Response()) + + def test_format_run_duration_handles_edge_cases(): assert format_run_duration(None) == "-" assert format_run_duration(float("inf")) == "infh" @@ -12,6 +23,35 @@ def test_format_run_duration_handles_edge_cases(): assert format_run_duration(3 * 3600) == "3.0h" +def test_read_artifact_display_lines_pretty_prints_json(vim_mlflow_env, tmp_path): + module, _, _ = vim_mlflow_env + + pretty_path = tmp_path / "artifact.json" + pretty_path.write_text('{"alpha":1,"nested":{"beta":2}}', encoding="utf-8") + + lines = module.read_artifact_display_lines("artifact.json", str(pretty_path)) + + assert lines == [ + "{", + ' "alpha": 1,', + ' "nested": {', + ' "beta": 2', + " }", + "}", + ] + + +def test_read_artifact_display_lines_falls_back_for_invalid_json(vim_mlflow_env, tmp_path): + module, _, _ = vim_mlflow_env + + raw_path = tmp_path / "artifact.json" + raw_path.write_text('{"alpha":', encoding="utf-8") + + lines = module.read_artifact_display_lines("artifact.json", str(raw_path)) + + assert lines == ['{"alpha":'] + + def test_get_mlflow_experiments_formats_output(vim_mlflow_env): module, state, fake_vim = vim_mlflow_env @@ -29,6 +69,8 @@ def test_get_mlflow_experiments_formats_output(vim_mlflow_env): "vim_mlflow_icon_scrollstop": "X", "vim_mlflow_icon_scrollup": "^", "vim_mlflow_icon_scrolldown": "v", + "vim_mlflow_timeout": 0.5, + "vim_mlflow_runs_cache_mode": "selected_expt", } ) fake_vim.s.update( @@ -37,7 +79,7 @@ def test_get_mlflow_experiments_formats_output(vim_mlflow_env): } ) - lines, experiment_ids = module.getMLflowExpts("http://example.com") + lines, experiment_ids = module.getMLflowExpts(module._get_session("http://example.com")) assert lines[0] == "2 Active Experiments:" assert lines[1] == "X" + "|" * 30 @@ -81,6 +123,8 @@ def test_get_runs_list_formats_columns(vim_mlflow_env): "vim_mlflow_icon_scrollup": "^", "vim_mlflow_icon_scrolldown": "v", "vim_mlflow_icon_markrun": ">", + "vim_mlflow_timeout": 0.5, + "vim_mlflow_runs_cache_mode": "selected_expt", } ) fake_vim.s.update( @@ -90,7 +134,7 @@ def test_get_runs_list_formats_columns(vim_mlflow_env): } ) - lines, run_ids = module.getRunsListForExpt("http://example.com", "99") + lines, run_ids = module.getRunsListForExpt(module._get_session("http://example.com"), "99") assert lines[0] == "2 Active Runs in expt #99:" assert lines[1] == "X" + "|" * 30 @@ -101,3 +145,226 @@ def test_get_runs_list_formats_columns(vim_mlflow_env): assert "RUNNING" in lines[3] assert lines[-1] == "X" assert run_ids == [run_1.info.run_id, run_2.info.run_id] + + +def test_main_page_reuses_cached_queries(vim_mlflow_env, monkeypatch): + module, state, fake_vim = vim_mlflow_env + _allow_tracking_probe(monkeypatch) + + run = make_run( + "run-aaa111", + 1_700_000_005_000, + 1_700_000_065_000, + experiment_id="99", + user_id="alice", + run_name="Warmup", + params={"lr": "0.1"}, + metrics={"loss": 0.2}, + ) + state["experiments"] = [make_experiment(99, "Alpha")] + state["runs"] = [run] + state["runs_by_id"] = {run.info.run_id: run} + state["metric_history"][(run.info.run_id, "loss")] = make_metric_history([0.4, 0.2]) + + fake_vim.g.update( + { + "vim_mlflow_version": "dev", + "vim_mlflow_viewtype": 1, + "vim_mlflow_timeout": 0.5, + "vim_mlflow_runs_cache_mode": "selected_expt", + "vim_mlflow_expts_length": 8, + "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": ">", + "vim_mlflow_section_order": ["params", "metrics", "tags", "artifacts"], + "vim_mlflow_artifacts_max_depth": 3, + } + ) + fake_vim.s.update( + { + "current_exptid": "", + "current_runid": "", + "expts_first_idx": 0, + "runs_first_idx": 0, + "params_are_showing": 1, + "metrics_are_showing": 1, + "tags_are_showing": 1, + "artifacts_are_showing": 0, + "markruns_list": [], + } + ) + + first = module.getMainPageMLflow("http://example.com", force_refresh=True) + second = module.getMainPageMLflow("http://example.com") + + assert first == second + assert state["calls"]["search_experiments"] == 1 + assert state["calls"]["search_runs"] == 1 + assert state["calls"]["get_run"] == 1 + assert state["calls"]["get_metric_history"] == 1 + + +def test_main_page_force_refresh_invalidates_cache(vim_mlflow_env, monkeypatch): + module, state, fake_vim = vim_mlflow_env + _allow_tracking_probe(monkeypatch) + + run = make_run( + "run-aaa111", + 1_700_000_005_000, + 1_700_000_065_000, + experiment_id="99", + metrics={"loss": 0.2}, + ) + state["experiments"] = [make_experiment(99, "Alpha")] + state["runs"] = [run] + state["runs_by_id"] = {run.info.run_id: run} + state["metric_history"][(run.info.run_id, "loss")] = make_metric_history([0.4, 0.2]) + + fake_vim.g.update( + { + "vim_mlflow_version": "dev", + "vim_mlflow_viewtype": 1, + "vim_mlflow_timeout": 0.5, + "vim_mlflow_runs_cache_mode": "selected_expt", + "vim_mlflow_expts_length": 8, + "vim_mlflow_runs_length": 8, + "vim_mlflow_show_scrollicons": 0, + "vim_mlflow_icon_vdivider": "|", + "vim_mlflow_icon_markrun": ">", + "vim_mlflow_section_order": ["metrics"], + "vim_mlflow_artifacts_max_depth": 3, + } + ) + fake_vim.s.update( + { + "current_exptid": "99", + "current_runid": run.info.run_id, + "expts_first_idx": 0, + "runs_first_idx": 0, + "params_are_showing": 0, + "metrics_are_showing": 1, + "tags_are_showing": 0, + "artifacts_are_showing": 0, + "markruns_list": [], + } + ) + + module.getMainPageMLflow("http://example.com", force_refresh=True) + module.getMainPageMLflow("http://example.com", force_refresh=True) + + assert state["calls"]["search_experiments"] == 2 + assert state["calls"]["search_runs"] == 2 + assert state["calls"]["get_run"] == 2 + assert state["calls"]["get_metric_history"] == 2 + + +def test_all_expts_mode_primes_run_summaries_for_each_experiment(vim_mlflow_env, monkeypatch): + module, state, fake_vim = vim_mlflow_env + _allow_tracking_probe(monkeypatch) + + run_1 = make_run("run-aaa111", 1_700_000_005_000, 1_700_000_065_000, experiment_id="99") + run_2 = make_run("run-bbb222", 1_700_000_010_000, 1_700_000_070_000, experiment_id="100") + state["experiments"] = [make_experiment(99, "Alpha"), make_experiment(100, "Beta")] + state["runs"] = [run_1, run_2] + state["runs_by_id"] = {run_1.info.run_id: run_1, run_2.info.run_id: run_2} + + fake_vim.g.update( + { + "vim_mlflow_version": "dev", + "vim_mlflow_viewtype": 1, + "vim_mlflow_timeout": 0.5, + "vim_mlflow_runs_cache_mode": "all_expts", + "vim_mlflow_expts_length": 8, + "vim_mlflow_runs_length": 8, + "vim_mlflow_show_scrollicons": 0, + "vim_mlflow_icon_vdivider": "|", + "vim_mlflow_icon_markrun": ">", + "vim_mlflow_section_order": ["params"], + "vim_mlflow_artifacts_max_depth": 3, + } + ) + fake_vim.s.update( + { + "current_exptid": "99", + "current_runid": "", + "expts_first_idx": 0, + "runs_first_idx": 0, + "params_are_showing": 0, + "metrics_are_showing": 0, + "tags_are_showing": 0, + "artifacts_are_showing": 0, + "markruns_list": [], + } + ) + + module.getMainPageMLflow("http://example.com", force_refresh=True) + + assert state["calls"]["search_experiments"] == 1 + assert state["calls"]["search_runs"] == 2 + + +def test_runs_page_uses_cached_run_details(vim_mlflow_env, monkeypatch): + module, state, fake_vim = vim_mlflow_env + _allow_tracking_probe(monkeypatch) + + run = make_run( + "run-aaa111", + 1_700_000_005_000, + 1_700_000_065_000, + experiment_id="99", + user_id="alice", + run_name="Warmup", + params={"lr": "0.1"}, + metrics={"loss": 0.2}, + ) + state["experiments"] = [make_experiment(99, "Alpha")] + state["runs"] = [run] + state["runs_by_id"] = {run.info.run_id: run} + state["metric_history"][(run.info.run_id, "loss")] = make_metric_history([0.4, 0.2]) + + fake_vim.g.update( + { + "vim_mlflow_version": "dev", + "vim_mlflow_viewtype": 1, + "vim_mlflow_timeout": 0.5, + "vim_mlflow_runs_cache_mode": "selected_expt", + "vim_mlflow_expts_length": 8, + "vim_mlflow_runs_length": 8, + "vim_mlflow_show_scrollicons": 0, + "vim_mlflow_icon_vdivider": "|", + "vim_mlflow_icon_markrun": ">", + "vim_mlflow_section_order": ["params", "metrics"], + "vim_mlflow_artifacts_max_depth": 3, + } + ) + fake_vim.s.update( + { + "current_exptid": "99", + "current_runid": run.info.run_id, + "expts_first_idx": 0, + "runs_first_idx": 0, + "params_are_showing": 1, + "metrics_are_showing": 1, + "tags_are_showing": 0, + "artifacts_are_showing": 0, + "markruns_list": [run.info.run_id[:5]], + "markruns_exptids": ["99"], + "runs_tags_are_showing": 1, + "runs_params_are_showing": 1, + "runs_metrics_are_showing": 1, + "debuglines": [], + } + ) + + module.getMainPageMLflow("http://example.com", force_refresh=True) + assert state["calls"]["get_run"] == 1 + + runs_module = importlib.import_module("vim_mlflow_runs") + out = runs_module.getRunsPageMLflow("http://example.com") + + assert any("#99" in line for line in out) + assert state["calls"]["get_run"] == 1 diff --git a/tests/vim/run_tests.vim b/tests/vim/run_tests.vim index 5f38231..e32efc3 100644 --- a/tests/vim/run_tests.vim +++ b/tests/vim/run_tests.vim @@ -3,10 +3,78 @@ let v:errors = [] source plugin/vim-mlflow.vim +highlight default Statement cterm=NONE gui=NONE +highlight default String cterm=NONE gui=NONE +highlight default Number cterm=NONE gui=NONE +highlight default Comment cterm=NONE gui=NONE +highlight default Constant cterm=NONE gui=NONE +highlight default vimParenSep cterm=NONE gui=NONE + +function! s:GetPluginSid() + for l:line in split(execute('scriptnames'), "\n") + if l:line =~# 'plugin/vim-mlflow.vim$' + return '' . matchstr(l:line, '^\s*\zs\d\+\ze:') . '_' + endif + endfor + return '' +endfunction + + +function! s:CallPluginFunc(name, args) + return call(function(s:plugin_sid . a:name), a:args) +endfunction + + +function! s:ResetLayout(vside) + silent! only + if bufexists(g:vim_mlflow_buffername) + execute 'bwipeout! ' . fnameescape(g:vim_mlflow_buffername) + endif + enew + setlocal noswapfile + let g:vim_mlflow_vside = a:vside + execute 'file ' . g:vim_mlflow_buffername +endfunction + + +function! s:GetWindowBufferNames() + let l:names = [] + for l:w in range(1, winnr('$')) + call add(l:names, bufname(winbufnr(l:w))) + endfor + return l:names +endfunction + + +function! s:GetWindowHeightForBuffer(bufname) + for l:w in range(1, winnr('$')) + if bufname(winbufnr(l:w)) ==# a:bufname + return winheight(l:w) + endif + endfor + return -1 +endfunction + + +function! s:GetWrapForBuffer(bufname) + for l:w in range(1, winnr('$')) + if bufname(winbufnr(l:w)) ==# a:bufname + return getwinvar(l:w, '&wrap') + endif + endfor + return -1 +endfunction + + +let s:plugin_sid = s:GetPluginSid() +call assert_notequal('', s:plugin_sid) + 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) +call assert_equal(66, g:vim_mlflow_plotpane_pct) +call assert_equal('selected_expt', g:vim_mlflow_runs_cache_mode) let g:vim_mlflow_icon_useunicode = 1 unlet g:vim_mlflow_icon_vdivider @@ -17,6 +85,63 @@ let g:vim_mlflow_section_order = 'invalid' call SetDefaults() call assert_equal(['params', 'metrics', 'tags', 'artifacts'], g:vim_mlflow_section_order) +call SetDefaults() +let s:file1 = tempname() +let s:file2 = tempname() +call writefile(['one'], s:file1) +call writefile(['two'], s:file2) +call s:ResetLayout('left') +call s:CallPluginFunc('ShowArtifactBuffer', ['first.txt', s:file1]) +call assert_equal([g:vim_mlflow_buffername, 'artifact://unknown/first.txt'], s:GetWindowBufferNames()) +call assert_equal(0, s:GetWrapForBuffer('artifact://unknown/first.txt')) +call s:CallPluginFunc('ShowArtifactBuffer', ['second.txt', s:file2]) +call assert_equal([g:vim_mlflow_buffername, 'artifact://unknown/second.txt'], s:GetWindowBufferNames()) +call delete(s:file1) +call delete(s:file2) + +call SetDefaults() +let s:artifact_file = tempname() +let s:artifact_file_2 = tempname() +call writefile(['artifact'], s:artifact_file) +call writefile(['artifact-two'], s:artifact_file_2) +call s:ResetLayout('left') +call s:CallPluginFunc('ShowArtifactBuffer', ['artifact.txt', s:artifact_file]) +call s:CallPluginFunc('OpenMetricPlotBuffer', ['Plot title', ['line one']]) +call assert_equal([g:vim_mlflow_buffername, '__MLflowMetricPlot__', 'artifact://unknown/artifact.txt'], s:GetWindowBufferNames()) +call assert_true(s:GetWindowHeightForBuffer('__MLflowMetricPlot__') > s:GetWindowHeightForBuffer('artifact://unknown/artifact.txt')) +call s:CallPluginFunc('ShowArtifactBuffer', ['artifact-two.txt', s:artifact_file_2]) +call assert_equal([g:vim_mlflow_buffername, '__MLflowMetricPlot__', 'artifact://unknown/artifact-two.txt'], s:GetWindowBufferNames()) +call assert_true(s:GetWindowHeightForBuffer('__MLflowMetricPlot__') > s:GetWindowHeightForBuffer('artifact://unknown/artifact-two.txt')) +call delete(s:artifact_file) +call delete(s:artifact_file_2) + +call SetDefaults() +let s:artifact_file = tempname() +let s:artifact_file_2 = tempname() +call writefile(['artifact'], s:artifact_file) +call writefile(['artifact-two'], s:artifact_file_2) +call s:ResetLayout('right') +call s:CallPluginFunc('OpenMetricPlotBuffer', ['Plot title', ['line one']]) +call s:CallPluginFunc('ShowArtifactBuffer', ['artifact.txt', s:artifact_file]) +call assert_equal(['__MLflowMetricPlot__', 'artifact://unknown/artifact.txt', g:vim_mlflow_buffername], s:GetWindowBufferNames()) +call assert_true(s:GetWindowHeightForBuffer('__MLflowMetricPlot__') > s:GetWindowHeightForBuffer('artifact://unknown/artifact.txt')) +call s:CallPluginFunc('ShowArtifactBuffer', ['artifact-two.txt', s:artifact_file_2]) +call assert_equal(['__MLflowMetricPlot__', 'artifact://unknown/artifact-two.txt', g:vim_mlflow_buffername], s:GetWindowBufferNames()) +call assert_true(s:GetWindowHeightForBuffer('__MLflowMetricPlot__') > s:GetWindowHeightForBuffer('artifact://unknown/artifact-two.txt')) +call delete(s:artifact_file) +call delete(s:artifact_file_2) + +call SetDefaults() +let g:vim_mlflow_plotpane_pct = 50 +call SetDefaults() +let s:artifact_file = tempname() +call writefile(['artifact'], s:artifact_file) +call s:ResetLayout('left') +call s:CallPluginFunc('OpenMetricPlotBuffer', ['Plot title', ['line one']]) +call s:CallPluginFunc('ShowArtifactBuffer', ['artifact.txt', s:artifact_file]) +call assert_true(abs(s:GetWindowHeightForBuffer('__MLflowMetricPlot__') - s:GetWindowHeightForBuffer('artifact://unknown/artifact.txt')) <= 1) +call delete(s:artifact_file) + if len(v:errors) > 0 echoerr join(v:errors, "\n") " echom string(v:errors)