odoorpc-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. odoorpc_cli-0.1.0/PKG-INFO +62 -0
  2. odoorpc_cli-0.1.0/README.md +52 -0
  3. odoorpc_cli-0.1.0/odoorpc_cli/__init__.py +1 -0
  4. odoorpc_cli-0.1.0/odoorpc_cli/cli.py +33 -0
  5. odoorpc_cli-0.1.0/odoorpc_cli/commands/__init__.py +17 -0
  6. odoorpc_cli-0.1.0/odoorpc_cli/commands/auth/__init__.py +14 -0
  7. odoorpc_cli-0.1.0/odoorpc_cli/commands/auth/info.py +15 -0
  8. odoorpc_cli-0.1.0/odoorpc_cli/commands/auth/login.py +26 -0
  9. odoorpc_cli-0.1.0/odoorpc_cli/commands/call_method.py +29 -0
  10. odoorpc_cli-0.1.0/odoorpc_cli/commands/create.py +21 -0
  11. odoorpc_cli-0.1.0/odoorpc_cli/commands/model/__init__.py +14 -0
  12. odoorpc_cli-0.1.0/odoorpc_cli/commands/model/field.py +13 -0
  13. odoorpc_cli-0.1.0/odoorpc_cli/commands/model/search.py +13 -0
  14. odoorpc_cli-0.1.0/odoorpc_cli/commands/search/__init__.py +14 -0
  15. odoorpc_cli-0.1.0/odoorpc_cli/commands/search/count.py +24 -0
  16. odoorpc_cli-0.1.0/odoorpc_cli/commands/search/read.py +44 -0
  17. odoorpc_cli-0.1.0/odoorpc_cli/commands/unlink.py +19 -0
  18. odoorpc_cli-0.1.0/odoorpc_cli/commands/write.py +24 -0
  19. odoorpc_cli-0.1.0/odoorpc_cli/settings.py +83 -0
  20. odoorpc_cli-0.1.0/odoorpc_cli/tools/__init__.py +0 -0
  21. odoorpc_cli-0.1.0/odoorpc_cli/tools/click_types.py +39 -0
  22. odoorpc_cli-0.1.0/odoorpc_cli/tools/odoo_client.py +117 -0
  23. odoorpc_cli-0.1.0/odoorpc_cli.egg-info/PKG-INFO +62 -0
  24. odoorpc_cli-0.1.0/odoorpc_cli.egg-info/SOURCES.txt +43 -0
  25. odoorpc_cli-0.1.0/odoorpc_cli.egg-info/dependency_links.txt +1 -0
  26. odoorpc_cli-0.1.0/odoorpc_cli.egg-info/entry_points.txt +2 -0
  27. odoorpc_cli-0.1.0/odoorpc_cli.egg-info/requires.txt +3 -0
  28. odoorpc_cli-0.1.0/odoorpc_cli.egg-info/top_level.txt +4 -0
  29. odoorpc_cli-0.1.0/pyproject.toml +23 -0
  30. odoorpc_cli-0.1.0/setup.cfg +4 -0
  31. odoorpc_cli-0.1.0/tests/conftest.py +144 -0
  32. odoorpc_cli-0.1.0/tests/test_auth_login.py +33 -0
  33. odoorpc_cli-0.1.0/tests/test_cli.py +49 -0
  34. odoorpc_cli-0.1.0/tests/test_command_auth_info.py +11 -0
  35. odoorpc_cli-0.1.0/tests/test_command_call_method.py +25 -0
  36. odoorpc_cli-0.1.0/tests/test_command_create.py +22 -0
  37. odoorpc_cli-0.1.0/tests/test_command_model_field.py +9 -0
  38. odoorpc_cli-0.1.0/tests/test_command_model_search.py +9 -0
  39. odoorpc_cli-0.1.0/tests/test_command_search_count.py +17 -0
  40. odoorpc_cli-0.1.0/tests/test_command_search_read.py +29 -0
  41. odoorpc_cli-0.1.0/tests/test_command_unlink.py +24 -0
  42. odoorpc_cli-0.1.0/tests/test_command_write.py +58 -0
  43. odoorpc_cli-0.1.0/tests/test_settings.py +50 -0
  44. odoorpc_cli-0.1.0/tests/test_tools_click_types.py +40 -0
  45. odoorpc_cli-0.1.0/tests/test_tools_odoo_client.py +134 -0
@@ -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`.
@@ -0,0 +1,52 @@
1
+ # Odoo CLI
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ ### Homebrew (tap)
8
+
9
+ Install `odoorpc_cli` from the project's Homebrew tap (recommended):
10
+
11
+ ```bash
12
+ brew tap biszx/tap https://github.com/biszx/homebrew-tap
13
+ brew install biszx/tap/odoorpc_cli
14
+ ```
15
+
16
+ ### Windows
17
+
18
+ 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.
19
+
20
+ 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.
21
+
22
+ ### Python (pip)
23
+
24
+ Install from PyPI:
25
+
26
+ ```bash
27
+ pip install odoorpc_cli
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Authenticate and save credentials (interactive):
33
+
34
+ ```
35
+ odoo auth login --host https://odoo.example.com --db demo --username admin --password secret
36
+ ```
37
+
38
+ Search for records:
39
+
40
+ ```
41
+ odoo search read res.partner --domain "[[\"name\", \"ilike\", \"Acme\"]]" --fields name,email
42
+ ```
43
+
44
+ Call a custom model method:
45
+
46
+ ```
47
+ odoo call-method res.partner --method custom_method --args "[]" --kwargs "{}"
48
+ ```
49
+
50
+ ## Contributing
51
+
52
+ Please open issues and pull requests on the repository. Run tests with `pytest`.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -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,14 @@
1
+ import click
2
+
3
+ from .info import info as auth_info
4
+ from .login import login as auth_login
5
+
6
+
7
+ @click.group("auth")
8
+ def auth():
9
+ """Authentication related commands"""
10
+ pass
11
+
12
+
13
+ auth.add_command(auth_login)
14
+ auth.add_command(auth_info)
@@ -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,14 @@
1
+ import click
2
+
3
+ from .field import model_field
4
+ from .search import model_search
5
+
6
+
7
+ @click.group("model")
8
+ def model():
9
+ """Model-introspection commands"""
10
+ pass
11
+
12
+
13
+ model.add_command(model_field)
14
+ model.add_command(model_search)
@@ -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,14 @@
1
+ import click
2
+
3
+ from .count import search_count
4
+ from .read import search_read
5
+
6
+
7
+ @click.group("search")
8
+ def search():
9
+ """Search related commands"""
10
+ pass
11
+
12
+
13
+ search.add_command(search_count)
14
+ search.add_command(search_read)
@@ -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)}))
@@ -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})"