fastweb3-console 0.1.0__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.
- fastweb3_console-0.1.0.dist-info/METADATA +98 -0
- fastweb3_console-0.1.0.dist-info/RECORD +16 -0
- fastweb3_console-0.1.0.dist-info/WHEEL +5 -0
- fastweb3_console-0.1.0.dist-info/entry_points.txt +2 -0
- fastweb3_console-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastweb3_console-0.1.0.dist-info/top_level.txt +1 -0
- fw3_console/__init__.py +0 -0
- fw3_console/autosuggest.py +63 -0
- fw3_console/cli.py +104 -0
- fw3_console/completion.py +163 -0
- fw3_console/contracts.py +155 -0
- fw3_console/display.py +71 -0
- fw3_console/history.py +114 -0
- fw3_console/parser.py +114 -0
- fw3_console/proxy_warnings.py +76 -0
- fw3_console/resolver.py +158 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastweb3-console
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Interactive console and script runner for working with EVM blockchains.
|
|
5
|
+
Author: iamdefinitelyahuman
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/iamdefinitelyahuman/fastweb3-console
|
|
8
|
+
Project-URL: Repository, https://github.com/iamdefinitelyahuman/fastweb3-console
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Requires-Dist: fastweb3-objects<0.2.0,>=0.1.3
|
|
13
|
+
Requires-Dist: lazy-object-proxy>=1.12.0
|
|
14
|
+
Requires-Dist: platformdirs>=4.0
|
|
15
|
+
Requires-Dist: prompt-toolkit<4.0.0,>=3.0.52
|
|
16
|
+
Requires-Dist: pygments>=2.18
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov>=5; extra == "dev"
|
|
20
|
+
Requires-Dist: ruff>=0.9; extra == "dev"
|
|
21
|
+
Requires-Dist: pre-commit>=3; extra == "dev"
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: twine; extra == "dev"
|
|
24
|
+
Requires-Dist: mkdocs>=1.6; extra == "dev"
|
|
25
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "dev"
|
|
26
|
+
Provides-Extra: docs
|
|
27
|
+
Requires-Dist: mkdocs>=1.6; extra == "docs"
|
|
28
|
+
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# fastweb3-console
|
|
32
|
+
|
|
33
|
+
Interactive console and script runner for working with EVM blockchains.
|
|
34
|
+
|
|
35
|
+
**NOTE**: This library is still in early alpha development. Prior to a `v1.0.0` (which may never come), expect breaking changes and no backward compatibility between versions.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
You can install the latest release via `pip`:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install fastweb3-console
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or clone the repository for the most up-to-date version:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
git clone https://github.com/iamdefinitelyahuman/fastweb3-console.git
|
|
49
|
+
cd fastweb3-console
|
|
50
|
+
pip install -e .
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
Launch the console with:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
fw3
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The console starts as a normal Python REPL with the main [`fastweb3-objects`](https://github.com/iamdefinitelyahuman/fastweb3-objects) entry points already available:
|
|
62
|
+
|
|
63
|
+
```py
|
|
64
|
+
>>> accounts
|
|
65
|
+
<Accounts ...>
|
|
66
|
+
>>> Chain
|
|
67
|
+
<class 'fw3_objects.chain.Chain'>
|
|
68
|
+
>>> Contract
|
|
69
|
+
<class 'fw3_objects.contract.Contract'>
|
|
70
|
+
>>> Transaction
|
|
71
|
+
<class 'fw3_objects.transaction.Transaction'>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Users familiar with the Brownie console should feel right at home.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
First, install the dev dependencies:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -e ".[dev]"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Run the test suite with:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pytest
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Run linting with:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
ruff check .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
This project is licensed under the MIT license.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
fastweb3_console-0.1.0.dist-info/licenses/LICENSE,sha256=S1B8PiiVvVAWPT42fJoAJdHprCKcCwU6RB8WlzAxlZE,1076
|
|
2
|
+
fw3_console/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
fw3_console/autosuggest.py,sha256=8iTw2XffOM2ceam0pLigGjwOBKB73aWXA9vpqjkpZmU,2032
|
|
4
|
+
fw3_console/cli.py,sha256=84f98r11PiZ1UHU-eWfTCEWPHJkFfzZapvlkPfPOjj8,3373
|
|
5
|
+
fw3_console/completion.py,sha256=P32kkV29oOoF3te6OX7g217DfQZha10aLjgXQnQvtxs,5746
|
|
6
|
+
fw3_console/contracts.py,sha256=u24cLc6D1xKlbysdRA4Uigd75uwR1pt32eSCpyFr8Is,5094
|
|
7
|
+
fw3_console/display.py,sha256=aHWBx19IK8d7_eR7rQVvUAvImsG36pWDhc50kv1aKis,2137
|
|
8
|
+
fw3_console/history.py,sha256=sDIj50AU8atWEzL4fRHLYShqRfwXU5e_Kt0XPuv7f3U,3702
|
|
9
|
+
fw3_console/parser.py,sha256=D8ajs_ApXjszzZjFMeLUDeebPamNQCpC7PHV6bSY42I,3180
|
|
10
|
+
fw3_console/proxy_warnings.py,sha256=581OrezKGNI5guc31Zp5PKrYeuna6Hm5L1QTIo_85Nk,2235
|
|
11
|
+
fw3_console/resolver.py,sha256=78SMEm-zI4FiUf6kbxJopDO2Y1JXyjswGkadIxPxhNU,5076
|
|
12
|
+
fastweb3_console-0.1.0.dist-info/METADATA,sha256=onYjBknSBhjwyjQBjZaPcL4o9T2tFCQAaTaKLGKRyiU,2408
|
|
13
|
+
fastweb3_console-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
14
|
+
fastweb3_console-0.1.0.dist-info/entry_points.txt,sha256=7NQOTjgsiKceMAFOWqpGffoeONUnbhfyhpHgvwFS4Vs,45
|
|
15
|
+
fastweb3_console-0.1.0.dist-info/top_level.txt,sha256=IDo0XskO5Vb7loaz5dSzUEr7PV9_wUvFmLQjHDgCSUE,12
|
|
16
|
+
fastweb3_console-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iamdefinitelyahuman
|
|
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 @@
|
|
|
1
|
+
fw3_console
|
fw3_console/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Ghost-text call suggestions for the interactive console."""
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion
|
|
6
|
+
|
|
7
|
+
from .contracts import call_suggestion_parts
|
|
8
|
+
from .parser import active_call_context
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def make_call_suggestion(method, arg_index, current_arg, bound_receiver=False, invocation=None):
|
|
12
|
+
"""Build the visible ghost text for the active call, if it is unambiguous."""
|
|
13
|
+
parts = call_suggestion_parts(method, bound_receiver, invocation)
|
|
14
|
+
if parts is None:
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
if parts and isinstance(parts[0], list):
|
|
18
|
+
matches = [item for item in parts if len(item) > arg_index or (not item and arg_index == 0)]
|
|
19
|
+
if len(matches) != 1:
|
|
20
|
+
return None
|
|
21
|
+
parts = matches[0]
|
|
22
|
+
|
|
23
|
+
if arg_index >= len(parts):
|
|
24
|
+
return Suggestion(")")
|
|
25
|
+
|
|
26
|
+
current_part = parts[arg_index]
|
|
27
|
+
next_parts = parts[arg_index + 1 :]
|
|
28
|
+
typed = current_arg.lstrip()
|
|
29
|
+
|
|
30
|
+
if typed:
|
|
31
|
+
if len(typed) < len(current_part):
|
|
32
|
+
suggestion = current_part[len(typed) :]
|
|
33
|
+
if next_parts:
|
|
34
|
+
suggestion += ", " + ", ".join(next_parts)
|
|
35
|
+
else:
|
|
36
|
+
suggestion = ", ".join(next_parts)
|
|
37
|
+
if suggestion:
|
|
38
|
+
suggestion = ", " + suggestion
|
|
39
|
+
return Suggestion(suggestion + ")")
|
|
40
|
+
|
|
41
|
+
remaining = ", ".join(parts[arg_index:])
|
|
42
|
+
prefix = " " if current_arg else ""
|
|
43
|
+
return Suggestion(prefix + remaining + ")")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ConsoleAutoSuggest(AutoSuggest):
|
|
47
|
+
"""Prompt-toolkit autosuggest adapter backed by call-context parsing."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, namespace):
|
|
50
|
+
self.namespace = namespace
|
|
51
|
+
|
|
52
|
+
def get_suggestion(self, buffer, document):
|
|
53
|
+
context = active_call_context(document.text_before_cursor, self.namespace)
|
|
54
|
+
if context is None:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
return make_call_suggestion(
|
|
58
|
+
context.obj,
|
|
59
|
+
context.arg_index,
|
|
60
|
+
context.current_arg,
|
|
61
|
+
context.bound_receiver,
|
|
62
|
+
context.invocation,
|
|
63
|
+
)
|
fw3_console/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Interactive console entry point and prompt-toolkit wiring."""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from code import InteractiveConsole
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fw3_objects import Accounts, Chain, Contract, Transaction
|
|
11
|
+
from platformdirs import user_cache_dir
|
|
12
|
+
from prompt_toolkit import PromptSession
|
|
13
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
14
|
+
from prompt_toolkit.lexers import PygmentsLexer
|
|
15
|
+
from prompt_toolkit.styles import style_from_pygments_cls
|
|
16
|
+
from pygments.lexers import PythonLexer
|
|
17
|
+
|
|
18
|
+
from .autosuggest import ConsoleAutoSuggest
|
|
19
|
+
from .completion import ConsoleCompleter
|
|
20
|
+
from .display import PYGMENTS_STYLE, displayhook, write_highlighted
|
|
21
|
+
from .history import SafeFileHistory
|
|
22
|
+
from .proxy_warnings import warn_proxy_identity_comparisons
|
|
23
|
+
|
|
24
|
+
_CONSOLE_STYLE = style_from_pygments_cls(PYGMENTS_STYLE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _console_key_bindings():
|
|
28
|
+
"""Return console key bindings that override awkward prompt defaults."""
|
|
29
|
+
key_bindings = KeyBindings()
|
|
30
|
+
|
|
31
|
+
@key_bindings.add("right")
|
|
32
|
+
def _(event):
|
|
33
|
+
event.current_buffer.cursor_right(count=event.arg)
|
|
34
|
+
|
|
35
|
+
return key_bindings
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Console(InteractiveConsole):
|
|
39
|
+
"""Interactive fastweb3 console.
|
|
40
|
+
|
|
41
|
+
This class owns the small amount of glue that makes the shell feel different
|
|
42
|
+
from a plain Python REPL: prompt-toolkit completions/autosuggest, sanitized
|
|
43
|
+
history, highlighted output, and warnings for proxy identity comparisons.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self):
|
|
47
|
+
history_path = Path(user_cache_dir("fw3-console")) / "history"
|
|
48
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
|
|
50
|
+
namespace = {
|
|
51
|
+
"accounts": Accounts(unlock=False),
|
|
52
|
+
"Contract": Contract,
|
|
53
|
+
"Chain": Chain,
|
|
54
|
+
"Transaction": Transaction,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
self.prompt_session = PromptSession(
|
|
58
|
+
completer=ConsoleCompleter(namespace),
|
|
59
|
+
lexer=PygmentsLexer(PythonLexer),
|
|
60
|
+
auto_suggest=ConsoleAutoSuggest(namespace),
|
|
61
|
+
key_bindings=_console_key_bindings(),
|
|
62
|
+
history=SafeFileHistory(history_path, namespace),
|
|
63
|
+
style=_CONSOLE_STYLE,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
super().__init__(namespace)
|
|
67
|
+
|
|
68
|
+
def raw_input(self, prompt=""):
|
|
69
|
+
return self.prompt_session.prompt(prompt)
|
|
70
|
+
|
|
71
|
+
def runsource(self, source, filename="<input>", symbol="single"):
|
|
72
|
+
"""Compile and run one input block with pre-execution console checks."""
|
|
73
|
+
try:
|
|
74
|
+
code = self.compile(source, filename, symbol)
|
|
75
|
+
except (OverflowError, SyntaxError, ValueError):
|
|
76
|
+
self.showsyntaxerror(filename)
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
if code is None:
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
warn_proxy_identity_comparisons(source, self.locals)
|
|
83
|
+
self.runcode(code)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
def runcode(self, code):
|
|
87
|
+
"""Run code with the console displayhook installed temporarily."""
|
|
88
|
+
old_displayhook = sys.displayhook
|
|
89
|
+
sys.displayhook = displayhook
|
|
90
|
+
try:
|
|
91
|
+
super().runcode(code)
|
|
92
|
+
finally:
|
|
93
|
+
sys.displayhook = old_displayhook
|
|
94
|
+
|
|
95
|
+
def write(self, data):
|
|
96
|
+
write_highlighted(data)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
"""Start the interactive console."""
|
|
101
|
+
ver = version("fastweb3-console")
|
|
102
|
+
print(f"fastweb3-console v{ver}: interactive console for EVM blockchains")
|
|
103
|
+
console = Console()
|
|
104
|
+
console.interact(banner="")
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Prompt-toolkit completions for the interactive console.
|
|
4
|
+
|
|
5
|
+
Most of the behavior here is intentionally console-specific rather than general
|
|
6
|
+
Python completion. It completes globals on blank input and assignment RHSes,
|
|
7
|
+
merges ABI method names into contract attribute completion, and installs small
|
|
8
|
+
``__getitem_completions__`` hooks onto fastweb3 objects so ``obj[`` can suggest
|
|
9
|
+
aliases, addresses, event names, or event argument names.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from fw3_objects import Accounts
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
from fw3_objects.events import Event, EventArgs, EventGroup, EventList
|
|
18
|
+
except ImportError: # pragma: no cover - optional for tests without fw3-objects events
|
|
19
|
+
Event = EventArgs = EventGroup = EventList = None
|
|
20
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
21
|
+
|
|
22
|
+
from .resolver import CannotComplete, contract_abi_names, normalize_for_completion, resolve_path
|
|
23
|
+
|
|
24
|
+
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
25
|
+
_OPTIONAL_IDENTIFIER = rf"(?:{_IDENTIFIER})?"
|
|
26
|
+
_PATH = rf"{_IDENTIFIER}(?:\.{_IDENTIFIER})*"
|
|
27
|
+
|
|
28
|
+
_NAME_RE = re.compile(rf"(?P<prefix>{_IDENTIFIER})$")
|
|
29
|
+
_ATTR_RE = re.compile(rf"(?P<path>{_PATH})\.(?P<prefix>{_OPTIONAL_IDENTIFIER})$")
|
|
30
|
+
_GETITEM_RE = re.compile(rf"(?P<path>{_PATH})\[\s*(?P<prefix>(?:[\'\"][^\'\"]*)?)$")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _accounts_getitem_completions(self):
|
|
34
|
+
return [
|
|
35
|
+
*(repr(alias) for alias in self.aliases()),
|
|
36
|
+
*(repr(str(account)) for account in self.accounts()),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _event_list_getitem_completions(self):
|
|
41
|
+
return [repr(name) for name in self.keys()]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _event_args_getitem_completions(self):
|
|
45
|
+
return [repr(name) for name in self.keys()]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _event_getitem_completions(self):
|
|
49
|
+
return self.args.__getitem_completions__()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _event_group_getitem_completions(self):
|
|
53
|
+
if len(self) != 1:
|
|
54
|
+
return []
|
|
55
|
+
return self[0].__getitem_completions__()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _install_getitem_completions():
|
|
59
|
+
"""Attach console-only getitem completion hooks to supported objects.
|
|
60
|
+
|
|
61
|
+
The underlying objects intentionally do not depend on prompt-toolkit. This
|
|
62
|
+
monkey-patch keeps completion behavior local to the console while giving the
|
|
63
|
+
completer a uniform ``__getitem_completions__`` protocol to ask for possible
|
|
64
|
+
keys. Event classes are optional so tests and older fw3-objects versions can
|
|
65
|
+
still import the console package.
|
|
66
|
+
"""
|
|
67
|
+
Accounts.__getitem_completions__ = _accounts_getitem_completions
|
|
68
|
+
if EventList is not None:
|
|
69
|
+
EventList.__getitem_completions__ = _event_list_getitem_completions
|
|
70
|
+
if EventArgs is not None:
|
|
71
|
+
EventArgs.__getitem_completions__ = _event_args_getitem_completions
|
|
72
|
+
if Event is not None:
|
|
73
|
+
Event.__getitem_completions__ = _event_getitem_completions
|
|
74
|
+
if EventGroup is not None:
|
|
75
|
+
EventGroup.__getitem_completions__ = _event_group_getitem_completions
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ConsoleCompleter(Completer):
|
|
79
|
+
"""Complete console globals, attributes, ABI names, and object getitem keys."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, namespace):
|
|
82
|
+
self.namespace = namespace
|
|
83
|
+
_install_getitem_completions()
|
|
84
|
+
|
|
85
|
+
def get_completions(self, document, complete_event):
|
|
86
|
+
text = document.text_before_cursor
|
|
87
|
+
if not text.strip() or re.search(r"=\s*$", text):
|
|
88
|
+
yield from self._make_completions(set(self.namespace), "")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
getitem_match = _GETITEM_RE.search(text)
|
|
92
|
+
|
|
93
|
+
if getitem_match is not None:
|
|
94
|
+
path = getitem_match.group("path").split(".")
|
|
95
|
+
prefix = getitem_match.group("prefix")
|
|
96
|
+
try:
|
|
97
|
+
obj = self._resolve_path(path)
|
|
98
|
+
except CannotComplete:
|
|
99
|
+
return
|
|
100
|
+
yield from self._getitem_completions(obj, prefix)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
attr_match = _ATTR_RE.search(text)
|
|
104
|
+
|
|
105
|
+
if attr_match is not None:
|
|
106
|
+
path = attr_match.group("path").split(".")
|
|
107
|
+
prefix = attr_match.group("prefix")
|
|
108
|
+
try:
|
|
109
|
+
obj = self._resolve_path(path)
|
|
110
|
+
except CannotComplete:
|
|
111
|
+
return
|
|
112
|
+
yield from self._attribute_completions(obj, prefix)
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
name_match = _NAME_RE.search(text)
|
|
116
|
+
if name_match is None:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
prefix = name_match.group("prefix")
|
|
120
|
+
yield from self._name_completions(prefix)
|
|
121
|
+
|
|
122
|
+
def _resolve_path(self, path):
|
|
123
|
+
return resolve_path(self.namespace, path)
|
|
124
|
+
|
|
125
|
+
def _name_completions(self, prefix):
|
|
126
|
+
names = set(self.namespace)
|
|
127
|
+
yield from self._make_completions(names, prefix)
|
|
128
|
+
|
|
129
|
+
def _attribute_completions(self, obj, prefix):
|
|
130
|
+
"""Return normal ``dir`` names plus ABI-defined contract method names."""
|
|
131
|
+
obj = normalize_for_completion(obj)
|
|
132
|
+
try:
|
|
133
|
+
names = set(dir(obj))
|
|
134
|
+
except Exception:
|
|
135
|
+
names = set()
|
|
136
|
+
|
|
137
|
+
names |= contract_abi_names(obj)
|
|
138
|
+
yield from self._make_completions(names, prefix)
|
|
139
|
+
|
|
140
|
+
def _getitem_completions(self, obj, prefix):
|
|
141
|
+
"""Yield bracket-key suggestions without letting hook failures leak out.
|
|
142
|
+
|
|
143
|
+
Completion runs on every keystroke. A broken hook should mean "no
|
|
144
|
+
completions", not a crashed prompt.
|
|
145
|
+
"""
|
|
146
|
+
obj = normalize_for_completion(obj)
|
|
147
|
+
hook = getattr(obj, "__getitem_completions__", None)
|
|
148
|
+
if hook is None:
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
names = hook()
|
|
153
|
+
except Exception:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
yield from self._make_completions(names, prefix, sort=False)
|
|
157
|
+
|
|
158
|
+
def _make_completions(self, names, prefix, sort=True):
|
|
159
|
+
if sort:
|
|
160
|
+
names = sorted(names)
|
|
161
|
+
for name in names:
|
|
162
|
+
if name.startswith(prefix) and (prefix.startswith("_") or not name.startswith("_")):
|
|
163
|
+
yield Completion(name, start_position=-len(prefix) if prefix else 0)
|
fw3_console/contracts.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Build autosuggest text for contract methods and regular Python callables.
|
|
4
|
+
|
|
5
|
+
Contract wrappers expose ABI metadata instead of ordinary Python signatures, so
|
|
6
|
+
this module translates ABI inputs into the ghost text shown by prompt-toolkit.
|
|
7
|
+
For normal callables it falls back to ``inspect.signature`` and finally the code
|
|
8
|
+
object so builtins, Python functions, and dynamically generated methods all get a
|
|
9
|
+
best-effort suggestion without calling the object.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def instance_attr(obj, name):
|
|
16
|
+
"""Read an instance attribute before falling back to normal getattr."""
|
|
17
|
+
try:
|
|
18
|
+
instance_attrs = object.__getattribute__(obj, "__dict__")
|
|
19
|
+
except AttributeError:
|
|
20
|
+
instance_attrs = None
|
|
21
|
+
|
|
22
|
+
if instance_attrs is not None and name in instance_attrs:
|
|
23
|
+
return instance_attrs[name]
|
|
24
|
+
|
|
25
|
+
return getattr(obj, name, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_abi_input(abi_input):
|
|
29
|
+
typ = abi_input.get("type", "")
|
|
30
|
+
name = abi_input.get("name")
|
|
31
|
+
if name:
|
|
32
|
+
return f"{name}: {typ}"
|
|
33
|
+
return typ
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def abi_suggestion_parts(method_abi, method, invocation=None):
|
|
37
|
+
"""Return ghost-text parts for one ABI method variant.
|
|
38
|
+
|
|
39
|
+
Transaction-style calls get console-only convenience kwargs appended:
|
|
40
|
+
``sender=Account`` for transaction submission/gas estimation and ``value=Wei``
|
|
41
|
+
only when the target ABI is payable.
|
|
42
|
+
"""
|
|
43
|
+
inputs = method_abi.get("inputs", [])
|
|
44
|
+
if not isinstance(inputs, list):
|
|
45
|
+
inputs = []
|
|
46
|
+
|
|
47
|
+
parts = [format_abi_input(item) for item in inputs if isinstance(item, dict)]
|
|
48
|
+
|
|
49
|
+
if invocation in {"estimate_gas", "transact"} or (
|
|
50
|
+
invocation is None and type(method).__name__ == "ContractTx"
|
|
51
|
+
):
|
|
52
|
+
parts.append("sender=Account")
|
|
53
|
+
|
|
54
|
+
is_transaction = invocation == "transact" or (
|
|
55
|
+
invocation is None and type(method).__name__ == "ContractTx"
|
|
56
|
+
)
|
|
57
|
+
is_payable = method_abi.get("stateMutability") == "payable" or method_abi.get("payable") is True
|
|
58
|
+
if is_transaction and is_payable:
|
|
59
|
+
parts.append("value=Wei")
|
|
60
|
+
|
|
61
|
+
return parts
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def call_suggestion_parts(method, bound_receiver=False, invocation=None):
|
|
65
|
+
"""Return suggestion parts for a callable-like object.
|
|
66
|
+
|
|
67
|
+
The return value is ``None`` for unsupported objects, a flat list for a single
|
|
68
|
+
signature, or a list of lists for overloaded ABI methods. The autosuggest
|
|
69
|
+
layer uses the current argument index to disambiguate overloads.
|
|
70
|
+
"""
|
|
71
|
+
method_abis = instance_attr(method, "method_abis")
|
|
72
|
+
if isinstance(method_abis, dict):
|
|
73
|
+
method_abis = list(method_abis.values())
|
|
74
|
+
|
|
75
|
+
if isinstance(method_abis, (list, tuple)):
|
|
76
|
+
return [
|
|
77
|
+
abi_suggestion_parts(abi, method, invocation)
|
|
78
|
+
for abi in method_abis
|
|
79
|
+
if isinstance(abi, dict)
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
method_abi = instance_attr(method, "method_abi")
|
|
83
|
+
if isinstance(method_abi, dict):
|
|
84
|
+
return abi_suggestion_parts(method_abi, method, invocation)
|
|
85
|
+
|
|
86
|
+
return callable_suggestion_parts(method, bound_receiver)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def format_default(default):
|
|
90
|
+
if default is inspect.Signature.empty:
|
|
91
|
+
return None
|
|
92
|
+
return repr(default)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_signature_parameter(parameter):
|
|
96
|
+
text = parameter.name
|
|
97
|
+
|
|
98
|
+
if parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
99
|
+
text = f"*{text}"
|
|
100
|
+
elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
101
|
+
text = f"**{text}"
|
|
102
|
+
|
|
103
|
+
default = format_default(parameter.default)
|
|
104
|
+
if default is not None:
|
|
105
|
+
text = f"{text}={default}"
|
|
106
|
+
|
|
107
|
+
return text
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def signature_suggestion_parts(method, bound_receiver):
|
|
111
|
+
"""Use ``inspect.signature`` without showing annotations or return types."""
|
|
112
|
+
try:
|
|
113
|
+
signature = inspect.signature(method)
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
parameters = list(signature.parameters.values())
|
|
118
|
+
if bound_receiver and parameters and parameters[0].name in {"self", "cls"}:
|
|
119
|
+
parameters = parameters[1:]
|
|
120
|
+
|
|
121
|
+
return [format_signature_parameter(parameter) for parameter in parameters]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def code_suggestion_parts(method, bound_receiver):
|
|
125
|
+
"""Fallback for callable objects where ``inspect.signature`` is unavailable."""
|
|
126
|
+
code = getattr(method, "__code__", None)
|
|
127
|
+
if code is None:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
arg_count = code.co_argcount + getattr(code, "co_kwonlyargcount", 0)
|
|
131
|
+
names = list(code.co_varnames[:arg_count])
|
|
132
|
+
if bound_receiver and names and names[0] in {"self", "cls"}:
|
|
133
|
+
names = names[1:]
|
|
134
|
+
|
|
135
|
+
defaults = getattr(method, "__defaults__", None) or ()
|
|
136
|
+
default_start = len(names) - len(defaults)
|
|
137
|
+
parts = []
|
|
138
|
+
for index, name in enumerate(names):
|
|
139
|
+
if index >= default_start:
|
|
140
|
+
parts.append(f"{name}={defaults[index - default_start]!r}")
|
|
141
|
+
else:
|
|
142
|
+
parts.append(name)
|
|
143
|
+
return parts
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def callable_suggestion_parts(method, bound_receiver):
|
|
147
|
+
"""Return best-effort ghost-text parts for a non-ABI callable."""
|
|
148
|
+
if not callable(method):
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
parts = signature_suggestion_parts(method, bound_receiver)
|
|
152
|
+
if parts is not None:
|
|
153
|
+
return parts
|
|
154
|
+
|
|
155
|
+
return code_suggestion_parts(method, bound_receiver)
|
fw3_console/display.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Console display hooks and syntax highlighting helpers."""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import builtins
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from lazy_object_proxy import Proxy
|
|
10
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
11
|
+
from prompt_toolkit.shortcuts import print_formatted_text
|
|
12
|
+
from pygments import highlight
|
|
13
|
+
from pygments.formatters import Terminal256Formatter
|
|
14
|
+
from pygments.lexers import PythonLexer
|
|
15
|
+
from pygments.lexers.python import PythonTracebackLexer
|
|
16
|
+
from pygments.styles import get_style_by_name
|
|
17
|
+
|
|
18
|
+
PYGMENTS_STYLE = get_style_by_name("native")
|
|
19
|
+
OUTPUT_FORMATTER = Terminal256Formatter(style=PYGMENTS_STYLE)
|
|
20
|
+
PYTHON_LEXER = PythonLexer()
|
|
21
|
+
TRACEBACK_LEXER = PythonTracebackLexer()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def highlight_output(text, lexer=PYTHON_LEXER):
|
|
25
|
+
return highlight(text, lexer, OUTPUT_FORMATTER).rstrip("\n")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_python_literal(text):
|
|
29
|
+
try:
|
|
30
|
+
ast.literal_eval(text)
|
|
31
|
+
except (SyntaxError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def displayhook(value):
|
|
37
|
+
"""Render expression results like Python, with console-specific polish.
|
|
38
|
+
|
|
39
|
+
Direct ``Proxy`` results are unwrapped so dumping a proxy at the prompt shows
|
|
40
|
+
the underlying object. ``builtins._`` is cleared before ``repr`` to match the
|
|
41
|
+
standard displayhook's recursion guard, then restored to the displayed value.
|
|
42
|
+
Literal-looking reprs are syntax-highlighted; repr failures fall back to
|
|
43
|
+
Python's default displayhook so the user still sees the real exception path.
|
|
44
|
+
"""
|
|
45
|
+
if value is None:
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if type(value) is Proxy:
|
|
49
|
+
value = value.__wrapped__
|
|
50
|
+
|
|
51
|
+
builtins._ = None
|
|
52
|
+
try:
|
|
53
|
+
output = repr(value)
|
|
54
|
+
except Exception:
|
|
55
|
+
sys.__displayhook__(value)
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if is_python_literal(output):
|
|
59
|
+
print_formatted_text(ANSI(highlight_output(output)))
|
|
60
|
+
else:
|
|
61
|
+
print(output)
|
|
62
|
+
|
|
63
|
+
builtins._ = value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def write_highlighted(data):
|
|
67
|
+
"""Write console stderr using Python or traceback highlighting."""
|
|
68
|
+
lexer = TRACEBACK_LEXER if data.startswith("Traceback ") else PYTHON_LEXER
|
|
69
|
+
sys.stderr.write(highlight_output(data, lexer))
|
|
70
|
+
if data.endswith("\n"):
|
|
71
|
+
sys.stderr.write("\n")
|
fw3_console/history.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Prompt history that strips sensitive call arguments before persistence.
|
|
4
|
+
|
|
5
|
+
Console history is useful, but private keys and similar secrets must not be
|
|
6
|
+
written to disk. Callables marked with ``__sensitive__`` have their positional
|
|
7
|
+
and keyword arguments removed from the stored history line while preserving the
|
|
8
|
+
fact that the call happened.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fw3_objects import Accounts
|
|
16
|
+
from lazy_object_proxy import Proxy
|
|
17
|
+
from prompt_toolkit.history import FileHistory
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SafeFileHistory(FileHistory):
|
|
21
|
+
"""File-backed prompt history with AST-based sensitive argument stripping."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, filename: str | Path, namespace: dict[str, Any]):
|
|
24
|
+
self.namespace = namespace
|
|
25
|
+
_mark_sensitive_calls()
|
|
26
|
+
super().__init__(filename)
|
|
27
|
+
|
|
28
|
+
def append_string(self, string: str) -> None:
|
|
29
|
+
super().append_string(_sanitize_history_string(string, self.namespace))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _mark_sensitive_calls() -> None:
|
|
33
|
+
"""Mark known secret-accepting APIs for history sanitization.
|
|
34
|
+
|
|
35
|
+
The marker lives on the external fw3 object method so the AST sanitizer can
|
|
36
|
+
resolve calls through normal console names instead of hard-coding source
|
|
37
|
+
strings such as ``accounts.add_private_key(...)``.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
Accounts.add_private_key.__sensitive__ = True
|
|
41
|
+
except AttributeError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sanitize_history_string(source: str, namespace: dict[str, Any]) -> str:
|
|
46
|
+
"""Strip arguments from sensitive calls in one history entry.
|
|
47
|
+
|
|
48
|
+
If the source is syntactically invalid we keep it unchanged. A broken line
|
|
49
|
+
cannot be represented as a trustworthy AST, and guessing with regexes would
|
|
50
|
+
be more likely to corrupt unrelated history than safely remove a secret.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
tree = ast.parse(source.lstrip(), mode="exec")
|
|
54
|
+
except SyntaxError:
|
|
55
|
+
return source
|
|
56
|
+
|
|
57
|
+
stripper = _SensitiveCallStripper(namespace)
|
|
58
|
+
sanitized = stripper.visit(tree)
|
|
59
|
+
|
|
60
|
+
if not stripper.changed:
|
|
61
|
+
return source
|
|
62
|
+
|
|
63
|
+
ast.fix_missing_locations(sanitized)
|
|
64
|
+
return ast.unparse(sanitized)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _SensitiveCallStripper(ast.NodeTransformer):
|
|
68
|
+
"""Remove arguments from calls whose resolved object is sensitive."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, namespace: dict[str, Any]):
|
|
71
|
+
self.namespace = namespace
|
|
72
|
+
self.changed = False
|
|
73
|
+
|
|
74
|
+
def visit_Call(self, node: ast.Call) -> ast.Call:
|
|
75
|
+
self.generic_visit(node)
|
|
76
|
+
|
|
77
|
+
target = _resolve_ast_object(node.func, self.namespace)
|
|
78
|
+
if _is_sensitive(target):
|
|
79
|
+
node.args = []
|
|
80
|
+
node.keywords = []
|
|
81
|
+
self.changed = True
|
|
82
|
+
|
|
83
|
+
return node
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _resolve_ast_object(node: ast.AST, namespace: dict[str, Any]) -> Any | None:
|
|
87
|
+
"""Resolve simple ``name.attr`` AST paths against the console namespace."""
|
|
88
|
+
if isinstance(node, ast.Name):
|
|
89
|
+
return namespace.get(node.id)
|
|
90
|
+
|
|
91
|
+
if isinstance(node, ast.Attribute):
|
|
92
|
+
parent = _resolve_ast_object(node.value, namespace)
|
|
93
|
+
if parent is None:
|
|
94
|
+
return None
|
|
95
|
+
return getattr(parent, node.attr, None)
|
|
96
|
+
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _is_sensitive(value: Any) -> bool:
|
|
101
|
+
"""Return whether a resolved callable is marked as sensitive.
|
|
102
|
+
|
|
103
|
+
Proxies are intentionally treated as not sensitive here. Checking attributes
|
|
104
|
+
on an unresolved proxy may resolve it, and history sanitization must not
|
|
105
|
+
trigger lazy object side effects while the prompt is saving text.
|
|
106
|
+
"""
|
|
107
|
+
if type(value) is Proxy:
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
if getattr(value, "__sensitive__", False):
|
|
111
|
+
return True
|
|
112
|
+
|
|
113
|
+
func = getattr(value, "__func__", None)
|
|
114
|
+
return func is not None and getattr(func, "__sensitive__", False)
|
fw3_console/parser.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Lightweight parser helpers for call autosuggest.
|
|
4
|
+
|
|
5
|
+
This is intentionally not a full Python parser. Autosuggest only needs the call
|
|
6
|
+
whose opening parenthesis is still active at the cursor and the current argument
|
|
7
|
+
index. The scanner is string-aware and bracket-aware so commas inside strings,
|
|
8
|
+
lists, dicts, tuples, or nested calls do not advance the outer call argument.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .resolver import CannotComplete, resolve_call_path
|
|
16
|
+
|
|
17
|
+
_IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
|
|
18
|
+
_CALL_RE = re.compile(rf"(?P<path>{_IDENTIFIER}(?:\.{_IDENTIFIER})*)$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ActiveCall:
|
|
23
|
+
"""Resolved call context used to build one autosuggest response."""
|
|
24
|
+
|
|
25
|
+
obj: Any
|
|
26
|
+
arg_index: int
|
|
27
|
+
current_arg: str
|
|
28
|
+
bound_receiver: bool = False
|
|
29
|
+
invocation: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_PAIRS = {("(", ")"), ("[", "]"), ("{", "}")}
|
|
33
|
+
_OPENERS = "([{"
|
|
34
|
+
_CLOSERS = ")] }".replace(" ", "")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iter_code_chars(text):
|
|
38
|
+
"""Yield non-string characters with their original offsets."""
|
|
39
|
+
quote = None
|
|
40
|
+
escaped = False
|
|
41
|
+
|
|
42
|
+
for index, char in enumerate(text):
|
|
43
|
+
if quote is not None:
|
|
44
|
+
if escaped:
|
|
45
|
+
escaped = False
|
|
46
|
+
elif char == "\\":
|
|
47
|
+
escaped = True
|
|
48
|
+
elif char == quote:
|
|
49
|
+
quote = None
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
if char in {'"', "'"}:
|
|
53
|
+
quote = char
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
yield index, char
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_active_open_paren(text):
|
|
60
|
+
"""Return the still-open call parenthesis nearest the cursor, if any."""
|
|
61
|
+
stack = []
|
|
62
|
+
|
|
63
|
+
for index, char in _iter_code_chars(text):
|
|
64
|
+
if char in _OPENERS:
|
|
65
|
+
stack.append((char, index))
|
|
66
|
+
elif char in _CLOSERS and stack:
|
|
67
|
+
opener = stack[-1][0]
|
|
68
|
+
if (opener, char) in _PAIRS:
|
|
69
|
+
stack.pop()
|
|
70
|
+
|
|
71
|
+
for char, index in reversed(stack):
|
|
72
|
+
if char == "(":
|
|
73
|
+
return index
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def count_call_args(text):
|
|
78
|
+
"""Return the outer call arg index and current arg text after ``(``."""
|
|
79
|
+
arg_index = 0
|
|
80
|
+
arg_start = 0
|
|
81
|
+
stack = []
|
|
82
|
+
|
|
83
|
+
for index, char in _iter_code_chars(text):
|
|
84
|
+
if char in _OPENERS:
|
|
85
|
+
stack.append(char)
|
|
86
|
+
elif char in _CLOSERS and stack:
|
|
87
|
+
opener = stack[-1]
|
|
88
|
+
if (opener, char) in _PAIRS:
|
|
89
|
+
stack.pop()
|
|
90
|
+
elif char == "," and not stack:
|
|
91
|
+
arg_index += 1
|
|
92
|
+
arg_start = index + 1
|
|
93
|
+
|
|
94
|
+
return arg_index, text[arg_start:]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def active_call_context(text, namespace):
|
|
98
|
+
"""Resolve the active textual call into an object and argument position."""
|
|
99
|
+
open_paren = find_active_open_paren(text)
|
|
100
|
+
if open_paren is None:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
match = _CALL_RE.search(text[:open_paren].rstrip())
|
|
104
|
+
if match is None:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
path = match.group("path").split(".")
|
|
108
|
+
try:
|
|
109
|
+
obj, bound_receiver, invocation = resolve_call_path(namespace, path)
|
|
110
|
+
except CannotComplete:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
arg_index, current_arg = count_call_args(text[open_paren + 1 :])
|
|
114
|
+
return ActiveCall(obj, arg_index, current_arg, bound_receiver, invocation)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Warnings for identity comparisons involving lazy proxies.
|
|
4
|
+
|
|
5
|
+
``is`` and ``is not`` never dispatch to the wrapped object; they compare the
|
|
6
|
+
``Proxy`` instance itself. The console checks compiled input before execution so
|
|
7
|
+
users get a warning for this specific footgun without changing Python semantics.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from lazy_object_proxy import Proxy
|
|
14
|
+
|
|
15
|
+
from .resolver import CannotComplete, resolve_name
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def proxy_name(namespace, node):
|
|
19
|
+
"""Return the source name if an AST node resolves to a Proxy."""
|
|
20
|
+
if not isinstance(node, ast.Name):
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
obj = resolve_name(namespace, node.id)
|
|
25
|
+
except CannotComplete:
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
if type(obj) is Proxy:
|
|
29
|
+
return node.id
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def proxy_identity_warnings(source, namespace):
|
|
34
|
+
"""Build warnings for ``is`` / ``is not`` comparisons against proxies."""
|
|
35
|
+
try:
|
|
36
|
+
tree = ast.parse(source, mode="exec")
|
|
37
|
+
except SyntaxError:
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
warnings = []
|
|
41
|
+
seen = set()
|
|
42
|
+
|
|
43
|
+
for node in ast.walk(tree):
|
|
44
|
+
if not isinstance(node, ast.Compare):
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
operands = [node.left, *node.comparators]
|
|
48
|
+
for op, left, right in zip(node.ops, operands, operands[1:]):
|
|
49
|
+
if not isinstance(op, (ast.Is, ast.IsNot)):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
proxy_names = [
|
|
53
|
+
name
|
|
54
|
+
for name in (proxy_name(namespace, left), proxy_name(namespace, right))
|
|
55
|
+
if name
|
|
56
|
+
]
|
|
57
|
+
if not proxy_names:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
names = ", ".join(f"`{name}`" for name in proxy_names)
|
|
61
|
+
key = (node.lineno, names)
|
|
62
|
+
if key in seen:
|
|
63
|
+
continue
|
|
64
|
+
seen.add(key)
|
|
65
|
+
warnings.append(
|
|
66
|
+
f"Warning: {names} is a Proxy. `is` / `is not` compares the proxy itself, "
|
|
67
|
+
"not the resolved object. Use `==` for value comparison."
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return warnings
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def warn_proxy_identity_comparisons(source, namespace):
|
|
74
|
+
"""Write proxy identity warnings to stderr before code execution."""
|
|
75
|
+
for warning in proxy_identity_warnings(source, namespace):
|
|
76
|
+
sys.stderr.write(warning + "\n")
|
fw3_console/resolver.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Side-effect-conscious object resolution for completion and autosuggest.
|
|
4
|
+
|
|
5
|
+
The console needs to inspect objects while the user is typing. Some fw3 objects
|
|
6
|
+
are lazy proxies or dynamic contract wrappers, so normal ``getattr`` can trigger
|
|
7
|
+
resolution, network work, or descriptor execution. Helpers in this module prefer
|
|
8
|
+
static inspection and explicitly opt into dynamic lookup only for known contract
|
|
9
|
+
ABI methods.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import builtins
|
|
13
|
+
import inspect
|
|
14
|
+
|
|
15
|
+
from lazy_object_proxy import Proxy
|
|
16
|
+
|
|
17
|
+
_CONTRACT_METHOD_INVOCATIONS = {"call", "estimate_gas", "transact"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CannotComplete(Exception):
|
|
21
|
+
"""Raised when resolving for completion would be unsafe or impossible."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def normalize_for_completion(obj):
|
|
26
|
+
"""Return a safe object for inspection without forcing unresolved proxies.
|
|
27
|
+
|
|
28
|
+
Resolved ``Proxy`` objects are unwrapped so completion sees the real object.
|
|
29
|
+
Unresolved proxies abort completion because resolving them may perform work
|
|
30
|
+
the user did not ask for just by pressing a key.
|
|
31
|
+
"""
|
|
32
|
+
if type(obj) is not Proxy:
|
|
33
|
+
return obj
|
|
34
|
+
|
|
35
|
+
if not object.__getattribute__(obj, "__resolved__"):
|
|
36
|
+
raise CannotComplete
|
|
37
|
+
return object.__getattribute__(obj, "__wrapped__")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def instance_dict(obj):
|
|
41
|
+
try:
|
|
42
|
+
return object.__getattribute__(obj, "__dict__")
|
|
43
|
+
except AttributeError:
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def safe_getattr(obj, name):
|
|
48
|
+
"""Read an attribute for completion while avoiding descriptor execution.
|
|
49
|
+
|
|
50
|
+
Instance attributes are returned directly because fastweb3 contract objects
|
|
51
|
+
often materialize ABI methods there. Otherwise ``inspect.getattr_static`` is
|
|
52
|
+
used so properties and descriptors are not invoked while typing.
|
|
53
|
+
"""
|
|
54
|
+
obj = normalize_for_completion(obj)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
instance_attrs = object.__getattribute__(obj, "__dict__")
|
|
58
|
+
except AttributeError:
|
|
59
|
+
instance_attrs = None
|
|
60
|
+
|
|
61
|
+
if instance_attrs is not None and name in instance_attrs:
|
|
62
|
+
return instance_attrs[name]
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
return inspect.getattr_static(obj, name)
|
|
66
|
+
except AttributeError as exc:
|
|
67
|
+
raise CannotComplete from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def contract_abi_names(obj):
|
|
71
|
+
"""Return function names advertised by a contract-like object's ABI."""
|
|
72
|
+
instance_attrs = instance_dict(obj)
|
|
73
|
+
|
|
74
|
+
abi = instance_attrs.get("abi")
|
|
75
|
+
if not isinstance(abi, list):
|
|
76
|
+
return set()
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
item["name"]
|
|
80
|
+
for item in abi
|
|
81
|
+
if isinstance(item, dict) and item.get("type", "function") == "function" and "name" in item
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def is_contract_method(obj):
|
|
86
|
+
"""Detect fastweb3 contract method wrappers by their ABI attributes."""
|
|
87
|
+
instance_attrs = instance_dict(obj)
|
|
88
|
+
method_abi = instance_attrs.get("method_abi")
|
|
89
|
+
method_abis = instance_attrs.get("method_abis")
|
|
90
|
+
|
|
91
|
+
if isinstance(method_abi, dict):
|
|
92
|
+
return True
|
|
93
|
+
if isinstance(method_abis, dict):
|
|
94
|
+
return True
|
|
95
|
+
return isinstance(method_abis, (list, tuple))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_name(namespace, name):
|
|
99
|
+
"""Resolve a name from console locals, then Python builtins."""
|
|
100
|
+
try:
|
|
101
|
+
return namespace[name]
|
|
102
|
+
except KeyError:
|
|
103
|
+
try:
|
|
104
|
+
return getattr(builtins, name)
|
|
105
|
+
except AttributeError as exc:
|
|
106
|
+
raise CannotComplete from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_path(namespace, path):
|
|
110
|
+
"""Resolve ``foo.bar.baz`` for completion.
|
|
111
|
+
|
|
112
|
+
Normal attributes are resolved statically. Missing attributes are allowed
|
|
113
|
+
only when the current object advertises an ABI function with that name, which
|
|
114
|
+
lets dynamic contract method access participate in completion.
|
|
115
|
+
"""
|
|
116
|
+
obj = resolve_name(namespace, path[0])
|
|
117
|
+
|
|
118
|
+
for name in path[1:]:
|
|
119
|
+
try:
|
|
120
|
+
obj = safe_getattr(obj, name)
|
|
121
|
+
except CannotComplete:
|
|
122
|
+
if name not in contract_abi_names(obj):
|
|
123
|
+
raise
|
|
124
|
+
obj = getattr(obj, name)
|
|
125
|
+
return normalize_for_completion(obj)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_call_path(namespace, path):
|
|
129
|
+
"""Resolve the callable currently being typed and its console context.
|
|
130
|
+
|
|
131
|
+
In addition to ``token.transfer(``, contract calls may be typed as
|
|
132
|
+
``token.transfer.call(``, ``.estimate_gas(``, or ``.transact(``. The final
|
|
133
|
+
invocation suffix is reported separately so autosuggest can choose the right
|
|
134
|
+
ABI extras, while the resolved object remains the underlying contract method.
|
|
135
|
+
"""
|
|
136
|
+
obj = resolve_name(namespace, path[0])
|
|
137
|
+
bound_receiver = False
|
|
138
|
+
invocation = None
|
|
139
|
+
|
|
140
|
+
for index, name in enumerate(path[1:], start=1):
|
|
141
|
+
receiver = normalize_for_completion(obj)
|
|
142
|
+
is_last = index == len(path) - 1
|
|
143
|
+
|
|
144
|
+
if is_last and name in _CONTRACT_METHOD_INVOCATIONS and is_contract_method(receiver):
|
|
145
|
+
invocation = name
|
|
146
|
+
obj = receiver
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
if is_last and not inspect.isclass(receiver):
|
|
150
|
+
bound_receiver = True
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
obj = safe_getattr(obj, name)
|
|
154
|
+
except CannotComplete:
|
|
155
|
+
if name not in contract_abi_names(obj):
|
|
156
|
+
raise
|
|
157
|
+
obj = getattr(obj, name)
|
|
158
|
+
return normalize_for_completion(obj), bound_receiver, invocation
|