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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ fw3 = fw3_console.cli:main
@@ -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
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)
@@ -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")
@@ -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