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.
- jlab_method_pull-1.0.0/LICENSE +21 -0
- jlab_method_pull-1.0.0/PKG-INFO +78 -0
- jlab_method_pull-1.0.0/README.md +64 -0
- jlab_method_pull-1.0.0/jlab_method_pull.egg-info/PKG-INFO +78 -0
- jlab_method_pull-1.0.0/jlab_method_pull.egg-info/SOURCES.txt +9 -0
- jlab_method_pull-1.0.0/jlab_method_pull.egg-info/dependency_links.txt +1 -0
- jlab_method_pull-1.0.0/jlab_method_pull.egg-info/requires.txt +3 -0
- jlab_method_pull-1.0.0/jlab_method_pull.egg-info/top_level.txt +1 -0
- jlab_method_pull-1.0.0/jlab_method_pull.py +339 -0
- jlab_method_pull-1.0.0/pyproject.toml +22 -0
- jlab_method_pull-1.0.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|