odoo-plugin-manager 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.
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.4
2
+ Name: odoo-plugin-manager
3
+ Version: 0.1.0
4
+ Summary: OPM - Plugin Manager for Odoo (CLI)
5
+ Author: Ahmet Atakan
6
+ License-Expression: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://example.com/opm
8
+ Project-URL: Source, https://example.com/opm/repo
9
+ Project-URL: Issues, https://example.com/opm/issues
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: typer>=0.12.3
19
+ Requires-Dist: rich>=13.7.1
20
+ Requires-Dist: pyyaml>=6.0.1
21
+ Requires-Dist: watchdog>=4.0.2
22
+ Requires-Dist: requests>=2.32.0
23
+ Requires-Dist: python-dotenv>=1.0.1
24
+ Requires-Dist: websockets>=12.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: build; extra == "dev"
27
+ Requires-Dist: twine; extra == "dev"
28
+ Requires-Dist: pytest; extra == "dev"
29
+ Requires-Dist: black; extra == "dev"
30
+ Requires-Dist: ruff; extra == "dev"
31
+ Requires-Dist: mypy; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ # 🧩 OPM — Odoo Plugin Manager (CLI)
35
+
36
+ **OPM** is a modern and lightweight command-line tool for Odoo developers.
37
+ It streamlines development and testing by providing smart automation for cache refreshes,
38
+ module testing, and environment management — without restarting Odoo.
39
+
40
+ Designed for developers who want to work faster and cleaner with **Odoo 14 → 17+**.
41
+
42
+ ---
43
+
44
+ ## ⚙️ Installation
45
+
46
+ Install from PyPI:
47
+
48
+ ```bash
49
+ pip install opm
50
+ ```
51
+
52
+ ---
53
+
54
+ ## 📁 Configuration
55
+
56
+ When you first run OPM, it automatically creates an `opm.yaml` configuration file in your working directory.
57
+ This file defines your Odoo connection details and development environment.
58
+
59
+ Example:
60
+
61
+ ```yaml
62
+ runtime:
63
+ odoo_url: "http://localhost:10017"
64
+ db: "main"
65
+ user: "admin"
66
+ pass: "admin"
67
+ addons:
68
+ - "/path/to/odoo/addons"
69
+ container: "odoo17"
70
+ ```
71
+
72
+ > OPM automatically reads this file for every command.
73
+ > No manual setup or environment variables required.
74
+
75
+ ---
76
+
77
+ ## 🚀 Commands
78
+
79
+ ### 🪄 `opm init`
80
+
81
+ Initializes a new OPM project by generating a sample `opm.yaml` configuration file.
82
+ You can edit this file to match your Odoo environment (URL, DB, user, etc.).
83
+
84
+ ```bash
85
+ opm init
86
+ ```
87
+
88
+ Example output:
89
+
90
+ ```
91
+ [opm] Creating opm.yaml configuration...
92
+ [opm] ✅ Configuration created successfully at ./opm.yaml
93
+ ```
94
+
95
+ After running `opm init`, you can immediately start developing with:
96
+
97
+ ```bash
98
+ opm dev
99
+ ```
100
+
101
+ ---
102
+
103
+ ### 🔧 `opm dev`
104
+
105
+ Starts **development mode**, watching your Odoo addons directory for changes.
106
+ Whenever you modify an XML, JS, or QWeb file, OPM triggers an automatic cache flush through RPC —
107
+ instantly reflecting UI and view updates without restarting Odoo.
108
+
109
+ ```bash
110
+ opm dev
111
+ ```
112
+
113
+ Example output:
114
+
115
+ ```
116
+ [opm] Connected to Odoo environment 'runtime'
117
+ [opm] Watching for changes in: /addons
118
+ [opm] Asset/template changed: queue_job/views/menu.xml → flush caches
119
+ ```
120
+
121
+ > ⚠️ Note: This is **not full hot reload** — Python code changes still require a manual reload.
122
+ > XML, QWeb, and JS updates are applied live through Odoo’s cache system.
123
+
124
+ ---
125
+
126
+ ### 🧪 `opm test <module>`
127
+
128
+ Runs tests for the specified Odoo module.
129
+ If the module is not yet installed, OPM automatically installs or upgrades it before running tests.
130
+
131
+ ```bash
132
+ opm test my_module
133
+ ```
134
+
135
+ Example:
136
+
137
+ ```
138
+ [opm] Odoo binary detected: /usr/bin/odoo
139
+ [opm] Running tests for module: my_module
140
+ ✅ Tests finished successfully.
141
+ ```
142
+
143
+ If something goes wrong:
144
+
145
+ ```
146
+ ❌ Tests failed. See .opm/artifacts/test_last.log for details.
147
+ ```
148
+
149
+ All test outputs and logs are automatically saved to:
150
+
151
+ ```
152
+ .opm/artifacts/
153
+ ```
154
+
155
+ > The test command is ideal for CI/CD pipelines or quick module validation
156
+ > without manually launching Odoo.
157
+
158
+ ---
159
+
160
+ ### 🩺 `opm diagnose`
161
+
162
+ Runs a quick environment diagnostic to ensure OPM and Odoo are properly connected.
163
+
164
+ ```bash
165
+ opm diagnose
166
+ ```
167
+
168
+ Example output:
169
+
170
+ ```
171
+ [opm] 🔍 Running environment diagnostics...
172
+ [opm] Docker CLI: ✅ Found
173
+ [opm] Odoo binary: ✅ Found (/usr/bin/odoo)
174
+ [opm] Testing Odoo URL: http://localhost:10017
175
+ [opm] ✅ Odoo instance reachable.
176
+ [opm] 🏁 Diagnose complete.
177
+ ```
178
+
179
+ ---
180
+
181
+ ## 🧩 Features
182
+
183
+ | Feature | Description |
184
+ | ------------------------------ | ------------------------------------------------------------------- |
185
+ | ⚙️ **Automatic Cache Refresh** | Detects XML, QWeb, or JS changes and flushes Odoo caches instantly. |
186
+ | 🧪 **Module Install/Upgrade** | Automatically installs or upgrades modules before running tests. |
187
+ | 🗱 **Docker Integration** | Detects and executes inside Odoo containers automatically. |
188
+ | 📦 **Artifact Logging** | Saves logs and test outputs under `.opm/artifacts/`. |
189
+ | ⚡ **YAML Config System** | Uses a single `opm.yaml` file for all environment details. |
190
+ | 🧠 **RPC-Based Architecture** | Works with Odoo via XML-RPC — no code injection or patching needed. |
191
+
192
+ ---
193
+
194
+ ## 🔮 Future Roadmap
195
+
196
+ These are upcoming features currently under development:
197
+
198
+ * 🔁 **Hot Reload** — true live reload support for Odoo front-end assets
199
+ * 🧩 **Advanced Helper Addon (`opm_dev_helper`)** — deeper cache and UI refresh controls
200
+ * 📊 **Improved Test Reporting** — detailed test result summaries and coverage integration
201
+
202
+ ---
203
+
204
+ ## 🧠 Technical Overview
205
+
206
+ | Key | Details |
207
+ | ---------------------- | --------------------------------------------------- |
208
+ | **Language** | Python 3.10+ |
209
+ | **Dependencies** | typer, rich, watchdog, requests, pyyaml, websockets |
210
+ | **Odoo Compatibility** | 14 → 17+ |
211
+ | **Platforms** | macOS / Linux |
212
+ | **Configuration File** | `opm.yaml` (auto-created on first run) |
213
+
214
+ ---
215
+
216
+ ## 🦦 Example Workflow
217
+
218
+ A simple developer workflow might look like this:
219
+
220
+ ```bash
221
+ # 1️⃣ Initialize config
222
+ opm init
223
+
224
+ # 2️⃣ Check your setup
225
+ opm diagnose
226
+
227
+ # 3️⃣ Start development mode (watch for file changes)
228
+ opm dev
229
+
230
+ # 4️⃣ Run tests for your module
231
+ opm test my_module
232
+ ```
233
+
234
+ This setup keeps your Odoo instance responsive
235
+ and your local development cycle short — no manual restarts needed.
236
+
237
+ ---
238
+
239
+ ## 📜 License
240
+
241
+ Licensed under the **GNU General Public License v3 (GPL-3.0-or-later)**.
242
+ The OPM CLI is open source.
243
+ Future Odoo-specific helper addons may be released under a separate commercial license.
244
+
245
+ ---
246
+
247
+ © 2025 Ahmet Atakan — Crafted for real Odoo developers who build faster, smarter, and cleaner.
@@ -0,0 +1,18 @@
1
+ odoo_plugin_manager-0.1.0.dist-info/licenses/LICENSE,sha256=MqaRH5XS93afus-UL_96hNFoj7RD-_xF9cHcpwap0MM,1919
2
+ opm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ opm/cli/__main__.py,sha256=ws0v0182aT9ydoJ50aIoOkz2OkgQ4Pyre_yE9r3TrSc,405
4
+ opm/cli/commands/dev.py,sha256=xDileiSHQKj3OBrHoMEYwav9L8vAKld_XypZvrafArU,7241
5
+ opm/cli/commands/diagnose.py,sha256=hwUVJSDYzQv8Y7P0Akpcaps-zeASSCY5RJ32lYxH4lw,3164
6
+ opm/cli/commands/init.py,sha256=QyyE04JVZKaLz8Fgho2q3FwMKWFdRtqad9xCNpc6nd0,1006
7
+ opm/cli/commands/test.py,sha256=1ByhEJHTO1uiFMj3ywgyHY1BJT3-To-4-Hd5DYC43pQ,6416
8
+ opm/core/docker.py,sha256=_LwmSdEgza5rZAQx1ZZuSbqeL43l4wei_4cAKSdFkJs,1516
9
+ opm/core/env.py,sha256=e0ZxpgHwSSNqlWIqGhvtPe8Zl_CYprdxRMAEEQtHi5U,4020
10
+ opm/core/env_cmds.py,sha256=WpT7zA1o5YrLqBJ6fAzwRMEYrZ1Z8APW-4mCeANKEg4,1082
11
+ opm/core/odoo_rpc.py,sha256=cqH-B1GX2802U4Z7npLrCPY59-E_RLdExmiw6rJ9M_I,838
12
+ opm/core/state.py,sha256=URQvvMtoYYbAU8P5i3dlnDP4jwpxrigBwBuojh0uKAE,1130
13
+ opm/core/utils.py,sha256=BUff88_sdvvVtS8fFkjtYV9qPJCrRDiAxcMne5abyYc,636
14
+ odoo_plugin_manager-0.1.0.dist-info/METADATA,sha256=ei0GvG30b5UmPJkxYW0b5WtMKkxtb_4nQNvKEZnXpsc,6769
15
+ odoo_plugin_manager-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
+ odoo_plugin_manager-0.1.0.dist-info/entry_points.txt,sha256=pXesOOkH4A05Z3PT-HzhE7x6lZ9Maxcko_1CpJjqE1E,45
17
+ odoo_plugin_manager-0.1.0.dist-info/top_level.txt,sha256=uRgSfi0ap7tsRORXPej5HOWFJ2slgVMIuzb00w-v43w,4
18
+ odoo_plugin_manager-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ opm = opm.cli.__main__:app
@@ -0,0 +1,23 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2025 Ahmet Atakan
5
+
6
+ Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU General Public License is a free, copyleft license for software and other kinds of works.
11
+
12
+ The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program–to make sure it remains free software for all its users.
13
+
14
+ When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
15
+
16
+ To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
17
+
18
+ For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
19
+
20
+ Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
21
+
22
+ For the full text of the GNU General Public License v3, please see:
23
+ https://www.gnu.org/licenses/gpl-3.0.en.html
opm/__init__.py ADDED
File without changes
opm/cli/__main__.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+ import typer
3
+ from rich import print
4
+ from .commands.dev import dev
5
+ from .commands.test import test
6
+ from .commands.init import init
7
+ from .commands.diagnose import diagnose
8
+
9
+ app = typer.Typer(add_completion=False, help="OPM - Plugin Manager for Odoo")
10
+
11
+ app.command()(init)
12
+ app.command()(diagnose)
13
+ app.command()(dev)
14
+ app.command()(test)
15
+
16
+ if __name__ == "__main__":
17
+ app()
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import asyncio
6
+ import threading
7
+ try:
8
+ import websockets # type: ignore
9
+ except Exception: # pragma: no cover
10
+ websockets = None
11
+
12
+ import typer
13
+ from watchdog.observers import Observer
14
+ from watchdog.events import FileSystemEventHandler
15
+
16
+ from ...core.env import load_config
17
+ from ...core.odoo_rpc import OdooRPC
18
+ from ...core.utils import info
19
+
20
+
21
+ class _WSBus:
22
+ def __init__(self):
23
+ self.clients = set()
24
+
25
+ async def handler(self, ws):
26
+ self.clients.add(ws)
27
+ try:
28
+ async for _ in ws:
29
+ pass
30
+ finally:
31
+ self.clients.discard(ws)
32
+
33
+ async def broadcast(self, msg: str):
34
+ if not self.clients:
35
+ return
36
+ await asyncio.gather(*(c.send(msg) for c in list(self.clients)), return_exceptions=True)
37
+
38
+
39
+ def _start_ws_server(host: str, port: int):
40
+ """Start a lightweight WS server in a background thread.
41
+ Returns (broadcast_sync, stop_sync). If websockets is unavailable, both are no-ops.
42
+ """
43
+ if websockets is None:
44
+ def _noop(*_a, **_k):
45
+ return None
46
+ return _noop, _noop
47
+
48
+ bus = _WSBus()
49
+ loop = asyncio.new_event_loop()
50
+ ready = threading.Event()
51
+ stopped = {"flag": False}
52
+
53
+ async def _main():
54
+ async with websockets.serve(bus.handler, host, port):
55
+ ready.set()
56
+ while not stopped["flag"]:
57
+ await asyncio.sleep(0.25)
58
+
59
+ def _runner():
60
+ try:
61
+ loop.run_until_complete(_main())
62
+ finally:
63
+ try:
64
+ loop.stop()
65
+ except Exception:
66
+ pass
67
+ loop.close()
68
+
69
+ t = threading.Thread(target=_runner, daemon=True)
70
+ t.start()
71
+ ready.wait()
72
+
73
+ def broadcast_sync(msg: str):
74
+ if loop.is_closed():
75
+ return
76
+ try:
77
+ asyncio.run_coroutine_threadsafe(bus.broadcast(msg), loop)
78
+ except Exception:
79
+ pass
80
+
81
+ def stop_sync():
82
+ stopped["flag"] = True
83
+
84
+ return broadcast_sync, stop_sync
85
+
86
+
87
+ class _WatchHandler(FileSystemEventHandler):
88
+ def __init__(self, on_change):
89
+ super().__init__()
90
+ self.on_change = on_change
91
+
92
+ def on_modified(self, event):
93
+ if event.is_directory:
94
+ return
95
+ self.on_change(Path(event.src_path))
96
+
97
+
98
+ def dev(
99
+ env: Optional[str] = typer.Option(
100
+ None, "--env", "-e",
101
+ help="Environment name (from opm.yaml 'environments'); if not provided, uses default runtime"
102
+ ),
103
+ config: str = typer.Option(None, help="Path to opm.yaml"),
104
+ addons: Optional[str] = typer.Option("./addons", help="Watch path for addons"),
105
+ module: Optional[str] = typer.Option(None, help="Only target this module for quick upgrades"),
106
+ ):
107
+ """
108
+ Watch files and trigger RPC-powered hot-reload-like actions.
109
+ """
110
+ cfg = load_config(config)
111
+
112
+ # Decide which environment to use
113
+ if env:
114
+ # If user asked for an env, we MUST use it (no silent fallback)
115
+ if not hasattr(cfg, "resolve_env"):
116
+ raise typer.BadParameter("Your config does not support 'environments'.")
117
+ try:
118
+ resolved = cfg.resolve_env(env)
119
+ except Exception as e:
120
+ raise typer.BadParameter(f"Environment '{env}' not found/invalid in opm.yaml: {e}")
121
+ if getattr(resolved, "kind", "runtime") != "runtime":
122
+ raise typer.BadParameter(f"'dev' requires kind=runtime, got kind={resolved.kind!r}")
123
+ data = resolved.data
124
+ info(f"Using environment '{env}' → URL: {data.get('odoo_url')}")
125
+ else:
126
+ # Explicit message when falling back to runtime
127
+ info("No environment provided, using default runtime configuration.")
128
+ data = {
129
+ "odoo_url": cfg.get("runtime", "odoo_url"),
130
+ "db": cfg.get("runtime", "db"),
131
+ "user": cfg.get("runtime", "user"),
132
+ "pass": cfg.get("runtime", "pass"),
133
+ }
134
+
135
+ # Resolve addons path (env → runtime → ./addons) if not explicitly provided
136
+ if not addons or addons == "./addons":
137
+ candidate = None
138
+ if env:
139
+ env_addons = (data.get("addons") or [])
140
+ if isinstance(env_addons, list) and env_addons:
141
+ candidate = env_addons[0]
142
+ if not candidate:
143
+ rt_addons = (cfg.get("runtime", "addons") or [])
144
+ if isinstance(rt_addons, list) and rt_addons:
145
+ candidate = rt_addons[0]
146
+ if not candidate and Path("./addons").exists():
147
+ candidate = "./addons"
148
+ addons = candidate
149
+ if addons:
150
+ info(f"No --addons provided, using resolved addons path: {addons}")
151
+ else:
152
+ raise typer.BadParameter("No addons path resolved. Pass --addons or define addons in env/runtime or create ./addons")
153
+
154
+ # Validate addons path exists and is a directory
155
+ addons_path = Path(addons).expanduser()
156
+ if not addons_path.exists():
157
+ raise typer.BadParameter(f"Addons path does not exist: {addons_path}")
158
+ if not addons_path.is_dir():
159
+ raise typer.BadParameter(f"Addons path is not a directory: {addons_path}")
160
+
161
+ rpc = OdooRPC(data["odoo_url"], data["db"], data["user"], data["pass"])
162
+ rpc.login()
163
+ info(f"Connected to Odoo environment '{env or 'runtime'}'. Watching for changes in: {addons_path}")
164
+
165
+ # Start WS server for live-reload notifications
166
+ ws_host = (cfg.get("runtime", "ws_host") or "127.0.0.1")
167
+ try:
168
+ ws_port = int(cfg.get("runtime", "ws_port") or 8765)
169
+ except Exception:
170
+ ws_port = 8765
171
+ broadcast, stop_ws = _start_ws_server(ws_host, ws_port)
172
+ if websockets is None:
173
+ info("[opm] websockets package not installed; live-reload WS disabled (pip install websockets)")
174
+ # Fallback no-ops
175
+ def broadcast(_msg: str):
176
+ return None
177
+ def stop_ws():
178
+ return None
179
+ else:
180
+ info(f"[opm] WebSocket listening on ws://{ws_host}:{ws_port}")
181
+
182
+ def on_change(path: Path):
183
+ p = str(path)
184
+ if p.endswith((".xml", ".scss", ".js")):
185
+ info(f"Asset/template changed: {p} → flush caches")
186
+ try:
187
+ rpc.call("opm.dev.tools", "flush_caches")
188
+ broadcast("reload")
189
+ except Exception as e:
190
+ info(f"flush error: {e}")
191
+ elif p.endswith(".py") or p.endswith("__manifest__.py"):
192
+ target_module = module or Path(p).parts[-2]
193
+ info(f"Python/manifest changed: {p} → quick upgrade {target_module}")
194
+ try:
195
+ rpc.call("opm.dev.tools", "quick_upgrade", target_module)
196
+ broadcast(f"reload:{target_module}")
197
+ except Exception as e:
198
+ info(f"upgrade error: {e}")
199
+ else:
200
+ info(f"Changed: {p} (no action)")
201
+
202
+ handler = _WatchHandler(on_change)
203
+ observer = Observer()
204
+ observer.schedule(handler, path=str(addons_path), recursive=True)
205
+ observer.start()
206
+ try:
207
+ while True:
208
+ time.sleep(1)
209
+ except KeyboardInterrupt:
210
+ observer.stop()
211
+ try:
212
+ stop_ws()
213
+ except Exception:
214
+ pass
215
+ observer.join()
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+ import subprocess
3
+ import typer
4
+ import requests
5
+
6
+ from ...core.utils import which, info
7
+ from ...core.env import load_config
8
+ from ...core.odoo_rpc import OdooRPC
9
+
10
+
11
+ def _check(cmd: str) -> str:
12
+ path = which(cmd)
13
+ return f"✅ Found ({path})" if path else "❌ Not found"
14
+
15
+
16
+ def diagnose(
17
+ env: str | None = typer.Option(None, "--env", "-e", help="Environment name (from opm.yaml 'environments'); if not provided, uses runtime"),
18
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show extra details"),
19
+ check_auth: bool = typer.Option(False, "--check-auth", help="Attempt XML-RPC authentication using configured DB/user/pass"),
20
+ ):
21
+ """Check Docker, Odoo reachability, and optional XML-RPC authentication."""
22
+ cfg = load_config()
23
+
24
+ info("🔍 Running environment diagnostics...")
25
+ info(f"Docker CLI: {_check('docker')}")
26
+
27
+ # Check local Odoo binary only if relevant (local profile) or when verbose
28
+ profile = getattr(cfg, 'profile', cfg.data.get('profile', 'docker'))
29
+ if verbose or profile == 'local':
30
+ od = which('odoo')
31
+ if od:
32
+ info(f"Odoo binary: ✅ Found ({od})")
33
+ else:
34
+ od2 = which('odoo-bin')
35
+ info(f"Odoo binary: {'✅ Found ('+od2+')' if od2 else '❌ Not found'}")
36
+
37
+ # Resolve environment or fallback to runtime
38
+ used_env = env or 'runtime'
39
+ data = None
40
+ if env and hasattr(cfg, 'resolve_env'):
41
+ try:
42
+ resolved = cfg.resolve_env(env)
43
+ data = resolved.data
44
+ except Exception as e:
45
+ raise typer.BadParameter(f"Environment '{env}' not found/invalid in opm.yaml: {e}")
46
+ if data is None:
47
+ data = {
48
+ "odoo_url": cfg.get("runtime", "odoo_url"),
49
+ "db": cfg.get("runtime", "db"),
50
+ "user": cfg.get("runtime", "user"),
51
+ "pass": cfg.get("runtime", "pass"),
52
+ }
53
+
54
+ url = data.get("odoo_url")
55
+ info(f"Using environment '{used_env}' → URL: {url} | DB: {data.get('db')}")
56
+
57
+ info(f"Testing Odoo URL: {url}")
58
+ try:
59
+ r = requests.get(url, timeout=3)
60
+ if r.status_code == 200:
61
+ info("✅ Odoo instance reachable.")
62
+ else:
63
+ info(f"⚠️ Odoo responded with status code: {r.status_code}")
64
+ except Exception as e:
65
+ info(f"❌ Could not reach Odoo: {e}")
66
+ return
67
+
68
+ if check_auth:
69
+ info("🔐 Checking XML-RPC authentication...")
70
+ rpc = OdooRPC(url, data.get("db"), data.get("user"), data.get("pass"))
71
+ try:
72
+ rpc.login()
73
+ info("✅ Authentication successful.")
74
+ except Exception as e:
75
+ info("❌ Authentication failed. Verify DB name, username/email, password, and dbfilter.")
76
+ info(f" Details: {e}")
77
+
78
+ if verbose and which("docker"):
79
+ try:
80
+ cp = subprocess.run(["docker", "version", "--format", "{{.Server.Version}}"], capture_output=True, text=True, timeout=3)
81
+ if cp.returncode == 0:
82
+ info(f"ℹ️ Docker server version: {cp.stdout.strip()}")
83
+ except Exception:
84
+ pass
85
+
86
+ info("🏁 Diagnose complete.")
@@ -0,0 +1,30 @@
1
+ # src/opm/cli/commands/init.py
2
+ from __future__ import annotations
3
+ import typer, shutil
4
+ from pathlib import Path
5
+ from ...core.utils import info
6
+
7
+ def init():
8
+ """
9
+ Initialize a new OPM project in the current directory.
10
+ """
11
+ root = Path(".")
12
+ example = root / "opm.example.yaml"
13
+ target = root / "opm.yaml"
14
+
15
+ if not target.exists():
16
+ if example.exists():
17
+ shutil.copy(example, target)
18
+ info("✅ Created opm.yaml configuration file.")
19
+ else:
20
+ info("⚠️ Missing opm.example.yaml — please create opm.yaml manually.")
21
+ else:
22
+ info("ℹ️ opm.yaml already exists, skipping creation.")
23
+
24
+ Path(".opm").mkdir(exist_ok=True)
25
+ Path("dist").mkdir(exist_ok=True)
26
+ info("✅ Created .opm/ and dist/ directories.")
27
+ info("🎉 Project initialized successfully.")
28
+ info("Next steps:")
29
+ info(" • Run `opm dev` to start watching for changes.")
30
+ info(" • Run `opm test <module>` to execute tests inside Docker.")
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+ import shlex
3
+ from pathlib import Path
4
+ import typer
5
+ import os
6
+
7
+ from datetime import datetime
8
+ import time
9
+
10
+ from ...core.env import load_config
11
+ from ...core.utils import info, run
12
+
13
+ def test(
14
+ module: str = typer.Argument(..., help="Module name (e.g. opm_dev_helper)"),
15
+ db: str = typer.Option(None, "--db", "-d", help="Database name (fallback: runtime.db)"),
16
+ container: str = typer.Option(None, "--container", "-c", help="Docker container name for Odoo (use `docker ps`)"),
17
+ addons: str = typer.Option(None, "--addons", help="Host addons path (fallback: runtime.addons[0])"),
18
+ extra_ports: bool = typer.Option(True, "--extra-ports/--no-extra-ports", help="Avoid port clashes by using 8070+"),
19
+ debug: bool = typer.Option(False, "--debug", help="Enable Odoo debug logging"),
20
+ no_tty: bool = typer.Option(False, "--no-tty", help="Disable TTY for docker exec (use -i instead of -it)"),
21
+ ):
22
+ cfg = load_config()
23
+ info("[opm] 🧪 Starting test run…")
24
+ container = container or cfg.get("runtime", "container")
25
+ db = db or cfg.get("runtime", "db") or "odoo"
26
+
27
+ info(f"[opm] Target DB: {db}")
28
+ info(f"[opm] Target container: {container or 'LOCAL (odoo in PATH)'}")
29
+
30
+ db_host = cfg.get("runtime", "db_host") or ""
31
+ db_port = cfg.get("runtime", "db_port") or ""
32
+ db_user = cfg.get("runtime", "db_user") or ""
33
+ db_password = cfg.get("runtime", "db_password") or ""
34
+
35
+ db_args = []
36
+ if db_host: db_args += [f"--db_host={db_host}"]
37
+ if db_port: db_args += [f"--db_port={db_port}"]
38
+ if db_user: db_args += [f"--db_user={db_user}"]
39
+ if db_password: db_args += [f"--db_password={db_password}"]
40
+
41
+ if db_host or db_port or db_user or db_password:
42
+ info(f"[opm] DB conn: host={db_host or '-'} port={db_port or '-'} user={db_user or '-'}")
43
+
44
+ # Resolve addons path (host)
45
+ host_addons = addons
46
+ if not host_addons:
47
+ rta = cfg.get("runtime", "addons") or []
48
+ if rta:
49
+ host_addons = str(Path(rta[0]).expanduser().resolve())
50
+
51
+ info(f"[opm] Host addons: {host_addons or '(not provided)'}")
52
+
53
+ # Build addons-path (inside container)
54
+ addons_path = "/usr/lib/python3/dist-packages/odoo/addons"
55
+ if host_addons:
56
+ # assumes your compose mounts host_addons -> /mnt/extra-addons
57
+ addons_path = f"{addons_path},/mnt/extra-addons"
58
+
59
+ info(f"[opm] In-container addons-path: {addons_path}")
60
+ info("[opm] Install/Upgrade mode: auto (-i & -u)")
61
+
62
+ if container:
63
+ info("[opm] Detecting Odoo binary inside the container…")
64
+ # Detect odoo binary inside the container (odoo or odoo-bin)
65
+ code, out, err = run([
66
+ "bash", "-lc",
67
+ f"docker exec -i {shlex.quote(container)} sh -lc 'command -v odoo || command -v odoo-bin'",
68
+ ])
69
+ if code != 0 or not (out or "").strip():
70
+ info(f"❌ No 'odoo' or 'odoo-bin' found in container: {container}")
71
+ raise typer.Exit(1)
72
+ odoo_bin = (out or "").strip()
73
+ info(f"[opm] Odoo binary: {odoo_bin}")
74
+
75
+ # Ports: Odoo 17+ doesn't support xmlrpc flags; http/longpolling only if desired
76
+ if extra_ports:
77
+ ports = "--http-port=8070 --longpolling-port=8071"
78
+ info("[opm] Extra ports enabled: http=8070, longpolling=8071")
79
+ else:
80
+ ports = ""
81
+ info("[opm] Extra ports disabled (using container defaults)")
82
+
83
+ tty_flag = "-i" if no_tty else "-it"
84
+ log_flag = "--log-level=debug" if debug else ""
85
+ # Always force Odoo to write to STDOUT so we can capture logs reliably
86
+ log_file_flag = "--logfile=-"
87
+ container_log = f"/tmp/opm_test_{int(time.time())}.log"
88
+ # Force logfile to a container path we can copy back reliably
89
+ log_file_flag = f"--logfile={container_log}"
90
+
91
+ db_args_str = " ".join(db_args)
92
+ cmd = f"""docker exec {tty_flag} {shlex.quote(container)} {shlex.quote(odoo_bin)} -d {shlex.quote(db)} \
93
+ -i {shlex.quote(module)} -u {shlex.quote(module)} --test-enable --stop-after-init \
94
+ --addons-path={shlex.quote(addons_path)} {db_args_str} {ports} {log_flag} {log_file_flag}"""
95
+ else:
96
+ # bare-metal fallback (expects `odoo` in PATH)
97
+ db_args_str = " ".join(db_args)
98
+ extra_log = " --log-level=debug" if debug else ""
99
+ cmd = f"""stdbuf -oL -eL odoo -d {shlex.quote(db)} -i {shlex.quote(module)} -u {shlex.quote(module)} \
100
+ --test-enable --stop-after-init --addons-path={shlex.quote(addons_path)} {db_args_str} --logfile=-{extra_log}"""
101
+
102
+ info("[opm] ▶️ Executing test command…")
103
+ info(f"[opm] Started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
104
+ t0 = time.time()
105
+ # redacted preview (do not leak db password in logs)
106
+ redacted_cmd = cmd
107
+ if db_password:
108
+ redacted_cmd = redacted_cmd.replace(f"--db_password={db_password}", "--db_password=******")
109
+ # Uncomment the next line if you want to see the full command for debugging
110
+ # info(f"[opm] running: {redacted_cmd}")
111
+
112
+ if no_tty:
113
+ info("[opm] no-tty mode active (docker exec -i; stdout/stderr will be captured)")
114
+
115
+ # Ensure artifacts dir and wrap command to capture ALL output (stdout+stderr) to a host log file
116
+ artifacts_dir = Path(".opm/artifacts")
117
+ artifacts_dir.mkdir(parents=True, exist_ok=True)
118
+ host_log = artifacts_dir / "test_last.log"
119
+
120
+ # Use bash pipefail + tee so we both capture and persist logs; preserve real exit code via PIPESTATUS
121
+ wrapped = (
122
+ f"{cmd}; ec=$?; "
123
+ f"docker cp {shlex.quote(container)}:{shlex.quote(container_log)} {shlex.quote(str(host_log.resolve()))} >/dev/null 2>&1 || true; "
124
+ f"exit $ec"
125
+ )
126
+
127
+ code, out, err = run(["bash", "-lc", wrapped])
128
+ info(f"[opm] ⏱️ Duration: {time.time() - t0:.1f}s")
129
+ if code == 0:
130
+ info("✅ Tests finished successfully.")
131
+ else:
132
+ info("❌ Tests failed. Command (redacted):")
133
+ print(redacted_cmd)
134
+ info("Last lines:")
135
+ tail = (out or err or "").splitlines()[-80:]
136
+ if not tail and host_log.exists():
137
+ try:
138
+ tail = host_log.read_text(errors="ignore").splitlines()[-200:]
139
+ except Exception:
140
+ tail = []
141
+ print("\n".join(tail))
142
+ info(f"[opm] 📄 Full log: {host_log}")
143
+ raise typer.Exit(code)
opm/core/docker.py ADDED
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+ from .env import Config
3
+ from .utils import run, info
4
+ from pathlib import Path
5
+ import os, json, tempfile
6
+
7
+ COMPOSE_TEMPLATE = """
8
+ services:
9
+ db:
10
+ image: {pg_image}
11
+ environment:
12
+ POSTGRES_DB: odoo
13
+ POSTGRES_USER: odoo
14
+ POSTGRES_PASSWORD: odoo
15
+ odoo:
16
+ image: {odoo_image}
17
+ depends_on: [db]
18
+ environment:
19
+ HOST: db
20
+ USER: odoo
21
+ PASSWORD: odoo
22
+ command: ["odoo", "--test-enable", "--stop-after-init"]
23
+ volumes:
24
+ {mounts}
25
+ """
26
+
27
+ def _mounts_yaml(mounts):
28
+ lines = []
29
+ for m in mounts:
30
+ lines.append(f" - {m}")
31
+ return "\n".join(lines) if lines else " - ./addons:/mnt/extra-addons:ro"
32
+
33
+ def compose_up_for_tests(cfg: Config) -> str:
34
+ pg_image = cfg.get("docker","postgres_image")
35
+ odoo_image = cfg.get("docker","odoo_image")
36
+ mounts = cfg.get("docker","mounts", default=[])
37
+ yml = COMPOSE_TEMPLATE.format(pg_image=pg_image, odoo_image=odoo_image, mounts=_mounts_yaml(mounts))
38
+ tmpdir = tempfile.mkdtemp(prefix="opm-")
39
+ compose_file = os.path.join(tmpdir, "docker-compose.yml")
40
+ with open(compose_file, "w", encoding="utf-8") as f:
41
+ f.write("version: '3.8'\n" + yml)
42
+ info(f"compose created at {compose_file}")
43
+ code, out, err = run(["docker","compose","-f",compose_file,"up","-d"])
44
+ if code != 0:
45
+ raise RuntimeError("docker compose up failed: " + err)
46
+ return compose_file
47
+
48
+ def compose_down(compose_file: str):
49
+ run(["docker","compose","-f",compose_file,"down"])
opm/core/env.py ADDED
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import yaml
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ from dotenv import load_dotenv
8
+ load_dotenv()
9
+
10
+ def _expand_env_vars(value):
11
+ """Recursively expand ${VAR} from environment variables in strings of nested dict/list structures.
12
+ If a variable is missing, leave the placeholder as-is.
13
+ """
14
+ if isinstance(value, str):
15
+ # Simple ${VAR} expansion
16
+ import re
17
+ def repl(m):
18
+ var = m.group(1)
19
+ return os.getenv(var, m.group(0))
20
+ return re.sub(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", repl, value)
21
+ elif isinstance(value, list):
22
+ return [_expand_env_vars(v) for v in value]
23
+ elif isinstance(value, dict):
24
+ return {k: _expand_env_vars(v) for k, v in value.items()}
25
+ return value
26
+
27
+
28
+ def _deep_merge(base: dict, override: dict) -> dict:
29
+ """Deep-merge two dictionaries without mutating inputs. override wins."""
30
+ out = dict(base or {})
31
+ for k, v in (override or {}).items():
32
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
33
+ out[k] = _deep_merge(out[k], v)
34
+ else:
35
+ out[k] = v
36
+ return out
37
+
38
+ DEFAULT_CONFIG = {
39
+ "profile": "docker",
40
+ "docker": {
41
+ "odoo_image": "odoo:17.0",
42
+ "postgres_image": "postgres:15",
43
+ "network": None,
44
+ "mounts": [],
45
+ "junit_path": ".opm/artifacts/junit.xml",
46
+ "parallel": 1,
47
+ },
48
+ "runtime": {
49
+ "odoo_url": "http://localhost:8069",
50
+ "db": "odoo",
51
+ "user": "admin",
52
+ "pass": "admin",
53
+ "addons": ["./addons"],
54
+ "vite_proxy": None,
55
+ }
56
+ }
57
+
58
+ class Config:
59
+ def __init__(self, data: Dict[str, Any]):
60
+ self.data = data
61
+
62
+ @property
63
+ def profile(self) -> str:
64
+ return self.data.get("profile", "docker")
65
+
66
+ def get(self, *path, default=None):
67
+ cur = self.data
68
+ for p in path:
69
+ if isinstance(cur, dict) and p in cur:
70
+ cur = cur[p]
71
+ else:
72
+ return default
73
+ return cur
74
+
75
+ def resolve_env(self, name: str):
76
+ """Return a lightweight environment object for the given name.
77
+ Supports optional `extends:` (e.g., extends: runtime or another env) and env var expansion.
78
+ """
79
+ envs = self.data.get("environments", {}) or {}
80
+ if name not in envs:
81
+ raise ValueError(f"Environment '{name}' not defined in opm.yaml")
82
+ entry = envs[name] or {}
83
+ base_name = entry.get("extends")
84
+
85
+ if base_name:
86
+ # base can be `runtime` or another env under `environments`
87
+ if base_name == "runtime":
88
+ base = self.data.get("runtime", {})
89
+ else:
90
+ base_env = envs.get(base_name)
91
+ if not base_env:
92
+ raise ValueError(f"Environment '{name}' extends unknown base '{base_name}'")
93
+ base = base_env
94
+ merged = _deep_merge(base, entry)
95
+ else:
96
+ # If no extends, still provide fallback to runtime for missing auth fields
97
+ fallback = self.data.get("runtime", {})
98
+ merged = _deep_merge(fallback, entry)
99
+
100
+ kind = merged.get("kind", "runtime")
101
+ merged = _expand_env_vars(merged)
102
+ return type("Env", (), {"kind": kind, "data": merged})()
103
+
104
+ def load_config(path: str|None = None) -> Config:
105
+ candidates = [path] if path else ["opm.yaml", "opm.yml"]
106
+ data = DEFAULT_CONFIG.copy()
107
+ for c in candidates:
108
+ p = Path(c)
109
+ if p.exists():
110
+ with open(p, "r", encoding="utf-8") as f:
111
+ loaded = yaml.safe_load(f) or {}
112
+ # shallow merge
113
+ def merge(a, b):
114
+ for k, v in b.items():
115
+ if isinstance(v, dict) and isinstance(a.get(k), dict):
116
+ merge(a[k], v)
117
+ else:
118
+ a[k] = v
119
+ merge(data, loaded)
120
+ break
121
+ data = _expand_env_vars(data)
122
+ return Config(data)
opm/core/env_cmds.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+ import typer
3
+ from ...core.env import load_config
4
+ from ...core.state import set_default_env, get_default_env
5
+ from ...core.utils import info
6
+
7
+ app = typer.Typer(help="Manage OPM environments")
8
+
9
+ @app.command("list")
10
+ def list_envs():
11
+ cfg = load_config()
12
+ envs = (cfg.data.get("environments") or {}).keys()
13
+ cur = get_default_env()
14
+ for name in envs:
15
+ mark = " (current)" if name == cur else ""
16
+ info(f"- {name}{mark}")
17
+ if not envs:
18
+ info("No environments defined. Using 'runtime' by default.")
19
+
20
+ @app.command("use")
21
+ def use_env(name: str):
22
+ cfg = load_config()
23
+ envs = cfg.data.get("environments") or {}
24
+ if name not in envs:
25
+ raise typer.BadParameter(f"Environment '{name}' not defined in opm.yaml")
26
+ set_default_env(name)
27
+ info(f"Default environment set to '{name}'.")
28
+
29
+ @app.command("current")
30
+ def current_env():
31
+ cur = get_default_env()
32
+ if cur:
33
+ info(f"Current default environment: {cur}")
34
+ else:
35
+ info("No default environment selected. Using 'runtime' by default.")
opm/core/odoo_rpc.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+ import xmlrpc.client
3
+
4
+ class OdooRPC:
5
+ def __init__(self, url: str, db: str, user: str, password: str):
6
+ self.url = url.rstrip("/")
7
+ self.db = db
8
+ self.user = user
9
+ self.password = password
10
+ self.uid = None
11
+ self.common = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/common")
12
+ self.models = xmlrpc.client.ServerProxy(f"{self.url}/xmlrpc/2/object")
13
+
14
+ def login(self):
15
+ self.uid = self.common.authenticate(self.db, self.user, self.password, {})
16
+ if not self.uid:
17
+ raise RuntimeError("Odoo login failed")
18
+
19
+ def call(self, model: str, method: str, *args, **kwargs):
20
+ if not self.uid:
21
+ self.login()
22
+ return self.models.execute_kw(self.db, self.uid, self.password, model, method, list(args), kwargs)
opm/core/state.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Optional, Dict, Any
5
+
6
+ STATE_DIR = Path(".opm")
7
+ STATE_FILE = STATE_DIR / "config.json"
8
+
9
+ DEFAULT_STATE = {
10
+ "default_env": None,
11
+ "default_addons": None,
12
+ }
13
+
14
+ def load_state() -> Dict[str, Any]:
15
+ try:
16
+ if STATE_FILE.exists():
17
+ return {**DEFAULT_STATE, **json.loads(STATE_FILE.read_text(encoding="utf-8"))}
18
+ except Exception:
19
+ pass
20
+ return DEFAULT_STATE.copy()
21
+
22
+ def save_state(updates: Dict[str, Any]) -> None:
23
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
24
+ state = load_state()
25
+ state.update({k: v for k, v in updates.items() if v is not None})
26
+ STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8")
27
+
28
+ def get_default_env() -> Optional[str]:
29
+ return load_state().get("default_env")
30
+
31
+ def set_default_env(name: Optional[str]) -> None:
32
+ save_state({"default_env": name})
33
+
34
+ def get_default_addons() -> Optional[str]:
35
+ return load_state().get("default_addons")
36
+
37
+ def set_default_addons(path: Optional[str]) -> None:
38
+ save_state({"default_addons": path})
opm/core/utils.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+ import subprocess, shlex, os, sys
3
+ from pathlib import Path
4
+ from typing import List, Tuple
5
+
6
+ def run(cmd: List[str], cwd: str|None=None, env: dict|None=None) -> Tuple[int, str, str]:
7
+ proc = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
8
+ out, err = proc.communicate()
9
+ return proc.returncode, out, err
10
+
11
+ def ensure_artifacts():
12
+ Path(".opm/artifacts").mkdir(parents=True, exist_ok=True)
13
+
14
+ def info(msg: str):
15
+ print(f"[opm] {msg}")
16
+
17
+ def which(bin_name: str) -> str|None:
18
+ from shutil import which as _which
19
+ return _which(bin_name)