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.
- odoo_plugin_manager-0.1.0.dist-info/METADATA +247 -0
- odoo_plugin_manager-0.1.0.dist-info/RECORD +18 -0
- odoo_plugin_manager-0.1.0.dist-info/WHEEL +5 -0
- odoo_plugin_manager-0.1.0.dist-info/entry_points.txt +2 -0
- odoo_plugin_manager-0.1.0.dist-info/licenses/LICENSE +23 -0
- odoo_plugin_manager-0.1.0.dist-info/top_level.txt +1 -0
- opm/__init__.py +0 -0
- opm/cli/__main__.py +17 -0
- opm/cli/commands/dev.py +215 -0
- opm/cli/commands/diagnose.py +86 -0
- opm/cli/commands/init.py +30 -0
- opm/cli/commands/test.py +143 -0
- opm/core/docker.py +49 -0
- opm/core/env.py +122 -0
- opm/core/env_cmds.py +35 -0
- opm/core/odoo_rpc.py +22 -0
- opm/core/state.py +38 -0
- opm/core/utils.py +19 -0
|
@@ -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,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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
opm
|
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()
|
opm/cli/commands/dev.py
ADDED
|
@@ -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.")
|
opm/cli/commands/init.py
ADDED
|
@@ -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.")
|
opm/cli/commands/test.py
ADDED
|
@@ -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)
|