odoo-mcp-tools 1.3.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 (46) hide show
  1. odoo_mcp_tools-1.3.0/.gitignore +25 -0
  2. odoo_mcp_tools-1.3.0/PKG-INFO +64 -0
  3. odoo_mcp_tools-1.3.0/README.md +38 -0
  4. odoo_mcp_tools-1.3.0/pyproject.toml +57 -0
  5. odoo_mcp_tools-1.3.0/run_stdio.py +28 -0
  6. odoo_mcp_tools-1.3.0/src/odoo_mcp/__init__.py +11 -0
  7. odoo_mcp_tools-1.3.0/src/odoo_mcp/__main__.py +36 -0
  8. odoo_mcp_tools-1.3.0/src/odoo_mcp/cache.py +193 -0
  9. odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/__init__.py +26 -0
  10. odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/deltas.py +143 -0
  11. odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/detect.py +81 -0
  12. odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/resolve.py +118 -0
  13. odoo_mcp_tools-1.3.0/src/odoo_mcp/config.py +133 -0
  14. odoo_mcp_tools-1.3.0/src/odoo_mcp/errors.py +42 -0
  15. odoo_mcp_tools-1.3.0/src/odoo_mcp/observability.py +72 -0
  16. odoo_mcp_tools-1.3.0/src/odoo_mcp/registry.py +153 -0
  17. odoo_mcp_tools-1.3.0/src/odoo_mcp/server.py +359 -0
  18. odoo_mcp_tools-1.3.0/src/odoo_mcp/session.py +111 -0
  19. odoo_mcp_tools-1.3.0/src/odoo_mcp/support.py +44 -0
  20. odoo_mcp_tools-1.3.0/src/odoo_mcp/telemetry.py +159 -0
  21. odoo_mcp_tools-1.3.0/src/odoo_mcp/tenancy.py +84 -0
  22. odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/__init__.py +7 -0
  23. odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/crud.py +217 -0
  24. odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/export.py +79 -0
  25. odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/i18n.py +70 -0
  26. odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/meta.py +129 -0
  27. odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/report.py +41 -0
  28. odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/studio.py +324 -0
  29. odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/__init__.py +16 -0
  30. odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/base.py +78 -0
  31. odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/fallback.py +144 -0
  32. odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/jsonrpc.py +182 -0
  33. odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/xmlrpc.py +118 -0
  34. odoo_mcp_tools-1.3.0/tests/test_cache.py +47 -0
  35. odoo_mcp_tools-1.3.0/tests/test_compat_resolve.py +94 -0
  36. odoo_mcp_tools-1.3.0/tests/test_config_placeholders.py +37 -0
  37. odoo_mcp_tools-1.3.0/tests/test_guided_route.py +289 -0
  38. odoo_mcp_tools-1.3.0/tests/test_mcp_interop.py +49 -0
  39. odoo_mcp_tools-1.3.0/tests/test_readonly_group.py +60 -0
  40. odoo_mcp_tools-1.3.0/tests/test_review_followups.py +283 -0
  41. odoo_mcp_tools-1.3.0/tests/test_security_redaction.py +48 -0
  42. odoo_mcp_tools-1.3.0/tests/test_stdio_server.py +351 -0
  43. odoo_mcp_tools-1.3.0/tests/test_studio_tools.py +302 -0
  44. odoo_mcp_tools-1.3.0/tests/test_telemetry_optin.py +128 -0
  45. odoo_mcp_tools-1.3.0/tests/test_transport_fallback.py +78 -0
  46. odoo_mcp_tools-1.3.0/tests/test_transports_stdlib.py +124 -0
@@ -0,0 +1,25 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+
14
+ # Node
15
+ node_modules/
16
+ cli/dist/
17
+
18
+ # Env / secrets
19
+ .env
20
+ *.env
21
+ !docker/*.example.yml
22
+
23
+ # OS / editors
24
+ .DS_Store
25
+ Thumbs.db
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.5
2
+ Name: odoo-mcp-tools
3
+ Version: 1.3.0
4
+ Summary: Clean-room MCP server for Odoo (Community, Enterprise, online) across versions 10-19.
5
+ Project-URL: Homepage, https://github.com/Transgenia/MCP-Odoo-Tools-CE-EE-and-online
6
+ Project-URL: Repository, https://github.com/Transgenia/MCP-Odoo-Tools-CE-EE-and-online
7
+ Author-email: Transgenia <dev@transgenia.org>
8
+ License: MIT
9
+ Keywords: erp,json-rpc,mcp,model-context-protocol,odoo,xml-rpc
10
+ Requires-Python: >=3.9
11
+ Provides-Extra: cache
12
+ Requires-Dist: cachetools>=5.3; extra == 'cache'
13
+ Provides-Extra: dev
14
+ Requires-Dist: mcp<2,>=1.2; (python_version >= '3.10') and extra == 'dev'
15
+ Requires-Dist: mypy>=1.10; extra == 'dev'
16
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
17
+ Requires-Dist: pytest>=8.0; extra == 'dev'
18
+ Requires-Dist: ruff>=0.5; extra == 'dev'
19
+ Provides-Extra: metrics
20
+ Requires-Dist: prometheus-client>=0.20; extra == 'metrics'
21
+ Provides-Extra: otel
22
+ Requires-Dist: opentelemetry-api>=1.24; extra == 'otel'
23
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.24; extra == 'otel'
24
+ Requires-Dist: opentelemetry-sdk>=1.24; extra == 'otel'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # odoo-mcp-tools (server)
28
+
29
+ Clean-room MIT MCP server for Odoo — Community, Enterprise and online (SaaS),
30
+ across major versions **10 to 19**. This is the Python package that backs the
31
+ `MCP-Odoo-Tools-CE-EE-and-online` Claude plugin.
32
+
33
+ It exposes generic Odoo tools (search / read / search_read / create / write /
34
+ unlink / execute / export / translate / report / version / connections) over a
35
+ transport layer that speaks JSON-RPC with automatic XML-RPC fallback, and a
36
+ **cross-version compatibility layer** that resolves model/field names so a
37
+ single tool call works across every supported version.
38
+
39
+ Not derived from any AGPL project; relies only on Odoo's public RPC interfaces.
40
+ It has **no third-party runtime dependencies**: the MCP stdio protocol and both
41
+ transports use only the Python standard library. Requires Python 3.9+.
42
+
43
+ ## Install & run
44
+
45
+ ```bash
46
+ python3 run_stdio.py # from a checkout of server/, nothing to install
47
+ pip install . && odoo-mcp # or install the package, then the entry point
48
+ ```
49
+
50
+ The Claude plugin runs `python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py`
51
+ directly, so nothing is installed when the plugin starts. A Dockerfile is in
52
+ the repository's `docker/` folder.
53
+
54
+ Configure via environment: `ODOO_URL`, `ODOO_DB`, `ODOO_LOGIN`,
55
+ `ODOO_API_KEY` (or `ODOO_PASSWORD`). Optional: `ODOO_TRANSPORT_PREF`
56
+ (`auto`|`jsonrpc`|`xmlrpc`), `ODOO_TIMEOUT`, `ODOO_CACHE_TTL`, `ODOO_METRICS`,
57
+ `ODOO_OTEL_ENDPOINT`.
58
+
59
+ See the repository root README for the full plugin, the CLI fallback, and the
60
+ compatibility matrix.
61
+
62
+ ## License
63
+
64
+ MIT © Transgenia (Centrum Transgenia S.A.S. de C.V.)
@@ -0,0 +1,38 @@
1
+ # odoo-mcp-tools (server)
2
+
3
+ Clean-room MIT MCP server for Odoo — Community, Enterprise and online (SaaS),
4
+ across major versions **10 to 19**. This is the Python package that backs the
5
+ `MCP-Odoo-Tools-CE-EE-and-online` Claude plugin.
6
+
7
+ It exposes generic Odoo tools (search / read / search_read / create / write /
8
+ unlink / execute / export / translate / report / version / connections) over a
9
+ transport layer that speaks JSON-RPC with automatic XML-RPC fallback, and a
10
+ **cross-version compatibility layer** that resolves model/field names so a
11
+ single tool call works across every supported version.
12
+
13
+ Not derived from any AGPL project; relies only on Odoo's public RPC interfaces.
14
+ It has **no third-party runtime dependencies**: the MCP stdio protocol and both
15
+ transports use only the Python standard library. Requires Python 3.9+.
16
+
17
+ ## Install & run
18
+
19
+ ```bash
20
+ python3 run_stdio.py # from a checkout of server/, nothing to install
21
+ pip install . && odoo-mcp # or install the package, then the entry point
22
+ ```
23
+
24
+ The Claude plugin runs `python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py`
25
+ directly, so nothing is installed when the plugin starts. A Dockerfile is in
26
+ the repository's `docker/` folder.
27
+
28
+ Configure via environment: `ODOO_URL`, `ODOO_DB`, `ODOO_LOGIN`,
29
+ `ODOO_API_KEY` (or `ODOO_PASSWORD`). Optional: `ODOO_TRANSPORT_PREF`
30
+ (`auto`|`jsonrpc`|`xmlrpc`), `ODOO_TIMEOUT`, `ODOO_CACHE_TTL`, `ODOO_METRICS`,
31
+ `ODOO_OTEL_ENDPOINT`.
32
+
33
+ See the repository root README for the full plugin, the CLI fallback, and the
34
+ compatibility matrix.
35
+
36
+ ## License
37
+
38
+ MIT © Transgenia (Centrum Transgenia S.A.S. de C.V.)
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "odoo-mcp-tools"
7
+ version = "1.3.0"
8
+ description = "Clean-room MCP server for Odoo (Community, Enterprise, online) across versions 10-19."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Transgenia", email = "dev@transgenia.org" }]
13
+ keywords = ["odoo", "erp", "mcp", "model-context-protocol", "xml-rpc", "json-rpc"]
14
+ # No runtime dependencies: the MCP stdio protocol and both Odoo transports are
15
+ # implemented on the standard library, so the plugin runs the shipped source
16
+ # directly with python3 and installs nothing. Extras below are optional.
17
+ dependencies = []
18
+
19
+ [project.optional-dependencies]
20
+ cache = ["cachetools>=5.3"]
21
+ metrics = ["prometheus-client>=0.20"]
22
+ otel = [
23
+ "opentelemetry-api>=1.24",
24
+ "opentelemetry-sdk>=1.24",
25
+ "opentelemetry-exporter-otlp-proto-http>=1.24",
26
+ ]
27
+ dev = [
28
+ # Interop check only: tests drive our server with the official MCP client.
29
+ "mcp>=1.2,<2; python_version >= '3.10'",
30
+ "pytest>=8.0",
31
+ "pytest-asyncio>=0.23",
32
+ "ruff>=0.5",
33
+ "mypy>=1.10",
34
+ ]
35
+
36
+ [project.scripts]
37
+ odoo-mcp = "odoo_mcp.__main__:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/Transgenia/MCP-Odoo-Tools-CE-EE-and-online"
41
+ Repository = "https://github.com/Transgenia/MCP-Odoo-Tools-CE-EE-and-online"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/odoo_mcp"]
45
+
46
+ [tool.ruff]
47
+ line-length = 100
48
+ target-version = "py39"
49
+
50
+ [tool.ruff.lint]
51
+ # Optional-dependency loaders intentionally catch broad exceptions to degrade
52
+ # gracefully when an extra (cachetools/prometheus/otel) is not installed.
53
+ ignore = ["BLE001"]
54
+
55
+ [tool.pytest.ini_options]
56
+ markers = ["live: tests that require a real/ephemeral Odoo instance (opt-in)"]
57
+ asyncio_mode = "auto"
@@ -0,0 +1,28 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Transgenia (Centrum Transgenia S.A.S. de C.V.)
3
+ """Entry point the plugin runs: ``python3 ${CLAUDE_PLUGIN_ROOT}/server/run_stdio.py``.
4
+
5
+ Standard library only. It puts the bundled ``src`` folder first on
6
+ ``sys.path`` (so a different ``odoo_mcp`` package installed on the machine can
7
+ never shadow the reviewed code) and starts the stdio MCP server. It installs
8
+ nothing and downloads nothing.
9
+ """
10
+
11
+ import os
12
+ import sys
13
+
14
+ # Runs before any project code, so it must also parse on older interpreters:
15
+ # hence the explicit check and %-formatting rather than an f-string.
16
+ if sys.version_info < (3, 9): # noqa: UP036
17
+ sys.stderr.write(
18
+ "odoo-tools: Python 3.9 or newer is required (found %s)\n" # noqa: UP031
19
+ % sys.version.split()[0]
20
+ )
21
+ sys.exit(1)
22
+
23
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "src"))
24
+
25
+ from odoo_mcp.__main__ import main
26
+
27
+ if __name__ == "__main__":
28
+ raise SystemExit(main())
@@ -0,0 +1,11 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Transgenia (Centrum Transgenia S.A.S. de C.V.)
3
+ """Clean-room MCP server for Odoo CE/EE/online, versions 10-19.
4
+
5
+ This package is an original implementation. It does NOT derive from any
6
+ AGPL-licensed Odoo MCP project; it only relies on Odoo's public XML-RPC /
7
+ JSON-RPC interfaces and publicly known model/field naming, which are not
8
+ themselves copyrightable.
9
+ """
10
+
11
+ __version__ = "0.1.0"
@@ -0,0 +1,36 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Transgenia (Centrum Transgenia S.A.S. de C.V.)
3
+ """CLI entry point: ``odoo-mcp`` / ``python -m odoo_mcp``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import logging
9
+ import sys
10
+
11
+ from .config import Settings
12
+ from .server import serve
13
+
14
+
15
+ def main(argv: list[str] | None = None) -> int:
16
+ parser = argparse.ArgumentParser(prog="odoo-mcp", description=__doc__)
17
+ parser.add_argument(
18
+ "--transport",
19
+ choices=["stdio"],
20
+ default="stdio",
21
+ help="MCP transport (only stdio is supported in this build)",
22
+ )
23
+ parser.add_argument("--log-level", default="INFO")
24
+ args = parser.parse_args(argv)
25
+
26
+ logging.basicConfig(
27
+ level=getattr(logging, args.log_level.upper(), logging.INFO),
28
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
29
+ stream=sys.stderr, # never pollute stdout (the MCP channel)
30
+ )
31
+ serve(Settings.from_env())
32
+ return 0
33
+
34
+
35
+ if __name__ == "__main__": # pragma: no cover
36
+ raise SystemExit(main())
@@ -0,0 +1,193 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Transgenia (Centrum Transgenia S.A.S. de C.V.)
3
+ """In-process TTL cache for schema metadata (fields_get / name_get / model lists).
4
+
5
+ Uses ``cachetools.TTLCache`` when available, otherwise a small stdlib fallback
6
+ so the server has zero hard dependency on the optional ``cache`` extra.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from collections.abc import Callable, Hashable
14
+ from typing import Any
15
+
16
+ try: # optional dependency
17
+ from cachetools import TTLCache # type: ignore
18
+
19
+ _HAS_CACHETOOLS = True
20
+ except Exception: # pragma: no cover - exercised when extra is absent
21
+ _HAS_CACHETOOLS = False
22
+
23
+
24
+ class _SimpleTTLCache:
25
+ """Minimal thread-safe TTL cache used when cachetools is not installed."""
26
+
27
+ def __init__(self, maxsize: int, ttl: float) -> None:
28
+ self._maxsize = maxsize
29
+ self._ttl = ttl
30
+ self._data: dict[Hashable, tuple[float, Any]] = {}
31
+ self._lock = threading.Lock()
32
+
33
+ def get(self, key: Hashable, default: Any = None) -> Any:
34
+ now = time.monotonic()
35
+ with self._lock:
36
+ item = self._data.get(key)
37
+ if item is None:
38
+ return default
39
+ expires, value = item
40
+ if expires < now:
41
+ self._data.pop(key, None)
42
+ return default
43
+ return value
44
+
45
+ def set(self, key: Hashable, value: Any) -> None:
46
+ now = time.monotonic()
47
+ with self._lock:
48
+ if len(self._data) >= self._maxsize:
49
+ # drop the oldest-expiring entry
50
+ oldest = min(self._data.items(), key=lambda kv: kv[1][0], default=None)
51
+ if oldest is not None:
52
+ self._data.pop(oldest[0], None)
53
+ self._data[key] = (now + self._ttl, value)
54
+
55
+ def clear(self) -> None:
56
+ with self._lock:
57
+ self._data.clear()
58
+
59
+ def delete(self, key: Hashable) -> None:
60
+ with self._lock:
61
+ self._data.pop(key, None)
62
+
63
+ def keys(self) -> list[Hashable]:
64
+ with self._lock:
65
+ return list(self._data.keys())
66
+
67
+
68
+ class SchemaCache:
69
+ """TTL cache keyed by ``(tenant, model, version, kind)``.
70
+
71
+ ``kind`` distinguishes ``fields`` from ``name`` and ``models`` lookups so a
72
+ single cache instance serves all schema reads. Records ``hit``/``miss``
73
+ counters that :mod:`odoo_mcp.observability` can export.
74
+ """
75
+
76
+ def __init__(self, ttl: int = 300, maxsize: int = 2048) -> None:
77
+ self.ttl = ttl
78
+ self.hits = 0
79
+ self.misses = 0
80
+ self._lock = threading.Lock()
81
+ # Per-(tenant, model) generation bumped by invalidate_fields. Callers
82
+ # include it in their cache key so an in-flight fill computed BEFORE
83
+ # an invalidation (but stored AFTER) lands under a stale generation
84
+ # and is never served.
85
+ self._generations: dict[tuple[str, str], int] = {}
86
+ if _HAS_CACHETOOLS:
87
+ self._impl: Any = TTLCache(maxsize=maxsize, ttl=ttl)
88
+ self._is_cachetools = True
89
+ else:
90
+ self._impl = _SimpleTTLCache(maxsize=maxsize, ttl=ttl)
91
+ self._is_cachetools = False
92
+
93
+ def get_or_compute(self, key: Hashable, compute: Callable[[], Any]) -> Any:
94
+ cached = self._get(key)
95
+ if cached is not None:
96
+ with self._lock:
97
+ self.hits += 1
98
+ return cached
99
+ with self._lock:
100
+ self.misses += 1
101
+ value = compute()
102
+ self._set(key, value)
103
+ return value
104
+
105
+ def _get(self, key: Hashable) -> Any:
106
+ if self._is_cachetools:
107
+ try:
108
+ return self._impl[key]
109
+ except KeyError:
110
+ return None
111
+ return self._impl.get(key)
112
+
113
+ def _set(self, key: Hashable, value: Any) -> None:
114
+ if self._is_cachetools:
115
+ self._impl[key] = value
116
+ else:
117
+ self._impl.set(key, value)
118
+
119
+ def clear(self) -> None:
120
+ if self._is_cachetools:
121
+ self._impl.clear()
122
+ else:
123
+ self._impl.clear()
124
+ with self._lock:
125
+ self.hits = 0
126
+ self.misses = 0
127
+
128
+ def delete(self, key: Hashable) -> None:
129
+ try:
130
+ if self._is_cachetools:
131
+ self._impl.pop(key, None)
132
+ else:
133
+ self._impl.delete(key)
134
+ except Exception: # noqa: S110 — best-effort cache eviction
135
+ pass
136
+
137
+ def invalidate(self, predicate: Callable[[Hashable], bool]) -> int:
138
+ """Delete every key matching ``predicate``. Returns count removed."""
139
+ if self._is_cachetools:
140
+ try:
141
+ keys = list(self._impl.keys())
142
+ except Exception:
143
+ return 0
144
+ removed = 0
145
+ for k in keys:
146
+ try:
147
+ if predicate(k):
148
+ self._impl.pop(k, None)
149
+ removed += 1
150
+ except Exception: # noqa: S112 — keep evicting other keys
151
+ continue
152
+ return removed
153
+ try:
154
+ keys = self._impl.keys()
155
+ except Exception:
156
+ return 0
157
+ removed = 0
158
+ for k in keys:
159
+ try:
160
+ if predicate(k):
161
+ self._impl.delete(k)
162
+ removed += 1
163
+ except Exception: # noqa: S112 — keep evicting other keys
164
+ continue
165
+ return removed
166
+
167
+ def invalidate_fields(self, fingerprint: str, model: str) -> int:
168
+ """Drop cached ``fields_get`` entries for ``model``/tenant.
169
+
170
+ Called after ``odoo_add_field`` so an immediate verification
171
+ ``odoo_fields_get`` does not serve the pre-create schema (TTL 300s).
172
+ Also bumps the per-model generation so concurrent fills that started
173
+ before this call cannot repopulate stale data afterwards.
174
+ """
175
+ with self._lock:
176
+ gen_key = (fingerprint, model)
177
+ self._generations[gen_key] = self._generations.get(gen_key, 0) + 1
178
+
179
+ def _match(key: Hashable) -> bool:
180
+ return (
181
+ isinstance(key, tuple)
182
+ and len(key) >= 3
183
+ and key[0] == fingerprint
184
+ and key[1] == "fields"
185
+ and key[2] == model
186
+ )
187
+
188
+ return self.invalidate(_match)
189
+
190
+ def generation(self, fingerprint: str, model: str) -> int:
191
+ """Current generation for ``(fingerprint, model)`` (0 = never invalidated)."""
192
+ with self._lock:
193
+ return self._generations.get((fingerprint, model), 0)
@@ -0,0 +1,26 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Transgenia (Centrum Transgenia S.A.S. de C.V.)
3
+ """Cross-version compatibility layer for Odoo 10-19 / CE / EE / online."""
4
+
5
+ from .detect import EnvFacts, probe
6
+ from .resolve import (
7
+ FieldResolution,
8
+ assert_capability,
9
+ has_capability,
10
+ requires_edition,
11
+ resolve_field,
12
+ resolve_fields,
13
+ resolve_model,
14
+ )
15
+
16
+ __all__ = [
17
+ "EnvFacts",
18
+ "FieldResolution",
19
+ "assert_capability",
20
+ "has_capability",
21
+ "probe",
22
+ "requires_edition",
23
+ "resolve_field",
24
+ "resolve_fields",
25
+ "resolve_model",
26
+ ]
@@ -0,0 +1,143 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 Transgenia (Centrum Transgenia S.A.S. de C.V.)
3
+ """Declarative cross-version delta map for Odoo 10-19.
4
+
5
+ This is DATA, not logic. New deltas are added here and covered by table-driven
6
+ tests. All entries reflect publicly known Odoo API/schema changes; version
7
+ boundaries marked "best-effort" should be confirmed by a live probe when a user
8
+ reports a mismatch (that is what the compat layer's graceful errors are for).
9
+
10
+ Conventions:
11
+ * ``changed_in`` = the first major version where the NEW name/behavior applies.
12
+ * ``since`` = feature/model available from this major version onward.
13
+ * ``until`` = feature/model available up to and including this major version.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ MIN_VERSION = 10
21
+ MAX_VERSION = 19
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ModelRename:
26
+ old: str
27
+ new: str
28
+ changed_in: int
29
+ note: str = ""
30
+ # A "merge" is NOT a clean rename: the ``new`` model already exists as a
31
+ # DISTINCT model before ``changed_in`` (e.g. account.move = journal entries
32
+ # exists on v10-12 alongside account.invoice = invoices, which was folded into
33
+ # account.move at v13). For merges we must only resolve old->new on
34
+ # ``version >= changed_in`` (where ``old`` is gone) and NEVER rewrite
35
+ # new->old on older versions, or a call meant for the ``new`` model would be
36
+ # redirected to a different model with different semantics.
37
+ merge: bool = False
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class FieldRename:
42
+ model: str
43
+ old: str
44
+ new: str
45
+ changed_in: int
46
+ note: str = ""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class FieldRemoved:
51
+ model: str
52
+ field: str
53
+ removed_in: int
54
+ use_instead: str = ""
55
+ note: str = ""
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class Capability:
60
+ feature: str
61
+ since: int | None = None # available from this version
62
+ until: int | None = None # available up to and including this version
63
+ note: str = ""
64
+
65
+
66
+ # --- Model renames -----------------------------------------------------------
67
+ MODEL_RENAMES: tuple[ModelRename, ...] = (
68
+ # MERGE, not a rename: account.move (journal entries) already exists on v10-12
69
+ # as a distinct model; account.invoice (invoices) was folded into it at v13.
70
+ ModelRename(
71
+ "account.invoice", "account.move", 13,
72
+ "Invoices merged into account.move in v13; account.move pre-exists as journal entries",
73
+ merge=True,
74
+ ),
75
+ ModelRename(
76
+ "account.invoice.line", "account.move.line", 13,
77
+ "Invoice lines merged into account.move.line in v13; account.move.line pre-exists",
78
+ merge=True,
79
+ ),
80
+ # True rename: stock.package did not exist before v19.
81
+ ModelRename(
82
+ "stock.quant.package", "stock.package", 19, "Package model renamed (best-effort; saas~19.3)"
83
+ ),
84
+ )
85
+
86
+ # --- Field renames -----------------------------------------------------------
87
+ FIELD_RENAMES: tuple[FieldRename, ...] = (
88
+ FieldRename(
89
+ "account.move.line",
90
+ "analytic_account_id",
91
+ "analytic_distribution",
92
+ 16,
93
+ "Analytic accounting became distribution-based in v16",
94
+ ),
95
+ )
96
+
97
+ # --- Field removals ----------------------------------------------------------
98
+ FIELD_REMOVED: tuple[FieldRemoved, ...] = (
99
+ FieldRemoved(
100
+ "product.template",
101
+ "uom_po_id",
102
+ 17,
103
+ use_instead="uom_id",
104
+ note="Purchase UoM unified into uom_id (best-effort boundary)",
105
+ ),
106
+ FieldRemoved(
107
+ "res.partner",
108
+ "company_type",
109
+ 19,
110
+ use_instead="is_company",
111
+ note="company_type dropped; use is_company boolean (best-effort; saas~19.3)",
112
+ ),
113
+ )
114
+
115
+ # --- Capabilities ------------------------------------------------------------
116
+ CAPABILITIES: tuple[Capability, ...] = (
117
+ Capability("api_key_auth", since=14, note="API keys introduced in v14; older needs password"),
118
+ Capability(
119
+ "jsonrpc_api_key",
120
+ until=16,
121
+ note="/jsonrpc execute_kw rejects API keys on v17+; use XML-RPC (auto-fallback handles it)",
122
+ ),
123
+ Capability(
124
+ "update_field_translations",
125
+ since=16,
126
+ note="Native translation write API; older versions need per-lang context writes",
127
+ ),
128
+ )
129
+
130
+ # --- Edition-only models (heuristic; runtime probe is authoritative) ---------
131
+ # Presence of these models strongly implies Enterprise. The compat layer only
132
+ # BLOCKS when edition was positively detected as community.
133
+ ENTERPRISE_ONLY_MODELS: frozenset[str] = frozenset(
134
+ {
135
+ "documents.document",
136
+ "sign.request",
137
+ "account.consolidation.period",
138
+ "quality.check", # quality is EE in most lines
139
+ "helpdesk.ticket",
140
+ "planning.slot",
141
+ "appraisal.appraisal",
142
+ }
143
+ )