odoorpc-cli 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.
- odoorpc_cli/__init__.py +1 -0
- odoorpc_cli/cli.py +33 -0
- odoorpc_cli/commands/__init__.py +17 -0
- odoorpc_cli/commands/auth/__init__.py +14 -0
- odoorpc_cli/commands/auth/info.py +15 -0
- odoorpc_cli/commands/auth/login.py +26 -0
- odoorpc_cli/commands/call_method.py +29 -0
- odoorpc_cli/commands/create.py +21 -0
- odoorpc_cli/commands/model/__init__.py +14 -0
- odoorpc_cli/commands/model/field.py +13 -0
- odoorpc_cli/commands/model/search.py +13 -0
- odoorpc_cli/commands/search/__init__.py +14 -0
- odoorpc_cli/commands/search/count.py +24 -0
- odoorpc_cli/commands/search/read.py +44 -0
- odoorpc_cli/commands/unlink.py +19 -0
- odoorpc_cli/commands/write.py +24 -0
- odoorpc_cli/settings.py +83 -0
- odoorpc_cli/tools/__init__.py +0 -0
- odoorpc_cli/tools/click_types.py +39 -0
- odoorpc_cli/tools/odoo_client.py +117 -0
- odoorpc_cli-0.1.0.dist-info/METADATA +62 -0
- odoorpc_cli-0.1.0.dist-info/RECORD +40 -0
- odoorpc_cli-0.1.0.dist-info/WHEEL +5 -0
- odoorpc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- odoorpc_cli-0.1.0.dist-info/top_level.txt +2 -0
- tests/conftest.py +144 -0
- tests/test_auth_login.py +33 -0
- tests/test_cli.py +49 -0
- tests/test_command_auth_info.py +11 -0
- tests/test_command_call_method.py +25 -0
- tests/test_command_create.py +22 -0
- tests/test_command_model_field.py +9 -0
- tests/test_command_model_search.py +9 -0
- tests/test_command_search_count.py +17 -0
- tests/test_command_search_read.py +29 -0
- tests/test_command_unlink.py +24 -0
- tests/test_command_write.py +58 -0
- tests/test_settings.py +50 -0
- tests/test_tools_click_types.py +40 -0
- tests/test_tools_odoo_client.py +134 -0
odoorpc_cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
odoorpc_cli/cli.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from odoorpc_cli import __version__
|
|
4
|
+
from odoorpc_cli.commands import auth, call_method, create, model, search, unlink, write
|
|
5
|
+
from odoorpc_cli.tools.odoo_client import odoorpc_client
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
9
|
+
@click.version_option(version=__version__, prog_name="odoorpc_cli")
|
|
10
|
+
@click.pass_context
|
|
11
|
+
def odoo(ctx):
|
|
12
|
+
"""Odoo CLI - interact with your Odoo instance from the command line"""
|
|
13
|
+
ctx.ensure_object(dict)
|
|
14
|
+
try:
|
|
15
|
+
ctx.obj["odoo"] = odoorpc_client.from_config()
|
|
16
|
+
except Exception:
|
|
17
|
+
ctx.obj["odoo"] = None
|
|
18
|
+
if ctx.invoked_subcommand != "auth":
|
|
19
|
+
click.echo("Not authenticated — run 'odoo auth login' to authenticate")
|
|
20
|
+
ctx.exit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
odoo.add_command(auth)
|
|
24
|
+
odoo.add_command(model)
|
|
25
|
+
odoo.add_command(search)
|
|
26
|
+
odoo.add_command(create)
|
|
27
|
+
odoo.add_command(write)
|
|
28
|
+
odoo.add_command(unlink)
|
|
29
|
+
odoo.add_command(call_method)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
odoo(obj={}) # pragma: no cover - script entrypoint
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .auth import auth
|
|
2
|
+
from .call_method import call_method
|
|
3
|
+
from .create import create
|
|
4
|
+
from .model import model
|
|
5
|
+
from .search import search
|
|
6
|
+
from .unlink import unlink
|
|
7
|
+
from .write import write
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"auth",
|
|
11
|
+
"call_method",
|
|
12
|
+
"create",
|
|
13
|
+
"model",
|
|
14
|
+
"search",
|
|
15
|
+
"unlink",
|
|
16
|
+
"write",
|
|
17
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.command("info")
|
|
7
|
+
@click.pass_context
|
|
8
|
+
def info(ctx):
|
|
9
|
+
"""Display information about the currently authenticated user."""
|
|
10
|
+
client = ctx.obj.get("odoo")
|
|
11
|
+
if client is None:
|
|
12
|
+
click.echo("Not authenticated — run 'odoo auth login' to authenticate")
|
|
13
|
+
return
|
|
14
|
+
res = client.get_current_user()
|
|
15
|
+
click.echo(json.dumps(res, indent=2, ensure_ascii=False))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from odoorpc_cli.settings import Settings
|
|
4
|
+
from odoorpc_cli.tools.odoo_client import odoorpc_client
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.command("login")
|
|
8
|
+
@click.option(
|
|
9
|
+
"--host",
|
|
10
|
+
prompt="Odoo server base URL",
|
|
11
|
+
help="Odoo server base URL, e.g. http://localhost:8069",
|
|
12
|
+
)
|
|
13
|
+
@click.option("--db", prompt="Odoo database name", help="Odoo database name")
|
|
14
|
+
@click.option("--username", prompt="Odoo username", help="Odoo username")
|
|
15
|
+
@click.option(
|
|
16
|
+
"--password",
|
|
17
|
+
prompt="Odoo password",
|
|
18
|
+
hide_input=True,
|
|
19
|
+
confirmation_prompt=True,
|
|
20
|
+
help="Odoo API key or password",
|
|
21
|
+
)
|
|
22
|
+
def login(host, db, username, password):
|
|
23
|
+
"""Authenticate and save Odoo connection settings."""
|
|
24
|
+
odoorpc_client(host=host, db=db, username=username, password=password)
|
|
25
|
+
Settings.save(host=host, db=db, username=username, password=password)
|
|
26
|
+
click.echo("Login successful!")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from odoorpc_cli.tools.click_types import JSON
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("call-method")
|
|
9
|
+
@click.argument("model")
|
|
10
|
+
@click.option("--method", required=True, help="Model method to call")
|
|
11
|
+
@click.option(
|
|
12
|
+
"--args",
|
|
13
|
+
type=JSON(expected="list"),
|
|
14
|
+
default=lambda: [],
|
|
15
|
+
help="JSON list of positional args, e.g. '[1, \"a\"]'. Defaults to an empty list.",
|
|
16
|
+
)
|
|
17
|
+
@click.option(
|
|
18
|
+
"--kwargs",
|
|
19
|
+
type=JSON(expected="dict"),
|
|
20
|
+
default=lambda: {},
|
|
21
|
+
help='JSON object of keyword args, e.g. \'{"key": "value"}\'.'
|
|
22
|
+
" Defaults to an empty object.",
|
|
23
|
+
)
|
|
24
|
+
@click.pass_context
|
|
25
|
+
def call_method(ctx, model, method, args, kwargs):
|
|
26
|
+
"""Call a model method on `model`"""
|
|
27
|
+
client = ctx.obj.get("odoo")
|
|
28
|
+
res = client.execute_method(model, method, args=args, kwargs=kwargs)
|
|
29
|
+
click.echo(json.dumps(res, indent=2, ensure_ascii=False))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from odoorpc_cli.tools.click_types import JSON
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("create")
|
|
9
|
+
@click.argument("model")
|
|
10
|
+
@click.option(
|
|
11
|
+
"--values",
|
|
12
|
+
type=JSON(expected="list"),
|
|
13
|
+
required=True,
|
|
14
|
+
help=('JSON list of record objects to create. Example: \'[{"name": "A"}]\'.'),
|
|
15
|
+
)
|
|
16
|
+
@click.pass_context
|
|
17
|
+
def create(ctx, model: str, values) -> None:
|
|
18
|
+
"""Create multiple records in `model`"""
|
|
19
|
+
client = ctx.obj.get("odoo")
|
|
20
|
+
ids = client.create(model, values)
|
|
21
|
+
click.echo(json.dumps({"ids": ids}, ensure_ascii=False))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.command("field")
|
|
7
|
+
@click.argument("model", required=True)
|
|
8
|
+
@click.pass_context
|
|
9
|
+
def model_field(ctx, model: str):
|
|
10
|
+
"""Retrieve metadata for fields of `model`"""
|
|
11
|
+
client = ctx.obj.get("odoo")
|
|
12
|
+
res = client.model_field(model)
|
|
13
|
+
click.echo(json.dumps(res, indent=2, ensure_ascii=False))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.command("search")
|
|
7
|
+
@click.argument("query", required=True)
|
|
8
|
+
@click.pass_context
|
|
9
|
+
def model_search(ctx, query: str):
|
|
10
|
+
"""Search for models by name or substring `query`"""
|
|
11
|
+
client = ctx.obj.get("odoo")
|
|
12
|
+
res = client.model_search(query)
|
|
13
|
+
click.echo(json.dumps(res, indent=2, ensure_ascii=False))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from odoorpc_cli.tools.click_types import JSON
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("count")
|
|
9
|
+
@click.argument("model", required=True)
|
|
10
|
+
@click.option(
|
|
11
|
+
"--domain",
|
|
12
|
+
type=JSON(expected="list"),
|
|
13
|
+
default=lambda: [],
|
|
14
|
+
help=(
|
|
15
|
+
'Odoo domain as a JSON list, e.g. \'[["name", "ilike", "test"]]\''
|
|
16
|
+
" Defaults to an empty list (no filter)."
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
@click.pass_context
|
|
20
|
+
def search_count(ctx, model: str, domain: list):
|
|
21
|
+
"""Count records in `model` matching domain"""
|
|
22
|
+
client = ctx.obj.get("odoo")
|
|
23
|
+
res = client.search_count(model, domain)
|
|
24
|
+
click.echo(json.dumps({"count": res}, indent=2, ensure_ascii=False))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from odoorpc_cli.tools.click_types import JSON
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("read")
|
|
9
|
+
@click.argument("model", required=True)
|
|
10
|
+
@click.option(
|
|
11
|
+
"--domain",
|
|
12
|
+
type=JSON(expected="list"),
|
|
13
|
+
default=lambda: [],
|
|
14
|
+
help=(
|
|
15
|
+
'Odoo domain as a JSON list, e.g. \'[["name", "ilike", "test"]]\'.'
|
|
16
|
+
" Defaults to an empty list (no filter)."
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--fields",
|
|
21
|
+
default="all",
|
|
22
|
+
help=(
|
|
23
|
+
"Comma-separated fields to read, e.g. 'name,email'."
|
|
24
|
+
" Defaults to 'all' (all fields)."
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
@click.option(
|
|
28
|
+
"--limit",
|
|
29
|
+
type=int,
|
|
30
|
+
help="Limit the number of records returned. Defaults to None (no limit).",
|
|
31
|
+
)
|
|
32
|
+
@click.pass_context
|
|
33
|
+
def search_read(ctx, model: str, domain, fields: str, limit: int | None):
|
|
34
|
+
"""Search and read records from `model`"""
|
|
35
|
+
client = ctx.obj.get("odoo")
|
|
36
|
+
|
|
37
|
+
# prepare fields argument
|
|
38
|
+
if fields == "all" or not fields:
|
|
39
|
+
fields_arg = ["id"]
|
|
40
|
+
else:
|
|
41
|
+
fields_arg = [f.strip() for f in fields.split(",")]
|
|
42
|
+
|
|
43
|
+
res = client.search_read(model, domain, fields_arg, limit)
|
|
44
|
+
click.echo(json.dumps(res, indent=2, ensure_ascii=False))
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@click.command("unlink")
|
|
7
|
+
@click.argument("model")
|
|
8
|
+
@click.option(
|
|
9
|
+
"--ids",
|
|
10
|
+
required=True,
|
|
11
|
+
help="Comma-separated record IDs to unlink (delete), e.g. '1,2,3'.",
|
|
12
|
+
)
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def unlink(ctx, model: str, ids: str) -> None:
|
|
15
|
+
"""Delete records in `model`"""
|
|
16
|
+
client = ctx.obj.get("odoo")
|
|
17
|
+
id_list = [int(x.strip()) for x in ids.split(",") if x.strip()]
|
|
18
|
+
res = client.unlink(model, id_list)
|
|
19
|
+
click.echo(json.dumps({"success": bool(res)}))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from odoorpc_cli.tools.click_types import JSON
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.command("write")
|
|
9
|
+
@click.argument("model")
|
|
10
|
+
@click.option("--id", "ids", required=True, help="Comma-separated id(s) to update")
|
|
11
|
+
@click.option(
|
|
12
|
+
"--value",
|
|
13
|
+
type=JSON(expected="dict"),
|
|
14
|
+
required=True,
|
|
15
|
+
help='JSON object of values to write to the records, e.g. \'{"name": "New"}\'.',
|
|
16
|
+
)
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def write(ctx, model: str, ids: str, value) -> None:
|
|
19
|
+
"""Update records in `model`"""
|
|
20
|
+
client = ctx.obj.get("odoo")
|
|
21
|
+
vals = value
|
|
22
|
+
id_list = [int(x.strip()) for x in ids.split(",") if x.strip()]
|
|
23
|
+
ok = client.write(model, id_list, vals)
|
|
24
|
+
click.echo(json.dumps({"success": bool(ok)}))
|
odoorpc_cli/settings.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from cryptography.fernet import Fernet
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Settings:
|
|
8
|
+
CONFIG_DIR = os.path.expanduser("~/.odoo")
|
|
9
|
+
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
|
10
|
+
KEY_PATH = os.path.join(CONFIG_DIR, "machine.key")
|
|
11
|
+
|
|
12
|
+
@classmethod
|
|
13
|
+
def ensure_dir(cls) -> None:
|
|
14
|
+
if not os.path.isdir(cls.CONFIG_DIR):
|
|
15
|
+
os.makedirs(cls.CONFIG_DIR, exist_ok=True)
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def _get_or_create_key(cls) -> bytes:
|
|
19
|
+
"""Return a persistent Fernet key stored in `~/.odoo/machine.key`.
|
|
20
|
+
|
|
21
|
+
The key is created once and written with restrictive permissions (0600).
|
|
22
|
+
"""
|
|
23
|
+
cls.ensure_dir()
|
|
24
|
+
if os.path.isfile(cls.KEY_PATH):
|
|
25
|
+
with open(cls.KEY_PATH, "rb") as f:
|
|
26
|
+
return f.read()
|
|
27
|
+
|
|
28
|
+
key = Fernet.generate_key()
|
|
29
|
+
# Write atomically and restrict permissions
|
|
30
|
+
temp_path = cls.KEY_PATH + ".tmp"
|
|
31
|
+
with open(temp_path, "wb") as f:
|
|
32
|
+
f.write(key)
|
|
33
|
+
try:
|
|
34
|
+
os.replace(temp_path, cls.KEY_PATH)
|
|
35
|
+
os.chmod(cls.KEY_PATH, 0o600)
|
|
36
|
+
finally:
|
|
37
|
+
if os.path.exists(temp_path):
|
|
38
|
+
try:
|
|
39
|
+
os.remove(temp_path)
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return key
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def encrypt_password(cls, plain: str) -> str:
|
|
46
|
+
key = cls._get_or_create_key()
|
|
47
|
+
f = Fernet(key)
|
|
48
|
+
return f.encrypt(plain.encode("utf-8")).decode("utf-8")
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def decrypt_password(cls, token: str) -> str:
|
|
52
|
+
key = cls._get_or_create_key()
|
|
53
|
+
f = Fernet(key)
|
|
54
|
+
return f.decrypt(token.encode("utf-8")).decode("utf-8")
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def save(cls, host: str, db: str, username: str, password: str) -> None:
|
|
58
|
+
cls.ensure_dir()
|
|
59
|
+
token = cls.encrypt_password(password)
|
|
60
|
+
conf = {
|
|
61
|
+
"host": host,
|
|
62
|
+
"db": db,
|
|
63
|
+
"username": username,
|
|
64
|
+
"password_encrypted": token,
|
|
65
|
+
}
|
|
66
|
+
with open(cls.CONFIG_PATH, "w", encoding="utf-8") as f:
|
|
67
|
+
json.dump(conf, f, indent=2)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def load(cls) -> tuple[str, str, str, str]:
|
|
71
|
+
cls.ensure_dir()
|
|
72
|
+
if not os.path.isfile(cls.CONFIG_PATH):
|
|
73
|
+
raise RuntimeError('Config not found; run "odoo auth" first')
|
|
74
|
+
with open(cls.CONFIG_PATH, encoding="utf-8") as f:
|
|
75
|
+
conf = json.load(f)
|
|
76
|
+
host = conf.get("host")
|
|
77
|
+
db = conf.get("db")
|
|
78
|
+
username = conf.get("username")
|
|
79
|
+
token = conf.get("password_encrypted")
|
|
80
|
+
if token is None:
|
|
81
|
+
raise RuntimeError("Encrypted password missing from config")
|
|
82
|
+
password = cls.decrypt_password(token)
|
|
83
|
+
return host, db, username, password
|
|
File without changes
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JSON(click.ParamType):
|
|
7
|
+
"""Click parameter type that parses JSON strings into Python objects.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
expected: Optional expected Python type as a string: "list" or "dict".
|
|
11
|
+
If provided, the value is validated to be that type.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
name = "json"
|
|
15
|
+
|
|
16
|
+
def __init__(self, expected: str | None = None):
|
|
17
|
+
if expected not in (None, "list", "dict"):
|
|
18
|
+
raise ValueError("expected must be None, 'list' or 'dict'")
|
|
19
|
+
self.expected = expected
|
|
20
|
+
|
|
21
|
+
def convert(self, value, param, ctx):
|
|
22
|
+
# If the value was already parsed (click may pass defaults), accept it
|
|
23
|
+
if not isinstance(value, str):
|
|
24
|
+
obj = value
|
|
25
|
+
else:
|
|
26
|
+
try:
|
|
27
|
+
obj = json.loads(value)
|
|
28
|
+
except Exception as exc: # pragma: no cover - error path
|
|
29
|
+
self.fail(f"Failed to parse JSON: {exc}", param, ctx)
|
|
30
|
+
|
|
31
|
+
if self.expected == "list" and not isinstance(obj, list):
|
|
32
|
+
self.fail("Value must be a JSON list", param, ctx)
|
|
33
|
+
if self.expected == "dict" and not isinstance(obj, dict):
|
|
34
|
+
self.fail("Value must be a JSON object", param, ctx)
|
|
35
|
+
|
|
36
|
+
return obj
|
|
37
|
+
|
|
38
|
+
def __repr__(self):
|
|
39
|
+
return f"JSON(expected={self.expected!r})"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import odoorpc
|
|
7
|
+
|
|
8
|
+
from ..settings import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class odoorpc_client:
|
|
12
|
+
"""
|
|
13
|
+
A thin Odoo JSON-RPC client using odoorpc (mirrors patterns from biszx-odoo-mcp)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self, host: str, db: str, username: str, password: str, timeout: int = 30
|
|
18
|
+
):
|
|
19
|
+
self.host = host.rstrip("/")
|
|
20
|
+
self.db = db
|
|
21
|
+
self.username = username
|
|
22
|
+
self.password = password
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
parsed = urllib.parse.urlparse(self.host)
|
|
25
|
+
self.hostname = parsed.hostname or "localhost"
|
|
26
|
+
self.port = parsed.port
|
|
27
|
+
self.is_https = parsed.scheme == "https"
|
|
28
|
+
self._connect()
|
|
29
|
+
|
|
30
|
+
def _connect(self) -> None:
|
|
31
|
+
protocol = "jsonrpc+ssl" if self.is_https else "jsonrpc"
|
|
32
|
+
port = self.port or (443 if self.is_https else 80)
|
|
33
|
+
self.odoo = odoorpc.ODOO(
|
|
34
|
+
self.hostname, protocol=protocol, port=port, timeout=self.timeout
|
|
35
|
+
)
|
|
36
|
+
# login will raise on auth failure
|
|
37
|
+
self.odoo.login(self.db, self.username, self.password)
|
|
38
|
+
|
|
39
|
+
# fetch user info
|
|
40
|
+
user_model = self.odoo.env["res.users"]
|
|
41
|
+
self.user = user_model.search_read(
|
|
42
|
+
[("login", "=", self.username)],
|
|
43
|
+
[
|
|
44
|
+
"id",
|
|
45
|
+
"name",
|
|
46
|
+
"login",
|
|
47
|
+
"email",
|
|
48
|
+
"lang",
|
|
49
|
+
"tz",
|
|
50
|
+
"company_id",
|
|
51
|
+
"partner_id",
|
|
52
|
+
"employee_ids",
|
|
53
|
+
],
|
|
54
|
+
limit=1,
|
|
55
|
+
)[0]
|
|
56
|
+
self.uid = self.user["id"]
|
|
57
|
+
|
|
58
|
+
def get_current_user(self) -> dict:
|
|
59
|
+
"""Return the current authenticated user's information."""
|
|
60
|
+
return getattr(self, "user", {})
|
|
61
|
+
|
|
62
|
+
def model_search(self, query: str) -> dict:
|
|
63
|
+
IrModel = self.odoo.env["ir.model"]
|
|
64
|
+
domain = ["|", ("model", "like", query), ("name", "like", query)]
|
|
65
|
+
models = (
|
|
66
|
+
IrModel.search_read(domain, ["model", "name"])
|
|
67
|
+
if hasattr(IrModel, "search_read")
|
|
68
|
+
else []
|
|
69
|
+
)
|
|
70
|
+
return {"length": len(models), "models": models}
|
|
71
|
+
|
|
72
|
+
def model_field(self, model: str):
|
|
73
|
+
m = self.odoo.env[model]
|
|
74
|
+
return m.fields_get()
|
|
75
|
+
|
|
76
|
+
def search_read(
|
|
77
|
+
self, model: str, domain: list[Any], fields: list[str], limit: int | None = None
|
|
78
|
+
):
|
|
79
|
+
m = self.odoo.env[model]
|
|
80
|
+
return m.search_read(domain, fields=fields, limit=limit)
|
|
81
|
+
|
|
82
|
+
def search_count(self, model: str, domain: list[Any]):
|
|
83
|
+
m = self.odoo.env[model]
|
|
84
|
+
try:
|
|
85
|
+
return m.search_count(domain)
|
|
86
|
+
except Exception:
|
|
87
|
+
return len(m.search(domain))
|
|
88
|
+
|
|
89
|
+
def create(self, model: str, vals: list[dict]):
|
|
90
|
+
m = self.odoo.env[model]
|
|
91
|
+
return m.create(vals)
|
|
92
|
+
|
|
93
|
+
def write(self, model: str, ids: list[int], vals: dict):
|
|
94
|
+
m = self.odoo.env[model]
|
|
95
|
+
return m.write(ids, vals)
|
|
96
|
+
|
|
97
|
+
def unlink(self, model: str, ids: list[int]):
|
|
98
|
+
m = self.odoo.env[model]
|
|
99
|
+
return m.unlink(ids)
|
|
100
|
+
|
|
101
|
+
def execute_method(
|
|
102
|
+
self,
|
|
103
|
+
model: str,
|
|
104
|
+
method: str,
|
|
105
|
+
args: list | None = None,
|
|
106
|
+
kwargs: dict | None = None,
|
|
107
|
+
):
|
|
108
|
+
m = self.odoo.env[model]
|
|
109
|
+
func = getattr(m, method)
|
|
110
|
+
if kwargs:
|
|
111
|
+
return func(*(args or []), **(kwargs or {}))
|
|
112
|
+
return func(*(args or []))
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_config(cls):
|
|
116
|
+
host, db, username, password = Settings.load()
|
|
117
|
+
return cls(host=host, db=db, username=username, password=password)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: odoorpc-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line tool for Odoo using xmlrpc
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click>=8.0
|
|
8
|
+
Requires-Dist: cryptography>=48.0
|
|
9
|
+
Requires-Dist: odoorpc>=0.10.1
|
|
10
|
+
|
|
11
|
+
# Odoo CLI
|
|
12
|
+
|
|
13
|
+
A command-line interface for Odoo, providing quick access to common operations like authentication, searching, creating, updating, and deleting records. It is designed for AI, developers and administrators who prefer working in the terminal.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
### Homebrew (tap)
|
|
18
|
+
|
|
19
|
+
Install `odoorpc_cli` from the project's Homebrew tap (recommended):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
brew tap biszx/tap https://github.com/biszx/homebrew-tap
|
|
23
|
+
brew install biszx/tap/odoorpc_cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Windows
|
|
27
|
+
|
|
28
|
+
Standalone executable (no Python required): download the `odoorpc_cli` Windows executable from a release's assets (the repository contains a CI workflow that builds an exe with PyInstaller and uploads it as an artifact). Place the exe on `PATH` or run it directly.
|
|
29
|
+
|
|
30
|
+
Installer (recommended): download the Inno Setup installer (`odoorpc_cli-X.Y.Z-setup.exe`) from a release's assets — it creates shortcuts and an uninstaller for easy setup on Windows.
|
|
31
|
+
|
|
32
|
+
### Python (pip)
|
|
33
|
+
|
|
34
|
+
Install from PyPI:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install odoorpc_cli
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Authenticate and save credentials (interactive):
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
odoo auth login --host https://odoo.example.com --db demo --username admin --password secret
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Search for records:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
odoo search read res.partner --domain "[[\"name\", \"ilike\", \"Acme\"]]" --fields name,email
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Call a custom model method:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
odoo call-method res.partner --method custom_method --args "[]" --kwargs "{}"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Contributing
|
|
61
|
+
|
|
62
|
+
Please open issues and pull requests on the repository. Run tests with `pytest`.
|