Skip to content

Commit 9ce7ce5

Browse files
committed
[18.0][ADD] shell_model_autocomplete: added autocomplete for Odoo shell based on models
1 parent 7473ce1 commit 9ce7ce5

10 files changed

Lines changed: 637 additions & 0 deletions

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
========================
2+
Shell Model Autocomplete
3+
========================
4+
5+
Adds intelligent Tab-completion to the Odoo shell (``odoo shell`` python mode).
6+
7+
.. image:: demo.gif
8+
:alt: Descriptive alternative text for the GIF
9+
10+
Features
11+
--------
12+
13+
**Model name completion**
14+
15+
.. code-block:: python
16+
17+
env['sale.<TAB>
18+
# → sale.order, sale.order.line, sale.report.invoice, ...
19+
20+
**Field name completion**
21+
22+
.. code-block:: python
23+
24+
env['sale.order'].<TAB>
25+
# → .name, .partner_id, .state, .amount_total, ...
26+
27+
env['sale.order'].nam<TAB>
28+
# → .name, .name_search
29+
30+
**Field name completion inside method arguments**
31+
32+
Works for ``search``, ``mapped``, ``filtered``, ``sorted``, and any other method
33+
that accepts a field name or domain string:
34+
35+
.. code-block:: python
36+
37+
env['sale.order'].search([('par<TAB>
38+
# → partner_id, partner_invoice_id, partner_shipping_id, ...
39+
40+
env['sale.order'].search([('state', '=', 'sale'), ('par<TAB>
41+
# → partner_id, ... (correctly skips the value position)
42+
43+
**Dotted relation path completion** (for ``mapped``, etc.)
44+
45+
.. code-block:: python
46+
47+
env['sale.order'].mapped('partner_id.<TAB>
48+
# → partner_id.name, partner_id.email, partner_id.country_id, ...
49+
50+
env['sale.order'].mapped('partner_id.country_id.<TAB>
51+
# → partner_id.country_id.name, partner_id.country_id.code, ...
52+
53+
How it works
54+
------------
55+
56+
The module monkey-patches ``odoo.cli.shell.Console.__init__`` at load time to
57+
replace readline's completer with a smart completer that detects context from
58+
the current input line. Outside of ``env[...]`` expressions it falls back to
59+
the standard ``rlcompleter`` Python completer, so normal attribute and variable
60+
completion is unaffected.
61+
62+
Configuration
63+
-------------
64+
65+
No configuration required.
66+
67+
Known limitations
68+
-----------------
69+
70+
* Only works with the built-in ``python`` shell mode, not IPython/ptpython/bpython.
71+
* Dotted-path completion traverses ``Many2one`` / ``One2many`` / ``Many2many``
72+
relations only (fields with a ``comodel_name``).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import dev_tools
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "Shell Model Autocomplete",
3+
"version": "18.0.1.0.0",
4+
"category": "Technical",
5+
"summary": "Tab-completion for Odoo models and fields in the Odoo shell"
6+
"(python mode)",
7+
"author": "Odoo Community Association (OCA), Alesis Manzano",
8+
"license": "LGPL-3",
9+
"depends": ["base"],
10+
"installable": True,
11+
"auto_install": False,
12+
"website": "https://github.com/OCA/server-tools",
13+
"maintainers": ["alesisjoan"],
14+
}

shell_model_autocomplete/demo.gif

1.1 MB
Loading
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import shell_auto_completer
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import re
2+
import readline
3+
import rlcompleter
4+
5+
# Common models.Model methods surfaced in completion alongside field names
6+
_MODEL_METHODS = (
7+
"browse",
8+
"create",
9+
"copy",
10+
"unlink",
11+
"read",
12+
"write",
13+
"search",
14+
"search_count",
15+
"search_fetch",
16+
"read_group",
17+
"filtered",
18+
"filtered_domain",
19+
"mapped",
20+
"sorted",
21+
"ensure_one",
22+
"exists",
23+
"name_search",
24+
"name_get",
25+
"default_get",
26+
"fields_get",
27+
"sudo",
28+
"with_context",
29+
"with_user",
30+
"with_company",
31+
)
32+
33+
34+
def _complete_dotted_field(env, model_name, field_prefix):
35+
"""Complete dotted field paths like 'partner_id.name_', navigating relations."""
36+
parts = field_prefix.split(".")
37+
current_model = model_name
38+
39+
# Navigate through all parts except the last (traverse relation chain)
40+
for part in parts[:-1]:
41+
if current_model not in env.registry.models:
42+
return []
43+
field = env.registry.models[current_model]._fields.get(part)
44+
comodel = getattr(field, "comodel_name", None) if field else None
45+
if not comodel:
46+
return []
47+
current_model = comodel
48+
49+
if current_model not in env.registry.models:
50+
return []
51+
52+
last_prefix = parts[-1]
53+
path_prefix = ".".join(parts[:-1])
54+
if path_prefix:
55+
path_prefix += "."
56+
57+
return [
58+
path_prefix + f
59+
for f in env.registry.models[current_model]._fields
60+
if f.startswith(last_prefix)
61+
]
62+
63+
64+
def _field_and_method_matches(env, model_name, prefix):
65+
"""Return '.<name>' completions for fields then common methods, deduplicated."""
66+
fields = [f for f in env[model_name]._fields if f.startswith(prefix)]
67+
methods = [
68+
m
69+
for m in _MODEL_METHODS
70+
if m.startswith(prefix) and m not in env[model_name]._fields
71+
]
72+
return ["." + n for n in fields + methods]
73+
74+
75+
def _setup():
76+
try:
77+
from odoo.cli.shell import Console
78+
except ImportError:
79+
return
80+
81+
_original_init = Console.__init__
82+
83+
def _patched_init(self, locals=None, filename="<console>"): # pylint: disable=redefined-builtin
84+
_original_init(self, locals, filename)
85+
86+
shell_locals = locals or {}
87+
python_completer = rlcompleter.Completer(shell_locals).complete
88+
89+
def smart_completer(text, state):
90+
line = readline.get_line_buffer()
91+
env = shell_locals.get("env")
92+
93+
if env is not None:
94+
# Field on browse/search result: env['sale.order'].browse(1).name_
95+
# env['sale.order'].search([...]).name_
96+
# ')' is a readline delimiter so text includes the leading '.'.
97+
# Greedy '.*' finds the outermost closing ')' even when the args
98+
# contain nested parens (e.g. domain tuples).
99+
result_match = re.search(
100+
r"env\[(['\"])([^'\"]+)\1\]\.\w+\(.*\)\.([\w]*)$", line
101+
)
102+
if result_match:
103+
model_name = result_match.group(2)
104+
field_prefix = result_match.group(3)
105+
if model_name in env.registry.models:
106+
matches = _field_and_method_matches(
107+
env, model_name, field_prefix
108+
)
109+
try:
110+
return matches[state]
111+
except IndexError:
112+
return None
113+
114+
# Field/method access: env['sale.order'].sea → .search, .search_count
115+
# ']' is a readline delimiter so text includes the leading '.'.
116+
field_match = re.search(r"env\[(['\"])([^'\"]+)\1\]\.([\w]*)$", line)
117+
if field_match:
118+
model_name = field_match.group(2)
119+
field_prefix = field_match.group(3)
120+
if model_name in env.registry.models:
121+
matches = _field_and_method_matches(
122+
env, model_name, field_prefix
123+
)
124+
try:
125+
return matches[state]
126+
except IndexError:
127+
return None
128+
129+
# Field name inside method args: env['sale.order'].search([('name_
130+
# Also handles dotted paths: env['sale.order'].mapped('partner_id.name_
131+
# '(' and "'" are readline delimiters so text = the partial field name
132+
method_match = re.search(
133+
r"env\[(['\"])([^'\"]+)\1\]\..*\(\s*['\"]([^'\"]*)$", line
134+
)
135+
if method_match:
136+
model_name = method_match.group(2)
137+
matches = _complete_dotted_field(env, model_name, text)
138+
try:
139+
return matches[state]
140+
except IndexError:
141+
return None
142+
143+
# Model name completion: env['sale.
144+
if re.search(r"env\[['\"][\w.]*$", line):
145+
matches = [m for m in env.registry.models if m.startswith(text)]
146+
try:
147+
return matches[state]
148+
except IndexError:
149+
return None
150+
151+
return python_completer(text, state)
152+
153+
readline.set_completer(smart_completer)
154+
readline.parse_and_bind("tab: complete")
155+
156+
Console.__init__ = _patched_init
157+
158+
159+
_setup()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[build-system]
2+
requires = ["whool"]
3+
build-backend = "whool.buildapi"
9.23 KB
Loading
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import test_shell_auto_completer

0 commit comments

Comments
 (0)