jlab-method-pull 1.0.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: jlab_method_pull
3
+ Version: 1.0.0
4
+ Summary: Pull class methods into Jupyter cells and inject edited versions back — with optional persistence to source files
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/schmidi314/jlab_method_pull
7
+ Project-URL: Issues, https://github.com/schmidi314/jlab_method_pull/issues
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: jupyter
12
+ Requires-Dist: ipython; extra == "jupyter"
13
+ Dynamic: license-file
14
+
15
+ # jlab_method_pull
16
+
17
+ Pull class methods into editable JupyterLab cells and inject modified versions back — optionally rewriting the source file for persistence.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install jlab_method_pull
23
+ ```
24
+
25
+ To make `pullMethodCode` and `injectMethod` available automatically in every JupyterLab kernel, run once:
26
+
27
+ ```python
28
+ from jlab_method_pull import install
29
+ install()
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Pull a method into a new cell
35
+
36
+ ```python
37
+ from jlab_method_pull import pullMethodCode, injectMethod
38
+ from mymodule import MyClass
39
+
40
+ pullMethodCode(MyClass.some_method)
41
+ ```
42
+
43
+ A new cell appears below, pre-filled with the method's source, all imports from the source file, and a ready-to-run `injectMethod` call:
44
+
45
+ ```python
46
+ import numpy as np
47
+ from mymodule import MyClass, OtherClass
48
+
49
+ def some_method(self, x):
50
+ ...
51
+
52
+ injectMethod(some_method, MyClass, persistent=False)
53
+ ```
54
+
55
+ ### Inject a method back
56
+
57
+ ```python
58
+ # In-memory only (survives the session, not reimports):
59
+ injectMethod(some_method, MyClass, persistent=False)
60
+
61
+ # Persistent (also rewrites mymodule.py on disk):
62
+ injectMethod(some_method, MyClass, persistent=True)
63
+ ```
64
+
65
+ `persistent=True` uses AST to locate the exact lines of the old method in the source file and replaces them. It works correctly even if the method was previously monkey-patched in memory.
66
+
67
+ ## How it compares
68
+
69
+ | Tool | Cell injection | Runtime patch | Rewrites source file |
70
+ |---|---|---|---|
71
+ | IPython `%load` | file-level only | — | — |
72
+ | gorilla | — | yes | — |
73
+ | **jlab_method_pull** | **method-level** | **yes** | **yes** |
74
+
75
+ ## Requirements
76
+
77
+ - Python ≥ 3.9
78
+ - IPython (only required for the Jupyter cell injection and `install()`)
@@ -0,0 +1,64 @@
1
+ # jlab_method_pull
2
+
3
+ Pull class methods into editable JupyterLab cells and inject modified versions back — optionally rewriting the source file for persistence.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install jlab_method_pull
9
+ ```
10
+
11
+ To make `pullMethodCode` and `injectMethod` available automatically in every JupyterLab kernel, run once:
12
+
13
+ ```python
14
+ from jlab_method_pull import install
15
+ install()
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### Pull a method into a new cell
21
+
22
+ ```python
23
+ from jlab_method_pull import pullMethodCode, injectMethod
24
+ from mymodule import MyClass
25
+
26
+ pullMethodCode(MyClass.some_method)
27
+ ```
28
+
29
+ A new cell appears below, pre-filled with the method's source, all imports from the source file, and a ready-to-run `injectMethod` call:
30
+
31
+ ```python
32
+ import numpy as np
33
+ from mymodule import MyClass, OtherClass
34
+
35
+ def some_method(self, x):
36
+ ...
37
+
38
+ injectMethod(some_method, MyClass, persistent=False)
39
+ ```
40
+
41
+ ### Inject a method back
42
+
43
+ ```python
44
+ # In-memory only (survives the session, not reimports):
45
+ injectMethod(some_method, MyClass, persistent=False)
46
+
47
+ # Persistent (also rewrites mymodule.py on disk):
48
+ injectMethod(some_method, MyClass, persistent=True)
49
+ ```
50
+
51
+ `persistent=True` uses AST to locate the exact lines of the old method in the source file and replaces them. It works correctly even if the method was previously monkey-patched in memory.
52
+
53
+ ## How it compares
54
+
55
+ | Tool | Cell injection | Runtime patch | Rewrites source file |
56
+ |---|---|---|---|
57
+ | IPython `%load` | file-level only | — | — |
58
+ | gorilla | — | yes | — |
59
+ | **jlab_method_pull** | **method-level** | **yes** | **yes** |
60
+
61
+ ## Requirements
62
+
63
+ - Python ≥ 3.9
64
+ - IPython (only required for the Jupyter cell injection and `install()`)
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: jlab_method_pull
3
+ Version: 1.0.0
4
+ Summary: Pull class methods into Jupyter cells and inject edited versions back — with optional persistence to source files
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/schmidi314/jlab_method_pull
7
+ Project-URL: Issues, https://github.com/schmidi314/jlab_method_pull/issues
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: jupyter
12
+ Requires-Dist: ipython; extra == "jupyter"
13
+ Dynamic: license-file
14
+
15
+ # jlab_method_pull
16
+
17
+ Pull class methods into editable JupyterLab cells and inject modified versions back — optionally rewriting the source file for persistence.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install jlab_method_pull
23
+ ```
24
+
25
+ To make `pullMethodCode` and `injectMethod` available automatically in every JupyterLab kernel, run once:
26
+
27
+ ```python
28
+ from jlab_method_pull import install
29
+ install()
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ### Pull a method into a new cell
35
+
36
+ ```python
37
+ from jlab_method_pull import pullMethodCode, injectMethod
38
+ from mymodule import MyClass
39
+
40
+ pullMethodCode(MyClass.some_method)
41
+ ```
42
+
43
+ A new cell appears below, pre-filled with the method's source, all imports from the source file, and a ready-to-run `injectMethod` call:
44
+
45
+ ```python
46
+ import numpy as np
47
+ from mymodule import MyClass, OtherClass
48
+
49
+ def some_method(self, x):
50
+ ...
51
+
52
+ injectMethod(some_method, MyClass, persistent=False)
53
+ ```
54
+
55
+ ### Inject a method back
56
+
57
+ ```python
58
+ # In-memory only (survives the session, not reimports):
59
+ injectMethod(some_method, MyClass, persistent=False)
60
+
61
+ # Persistent (also rewrites mymodule.py on disk):
62
+ injectMethod(some_method, MyClass, persistent=True)
63
+ ```
64
+
65
+ `persistent=True` uses AST to locate the exact lines of the old method in the source file and replaces them. It works correctly even if the method was previously monkey-patched in memory.
66
+
67
+ ## How it compares
68
+
69
+ | Tool | Cell injection | Runtime patch | Rewrites source file |
70
+ |---|---|---|---|
71
+ | IPython `%load` | file-level only | — | — |
72
+ | gorilla | — | yes | — |
73
+ | **jlab_method_pull** | **method-level** | **yes** | **yes** |
74
+
75
+ ## Requirements
76
+
77
+ - Python ≥ 3.9
78
+ - IPython (only required for the Jupyter cell injection and `install()`)
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ jlab_method_pull.py
4
+ pyproject.toml
5
+ jlab_method_pull.egg-info/PKG-INFO
6
+ jlab_method_pull.egg-info/SOURCES.txt
7
+ jlab_method_pull.egg-info/dependency_links.txt
8
+ jlab_method_pull.egg-info/requires.txt
9
+ jlab_method_pull.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+
2
+ [jupyter]
3
+ ipython
@@ -0,0 +1 @@
1
+ jlab_method_pull
@@ -0,0 +1,339 @@
1
+ import ast
2
+ import inspect
3
+ import shutil
4
+ import textwrap
5
+ import types
6
+ from pathlib import Path
7
+
8
+
9
+ def install():
10
+ """Copy jlab_method_pull.py to the IPython startup directory.
11
+
12
+ Files in that directory are executed automatically at the start of every
13
+ IPython/JupyterLab kernel, making pullMethodCode and injectMethod
14
+ available without any explicit import.
15
+
16
+ If other startup files already define pullMethodCode or injectMethod,
17
+ the user is offered the choice to delete or disable each one.
18
+ """
19
+ try:
20
+ from IPython.paths import get_ipython_dir
21
+ startup_dir = Path(get_ipython_dir()) / "profile_default" / "startup"
22
+ except ImportError:
23
+ startup_dir = Path.home() / ".ipython" / "profile_default" / "startup"
24
+
25
+ startup_dir.mkdir(parents=True, exist_ok=True)
26
+ src = Path(__file__).resolve()
27
+ dst = startup_dir / src.name
28
+
29
+ _resolve_conflicts(startup_dir, skip=dst)
30
+
31
+ shutil.copy2(src, dst)
32
+ print(f"Installed to {dst}")
33
+ print("pullMethodCode and injectMethod will be available in every new JupyterLab kernel.")
34
+
35
+
36
+ def _defines_our_functions(path: Path) -> list[str]:
37
+ """Return which of pullMethodCode / injectMethod are defined in path."""
38
+ try:
39
+ tree = ast.parse(path.read_text())
40
+ except SyntaxError:
41
+ return []
42
+ names = {
43
+ node.name
44
+ for node in ast.walk(tree)
45
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
46
+ }
47
+ return [n for n in ("pullMethodCode", "injectMethod") if n in names]
48
+
49
+
50
+ def _resolve_conflicts(startup_dir: Path, skip: Path):
51
+ """Check every .py file in startup_dir for conflicting definitions."""
52
+ conflicts = {
53
+ p: found
54
+ for p in sorted(startup_dir.glob("*.py"))
55
+ if p != skip and (found := _defines_our_functions(p))
56
+ }
57
+
58
+ if not conflicts:
59
+ return
60
+
61
+ print("\nThe following startup files already define conflicting functions:")
62
+ for path, names in conflicts.items():
63
+ print(f" {path.name} ({', '.join(names)})")
64
+
65
+ print("\nFor each file, choose an action:")
66
+ print(" d = delete the file")
67
+ print(" x = disable it (rename to .py.disabled)")
68
+ print(" s = skip (keep as-is)")
69
+
70
+ for path, names in conflicts.items():
71
+ while True:
72
+ choice = input(f"\n [{path.name}] d / x / s? ").strip().lower()
73
+ if choice == "d":
74
+ path.unlink()
75
+ print(f" Deleted {path.name}")
76
+ break
77
+ elif choice == "x":
78
+ disabled = path.with_suffix(".py.disabled")
79
+ path.rename(disabled)
80
+ print(f" Disabled → {disabled.name}")
81
+ break
82
+ elif choice == "s":
83
+ print(f" Kept {path.name}")
84
+ break
85
+ else:
86
+ print(" Please enter d, x, or s.")
87
+
88
+
89
+ def pullMethodCode(func) -> str:
90
+ """Open a new cell with the function/method source, ready to edit and re-inject."""
91
+ # Dedent so the def is at column 0
92
+ source = textwrap.dedent(inspect.getsource(func))
93
+
94
+ qualname_parts = func.__qualname__.split(".")
95
+ is_method = len(qualname_parts) >= 2 and "<locals>" not in qualname_parts
96
+ class_name = qualname_parts[-2] if is_method else None
97
+ func_name = func.__name__
98
+ module = inspect.getmodule(func)
99
+ module_name = module.__name__ if module else None
100
+
101
+ # Detect static method and strip @staticmethod decorator from cell source
102
+ # so the user edits a plain function (injectMethod re-applies the wrapper).
103
+ cls = getattr(module, class_name, None) if (module and class_name) else None
104
+ is_static = cls is not None and isinstance(cls.__dict__.get(func_name), staticmethod)
105
+ if is_static:
106
+ source = _strip_staticmethod_decorator(source)
107
+
108
+ source_file = inspect.getfile(func)
109
+
110
+ # Collect top-level imports from the source file
111
+ file_imports = _extract_file_imports(source_file)
112
+
113
+ # All public names defined in the source file (for from-import line)
114
+ all_module_names = _all_module_level_names(source_file)
115
+
116
+ # Build cell content
117
+ lines = []
118
+ if file_imports:
119
+ lines.append(file_imports)
120
+ if module_name:
121
+ if is_method and all_module_names:
122
+ names_str = ", ".join(sorted(all_module_names))
123
+ lines.append(f"from {module_name} import {names_str}")
124
+ else:
125
+ # Module-level function: import the module itself so injectMethod can target it
126
+ lines.append(f"import {module_name}")
127
+ if all_module_names:
128
+ names_str = ", ".join(sorted(all_module_names))
129
+ lines.append(f"from {module_name} import {names_str}")
130
+ lines.append("")
131
+ lines.append(source.rstrip())
132
+ lines.append("")
133
+ target = class_name if is_method else module_name
134
+ lines.append(f"injectMethod({func_name}, {target}, persistent=False)")
135
+
136
+ cell_content = "\n".join(lines)
137
+
138
+ try:
139
+ ip = get_ipython() # noqa: F821 — available in Jupyter kernels
140
+ ip.set_next_input(cell_content, replace=False)
141
+ except NameError:
142
+ pass # not running in a Jupyter kernel
143
+
144
+
145
+ def _extract_file_imports(source_file: str) -> str:
146
+ """Return all top-level import statements from a source file as a string."""
147
+ with open(source_file, "r") as fh:
148
+ tree = ast.parse(fh.read())
149
+ lines = [
150
+ ast.unparse(node)
151
+ for node in tree.body
152
+ if isinstance(node, (ast.Import, ast.ImportFrom))
153
+ ]
154
+ return "\n".join(lines)
155
+
156
+
157
+ def _all_module_level_names(source_file: str) -> list[str]:
158
+ """Return every public name defined at module level in a source file.
159
+
160
+ Respects __all__ if present; otherwise returns all names that don't
161
+ start with an underscore (classes, functions, top-level assignments).
162
+ """
163
+ with open(source_file, "r") as fh:
164
+ tree = ast.parse(fh.read())
165
+
166
+ names = []
167
+ for node in tree.body:
168
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
169
+ names.append(node.name)
170
+ elif isinstance(node, ast.Assign):
171
+ for target in node.targets:
172
+ if isinstance(target, ast.Name):
173
+ names.append(target.id)
174
+ return names
175
+
176
+
177
+ def injectMethod(new_implementation, target, persistent=True):
178
+ """Inject a function as a method into a class, or replace a function in a module.
179
+
180
+ Parameters
181
+ ----------
182
+ new_implementation : callable
183
+ The function to inject. Its __name__ determines what is replaced or added.
184
+ target : type or module
185
+ The class or module to inject into.
186
+ persistent : bool
187
+ If True, also rewrites the target's source file so the change
188
+ survives the next import. If False, only patches in memory.
189
+ """
190
+ func_name = new_implementation.__name__
191
+
192
+ is_static = (
193
+ not isinstance(target, types.ModuleType)
194
+ and isinstance(target.__dict__.get(func_name), staticmethod)
195
+ )
196
+
197
+ if persistent:
198
+ _persist_method(new_implementation, target, func_name, is_static=is_static)
199
+
200
+ value = staticmethod(new_implementation) if is_static else new_implementation
201
+ setattr(target, func_name, value)
202
+ location = "persistent" if persistent else "in-memory only"
203
+ target_name = target.__name__ if hasattr(target, "__name__") else str(target)
204
+ print(f"Injected '{func_name}' into {target_name} ({location})")
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Internal helpers
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def _get_indented_source(new_func, class_indent: str) -> list[str]:
212
+ """Return new_func's source dedented and re-indented for the class body."""
213
+ raw = inspect.getsource(new_func)
214
+ dedented = textwrap.dedent(raw)
215
+ indented = textwrap.indent(dedented, class_indent)
216
+ if not indented.endswith("\n"):
217
+ indented += "\n"
218
+ return indented.splitlines(keepends=True)
219
+
220
+
221
+ def _class_body_indent(target_class, file_lines: list[str]) -> str:
222
+ """Detect the indentation used for method definitions inside target_class."""
223
+ class_name = target_class.__name__
224
+ inside = False
225
+ for line in file_lines:
226
+ stripped = line.lstrip()
227
+ if stripped.startswith(f"class {class_name}"):
228
+ inside = True
229
+ continue
230
+ if inside and (stripped.startswith("def ") or stripped.startswith("async def ")):
231
+ indent = line[: len(line) - len(stripped)]
232
+ if indent:
233
+ return indent
234
+ return " " # fall back to 4 spaces
235
+
236
+
237
+ def _find_func_lines_in_file(source: str, func_name: str, class_name: str | None = None):
238
+ """Return (start_lineno, end_lineno) of a function/method using AST.
239
+
240
+ If class_name is given, searches inside that class; otherwise searches at
241
+ module level. start_lineno includes any leading decorators. Both line
242
+ numbers are 1-indexed; end_lineno is inclusive.
243
+ Returns (None, None) if not found.
244
+ """
245
+ tree = ast.parse(source)
246
+
247
+ def _lines(item):
248
+ start = item.decorator_list[0].lineno if item.decorator_list else item.lineno
249
+ return start, item.end_lineno
250
+
251
+ if class_name is not None:
252
+ for node in ast.walk(tree):
253
+ if isinstance(node, ast.ClassDef) and node.name == class_name:
254
+ for item in node.body:
255
+ if (
256
+ isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
257
+ and item.name == func_name
258
+ ):
259
+ return _lines(item)
260
+ else:
261
+ for node in tree.body:
262
+ if (
263
+ isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
264
+ and node.name == func_name
265
+ ):
266
+ return _lines(node)
267
+ return None, None
268
+
269
+
270
+ def _strip_staticmethod_decorator(source: str) -> str:
271
+ """Remove the @staticmethod decorator line from a dedented function source."""
272
+ lines = source.splitlines(keepends=True)
273
+ return "".join(
274
+ line for line in lines
275
+ if line.strip() not in ("@staticmethod",)
276
+ )
277
+
278
+
279
+ def _persist_method(new_func, target, func_name: str, is_static: bool = False):
280
+ source_file = inspect.getfile(target)
281
+
282
+ with open(source_file, "r") as fh:
283
+ content = fh.read()
284
+ file_lines = content.splitlines(keepends=True)
285
+
286
+ is_module = isinstance(target, types.ModuleType)
287
+ class_name = None if is_module else target.__name__
288
+
289
+ if is_module:
290
+ # Module-level function: no extra indentation
291
+ raw = textwrap.dedent(inspect.getsource(new_func))
292
+ if not raw.endswith("\n"):
293
+ raw += "\n"
294
+ new_lines = raw.splitlines(keepends=True)
295
+ else:
296
+ class_indent = _class_body_indent(target, file_lines)
297
+ new_lines = _get_indented_source(new_func, class_indent)
298
+ if is_static:
299
+ new_lines = [f"{class_indent}@staticmethod\n"] + new_lines
300
+
301
+ start_lineno, end_lineno = _find_func_lines_in_file(content, func_name, class_name)
302
+
303
+ if start_lineno is not None:
304
+ updated = file_lines[: start_lineno - 1] + new_lines + file_lines[end_lineno:]
305
+ elif is_module:
306
+ # New module-level function — append at end of file
307
+ updated = file_lines + ["\n"] + new_lines
308
+ else:
309
+ updated = _insert_into_class(file_lines, class_name, new_lines, class_indent)
310
+
311
+ with open(source_file, "w") as fh:
312
+ fh.writelines(updated)
313
+
314
+
315
+ def _insert_into_class(
316
+ file_lines: list[str], class_name: str, new_lines: list[str], class_indent: str
317
+ ) -> list[str]:
318
+ """Insert new_lines at the end of the named class body."""
319
+ # Walk backwards from end of file to find the last line that belongs to
320
+ # the class (i.e. has at least class_indent indentation, or is blank).
321
+ in_class = False
322
+ class_start = None
323
+ for i, line in enumerate(file_lines):
324
+ stripped = line.lstrip()
325
+ if stripped.startswith(f"class {class_name}"):
326
+ in_class = True
327
+ class_start = i
328
+ continue
329
+ if in_class and line.strip() == "" :
330
+ continue
331
+ if in_class and not line.startswith(class_indent) and line.strip():
332
+ # First non-blank line after class body that is not indented
333
+ insert_at = i
334
+ break
335
+ else:
336
+ insert_at = len(file_lines)
337
+
338
+ # Add a blank separator line before the new method for readability.
339
+ return file_lines[:insert_at] + ["\n"] + new_lines + file_lines[insert_at:]
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jlab_method_pull"
7
+ version = "1.0.0"
8
+ description = "Pull class methods into Jupyter cells and inject edited versions back — with optional persistence to source files"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ jupyter = ["ipython"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/schmidi314/jlab_method_pull"
19
+ Issues = "https://github.com/schmidi314/jlab_method_pull/issues"
20
+
21
+ [tool.setuptools]
22
+ py-modules = ["jlab_method_pull"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+