plotis 0.1.9__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plotis-0.1.9 → plotis-0.2.1}/.github/workflows/publish_prod.yml +12 -5
- {plotis-0.1.9 → plotis-0.2.1}/.github/workflows/publish_test.yml +4 -2
- plotis-0.2.1/.github/workflows/tests.yml +55 -0
- {plotis-0.1.9 → plotis-0.2.1}/.gitignore +7 -0
- plotis-0.2.1/CHANGELOG.md +50 -0
- plotis-0.2.1/PKG-INFO +177 -0
- plotis-0.2.1/README.md +153 -0
- plotis-0.2.1/examples/ex1/Makefile +27 -0
- plotis-0.2.1/examples/ex1/plot_script.py +47 -0
- plotis-0.2.1/examples/ex2/Makefile +24 -0
- plotis-0.2.1/examples/ex2/house.mplstyle +10 -0
- plotis-0.2.1/examples/ex2/main.tex +32 -0
- plotis-0.2.1/examples/ex2/plot_script.py +67 -0
- plotis-0.2.1/plotis/__init__.py +10 -0
- plotis-0.2.1/plotis/plotis.py +296 -0
- {plotis-0.1.9 → plotis-0.2.1}/pyproject.toml +10 -3
- plotis-0.2.1/tests/conftest.py +31 -0
- plotis-0.2.1/tests/test_features.py +215 -0
- plotis-0.1.9/.github/workflows/tests.yml +0 -39
- plotis-0.1.9/PKG-INFO +0 -117
- plotis-0.1.9/README.md +0 -100
- plotis-0.1.9/examples/ex1/Makefile +0 -23
- plotis-0.1.9/examples/ex1/main.pdf +0 -0
- plotis-0.1.9/examples/ex1/plot_sript.py +0 -51
- plotis-0.1.9/plotis/__init__.py +0 -1
- plotis-0.1.9/plotis/plotis.py +0 -334
- plotis-0.1.9/tests/conftest.py +0 -16
- plotis-0.1.9/tests/data/__init__.py +0 -0
- plotis-0.1.9/tests/data/sample_calling_file.py +0 -112
- plotis-0.1.9/tests/data/sample_calling_file_linecount.py +0 -37
- plotis-0.1.9/tests/test_plotis.py +0 -112
- {plotis-0.1.9 → plotis-0.2.1}/LICENCE +0 -0
- {plotis-0.1.9 → plotis-0.2.1}/examples/ex1/main.tex +0 -0
- {plotis-0.1.9 → plotis-0.2.1}/tests/__init__.py +0 -0
|
@@ -30,7 +30,7 @@ jobs:
|
|
|
30
30
|
run: python3 -m build
|
|
31
31
|
|
|
32
32
|
- name: Store the distribution packages
|
|
33
|
-
uses: actions/upload-artifact@
|
|
33
|
+
uses: actions/upload-artifact@v4
|
|
34
34
|
with:
|
|
35
35
|
name: python-package-distributions
|
|
36
36
|
path: dist/
|
|
@@ -50,7 +50,7 @@ jobs:
|
|
|
50
50
|
|
|
51
51
|
steps:
|
|
52
52
|
- name: Download all the dists
|
|
53
|
-
uses: actions/download-artifact@
|
|
53
|
+
uses: actions/download-artifact@v4
|
|
54
54
|
with:
|
|
55
55
|
name: python-package-distributions
|
|
56
56
|
path: dist/
|
|
@@ -71,13 +71,20 @@ jobs:
|
|
|
71
71
|
id-token: write # IMPORTANT: mandatory for sigstore
|
|
72
72
|
|
|
73
73
|
steps:
|
|
74
|
+
- uses: actions/checkout@v4
|
|
74
75
|
- name: Download all the dists
|
|
75
|
-
uses: actions/download-artifact@
|
|
76
|
+
uses: actions/download-artifact@v4
|
|
76
77
|
with:
|
|
77
78
|
name: python-package-distributions
|
|
78
79
|
path: dist/
|
|
80
|
+
- name: Extract release notes from CHANGELOG.md
|
|
81
|
+
# Prints the section between "## [<version>]" and the next "## [" heading
|
|
82
|
+
run: >-
|
|
83
|
+
awk -v v="${GITHUB_REF_NAME#v}"
|
|
84
|
+
'/^## \[/ { p = index($0, "## [" v "]") == 1; next } p'
|
|
85
|
+
CHANGELOG.md > release-notes.md
|
|
79
86
|
- name: Sign the dists with Sigstore
|
|
80
|
-
uses: sigstore/gh-action-sigstore-python@
|
|
87
|
+
uses: sigstore/gh-action-sigstore-python@v3.0.0
|
|
81
88
|
with:
|
|
82
89
|
inputs: >-
|
|
83
90
|
./dist/*.tar.gz
|
|
@@ -89,7 +96,7 @@ jobs:
|
|
|
89
96
|
gh release create
|
|
90
97
|
'${{ github.ref_name }}'
|
|
91
98
|
--repo '${{ github.repository }}'
|
|
92
|
-
--notes
|
|
99
|
+
--notes-file release-notes.md
|
|
93
100
|
- name: Upload artifact signatures to GitHub Release
|
|
94
101
|
env:
|
|
95
102
|
GITHUB_TOKEN: ${{ github.token }}
|
|
@@ -30,7 +30,7 @@ jobs:
|
|
|
30
30
|
run: python3 -m build
|
|
31
31
|
|
|
32
32
|
- name: Store the distribution packages
|
|
33
|
-
uses: actions/upload-artifact@
|
|
33
|
+
uses: actions/upload-artifact@v4
|
|
34
34
|
with:
|
|
35
35
|
name: python-package-distributions
|
|
36
36
|
path: dist/
|
|
@@ -50,7 +50,7 @@ jobs:
|
|
|
50
50
|
|
|
51
51
|
steps:
|
|
52
52
|
- name: Download all the dists
|
|
53
|
-
uses: actions/download-artifact@
|
|
53
|
+
uses: actions/download-artifact@v4
|
|
54
54
|
with:
|
|
55
55
|
name: python-package-distributions
|
|
56
56
|
path: dist/
|
|
@@ -58,3 +58,5 @@ jobs:
|
|
|
58
58
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
59
59
|
with:
|
|
60
60
|
repository-url: https://test.pypi.org/legacy/
|
|
61
|
+
# Pushes to main that don't bump the version must not fail
|
|
62
|
+
skip-existing: true
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Installs the package with its dev extras, lints, runs the tests and the
|
|
2
|
+
# ex2 example on every supported Python version.
|
|
3
|
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
|
4
|
+
|
|
5
|
+
name: Test workflow
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
branches: [ "main" ]
|
|
10
|
+
pull_request:
|
|
11
|
+
branches: [ "main" ]
|
|
12
|
+
|
|
13
|
+
permissions:
|
|
14
|
+
contents: read
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
tests:
|
|
18
|
+
name: tests (${{ matrix.python-version }})
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
strategy:
|
|
21
|
+
fail-fast: false
|
|
22
|
+
matrix:
|
|
23
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
24
|
+
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
28
|
+
uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python-version }}
|
|
31
|
+
- name: Install package with dev extras
|
|
32
|
+
run: |
|
|
33
|
+
python -m pip install --upgrade pip
|
|
34
|
+
pip install -e ".[dev]"
|
|
35
|
+
- name: Lint with flake8
|
|
36
|
+
run: |
|
|
37
|
+
# stop the build if there are Python syntax errors or undefined names
|
|
38
|
+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.venv
|
|
39
|
+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
|
40
|
+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics --exclude=.venv
|
|
41
|
+
- name: Test with pytest
|
|
42
|
+
run: pytest
|
|
43
|
+
- name: Run the ex2 example and reproduce its figures (no LaTeX)
|
|
44
|
+
run: make -C examples/ex2 reproduce
|
|
45
|
+
|
|
46
|
+
# Single check named "test" that branch protection on main requires.
|
|
47
|
+
# It passes only if every Python version in the matrix passed, so the
|
|
48
|
+
# matrix can change without updating the protection rule.
|
|
49
|
+
test:
|
|
50
|
+
needs: tests
|
|
51
|
+
if: always()
|
|
52
|
+
runs-on: ubuntu-latest
|
|
53
|
+
steps:
|
|
54
|
+
- name: Check that all matrix jobs passed
|
|
55
|
+
run: test "${{ needs.tests.result }}" = "success"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.2.1]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- Release workflow: the Sigstore signing action is bumped to v3, since v2
|
|
11
|
+
relied on the shut-down `upload-artifact@v3`. The 0.2.0 GitHub release
|
|
12
|
+
failed because of this, so 0.2.1 replaces it. The package code is the
|
|
13
|
+
same as in 0.2.0.
|
|
14
|
+
- TestPyPI publishing on pushes to `main` skips versions that are already
|
|
15
|
+
uploaded instead of failing.
|
|
16
|
+
|
|
17
|
+
## [0.2.0]
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- `format`, `savefig_kwargs`, `style` and `render` arguments to `PlotIs`.
|
|
21
|
+
- `PlotIsError`, raised when a context cannot be isolated.
|
|
22
|
+
- `plotis.__version__`.
|
|
23
|
+
- `examples/ex2`: every option, a LaTeX document and a `make reproduce` target.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- The `with` statement is located by parsing the calling file with `ast`
|
|
27
|
+
instead of regexes and line counting. Multi-line `with` statements,
|
|
28
|
+
keyword arguments in any order, one-line bodies and nested blocks work.
|
|
29
|
+
- `run.py` resolves `data.csv`, the style file and the figure relative to
|
|
30
|
+
itself, so the folder can be moved and run from any working directory.
|
|
31
|
+
- The data is captured when the context is entered, so changes the body makes
|
|
32
|
+
to the data frame are not saved to `data.csv` (and applied twice by `run.py`).
|
|
33
|
+
- `savefig()`/`show()` statements are replaced by `pass` in `run.py`.
|
|
34
|
+
- `data.csv` is written without the index column.
|
|
35
|
+
- Nothing is written if the context raises an exception.
|
|
36
|
+
- `numpy` is no longer a runtime dependency.
|
|
37
|
+
|
|
38
|
+
### Breaking
|
|
39
|
+
- Python 3.9 is no longer supported (end-of-life); 3.10 or newer is required.
|
|
40
|
+
- Errors are raised as `PlotIsError` (or `TypeError`/`FileNotFoundError` for
|
|
41
|
+
invalid arguments) instead of a generic `Exception`.
|
|
42
|
+
- `data` must be passed as a plain variable name.
|
|
43
|
+
- `data.csv` has no index column: read it with `pd.read_csv(path)` and
|
|
44
|
+
without `index_col=0`.
|
|
45
|
+
- `run.py` saves `figure.<format>` next to itself. In 0.1 the path was
|
|
46
|
+
`<figpath>/figure.png` relative to the working directory, so `run.py`
|
|
47
|
+
only worked when run from the same directory as the original script.
|
|
48
|
+
|
|
49
|
+
## [0.1.9]
|
|
50
|
+
- Last release of the regex-based implementation.
|
plotis-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: plotis
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Plot isolation tool for python
|
|
5
|
+
Project-URL: Homepage, https://github.com/victorbrun/plotis
|
|
6
|
+
Project-URL: Issues, https://github.com/victorbrun/plotis/issues
|
|
7
|
+
Author-email: Victor Brun <me@victorbrun.com>
|
|
8
|
+
License-File: LICENCE
|
|
9
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: matplotlib
|
|
18
|
+
Requires-Dist: pandas
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: flake8; extra == 'dev'
|
|
21
|
+
Requires-Dist: numpy; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# `plotis`
|
|
26
|
+
`plotis` is a plot isolation tool for Python. It saves the data and Python code needed to reproduce a plot independently, using a pythonic interface.
|
|
27
|
+
|
|
28
|
+
This project started as a fix for repositories that balloon in size in documentation projects where images are updated often. The most common image formats are not plain text, so Git cannot version them efficiently and stores every version of every image. Instead of converting images to a plain-text format or pruning the Git history, I chose to overengineer this solution: a Python context manager extracts the code and data needed to reproduce each plot.
|
|
29
|
+
|
|
30
|
+
# Installation
|
|
31
|
+
```bash
|
|
32
|
+
pip install plotis
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
# Usage
|
|
36
|
+
## Simple example
|
|
37
|
+
`plotis` relies on the context manager `PlotIs`. Put the plotting logic inside a `PlotIs` context and pass it the data used for plotting, as shown below. On exit, `PlotIs` creates a folder containing the data (`<figfoldername>/data.csv`) and code (`<figfoldername>/run.py`) needed to reproduce the plot independently.
|
|
38
|
+
```python
|
|
39
|
+
import numpy as np
|
|
40
|
+
import pandas as pd
|
|
41
|
+
import matplotlib.pyplot as plt
|
|
42
|
+
|
|
43
|
+
from plotis import PlotIs
|
|
44
|
+
|
|
45
|
+
# Generates sample data
|
|
46
|
+
x = np.linspace(0, 4 * np.pi)
|
|
47
|
+
sample_data = pd.DataFrame({"x": x, "y": np.sin(x)})
|
|
48
|
+
|
|
49
|
+
# Creates the plot inside a PlotIs context. The folder fig1 will contain
|
|
50
|
+
# the code and data needed to reproduce the plot independently.
|
|
51
|
+
with PlotIs("fig1", sample_data):
|
|
52
|
+
sample_data.plot(x="x", y="y")
|
|
53
|
+
plt.title("Sine function")
|
|
54
|
+
plt.xlabel("x")
|
|
55
|
+
plt.ylabel("sin(x)")
|
|
56
|
+
|
|
57
|
+
# Shows the plot here; replaced by `pass` in run.py
|
|
58
|
+
plt.show()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Running `python fig1/run.py`, from any directory, writes `fig1/figure.png`.
|
|
62
|
+
|
|
63
|
+
## Options
|
|
64
|
+
```python
|
|
65
|
+
with PlotIs(
|
|
66
|
+
"figures/sine", # folder for data.csv, run.py (and the figure)
|
|
67
|
+
sample_data, # must be a plain variable name
|
|
68
|
+
format="pdf", # any format plt.savefig understands (default "png")
|
|
69
|
+
savefig_kwargs={"bbox_inches": "tight"}, # literals only
|
|
70
|
+
style="house.mplstyle", # applied in the context, copied next to run.py
|
|
71
|
+
render=True, # also write figures/sine/figure.pdf on exit
|
|
72
|
+
):
|
|
73
|
+
fig, ax = plt.subplots()
|
|
74
|
+
for name, group in sample_data.groupby("series"): # nested blocks are fine
|
|
75
|
+
ax.plot(group["x"], group["y"], label=name)
|
|
76
|
+
ax.legend()
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- `run.py` resolves `data.csv`, the style file and the output figure relative to its own location, so the folder can be moved and the script run from any working directory.
|
|
80
|
+
- The `with` statement is parsed with `ast`: it may span several lines, use keyword arguments in any order, or put the body on the same line.
|
|
81
|
+
- The data is captured when the context is entered, so changes the body makes to the data frame are not saved (and are not applied twice by `run.py`).
|
|
82
|
+
- `data.csv` is written without the index column.
|
|
83
|
+
- Nothing is written if the context raises an exception.
|
|
84
|
+
- `savefig()`/`show()` statements in the context are replaced by `pass` in `run.py`.
|
|
85
|
+
|
|
86
|
+
`examples/ex2` uses every option above. Run `make` there to build the figures and a LaTeX document, and `make reproduce` to regenerate the figures from the moved `run.py` files alone.
|
|
87
|
+
|
|
88
|
+
## Limitations
|
|
89
|
+
`run.py` contains only the body of the `with` block, preceded by `import pandas as pd`, `import matplotlib.pyplot as plt` and the data variable loaded from `data.csv`. So the body:
|
|
90
|
+
|
|
91
|
+
- may only use `pd`, `plt`, the data variable and names it defines or imports itself. Other names from the surrounding script, such as `np`, helper functions or other variables, do not exist in `run.py`.
|
|
92
|
+
- must produce a single figure, with at most one `savefig()` and one `show()` call.
|
|
93
|
+
- cannot contain another `PlotIs` context.
|
|
94
|
+
|
|
95
|
+
With `render=True`, a `plt.show()` in the body can close the figure on interactive backends, which leaves the rendered file empty. Use `render=True` without `show()`, or use a non-interactive backend such as `Agg`.
|
|
96
|
+
|
|
97
|
+
`PlotIs` must be used in a source file (not an interactive prompt or notebook cell), since it reads the `with` block from the calling file.
|
|
98
|
+
|
|
99
|
+
## Errors
|
|
100
|
+
| Exception | Raised when |
|
|
101
|
+
|---|---|
|
|
102
|
+
| `PlotIsError` | `PlotIs` is not used as a `with` statement, `data` is not a plain variable name, the body nests `PlotIs` or has more than one `savefig()`/`show()` call, `savefig_kwargs` holds non-literal values, or the calling source file cannot be read. |
|
|
103
|
+
| `TypeError` | `data` is not a `pandas.DataFrame`. |
|
|
104
|
+
| `FileNotFoundError` | The `style` file does not exist. |
|
|
105
|
+
|
|
106
|
+
## Migrating from 0.1
|
|
107
|
+
- Python 3.10 or newer is required.
|
|
108
|
+
- Catch `PlotIsError` instead of `Exception`.
|
|
109
|
+
- Pass `data` as a plain variable name (`PlotIs(path, df)`, not `PlotIs(path, df.copy())`).
|
|
110
|
+
- `data.csv` has no index column. Read it with `pd.read_csv(path)`, without `index_col=0`.
|
|
111
|
+
- `run.py` now saves `figure.<format>` next to itself instead of relative to the working directory.
|
|
112
|
+
|
|
113
|
+
See [CHANGELOG.md](CHANGELOG.md) for the full list of changes.
|
|
114
|
+
|
|
115
|
+
## LaTeX project example
|
|
116
|
+
Since this package was motivated by large documentation projects, this example shows how to hook into the compilation of a LaTeX document to insert the figure produced by the example above.
|
|
117
|
+
|
|
118
|
+
Consider a LaTeX document with the following content:
|
|
119
|
+
```latex
|
|
120
|
+
\documentclass[12pt]{article}
|
|
121
|
+
\usepackage{graphicx}
|
|
122
|
+
\usepackage{hyperref}
|
|
123
|
+
|
|
124
|
+
\title{PlotIs example one}
|
|
125
|
+
\author{Victor Brun\\ \href{https://victorbrun.com}{victorbrun.com}}
|
|
126
|
+
\date{\today}
|
|
127
|
+
|
|
128
|
+
\begin{document}
|
|
129
|
+
|
|
130
|
+
\maketitle
|
|
131
|
+
|
|
132
|
+
\section{The sine function}
|
|
133
|
+
The sine function is a trigonometric function which we denote by $\sin{x}$. In figure \ref{fig:sin} we can see that it looks like a wave.
|
|
134
|
+
|
|
135
|
+
\begin{figure}[h]
|
|
136
|
+
\centering
|
|
137
|
+
\includegraphics[width=8cm]{figures/sinus_curve.png}
|
|
138
|
+
\caption{The sine function plotted on the interval $[0, 4\pi]$.}
|
|
139
|
+
\label{fig:sin}
|
|
140
|
+
\end{figure}
|
|
141
|
+
|
|
142
|
+
\end{document}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
To keep the repository small, `figures/sinus_curve.png` is not committed, since it is not a plain-text format. The plotting script (`plot_script.py`, using `PlotIs` as above) writes `sinus_curve/data.csv` and `sinus_curve/run.py`, which are enough to recreate the figure. A Makefile then runs `run.py`, copies the image to where the LaTeX document expects it and compiles the document:
|
|
146
|
+
```make
|
|
147
|
+
PYTHON ?= python3
|
|
148
|
+
FIG := sinus_curve
|
|
149
|
+
|
|
150
|
+
all: main.pdf
|
|
151
|
+
|
|
152
|
+
# Runs the plotting script, which writes the isolated data and code
|
|
153
|
+
$(FIG)/run.py $(FIG)/data.csv: plot_script.py
|
|
154
|
+
$(PYTHON) plot_script.py
|
|
155
|
+
|
|
156
|
+
# Runs the automatically generated python script to create the plot
|
|
157
|
+
$(FIG)/figure.png: $(FIG)/run.py $(FIG)/data.csv
|
|
158
|
+
$(PYTHON) $(FIG)/run.py
|
|
159
|
+
|
|
160
|
+
# Copies the produced figure to where main.tex expects it
|
|
161
|
+
figures/$(FIG).png: $(FIG)/figure.png
|
|
162
|
+
mkdir -p figures
|
|
163
|
+
cp $< $@
|
|
164
|
+
|
|
165
|
+
# Runs pdflatex twice so that references are resolved
|
|
166
|
+
main.pdf: main.tex figures/$(FIG).png
|
|
167
|
+
pdflatex main.tex
|
|
168
|
+
pdflatex main.tex
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
In a real project you would commit `sinus_curve/` and run only the last three rules. The working example is in `examples/ex1`.
|
|
172
|
+
|
|
173
|
+
# Development
|
|
174
|
+
```bash
|
|
175
|
+
pip install -e ".[dev]"
|
|
176
|
+
pytest
|
|
177
|
+
```
|
plotis-0.2.1/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# `plotis`
|
|
2
|
+
`plotis` is a plot isolation tool for Python. It saves the data and Python code needed to reproduce a plot independently, using a pythonic interface.
|
|
3
|
+
|
|
4
|
+
This project started as a fix for repositories that balloon in size in documentation projects where images are updated often. The most common image formats are not plain text, so Git cannot version them efficiently and stores every version of every image. Instead of converting images to a plain-text format or pruning the Git history, I chose to overengineer this solution: a Python context manager extracts the code and data needed to reproduce each plot.
|
|
5
|
+
|
|
6
|
+
# Installation
|
|
7
|
+
```bash
|
|
8
|
+
pip install plotis
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
# Usage
|
|
12
|
+
## Simple example
|
|
13
|
+
`plotis` relies on the context manager `PlotIs`. Put the plotting logic inside a `PlotIs` context and pass it the data used for plotting, as shown below. On exit, `PlotIs` creates a folder containing the data (`<figfoldername>/data.csv`) and code (`<figfoldername>/run.py`) needed to reproduce the plot independently.
|
|
14
|
+
```python
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import matplotlib.pyplot as plt
|
|
18
|
+
|
|
19
|
+
from plotis import PlotIs
|
|
20
|
+
|
|
21
|
+
# Generates sample data
|
|
22
|
+
x = np.linspace(0, 4 * np.pi)
|
|
23
|
+
sample_data = pd.DataFrame({"x": x, "y": np.sin(x)})
|
|
24
|
+
|
|
25
|
+
# Creates the plot inside a PlotIs context. The folder fig1 will contain
|
|
26
|
+
# the code and data needed to reproduce the plot independently.
|
|
27
|
+
with PlotIs("fig1", sample_data):
|
|
28
|
+
sample_data.plot(x="x", y="y")
|
|
29
|
+
plt.title("Sine function")
|
|
30
|
+
plt.xlabel("x")
|
|
31
|
+
plt.ylabel("sin(x)")
|
|
32
|
+
|
|
33
|
+
# Shows the plot here; replaced by `pass` in run.py
|
|
34
|
+
plt.show()
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Running `python fig1/run.py`, from any directory, writes `fig1/figure.png`.
|
|
38
|
+
|
|
39
|
+
## Options
|
|
40
|
+
```python
|
|
41
|
+
with PlotIs(
|
|
42
|
+
"figures/sine", # folder for data.csv, run.py (and the figure)
|
|
43
|
+
sample_data, # must be a plain variable name
|
|
44
|
+
format="pdf", # any format plt.savefig understands (default "png")
|
|
45
|
+
savefig_kwargs={"bbox_inches": "tight"}, # literals only
|
|
46
|
+
style="house.mplstyle", # applied in the context, copied next to run.py
|
|
47
|
+
render=True, # also write figures/sine/figure.pdf on exit
|
|
48
|
+
):
|
|
49
|
+
fig, ax = plt.subplots()
|
|
50
|
+
for name, group in sample_data.groupby("series"): # nested blocks are fine
|
|
51
|
+
ax.plot(group["x"], group["y"], label=name)
|
|
52
|
+
ax.legend()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `run.py` resolves `data.csv`, the style file and the output figure relative to its own location, so the folder can be moved and the script run from any working directory.
|
|
56
|
+
- The `with` statement is parsed with `ast`: it may span several lines, use keyword arguments in any order, or put the body on the same line.
|
|
57
|
+
- The data is captured when the context is entered, so changes the body makes to the data frame are not saved (and are not applied twice by `run.py`).
|
|
58
|
+
- `data.csv` is written without the index column.
|
|
59
|
+
- Nothing is written if the context raises an exception.
|
|
60
|
+
- `savefig()`/`show()` statements in the context are replaced by `pass` in `run.py`.
|
|
61
|
+
|
|
62
|
+
`examples/ex2` uses every option above. Run `make` there to build the figures and a LaTeX document, and `make reproduce` to regenerate the figures from the moved `run.py` files alone.
|
|
63
|
+
|
|
64
|
+
## Limitations
|
|
65
|
+
`run.py` contains only the body of the `with` block, preceded by `import pandas as pd`, `import matplotlib.pyplot as plt` and the data variable loaded from `data.csv`. So the body:
|
|
66
|
+
|
|
67
|
+
- may only use `pd`, `plt`, the data variable and names it defines or imports itself. Other names from the surrounding script, such as `np`, helper functions or other variables, do not exist in `run.py`.
|
|
68
|
+
- must produce a single figure, with at most one `savefig()` and one `show()` call.
|
|
69
|
+
- cannot contain another `PlotIs` context.
|
|
70
|
+
|
|
71
|
+
With `render=True`, a `plt.show()` in the body can close the figure on interactive backends, which leaves the rendered file empty. Use `render=True` without `show()`, or use a non-interactive backend such as `Agg`.
|
|
72
|
+
|
|
73
|
+
`PlotIs` must be used in a source file (not an interactive prompt or notebook cell), since it reads the `with` block from the calling file.
|
|
74
|
+
|
|
75
|
+
## Errors
|
|
76
|
+
| Exception | Raised when |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `PlotIsError` | `PlotIs` is not used as a `with` statement, `data` is not a plain variable name, the body nests `PlotIs` or has more than one `savefig()`/`show()` call, `savefig_kwargs` holds non-literal values, or the calling source file cannot be read. |
|
|
79
|
+
| `TypeError` | `data` is not a `pandas.DataFrame`. |
|
|
80
|
+
| `FileNotFoundError` | The `style` file does not exist. |
|
|
81
|
+
|
|
82
|
+
## Migrating from 0.1
|
|
83
|
+
- Python 3.10 or newer is required.
|
|
84
|
+
- Catch `PlotIsError` instead of `Exception`.
|
|
85
|
+
- Pass `data` as a plain variable name (`PlotIs(path, df)`, not `PlotIs(path, df.copy())`).
|
|
86
|
+
- `data.csv` has no index column. Read it with `pd.read_csv(path)`, without `index_col=0`.
|
|
87
|
+
- `run.py` now saves `figure.<format>` next to itself instead of relative to the working directory.
|
|
88
|
+
|
|
89
|
+
See [CHANGELOG.md](CHANGELOG.md) for the full list of changes.
|
|
90
|
+
|
|
91
|
+
## LaTeX project example
|
|
92
|
+
Since this package was motivated by large documentation projects, this example shows how to hook into the compilation of a LaTeX document to insert the figure produced by the example above.
|
|
93
|
+
|
|
94
|
+
Consider a LaTeX document with the following content:
|
|
95
|
+
```latex
|
|
96
|
+
\documentclass[12pt]{article}
|
|
97
|
+
\usepackage{graphicx}
|
|
98
|
+
\usepackage{hyperref}
|
|
99
|
+
|
|
100
|
+
\title{PlotIs example one}
|
|
101
|
+
\author{Victor Brun\\ \href{https://victorbrun.com}{victorbrun.com}}
|
|
102
|
+
\date{\today}
|
|
103
|
+
|
|
104
|
+
\begin{document}
|
|
105
|
+
|
|
106
|
+
\maketitle
|
|
107
|
+
|
|
108
|
+
\section{The sine function}
|
|
109
|
+
The sine function is a trigonometric function which we denote by $\sin{x}$. In figure \ref{fig:sin} we can see that it looks like a wave.
|
|
110
|
+
|
|
111
|
+
\begin{figure}[h]
|
|
112
|
+
\centering
|
|
113
|
+
\includegraphics[width=8cm]{figures/sinus_curve.png}
|
|
114
|
+
\caption{The sine function plotted on the interval $[0, 4\pi]$.}
|
|
115
|
+
\label{fig:sin}
|
|
116
|
+
\end{figure}
|
|
117
|
+
|
|
118
|
+
\end{document}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
To keep the repository small, `figures/sinus_curve.png` is not committed, since it is not a plain-text format. The plotting script (`plot_script.py`, using `PlotIs` as above) writes `sinus_curve/data.csv` and `sinus_curve/run.py`, which are enough to recreate the figure. A Makefile then runs `run.py`, copies the image to where the LaTeX document expects it and compiles the document:
|
|
122
|
+
```make
|
|
123
|
+
PYTHON ?= python3
|
|
124
|
+
FIG := sinus_curve
|
|
125
|
+
|
|
126
|
+
all: main.pdf
|
|
127
|
+
|
|
128
|
+
# Runs the plotting script, which writes the isolated data and code
|
|
129
|
+
$(FIG)/run.py $(FIG)/data.csv: plot_script.py
|
|
130
|
+
$(PYTHON) plot_script.py
|
|
131
|
+
|
|
132
|
+
# Runs the automatically generated python script to create the plot
|
|
133
|
+
$(FIG)/figure.png: $(FIG)/run.py $(FIG)/data.csv
|
|
134
|
+
$(PYTHON) $(FIG)/run.py
|
|
135
|
+
|
|
136
|
+
# Copies the produced figure to where main.tex expects it
|
|
137
|
+
figures/$(FIG).png: $(FIG)/figure.png
|
|
138
|
+
mkdir -p figures
|
|
139
|
+
cp $< $@
|
|
140
|
+
|
|
141
|
+
# Runs pdflatex twice so that references are resolved
|
|
142
|
+
main.pdf: main.tex figures/$(FIG).png
|
|
143
|
+
pdflatex main.tex
|
|
144
|
+
pdflatex main.tex
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
In a real project you would commit `sinus_curve/` and run only the last three rules. The working example is in `examples/ex1`.
|
|
148
|
+
|
|
149
|
+
# Development
|
|
150
|
+
```bash
|
|
151
|
+
pip install -e ".[dev]"
|
|
152
|
+
pytest
|
|
153
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
PYTHON ?= python3
|
|
2
|
+
FIG := sinus_curve
|
|
3
|
+
|
|
4
|
+
all: main.pdf
|
|
5
|
+
|
|
6
|
+
# Runs the plotting script, which writes the isolated data and code
|
|
7
|
+
$(FIG)/run.py $(FIG)/data.csv: plot_script.py
|
|
8
|
+
$(PYTHON) plot_script.py
|
|
9
|
+
|
|
10
|
+
# Runs the automatically generated python script to create the plot
|
|
11
|
+
$(FIG)/figure.png: $(FIG)/run.py $(FIG)/data.csv
|
|
12
|
+
$(PYTHON) $(FIG)/run.py
|
|
13
|
+
|
|
14
|
+
# Copies the produced figure to where main.tex expects it
|
|
15
|
+
figures/$(FIG).png: $(FIG)/figure.png
|
|
16
|
+
mkdir -p figures
|
|
17
|
+
cp $< $@
|
|
18
|
+
|
|
19
|
+
# Runs pdflatex twice so that references are resolved
|
|
20
|
+
main.pdf: main.tex figures/$(FIG).png
|
|
21
|
+
pdflatex main.tex
|
|
22
|
+
pdflatex main.tex
|
|
23
|
+
|
|
24
|
+
clean:
|
|
25
|
+
rm -rf $(FIG) figures main.aux main.log main.out main.pdf
|
|
26
|
+
|
|
27
|
+
.PHONY: all clean
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Example showing a simple use case of PlotIs.
|
|
2
|
+
|
|
3
|
+
Run ``make`` in this folder. It runs this script, which writes
|
|
4
|
+
``sinus_curve/data.csv`` and ``sinus_curve/run.py``, then regenerates the
|
|
5
|
+
figure from ``run.py`` alone and builds ``main.pdf``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import matplotlib
|
|
11
|
+
|
|
12
|
+
matplotlib.use("Agg")
|
|
13
|
+
|
|
14
|
+
import matplotlib.pyplot as plt # noqa: E402
|
|
15
|
+
import numpy as np # noqa: E402
|
|
16
|
+
import pandas as pd # noqa: E402
|
|
17
|
+
|
|
18
|
+
from plotis import PlotIs # noqa: E402
|
|
19
|
+
|
|
20
|
+
# The figure folder is placed next to this file, whatever the working directory
|
|
21
|
+
FIGURE_FOLDER = Path(__file__).resolve().parent / "sinus_curve"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_data() -> pd.DataFrame:
|
|
25
|
+
"""Generate sample data using the sine function."""
|
|
26
|
+
x = np.linspace(0, 4 * np.pi)
|
|
27
|
+
return pd.DataFrame({"x": x, "y": np.sin(x)})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run() -> None:
|
|
31
|
+
sample_data = generate_data()
|
|
32
|
+
|
|
33
|
+
# Plot the data frame as usual, but inside the PlotIs context. This saves
|
|
34
|
+
# sample_data and the code in the context, so that the plot can be
|
|
35
|
+
# reproduced without any dependency on this file.
|
|
36
|
+
with PlotIs(FIGURE_FOLDER, sample_data):
|
|
37
|
+
sample_data.plot(x="x", y="y")
|
|
38
|
+
plt.title("Sine function")
|
|
39
|
+
plt.xlabel("x")
|
|
40
|
+
plt.ylabel("sin(x)")
|
|
41
|
+
|
|
42
|
+
# Replaced by `pass` in run.py
|
|
43
|
+
plt.show()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
run()
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
PYTHON ?= python3
|
|
2
|
+
|
|
3
|
+
all: figures main.pdf
|
|
4
|
+
|
|
5
|
+
# Runs the plotting script: writes figures/*/{data.csv,run.py,house.mplstyle,figure.pdf}
|
|
6
|
+
figures: plot_script.py house.mplstyle
|
|
7
|
+
$(PYTHON) plot_script.py
|
|
8
|
+
|
|
9
|
+
main.pdf: main.tex figures
|
|
10
|
+
pdflatex main.tex
|
|
11
|
+
pdflatex main.tex
|
|
12
|
+
|
|
13
|
+
# Shows that run.py is self-contained and relocatable: the figure folders are
|
|
14
|
+
# moved, and each figure is regenerated from run.py + data.csv, run from /
|
|
15
|
+
reproduce: figures
|
|
16
|
+
rm -rf moved && mkdir moved && cp -R figures/waves figures/bars moved/
|
|
17
|
+
rm moved/*/figure.pdf
|
|
18
|
+
cd / && for d in "$(CURDIR)"/moved/*; do $(PYTHON) "$$d/run.py"; done
|
|
19
|
+
ls moved/*/figure.pdf
|
|
20
|
+
|
|
21
|
+
clean:
|
|
22
|
+
rm -rf figures moved main.aux main.log main.out main.pdf
|
|
23
|
+
|
|
24
|
+
.PHONY: all figures reproduce clean
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Shared house style, applied inside the PlotIs context and copied next to run.py
|
|
2
|
+
figure.figsize: 4.5, 2.8
|
|
3
|
+
font.family: serif
|
|
4
|
+
font.size: 9
|
|
5
|
+
axes.spines.top: False
|
|
6
|
+
axes.spines.right: False
|
|
7
|
+
axes.grid: True
|
|
8
|
+
grid.color: "#e4e3df"
|
|
9
|
+
legend.frameon: False
|
|
10
|
+
axes.prop_cycle: cycler("color", ["2a78d6", "eb6834", "1f9e74"])
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
\documentclass[11pt]{article}
|
|
2
|
+
\usepackage{graphicx}
|
|
3
|
+
\usepackage{hyperref}
|
|
4
|
+
\usepackage[utf8]{inputenc}
|
|
5
|
+
|
|
6
|
+
\title{PlotIs example two: every 0.2 option}
|
|
7
|
+
\author{Victor Brun\\ \href{https://victorbrun.com}{victorbrun.com}}
|
|
8
|
+
\date{\today}
|
|
9
|
+
|
|
10
|
+
\begin{document}
|
|
11
|
+
\maketitle
|
|
12
|
+
|
|
13
|
+
Both figures below are vector PDFs written by \texttt{PlotIs(..., format="pdf",
|
|
14
|
+
render=True)} and included straight from their figure folders. Nothing is
|
|
15
|
+
copied or moved.
|
|
16
|
+
|
|
17
|
+
\begin{figure}[h]
|
|
18
|
+
\centering
|
|
19
|
+
\includegraphics{figures/waves/figure.pdf}
|
|
20
|
+
\caption{Three phase-shifted sine waves, drawn with a \texttt{for} loop and
|
|
21
|
+
an \texttt{if} inside the PlotIs context and styled by \texttt{house.mplstyle}.}
|
|
22
|
+
\label{fig:waves}
|
|
23
|
+
\end{figure}
|
|
24
|
+
|
|
25
|
+
\begin{figure}[h]
|
|
26
|
+
\centering
|
|
27
|
+
\includegraphics{figures/bars/figure.pdf}
|
|
28
|
+
\caption{A bar chart with non-ASCII labels.}
|
|
29
|
+
\label{fig:bars}
|
|
30
|
+
\end{figure}
|
|
31
|
+
|
|
32
|
+
\end{document}
|