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.
- odoo_mcp_tools-1.3.0/.gitignore +25 -0
- odoo_mcp_tools-1.3.0/PKG-INFO +64 -0
- odoo_mcp_tools-1.3.0/README.md +38 -0
- odoo_mcp_tools-1.3.0/pyproject.toml +57 -0
- odoo_mcp_tools-1.3.0/run_stdio.py +28 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/__init__.py +11 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/__main__.py +36 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/cache.py +193 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/__init__.py +26 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/deltas.py +143 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/detect.py +81 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/compat/resolve.py +118 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/config.py +133 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/errors.py +42 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/observability.py +72 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/registry.py +153 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/server.py +359 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/session.py +111 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/support.py +44 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/telemetry.py +159 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tenancy.py +84 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/__init__.py +7 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/crud.py +217 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/export.py +79 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/i18n.py +70 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/meta.py +129 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/report.py +41 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/tools/studio.py +324 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/__init__.py +16 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/base.py +78 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/fallback.py +144 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/jsonrpc.py +182 -0
- odoo_mcp_tools-1.3.0/src/odoo_mcp/transport/xmlrpc.py +118 -0
- odoo_mcp_tools-1.3.0/tests/test_cache.py +47 -0
- odoo_mcp_tools-1.3.0/tests/test_compat_resolve.py +94 -0
- odoo_mcp_tools-1.3.0/tests/test_config_placeholders.py +37 -0
- odoo_mcp_tools-1.3.0/tests/test_guided_route.py +289 -0
- odoo_mcp_tools-1.3.0/tests/test_mcp_interop.py +49 -0
- odoo_mcp_tools-1.3.0/tests/test_readonly_group.py +60 -0
- odoo_mcp_tools-1.3.0/tests/test_review_followups.py +283 -0
- odoo_mcp_tools-1.3.0/tests/test_security_redaction.py +48 -0
- odoo_mcp_tools-1.3.0/tests/test_stdio_server.py +351 -0
- odoo_mcp_tools-1.3.0/tests/test_studio_tools.py +302 -0
- odoo_mcp_tools-1.3.0/tests/test_telemetry_optin.py +128 -0
- odoo_mcp_tools-1.3.0/tests/test_transport_fallback.py +78 -0
- 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
|
+
)
|