plotis 0.1.6.2__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .plotis import *
plotis/plotis.py ADDED
@@ -0,0 +1,334 @@
1
+ # Standard lib imports
2
+ import os
3
+ import inspect
4
+ import re
5
+
6
+ # Dependencies imports
7
+ import pandas as pd
8
+
9
+ # Specified imports
10
+ from contextlib import AbstractContextManager
11
+ from typing import Any, List
12
+ from types import TracebackType
13
+
14
+ class PlotIs(AbstractContextManager):
15
+ """A context manager for isolating, and packaging everything
16
+ needed to independently reproduce plots.
17
+
18
+ The plotting logic placed in this context may only rely on
19
+ matplotlib.pyplot imported as `import matplotlib,pyplot as plt`.
20
+
21
+ The plotting logic in this context may only produce a single figure.
22
+
23
+ Every `.show()` and `.savefig()` call will be removed from the plotting
24
+ logic before being saved to `run.py`.
25
+
26
+ This class can only be used in compination with the keywork `with`.
27
+
28
+ Attributes
29
+ ----------
30
+ figpath : str
31
+ Path of the folder in which `data.csv` and `run.py` will be saved.
32
+ data : pandas.DataFrame
33
+ Data used in plotting logic.
34
+ calling_filename : str
35
+ Name of the file calling the contructor. This will be set
36
+ during runtime.
37
+ calling_context_line_start : int
38
+ Line number + 1 of the constructor call. This will be set during
39
+ runtime.
40
+ calling_line_end : int
41
+ Last line of context related to the call of this constructor.
42
+ This will be set during runtime.
43
+ context_source_lines : List[str]
44
+ Lines of code contain in the context related to the call to
45
+ the constructor, i.e. the lines
46
+ [calling_context_line_start, calling_context_line_end].
47
+ """
48
+
49
+ def __init__(self, figpath: str, data: pd.DataFrame) -> None:
50
+ self.figpath = figpath
51
+ self.data = data
52
+
53
+ # Information about the calling file
54
+ self.calling_filename = ""
55
+ self.calling_context_line_start = -1 # This is set to calling line + 1
56
+ self.calling_line_end = -1
57
+ self.constructing_line = "" # Line of code conaining the constructor call
58
+ self.context_source_lines = []
59
+
60
+ def __enter__(self) -> Any:
61
+ calling_frame = inspect.stack()[1]
62
+
63
+ # Ensuring we have calling code context
64
+ if calling_frame.code_context is None:
65
+ raise Exception("Could not find code context")
66
+ self.constructing_line = calling_frame.code_context[0]
67
+
68
+ # Ensuring that this function is called
69
+ # as part of a with statement
70
+ regex_str = r"(\s{1,}|)with PlotIs[(](\s|\S|){1,}[)] as \S{1,}:[\n]|(\s{1,}|)with PlotIs[(](\s|\S|){1,}[)]:[\n]"
71
+ with_context_pattern = re.compile(regex_str)
72
+ if with_context_pattern.fullmatch(self.constructing_line) is None :
73
+ raise Exception(f"PlotIs must be called by `with`: {calling_frame.code_context[0]}")
74
+
75
+ # Parsing calling frame to set up attributes
76
+ self.calling_filename = calling_frame.filename
77
+ self.calling_context_line_start = calling_frame.lineno + 1 # Exclude calling line
78
+ self.calling_context_line_end = self._get_last_lineno_of_context(
79
+ self.calling_filename,
80
+ self.calling_context_line_start,
81
+ include_calling_line=False
82
+ )
83
+
84
+ # Extacting source lines
85
+ lines = []
86
+ with open(self.calling_filename, "r") as fp:
87
+ # Colleting the source lines of our context.
88
+ # Note that code lines start counting at 1 while
89
+ # list index starts at 0.
90
+ lbnd_inclusive = self.calling_context_line_start - 1
91
+ ubnd_exclusive = self.calling_context_line_end
92
+ lines = fp.readlines()[lbnd_inclusive:ubnd_exclusive]
93
+
94
+ # Ensuring that there is not more than one plot in the context
95
+ # NOTE: this method is very yanky, see _has_multiple_plots().
96
+ if PlotIs._has_multiple_plots(lines) is True:
97
+ raise Exception("Multiple figures are not supported. You cannot have more than one savefig or show calls in context")
98
+
99
+ # Formats and cleans source code and save it in attribute
100
+ self.context_source_lines = self._clean_context_source(lines)
101
+
102
+ # Ensuring that there are no nested with contexts using PlotIs
103
+ line_match_list = [with_context_pattern.match(line) is not None for line in self.context_source_lines]
104
+ if True in line_match_list:
105
+ raise Exception("PlotIs does not support nested `with` contexts using PlotIs")
106
+
107
+ def __exit__(
108
+ self,
109
+ __exc_type: type[BaseException] | None,
110
+ __exc_value: BaseException | None,
111
+ __traceback: TracebackType | None
112
+ ) -> bool | None:
113
+ calling_frame = inspect.stack()[1]
114
+ self.calling_line_end = calling_frame.lineno
115
+
116
+ # Creates the folder in which to write the data and code
117
+ if not os.path.exists(self.figpath):
118
+ os.makedirs(self.figpath)
119
+
120
+ # Writing data to figpath
121
+ data_file_path = self.figpath + f"/data.csv"
122
+ print(f"Writing data to: {data_file_path}")
123
+ with open(data_file_path, "w+") as fp:
124
+ self.data.to_csv(fp)
125
+
126
+ # Writing concatinating data load code with
127
+ # context source and writing it to file
128
+ code_file_path = self.figpath + "/run.py"
129
+ print(f"Writing context code to: {code_file_path}")
130
+ with open(code_file_path, "w+") as fp:
131
+ code_to_write = self._get_import_code()
132
+ code_to_write += self._get_data_load_code()
133
+ code_to_write += self.context_source_lines
134
+ code_to_write += self._get_savefig_code("png")
135
+ fp.writelines(code_to_write)
136
+
137
+ def _get_last_lineno_of_context(
138
+ self,
139
+ calling_filename: str,
140
+ calling_line_no: int,
141
+ include_calling_line: bool = False
142
+ ) -> int:
143
+ """Returns the last line number of the calling context specified in
144
+ `calling_frame`.
145
+ """
146
+ with open(calling_filename, "r") as fp:
147
+ lines = fp.readlines()
148
+
149
+ # Initiates the returning variable as the base case
150
+ last_lineno_context = len(lines)
151
+
152
+ # Specifies the line to start analysing our context from.
153
+ # The number is dependent on if we want to include the calling
154
+ # line. The reason for this is that in a for caluse, or with
155
+ # clause, the calling line has a different indentation than
156
+ # the context we are trying to analyse.
157
+ starting_line_no = None
158
+ if include_calling_line is True:
159
+ starting_line_no = calling_line_no
160
+ else:
161
+ starting_line_no = calling_line_no + 1
162
+
163
+ # Iterate over each line of code in starting at calling frame
164
+ # (or one line after it) until indentation becomes less than
165
+ # the initial one, i.e. stepping out of the context.
166
+ context_indent_base = None
167
+ for lineno in range(starting_line_no, len(lines)+1):
168
+ line = lines[lineno-1]
169
+ current_indent = self._get_indentation(line)
170
+
171
+ # For the first iteration we save the number
172
+ # of indentation defining the calling context
173
+ if lineno == starting_line_no:
174
+ context_indent_base = current_indent
175
+ continue
176
+
177
+ # The first time at which the number of
178
+ # indentations are less than the context base
179
+ # defines beginning of the next context.
180
+ #
181
+ # Note: that we do not consider a new line. If we do not do this
182
+ # code in same context but separated by new line will be
183
+ # incorrecly excluded.
184
+ any_white_space = re.compile(r"\s")
185
+ if current_indent < context_indent_base and not any_white_space.fullmatch(line):
186
+ # When current indent is less than base indent
187
+ # for the first time we are at first line in next
188
+ # context. Therefore we subtract one to get last
189
+ # line in context we are analysing
190
+ last_lineno_context = lineno - 1
191
+ break
192
+
193
+ return last_lineno_context
194
+
195
+ @staticmethod
196
+ def _get_indentation(line: str) -> int:
197
+ """Gets the number of indentation charachters used in `line`.
198
+
199
+ Parameters
200
+ ----------
201
+ line : str
202
+ Code line for which to get the number of indentations.
203
+
204
+ Returns
205
+ -------
206
+ int
207
+ Number of whitespace characters used for indentation in `line`.
208
+ """
209
+ leading_spaces = len(line) - len(line.lstrip())
210
+ return leading_spaces
211
+
212
+
213
+ def _get_data_load_code(self) -> List[str]:
214
+ """Returns the strings of code needed to load the data, which is saved in
215
+ data.csv, into a variable of the same name as was supplied to the data argument
216
+ in the PlotIs constructor.
217
+
218
+ Returns
219
+ -------
220
+ List[str]
221
+ List of strings. Each entry is one line of pyhton code.
222
+ """
223
+ # Checking if the contructor call is using PlotIs(data=xxx, xxx)
224
+ # or PlotIs(some_file_path, some_pd_df)
225
+ variable_name = ""
226
+ regex_str = r"data=[\S\s]+"
227
+ data_pattern_match = re.search(regex_str, self.constructing_line)
228
+ if data_pattern_match is not None:
229
+ # Extracts the name of the variable storing the data
230
+ variable_name = self.constructing_line.split("data=")[1].strip()
231
+ variable_name = variable_name.replace(")", "").replace(":", "")
232
+ variable_name = variable_name.strip()
233
+ else:
234
+ # Extracts the name of the variable storgin the data
235
+ variable_name = self.constructing_line.split(",")[1].strip()
236
+ variable_name = variable_name.replace(")", "").replace(":", "")
237
+ variable_name = variable_name.strip()
238
+
239
+ # Ensuring that something was catpured by above logic
240
+ if variable_name == "":
241
+ raise ValueError("Something went wrong. Could not parse varable name containing data")
242
+
243
+ # Constructing line of code to load data from data.csv into variable
244
+ data_file_path = self.figpath + f"/data.csv"
245
+ data_load_code = [
246
+ f"{variable_name} = pd.read_csv(\"{data_file_path}\")\n\n"
247
+ ]
248
+
249
+ return data_load_code
250
+
251
+ def _get_savefig_code(self, format: str) -> List[str]:
252
+ """Returns lines of code needed to save
253
+ the most recently created figure.
254
+ """
255
+ save_path = self.figpath + "/figure." + format
256
+ return [f"plt.savefig(\"{save_path}\")"]
257
+
258
+ def _get_import_code(self) -> List[str]:
259
+ """Returns lines of code with neccessary imports.
260
+ """
261
+ code = [
262
+ "import pandas as pd\n",
263
+ "import matplotlib.pyplot as plt\n",
264
+ "\n"
265
+ ]
266
+
267
+ return code
268
+
269
+ def _clean_context_source(self, code: List[str]) -> List[str]:
270
+ """Cleans and formats the input code such that it can be writen
271
+ to independent pyhton file without any unexpected behaviour.
272
+
273
+ Parameters
274
+ ----------
275
+ code : List[str]
276
+ List of code lines to be cleaned and formated.
277
+
278
+ Returns
279
+ -------
280
+ List[str]
281
+ Input list where each string element has been formated and
282
+ cleaned.
283
+ """
284
+ # Removing indentation
285
+ code = [line.lstrip() for line in code]
286
+
287
+ # Translating empty lines to blank rows
288
+ for ix, line in enumerate(code):
289
+ if len(line) == 0:
290
+ code[ix] = "\n"
291
+
292
+ # Ensuring that last element in each code line is a line break
293
+ for ix, line in enumerate(code):
294
+ if line[len(line)-1] != "\n":
295
+ line = line + "\n"
296
+ code[ix] = line
297
+
298
+ # Removing any lines containing savefig
299
+ code = [line for line in code if ".savefig" not in line]
300
+
301
+ # Removing any lines containing plt.show()
302
+ code = [line for line in code if "plt.show(" not in line]
303
+
304
+ # Removes all in line comments
305
+ code = [line for line in code if line[0] != "#"]
306
+
307
+ return code
308
+
309
+ @staticmethod
310
+ def _has_multiple_plots(code: List[str]) -> bool:
311
+ """Returns false if there are more than one
312
+ savefig() and or plt.show() call in `code`,
313
+ otherwise true.
314
+
315
+ Parameters
316
+ ----------
317
+ code : List[str]
318
+ List of code lines in which to check if there are
319
+ more than one figure.
320
+
321
+ Returns
322
+ -------
323
+ bool
324
+ False if there are more than one savefig() and or
325
+ plt.show() call in `code`, otherwise it returns True.
326
+ """
327
+ n_show = 0
328
+ n_savefig = 0
329
+ for line in code:
330
+ n_show += 1 if ".show()" in line else 0
331
+ n_savefig += 1 if "savefig(" in line else 0
332
+
333
+ return n_show > 1 or n_savefig > 1
334
+
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.3
2
+ Name: plotis
3
+ Version: 0.1.6.2
4
+ Summary: Plot isolation tool for pyhton
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
+ Requires-Python: >=3.9
13
+ Requires-Dist: matplotlib
14
+ Requires-Dist: numpy
15
+ Requires-Dist: pandas
16
+ Description-Content-Type: text/markdown
17
+
18
+ # `plotis`
19
+ `plotis` is a plot isolation tool for python. It saves the data and python code needed to independently reproduce plots using a pythonic interface.
20
+
21
+ This project was initiated to address the issue of repository size ballooning in documentation projects where images are frequently updated. Since the most common image formats are not plain text, Git cannot version control them effectively, resulting in the storage of 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. This solution uses Python context managers to extract the necessary code and data, enabling the independent reproduction of plots.
22
+
23
+ # Installation
24
+ ```bash
25
+ pip install plotis
26
+ ```
27
+
28
+ # Usage
29
+ ## Simple example
30
+ `plotis` relies on the context manager `PlotIs`. By placing the plotting logic inside the `PlotIs` context and providing the data used for plotting, as shown below, a folder will be created in the working directory. This folder contains the data (`<figfoldername>/data.csv`) and code (`<figfoldername>/run.py`) needed to reproduce the plot independently.
31
+ ```python
32
+ # Import base dependencies
33
+ import pandas as pd
34
+ import matplotlib.pyplot as plt
35
+
36
+ # Imports PlotIs context manager from plotis package
37
+ from plotis import PlotIs
38
+
39
+ # Generates sample data
40
+ x = np.linspace(0, 4*math.pi)
41
+ y = np.sin(x)
42
+ sample_data = pd.DataFrame(data={"x": x, "y": y})
43
+
44
+ # Specify the name of the folder which will contain
45
+ # code and data needed to independently reproduce the plot.
46
+ figure_folder = "fig1"
47
+
48
+ # Creates plot inside PlotIs context
49
+ with PlotIs(figure_folder, sample_data):
50
+ # Plots data
51
+ sample_data.plot(x="x", y="y")
52
+ plt.title("Sin function")
53
+ plt.xlabel("x")
54
+ plt.ylabel("sin(x)")
55
+
56
+ # Shows plot
57
+ plt.show()
58
+ ```
59
+
60
+ ## LaTeX project example
61
+ As this package was motivate by large documentation projects, this example will showcase how to "highjack" the compilation process of a LaTeX document to insert the figure produced by above example.
62
+
63
+ Consider a LaTeX document containing the following content:
64
+ ```latex
65
+ \documentclass[12pt]{article}
66
+ \usepackage{graphicx}
67
+ \usepackage{hyperref}
68
+
69
+ \title{PlotIs example one}
70
+ \author{Victor Brun\\ \href{https://victorbrun.com}{victorbrun.com}}
71
+ \date{\today}
72
+
73
+ \begin{document}
74
+
75
+ \maketitle
76
+
77
+ \section{The sinus function}
78
+ The sinus 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.
79
+
80
+ \begin{figure}[h]
81
+ \centering
82
+ \includegraphics[width=8cm]{figures/sinus_curve.png}
83
+ \caption{The sinus function plotted on the interval $[0, 4\pi]$.}
84
+ \label{fig:sin}
85
+ \end{figure}
86
+
87
+ \end{document}
88
+ ```
89
+
90
+ Since you want to version control the figures in this huge LaTeX project, `figures/sinus_curve.png` does not exist in the repository (as it is a non-plain text image format). Fortunately, you have already written the logic using `plotis` (the code in the above example) needed to produce the figure `sinus_curve.png`. The result produced by running the `plotis` code, `fig1/data.csv`, and `fig1/run.py`, is sufficient to recreate the figure. You can create a Makefile to run the plotting script `fig1/run.py` and subsequently move the produced image to the appropriate folder in your LaTeX project. The Makefile is as follows:
91
+ ```make
92
+ all: | plotgen move pdflatex
93
+ # Executes below targets in the following order
94
+ # 1. plotget
95
+ # 2. move
96
+ # 3. pdflatex
97
+
98
+ plotgen: sinus_curve/run.py sinus_curve/data.csv
99
+ # Runs the automatically generated pyhton
100
+ # script to create the plot
101
+ python3 fig1/run.py
102
+
103
+ move: sinus_curve/figure.png
104
+ @echo "\nCreating folder figures if it does not exist"
105
+ mkdir -p figures
106
+
107
+ @echo "\nCopying produced fiure to figures/"
108
+ cp fig1/figure.png figures/sinus_curve.png
109
+
110
+ pdflatex: main.tex
111
+ # Runs pdflatex twice to make sure
112
+ # references are created correctly
113
+ pdflatex main.tex
114
+ pdflatex main.tex
115
+ ```
116
+
117
+ This can be found as a working example in `examples/ex1`.
@@ -0,0 +1,6 @@
1
+ plotis/__init__.py,sha256=8iupCzp5AV4mIRN1ir5GOGiLKpcU4PbcZDP4g_UBCgE,22
2
+ plotis/plotis.py,sha256=AblIIVylWg_fIy2wHoO7VXxn9x1DlE0wNMFOZcETvkk,12831
3
+ plotis-0.1.6.2.dist-info/METADATA,sha256=DMJWa_qxbO3Cs3SKaIdMgkpzwvmgSn37zHZdwpoERdY,4523
4
+ plotis-0.1.6.2.dist-info/WHEEL,sha256=1yFddiXMmvYK7QYTqtRNtX66WJ0Mz8PYEiEUoOUUxRY,87
5
+ plotis-0.1.6.2.dist-info/licenses/LICENCE,sha256=GjwAED6oFuY0O8Hpmq8JTEqsPLMY596DuBSNh46Irq8,1498
6
+ plotis-0.1.6.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.25.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Victor Brun
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.