odoo-devops-tools 1.0.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_devops_tools/__init__.py +1 -0
- odoo_devops_tools/env.py +2640 -0
- odoo_devops_tools-1.0.0.dist-info/METADATA +576 -0
- odoo_devops_tools-1.0.0.dist-info/RECORD +8 -0
- odoo_devops_tools-1.0.0.dist-info/WHEEL +5 -0
- odoo_devops_tools-1.0.0.dist-info/entry_points.txt +3 -0
- odoo_devops_tools-1.0.0.dist-info/licenses/LICENSE +21 -0
- odoo_devops_tools-1.0.0.dist-info/top_level.txt +1 -0
odoo_devops_tools/env.py
ADDED
|
@@ -0,0 +1,2640 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
odk-env
|
|
4
|
+
|
|
5
|
+
Provision and sync a reproducible Odoo workspace from an INI configuration file
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import configparser
|
|
12
|
+
import io
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any, Dict, Iterable, Optional
|
|
23
|
+
|
|
24
|
+
from . import __version__
|
|
25
|
+
|
|
26
|
+
_logger = logging.getLogger("odk-env")
|
|
27
|
+
|
|
28
|
+
_DEFAULT_REQUIREMENTS = [
|
|
29
|
+
"pip",
|
|
30
|
+
"setuptools",
|
|
31
|
+
"wheel",
|
|
32
|
+
"click-odoo-contrib",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
_SENSITIVE_KEYS = ("password", "passwd", "secret", "token", "api_key", "apikey", "private_key")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# -----------------------------
|
|
39
|
+
# Data models
|
|
40
|
+
# -----------------------------
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RepoSpec:
|
|
44
|
+
repo: str
|
|
45
|
+
branch: str
|
|
46
|
+
# If True, keep repo as a shallow, single-branch clone (depth=1).
|
|
47
|
+
# If False (default), do a full clone/fetch.
|
|
48
|
+
shallow_clone: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class VirtualenvConfig:
|
|
53
|
+
python_version: str
|
|
54
|
+
build_constraints: list[str]
|
|
55
|
+
requirements: list[str]
|
|
56
|
+
requirements_ignore: list[str]
|
|
57
|
+
managed_python: bool = True
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class ProjectConfig:
|
|
62
|
+
virtualenv: VirtualenvConfig
|
|
63
|
+
odoo: RepoSpec
|
|
64
|
+
addons: Dict[str, RepoSpec]
|
|
65
|
+
config: Dict[str, Any]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Layout:
|
|
70
|
+
root: Path
|
|
71
|
+
odoo_dir: Path
|
|
72
|
+
addons_root: Path
|
|
73
|
+
backups_dir: Path
|
|
74
|
+
configs_dir: Path
|
|
75
|
+
conf_path: Path
|
|
76
|
+
data_dir: Path
|
|
77
|
+
scripts_dir: Path
|
|
78
|
+
wheelhouse_dir: Path
|
|
79
|
+
run_sh: Path
|
|
80
|
+
instance_sh: Path
|
|
81
|
+
run_bat: Path
|
|
82
|
+
test_sh: Path
|
|
83
|
+
test_bat: Path
|
|
84
|
+
shell_sh: Path
|
|
85
|
+
shell_bat: Path
|
|
86
|
+
initdb_sh: Path
|
|
87
|
+
initdb_bat: Path
|
|
88
|
+
update_sh: Path
|
|
89
|
+
update_bat: Path
|
|
90
|
+
update_all_sh: Path
|
|
91
|
+
update_all_bat: Path
|
|
92
|
+
backup_sh: Path
|
|
93
|
+
backup_bat: Path
|
|
94
|
+
restore_sh: Path
|
|
95
|
+
restore_bat: Path
|
|
96
|
+
restore_force_sh: Path
|
|
97
|
+
restore_force_bat: Path
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def from_root(root: Path) -> "Layout":
|
|
101
|
+
odoo_dir = root / "odoo"
|
|
102
|
+
addons_root = root / "odoo-addons"
|
|
103
|
+
backups_dir = root / "odoo-backups"
|
|
104
|
+
configs_dir = root / "odoo-configs"
|
|
105
|
+
conf_path = configs_dir / "odoo-server.conf"
|
|
106
|
+
data_dir = root / "odoo-data"
|
|
107
|
+
scripts_dir = root / "odoo-scripts"
|
|
108
|
+
wheelhouse_dir = root / "wheelhouse"
|
|
109
|
+
run_sh = scripts_dir / "run.sh"
|
|
110
|
+
instance_sh = scripts_dir / "instance.sh"
|
|
111
|
+
run_bat = scripts_dir / "run.bat"
|
|
112
|
+
test_sh = scripts_dir / "test.sh"
|
|
113
|
+
test_bat = scripts_dir / "test.bat"
|
|
114
|
+
shell_sh = scripts_dir / "shell.sh"
|
|
115
|
+
shell_bat = scripts_dir / "shell.bat"
|
|
116
|
+
initdb_sh = scripts_dir / "initdb.sh"
|
|
117
|
+
initdb_bat = scripts_dir / "initdb.bat"
|
|
118
|
+
update_sh = scripts_dir / "update.sh"
|
|
119
|
+
update_bat = scripts_dir / "update.bat"
|
|
120
|
+
update_all_sh = scripts_dir / "update_all.sh"
|
|
121
|
+
update_all_bat = scripts_dir / "update_all.bat"
|
|
122
|
+
backup_sh = scripts_dir / "backup.sh"
|
|
123
|
+
backup_bat = scripts_dir / "backup.bat"
|
|
124
|
+
restore_sh = scripts_dir / "restore.sh"
|
|
125
|
+
restore_bat = scripts_dir / "restore.bat"
|
|
126
|
+
restore_force_sh = scripts_dir / "restore_force.sh"
|
|
127
|
+
restore_force_bat = scripts_dir / "restore_force.bat"
|
|
128
|
+
return Layout(
|
|
129
|
+
root=root,
|
|
130
|
+
odoo_dir=odoo_dir,
|
|
131
|
+
addons_root=addons_root,
|
|
132
|
+
backups_dir=backups_dir,
|
|
133
|
+
configs_dir=configs_dir,
|
|
134
|
+
conf_path=conf_path,
|
|
135
|
+
data_dir=data_dir,
|
|
136
|
+
scripts_dir=scripts_dir,
|
|
137
|
+
wheelhouse_dir=wheelhouse_dir,
|
|
138
|
+
run_sh=run_sh,
|
|
139
|
+
instance_sh=instance_sh,
|
|
140
|
+
run_bat=run_bat,
|
|
141
|
+
test_sh=test_sh,
|
|
142
|
+
test_bat=test_bat,
|
|
143
|
+
shell_sh=shell_sh,
|
|
144
|
+
shell_bat=shell_bat,
|
|
145
|
+
initdb_sh=initdb_sh,
|
|
146
|
+
initdb_bat=initdb_bat,
|
|
147
|
+
update_sh=update_sh,
|
|
148
|
+
update_bat=update_bat,
|
|
149
|
+
update_all_sh=update_all_sh,
|
|
150
|
+
update_all_bat=update_all_bat,
|
|
151
|
+
backup_sh=backup_sh,
|
|
152
|
+
backup_bat=backup_bat,
|
|
153
|
+
restore_sh=restore_sh,
|
|
154
|
+
restore_bat=restore_bat,
|
|
155
|
+
restore_force_sh=restore_force_sh,
|
|
156
|
+
restore_force_bat=restore_force_bat,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# -----------------------------
|
|
161
|
+
# Helpers: validation & parsing
|
|
162
|
+
# -----------------------------
|
|
163
|
+
|
|
164
|
+
def _handle_process_output(p, err_msg: str):
|
|
165
|
+
if p.stdout:
|
|
166
|
+
_logger.info(p.stdout)
|
|
167
|
+
if p.stderr:
|
|
168
|
+
_logger.warning(p.stderr)
|
|
169
|
+
if p.returncode != 0:
|
|
170
|
+
raise Exception(err_msg)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _rmtree(path: Path) -> None:
|
|
174
|
+
"""Remove a directory tree (best-effort handling for read-only files on Windows)."""
|
|
175
|
+
if not path.exists():
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
def onerror(func, p, exc_info):
|
|
179
|
+
try:
|
|
180
|
+
os.chmod(p, stat.S_IWRITE)
|
|
181
|
+
func(p)
|
|
182
|
+
except Exception:
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
shutil.rmtree(path, onerror=onerror)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _require_table(d: Dict[str, Any], key: str) -> Dict[str, Any]:
|
|
189
|
+
v = d.get(key)
|
|
190
|
+
if not isinstance(v, dict):
|
|
191
|
+
raise Exception(f"Missing or invalid [{key}] table in INI.")
|
|
192
|
+
return v
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _require_str(d: Dict[str, Any], key: str) -> str:
|
|
196
|
+
v = d.get(key)
|
|
197
|
+
if not isinstance(v, str) or not v.strip():
|
|
198
|
+
raise Exception(f"Missing or invalid '{key}' (expected non-empty string).")
|
|
199
|
+
return v
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _require_int(d: Dict[str, Any], key: str) -> int:
|
|
203
|
+
v = d.get(key)
|
|
204
|
+
if not isinstance(v, int):
|
|
205
|
+
raise Exception(f"Missing or invalid '{key}' (expected integer).")
|
|
206
|
+
return v
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _require_list_str(d: Dict[str, Any], key: str) -> list[str]:
|
|
210
|
+
v = d.get(key)
|
|
211
|
+
if v is None:
|
|
212
|
+
return []
|
|
213
|
+
if not isinstance(v, list) or any((not isinstance(x, str) or not x.strip()) for x in v):
|
|
214
|
+
raise Exception(f"Missing or invalid '{key}' (expected list of non-empty strings).")
|
|
215
|
+
return [x.strip() for x in v]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _ini_for_audit_log(cp: configparser.ConfigParser) -> str:
|
|
219
|
+
"""
|
|
220
|
+
Return a resolved (interpolated) INI representation suitable for audit logging.
|
|
221
|
+
Comments are not preserved by ConfigParser by design.
|
|
222
|
+
"""
|
|
223
|
+
# Build a resolved copy (so ${vars:...} etc. is expanded in the log).
|
|
224
|
+
resolved = configparser.ConfigParser(interpolation=None)
|
|
225
|
+
|
|
226
|
+
for section in cp.sections():
|
|
227
|
+
if not resolved.has_section(section):
|
|
228
|
+
resolved.add_section(section)
|
|
229
|
+
|
|
230
|
+
for option in cp._sections.get(section, {}).keys():
|
|
231
|
+
value = cp.get(section, option, raw=False) # resolve interpolation
|
|
232
|
+
opt_l = option.lower()
|
|
233
|
+
if any(k in opt_l for k in _SENSITIVE_KEYS):
|
|
234
|
+
value = "******"
|
|
235
|
+
resolved.set(section, option, value)
|
|
236
|
+
|
|
237
|
+
buf = io.StringIO()
|
|
238
|
+
resolved.write(buf)
|
|
239
|
+
return buf.getvalue()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# -----------------------------
|
|
243
|
+
# Include support
|
|
244
|
+
# -----------------------------
|
|
245
|
+
|
|
246
|
+
_INCLUDE_SECTION = "include"
|
|
247
|
+
_INCLUDE_OPTION = "files"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _split_ini_list(value: str) -> list[str]:
|
|
251
|
+
"""Split a multi-line and/or comma-separated INI value into a list of tokens."""
|
|
252
|
+
parts: list[str] = []
|
|
253
|
+
for ln in (value or "").splitlines():
|
|
254
|
+
ln = ln.strip()
|
|
255
|
+
if not ln:
|
|
256
|
+
continue
|
|
257
|
+
for chunk in ln.split(","):
|
|
258
|
+
chunk = chunk.strip()
|
|
259
|
+
if chunk:
|
|
260
|
+
parts.append(chunk)
|
|
261
|
+
return parts
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _expand_include_token(token: str, runtime_vars: Optional[Dict[str, str]]) -> str:
|
|
265
|
+
"""Expand runtime vars like ${ini_dir} in include paths (does NOT evaluate ${section:option})."""
|
|
266
|
+
s = token
|
|
267
|
+
if runtime_vars:
|
|
268
|
+
for k, v in runtime_vars.items():
|
|
269
|
+
if v is None:
|
|
270
|
+
continue
|
|
271
|
+
s = s.replace(f"${{{k}}}", str(v))
|
|
272
|
+
return os.path.expandvars(s)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _read_ini_with_includes(
|
|
276
|
+
entry_ini: Path,
|
|
277
|
+
runtime_vars: Optional[Dict[str, str]] = None,
|
|
278
|
+
) -> tuple[configparser.ConfigParser, list[Path]]:
|
|
279
|
+
"""
|
|
280
|
+
Read an INI config with a lightweight include mechanism:
|
|
281
|
+
|
|
282
|
+
[include]
|
|
283
|
+
files =
|
|
284
|
+
base.ini
|
|
285
|
+
?local.ini
|
|
286
|
+
|
|
287
|
+
Rules:
|
|
288
|
+
- Paths are resolved relative to the INI that declares the include.
|
|
289
|
+
- Included files are loaded first; the including file overrides them.
|
|
290
|
+
- Prefix a path with '?' to make it optional (missing => skipped).
|
|
291
|
+
- Cycles are detected and reported.
|
|
292
|
+
"""
|
|
293
|
+
cp = configparser.ConfigParser(interpolation=configparser.ExtendedInterpolation())
|
|
294
|
+
loaded_order: list[Path] = []
|
|
295
|
+
loaded: set[Path] = set()
|
|
296
|
+
stack: list[Path] = []
|
|
297
|
+
|
|
298
|
+
def _resolve_token(token: str, base_dir: Path) -> tuple[Path, bool]:
|
|
299
|
+
raw = (token or "").strip()
|
|
300
|
+
optional = raw.startswith("?")
|
|
301
|
+
if optional:
|
|
302
|
+
raw = raw[1:].strip()
|
|
303
|
+
|
|
304
|
+
raw = _expand_include_token(raw, runtime_vars=runtime_vars)
|
|
305
|
+
p = Path(raw).expanduser()
|
|
306
|
+
if not p.is_absolute():
|
|
307
|
+
p = (base_dir / p).resolve()
|
|
308
|
+
else:
|
|
309
|
+
p = p.resolve()
|
|
310
|
+
return p, optional
|
|
311
|
+
|
|
312
|
+
def _load_token(token: str, base_dir: Path) -> None:
|
|
313
|
+
p, optional = _resolve_token(token, base_dir=base_dir)
|
|
314
|
+
|
|
315
|
+
if p in loaded:
|
|
316
|
+
return
|
|
317
|
+
if p in stack:
|
|
318
|
+
cycle = " -> ".join([str(x) for x in stack] + [str(p)])
|
|
319
|
+
raise Exception(f"INI include cycle detected: {cycle}")
|
|
320
|
+
|
|
321
|
+
if not p.exists():
|
|
322
|
+
if optional:
|
|
323
|
+
_logger.info("Optional included INI not found (skipping): %s", p)
|
|
324
|
+
return
|
|
325
|
+
raise Exception(f"Included INI not found: {p}")
|
|
326
|
+
if not p.is_file():
|
|
327
|
+
raise Exception(f"Included INI path is not a file: {p}")
|
|
328
|
+
|
|
329
|
+
stack.append(p)
|
|
330
|
+
|
|
331
|
+
# Probe includes without interpolation to avoid requiring runtime/default vars at this stage.
|
|
332
|
+
probe = configparser.ConfigParser(interpolation=None)
|
|
333
|
+
probe.read(p, encoding="utf-8")
|
|
334
|
+
|
|
335
|
+
if probe.has_section(_INCLUDE_SECTION) and probe.has_option(_INCLUDE_SECTION, _INCLUDE_OPTION):
|
|
336
|
+
inc_raw = probe.get(_INCLUDE_SECTION, _INCLUDE_OPTION, fallback="")
|
|
337
|
+
for inc in _split_ini_list(inc_raw):
|
|
338
|
+
_load_token(inc, base_dir=p.parent)
|
|
339
|
+
|
|
340
|
+
stack.pop()
|
|
341
|
+
|
|
342
|
+
read_ok = cp.read(p, encoding="utf-8")
|
|
343
|
+
if not read_ok:
|
|
344
|
+
raise Exception(f"Failed to read INI config: {p}")
|
|
345
|
+
loaded.add(p)
|
|
346
|
+
loaded_order.append(p)
|
|
347
|
+
|
|
348
|
+
# Entry INI is required and loaded last (after its includes).
|
|
349
|
+
_load_token(str(entry_ini), base_dir=entry_ini.parent)
|
|
350
|
+
|
|
351
|
+
return cp, loaded_order
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def load_project_config(
|
|
355
|
+
ini_path: Path,
|
|
356
|
+
runtime_vars: Optional[Dict[str, str]] = None,
|
|
357
|
+
include_runtime_vars: Optional[Dict[str, str]] = None,
|
|
358
|
+
) -> ProjectConfig:
|
|
359
|
+
if not ini_path.exists():
|
|
360
|
+
raise Exception(f"INI config not found: {ini_path}")
|
|
361
|
+
|
|
362
|
+
include_vars = include_runtime_vars if include_runtime_vars is not None else runtime_vars
|
|
363
|
+
cp, loaded_files = _read_ini_with_includes(ini_path, runtime_vars=include_vars)
|
|
364
|
+
|
|
365
|
+
# Inject runtime variables into DEFAULT so ${root_dir} etc. work with ExtendedInterpolation.
|
|
366
|
+
# NOTE: interpolation is resolved on access (cp.get / cp.items), so it is safe to set these
|
|
367
|
+
# after cp.read() but before we access options.
|
|
368
|
+
if runtime_vars:
|
|
369
|
+
for k, v in runtime_vars.items():
|
|
370
|
+
if v is None:
|
|
371
|
+
continue
|
|
372
|
+
cp["DEFAULT"][str(k)] = str(v)
|
|
373
|
+
|
|
374
|
+
loaded_label = "\n".join([f" - {p}" for p in loaded_files])
|
|
375
|
+
_logger.info("Loaded INI stack (resolved) from %s:\n%s\n\nMerged INI (resolved):\n%s", ini_path, loaded_label, _ini_for_audit_log(cp))
|
|
376
|
+
|
|
377
|
+
def _require_option(section: str, option: str) -> str:
|
|
378
|
+
if not cp.has_section(section):
|
|
379
|
+
raise Exception(f"Missing INI section: [{section}]")
|
|
380
|
+
if not cp.has_option(section, option):
|
|
381
|
+
raise Exception(f"Missing option '{option}' in section [{section}]")
|
|
382
|
+
return cp.get(section, option)
|
|
383
|
+
|
|
384
|
+
def _get_list(section: str, option: str) -> list[str]:
|
|
385
|
+
if not cp.has_section(section) or not cp.has_option(section, option):
|
|
386
|
+
return []
|
|
387
|
+
raw = cp.get(section, option)
|
|
388
|
+
# Multi-line INI values are used to represent lists. Empty value => empty list.
|
|
389
|
+
return [ln.strip() for ln in raw.splitlines() if ln.strip()]
|
|
390
|
+
|
|
391
|
+
def _get_bool(section: str, option: str, default: bool = False) -> bool:
|
|
392
|
+
if not cp.has_section(section) or not cp.has_option(section, option):
|
|
393
|
+
return default
|
|
394
|
+
try:
|
|
395
|
+
return cp.getboolean(section, option)
|
|
396
|
+
except ValueError as e:
|
|
397
|
+
raise Exception(
|
|
398
|
+
f"Invalid value for option '{option}' in section [{section}] (expected a boolean like true/false)."
|
|
399
|
+
) from e
|
|
400
|
+
|
|
401
|
+
# Sections expected:
|
|
402
|
+
# [virtualenv]
|
|
403
|
+
# [odoo]
|
|
404
|
+
# [addons.<name>] for each addon
|
|
405
|
+
# [config]
|
|
406
|
+
|
|
407
|
+
if not cp.has_section("virtualenv"):
|
|
408
|
+
raise Exception("Missing INI section: [virtualenv]")
|
|
409
|
+
|
|
410
|
+
python_version = cp.get("virtualenv", "python_version", fallback="").strip()
|
|
411
|
+
if not python_version:
|
|
412
|
+
raise Exception("Missing option 'python_version' in section [virtualenv].")
|
|
413
|
+
|
|
414
|
+
venv = VirtualenvConfig(
|
|
415
|
+
python_version=python_version,
|
|
416
|
+
build_constraints=_get_list("virtualenv", "build_constraints"),
|
|
417
|
+
requirements=_get_list("virtualenv", "requirements"),
|
|
418
|
+
requirements_ignore=_get_list("virtualenv", "requirements_ignore"),
|
|
419
|
+
managed_python=_get_bool("virtualenv", "managed_python", default=True),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
odoo = RepoSpec(
|
|
423
|
+
repo=_require_option("odoo", "repo"),
|
|
424
|
+
branch=_require_option("odoo", "branch"),
|
|
425
|
+
shallow_clone=_get_bool("odoo", "shallow_clone", default=False),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Addons are optional. If there are no [addons.<name>] sections, keep addons empty.
|
|
429
|
+
addons: Dict[str, RepoSpec] = {}
|
|
430
|
+
for sec in cp.sections():
|
|
431
|
+
if sec.startswith("addons."):
|
|
432
|
+
name = sec.split(".", 1)[1]
|
|
433
|
+
addons[name] = RepoSpec(
|
|
434
|
+
repo=_require_option(sec, "repo"),
|
|
435
|
+
branch=_require_option(sec, "branch"),
|
|
436
|
+
shallow_clone=_get_bool(sec, "shallow_clone", default=False),
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if not cp.has_section("config"):
|
|
440
|
+
raise Exception("Missing INI section: [config]")
|
|
441
|
+
config: Dict[str, Any] = {}
|
|
442
|
+
# Only include keys explicitly defined in [config] (exclude DEFAULT/runtime vars).
|
|
443
|
+
for key in cp._sections.get("config", {}).keys():
|
|
444
|
+
config[key] = cp.get("config", key)
|
|
445
|
+
|
|
446
|
+
return ProjectConfig(virtualenv=venv, odoo=odoo, addons=addons, config=config)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def require_venv(
|
|
450
|
+
layout: Layout,
|
|
451
|
+
python_version: str,
|
|
452
|
+
reuse_wheelhouse: bool = False,
|
|
453
|
+
managed_python: bool = True,
|
|
454
|
+
) -> None:
|
|
455
|
+
venv_dir = layout.root / "venv"
|
|
456
|
+
|
|
457
|
+
if not (python_version or "").strip():
|
|
458
|
+
raise Exception("Missing required uv python version (python_version).")
|
|
459
|
+
|
|
460
|
+
# Validate that 'uv' exists in PATH before doing anything else.
|
|
461
|
+
if shutil.which("uv") is None:
|
|
462
|
+
print("ERROR: 'uv' command not found in PATH.", file=sys.stderr)
|
|
463
|
+
raise SystemExit(1)
|
|
464
|
+
|
|
465
|
+
if venv_dir.exists() and not venv_dir.is_dir():
|
|
466
|
+
raise Exception(f"venv path exists but is not a directory: {venv_dir}")
|
|
467
|
+
|
|
468
|
+
if not venv_dir.exists():
|
|
469
|
+
# Install managed python
|
|
470
|
+
if managed_python:
|
|
471
|
+
is_windows = sys.platform.startswith("win")
|
|
472
|
+
if is_windows:
|
|
473
|
+
cpy_tag = f"cpython-{python_version}-windows-x86_64-none"
|
|
474
|
+
else:
|
|
475
|
+
cpy_tag = f"cpython-{python_version}-linux-x86_64-gnu"
|
|
476
|
+
cmd = ["uv", "python", "install", cpy_tag]
|
|
477
|
+
_logger.info(f"Installing managed python {python_version} (x64) with uv: {cpy_tag}")
|
|
478
|
+
p = subprocess.run(
|
|
479
|
+
cmd,
|
|
480
|
+
cwd=str(layout.root),
|
|
481
|
+
text=True,
|
|
482
|
+
capture_output=True,
|
|
483
|
+
)
|
|
484
|
+
_handle_process_output(p, err_msg=(
|
|
485
|
+
f"Failed to install managed python {python_version} (x64) with uv: {cpy_tag}\n"
|
|
486
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
487
|
+
f"{p.stdout}\n{p.stderr}"
|
|
488
|
+
))
|
|
489
|
+
|
|
490
|
+
# Create virtualenv
|
|
491
|
+
_logger.info("Creating virtualenv with uv: %s (python=%s)", venv_dir, python_version)
|
|
492
|
+
cmd = [
|
|
493
|
+
"uv", "venv",
|
|
494
|
+
"-p", python_version,
|
|
495
|
+
str(venv_dir),
|
|
496
|
+
]
|
|
497
|
+
if not managed_python:
|
|
498
|
+
cmd.extend([
|
|
499
|
+
"--no-managed-python",
|
|
500
|
+
])
|
|
501
|
+
p = subprocess.run(
|
|
502
|
+
cmd,
|
|
503
|
+
cwd=str(layout.root),
|
|
504
|
+
text=True,
|
|
505
|
+
capture_output=True,
|
|
506
|
+
)
|
|
507
|
+
_handle_process_output(p, err_msg=(
|
|
508
|
+
f"Failed to create virtualenv at: {venv_dir}\n"
|
|
509
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
510
|
+
f"{p.stdout}\n{p.stderr}"
|
|
511
|
+
))
|
|
512
|
+
|
|
513
|
+
# Install seed packages into virtualenv
|
|
514
|
+
if not reuse_wheelhouse:
|
|
515
|
+
venv_py = venv_dir / ("Scripts/python.exe" if sys.platform.startswith("win") else "bin/python")
|
|
516
|
+
if not venv_py.exists():
|
|
517
|
+
raise Exception(f"venv python not found at expected path: {venv_py}")
|
|
518
|
+
seed_packages = [
|
|
519
|
+
"pip",
|
|
520
|
+
"setuptools",
|
|
521
|
+
"wheel",
|
|
522
|
+
]
|
|
523
|
+
_logger.info("Installing seed packages into venv: %s", venv_dir)
|
|
524
|
+
cmd = ["uv", "pip", "install", "-p", str(venv_py), *seed_packages]
|
|
525
|
+
p = subprocess.run(
|
|
526
|
+
cmd,
|
|
527
|
+
cwd=str(layout.root),
|
|
528
|
+
text=True,
|
|
529
|
+
capture_output=True,
|
|
530
|
+
)
|
|
531
|
+
_handle_process_output(p, err_msg=(
|
|
532
|
+
"Failed to install seed packages into venv.\n"
|
|
533
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
534
|
+
f"{p.stdout}\n{p.stderr}"
|
|
535
|
+
))
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# -----------------------------
|
|
539
|
+
# Requirements filtering helpers
|
|
540
|
+
# -----------------------------
|
|
541
|
+
|
|
542
|
+
def _canonicalize_project_name(name: str) -> str:
|
|
543
|
+
"""Canonicalize a Python distribution name similar to packaging.utils.canonicalize_name."""
|
|
544
|
+
return re.sub(r"[-_.]+", "-", name.strip().lower())
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _strip_inline_comment(line: str) -> str:
|
|
548
|
+
"""Remove trailing comments (a '#' preceded by whitespace)."""
|
|
549
|
+
m = re.search(r"\s+#", line)
|
|
550
|
+
return line[: m.start()].rstrip() if m else line.rstrip()
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _extract_req_name_from_spec(spec: str) -> Optional[str]:
|
|
554
|
+
"""Best-effort extraction of a requirement project name from a requirement spec line."""
|
|
555
|
+
s = spec.strip()
|
|
556
|
+
if not s:
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
# VCS/URL requirement like: git+...#egg=foo
|
|
560
|
+
if "egg=" in s:
|
|
561
|
+
m = re.search(r"[#&]egg=([^&]+)", s)
|
|
562
|
+
if m:
|
|
563
|
+
return _canonicalize_project_name(m.group(1))
|
|
564
|
+
|
|
565
|
+
# Direct reference: name @ https://...
|
|
566
|
+
if "@" in s:
|
|
567
|
+
left, right = s.split("@", 1)
|
|
568
|
+
if left.strip() and right.strip():
|
|
569
|
+
return _canonicalize_project_name(left.strip())
|
|
570
|
+
|
|
571
|
+
# Standard requirement: name[extra] >= 1.0 ; markers
|
|
572
|
+
m = re.match(r"([A-Za-z0-9][A-Za-z0-9._-]*)", s)
|
|
573
|
+
if m:
|
|
574
|
+
return _canonicalize_project_name(m.group(1))
|
|
575
|
+
|
|
576
|
+
return None
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _filter_requirements_file(
|
|
580
|
+
req_path: Path,
|
|
581
|
+
ignore_names: set[str],
|
|
582
|
+
visited: set[Path],
|
|
583
|
+
) -> list[str]:
|
|
584
|
+
"""Return requirements file content with ignored packages removed. Supports nested -r includes."""
|
|
585
|
+
out_lines: list[str] = []
|
|
586
|
+
|
|
587
|
+
try:
|
|
588
|
+
raw_lines = req_path.read_text(encoding="utf-8").splitlines()
|
|
589
|
+
except OSError as e:
|
|
590
|
+
raise Exception(f"Failed to read requirements file: {req_path} ({e})") from e
|
|
591
|
+
|
|
592
|
+
for raw in raw_lines:
|
|
593
|
+
stripped = raw.strip()
|
|
594
|
+
if not stripped or stripped.startswith("#"):
|
|
595
|
+
out_lines.append(raw)
|
|
596
|
+
continue
|
|
597
|
+
|
|
598
|
+
no_comment = _strip_inline_comment(raw)
|
|
599
|
+
|
|
600
|
+
# Include other requirement files (inline them so ignore works recursively).
|
|
601
|
+
if no_comment.startswith(("-r ", "--requirement ")):
|
|
602
|
+
parts = no_comment.split(maxsplit=1)
|
|
603
|
+
if len(parts) == 2:
|
|
604
|
+
include_rel = parts[1].strip()
|
|
605
|
+
include_path = (req_path.parent / include_rel).resolve()
|
|
606
|
+
|
|
607
|
+
out_lines.append(f"# odk-env: begin include {include_rel}")
|
|
608
|
+
if include_path in visited:
|
|
609
|
+
out_lines.append(f"# odk-env: skipped recursive include {include_rel}")
|
|
610
|
+
else:
|
|
611
|
+
visited.add(include_path)
|
|
612
|
+
out_lines.extend(_filter_requirements_file(include_path, ignore_names, visited=visited))
|
|
613
|
+
out_lines.append(f"# odk-env: end include {include_rel}")
|
|
614
|
+
continue
|
|
615
|
+
|
|
616
|
+
# Editable installs: -e <spec> / --editable <spec>
|
|
617
|
+
spec = no_comment.strip()
|
|
618
|
+
if spec.startswith(("-e ", "--editable ")):
|
|
619
|
+
parts = spec.split(maxsplit=1)
|
|
620
|
+
spec = parts[1] if len(parts) == 2 else ""
|
|
621
|
+
|
|
622
|
+
name = _extract_req_name_from_spec(spec)
|
|
623
|
+
if name and name in ignore_names:
|
|
624
|
+
out_lines.append(f"# odk-env: skipped (ignored package '{name}'): {raw}")
|
|
625
|
+
continue
|
|
626
|
+
|
|
627
|
+
out_lines.append(raw)
|
|
628
|
+
|
|
629
|
+
return out_lines
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def compile_all_requirements_lock(
|
|
633
|
+
venv_python: Path,
|
|
634
|
+
workspace_root: Path,
|
|
635
|
+
requirement_files: list[Path],
|
|
636
|
+
base_requirements: list[str],
|
|
637
|
+
requirements_ignore: list[str],
|
|
638
|
+
output_lock_path: Path,
|
|
639
|
+
wheelhouse_dir: Path,
|
|
640
|
+
build_constraints_path: Path,
|
|
641
|
+
) -> Path:
|
|
642
|
+
"""Compile a single lock file from multiple requirements sources using `uv pip compile`.
|
|
643
|
+
|
|
644
|
+
- Collects `base_requirements` (packages listed in INI + OPM defaults)
|
|
645
|
+
- Inlines and filters each requirements.txt (supports nested -r includes)
|
|
646
|
+
- Applies `requirements_ignore` consistently before compilation
|
|
647
|
+
- Writes:
|
|
648
|
+
- ROOT/wheelhouse/all-requirements.in.txt (input)
|
|
649
|
+
- ROOT/wheelhouse/all-requirements.lock.txt (lock output)
|
|
650
|
+
- Reads:
|
|
651
|
+
- ROOT/wheelhouse/build-constraints.txt
|
|
652
|
+
|
|
653
|
+
Returns the path to the generated lock file.
|
|
654
|
+
"""
|
|
655
|
+
if shutil.which("uv") is None:
|
|
656
|
+
raise Exception("Required command not found in PATH: uv")
|
|
657
|
+
|
|
658
|
+
ignore_set = {_canonicalize_project_name(x) for x in (requirements_ignore or []) if x.strip()}
|
|
659
|
+
wheelhouse_dir.mkdir(parents=True, exist_ok=True)
|
|
660
|
+
in_path = wheelhouse_dir / "all-requirements.in.txt"
|
|
661
|
+
|
|
662
|
+
req_lines: list[str] = []
|
|
663
|
+
for req_path in requirement_files:
|
|
664
|
+
if not req_path.exists():
|
|
665
|
+
# Skip silently; the caller may include optional files.
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
try:
|
|
669
|
+
rel = req_path.resolve().relative_to(workspace_root.resolve())
|
|
670
|
+
rel_label = rel.as_posix()
|
|
671
|
+
except Exception:
|
|
672
|
+
rel_label = str(req_path)
|
|
673
|
+
|
|
674
|
+
req_lines.append(f"# --- from {rel_label} ---")
|
|
675
|
+
visited = {req_path.resolve()}
|
|
676
|
+
filtered_lines = _filter_requirements_file(req_path.resolve(), ignore_set, visited=visited)
|
|
677
|
+
req_lines.extend(filtered_lines)
|
|
678
|
+
req_lines.append("")
|
|
679
|
+
|
|
680
|
+
lines: list[str] = [
|
|
681
|
+
"# This file is generated by odk-env (DO NOT EDIT).",
|
|
682
|
+
"# Source: Odoo + addon repository requirements, plus [virtualenv].requirements and OPM defaults.",
|
|
683
|
+
"",
|
|
684
|
+
]
|
|
685
|
+
|
|
686
|
+
if base_requirements:
|
|
687
|
+
lines.append("# --- base requirements (from INI + OPM defaults) ---")
|
|
688
|
+
for spec in base_requirements:
|
|
689
|
+
lines.append(spec)
|
|
690
|
+
lines.append("")
|
|
691
|
+
|
|
692
|
+
lines.extend(req_lines)
|
|
693
|
+
|
|
694
|
+
in_path.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8")
|
|
695
|
+
|
|
696
|
+
_logger.info("Compiling lock file with uv: %s -> %s", in_path, output_lock_path)
|
|
697
|
+
cmd = [
|
|
698
|
+
"uv", "pip", "compile",
|
|
699
|
+
"-p", str(venv_python),
|
|
700
|
+
str(in_path),
|
|
701
|
+
"-o", str(output_lock_path),
|
|
702
|
+
]
|
|
703
|
+
|
|
704
|
+
if build_constraints_path.is_file():
|
|
705
|
+
cmd.extend([
|
|
706
|
+
'--build-constraints', str(build_constraints_path),
|
|
707
|
+
])
|
|
708
|
+
|
|
709
|
+
p = subprocess.run(
|
|
710
|
+
cmd,
|
|
711
|
+
cwd=str(workspace_root),
|
|
712
|
+
text=True,
|
|
713
|
+
capture_output=True,
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
_handle_process_output(p, err_msg=(
|
|
717
|
+
"Failed to compile requirements lock file.\n"
|
|
718
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
719
|
+
f"Input: {in_path}\n"
|
|
720
|
+
f"Output: {output_lock_path}\n"
|
|
721
|
+
f"{p.stdout}\n{p.stderr}"
|
|
722
|
+
))
|
|
723
|
+
|
|
724
|
+
return output_lock_path
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def build_wheelhouse_from_requirements(
|
|
728
|
+
venv_python: Path,
|
|
729
|
+
workspace_root: Path,
|
|
730
|
+
requirements_path: Path,
|
|
731
|
+
wheelhouse_dir: Path,
|
|
732
|
+
build_constraints_path: Path,
|
|
733
|
+
clear_pip_wheel_cache: bool = True,
|
|
734
|
+
) -> None:
|
|
735
|
+
"""Build wheelhouse."""
|
|
736
|
+
if not requirements_path.exists():
|
|
737
|
+
raise Exception(f"Requirements file not found: {requirements_path}")
|
|
738
|
+
|
|
739
|
+
if shutil.which("uv") is None:
|
|
740
|
+
raise Exception("Required command not found in PATH: uv")
|
|
741
|
+
|
|
742
|
+
wheelhouse_dir.mkdir(parents=True, exist_ok=True)
|
|
743
|
+
|
|
744
|
+
# Clear pip's wheel cache
|
|
745
|
+
if clear_pip_wheel_cache:
|
|
746
|
+
cmd = [
|
|
747
|
+
str(venv_python), "-m", "pip", "cache", "purge",
|
|
748
|
+
]
|
|
749
|
+
_logger.info("Clearing pip's wheel cache")
|
|
750
|
+
p = subprocess.run(
|
|
751
|
+
cmd,
|
|
752
|
+
cwd=str(workspace_root),
|
|
753
|
+
text=True,
|
|
754
|
+
capture_output=True,
|
|
755
|
+
)
|
|
756
|
+
_handle_process_output(p, err_msg=(
|
|
757
|
+
"Failed to clear pip's wheel cache.\n"
|
|
758
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
759
|
+
f"{p.stdout}\n{p.stderr}"
|
|
760
|
+
))
|
|
761
|
+
|
|
762
|
+
# Install build constraints to virtualenv before creating wheelhouse
|
|
763
|
+
if build_constraints_path.is_file():
|
|
764
|
+
_logger.info(f"Installing build constraints to virtualenv: {build_constraints_path}")
|
|
765
|
+
cmd = [
|
|
766
|
+
"uv", "pip", "install", "-p", str(venv_python),
|
|
767
|
+
"-U", "-r", str(build_constraints_path),
|
|
768
|
+
]
|
|
769
|
+
p = subprocess.run(
|
|
770
|
+
cmd,
|
|
771
|
+
cwd=str(workspace_root),
|
|
772
|
+
text=True,
|
|
773
|
+
capture_output=True,
|
|
774
|
+
)
|
|
775
|
+
_handle_process_output(p, err_msg=(
|
|
776
|
+
f"Failed to install build constraints to virtualenv: {build_constraints_path}\n"
|
|
777
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
778
|
+
f"{p.stdout}\n{p.stderr}"
|
|
779
|
+
))
|
|
780
|
+
|
|
781
|
+
# Create wheelhouse
|
|
782
|
+
cmd = [
|
|
783
|
+
str(venv_python), "-m", "pip", "wheel",
|
|
784
|
+
"-r", str(requirements_path),
|
|
785
|
+
"-w", str(wheelhouse_dir),
|
|
786
|
+
"--no-deps",
|
|
787
|
+
]
|
|
788
|
+
_logger.info("Creating wheelhouse: %s -> %s", requirements_path, wheelhouse_dir)
|
|
789
|
+
p = subprocess.run(
|
|
790
|
+
cmd,
|
|
791
|
+
cwd=str(workspace_root),
|
|
792
|
+
text=True,
|
|
793
|
+
capture_output=True,
|
|
794
|
+
)
|
|
795
|
+
_handle_process_output(p, err_msg=(
|
|
796
|
+
"Failed to create wheelhouse.\n"
|
|
797
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
798
|
+
f"{p.stdout}\n{p.stderr}"
|
|
799
|
+
))
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def pip_install_requirements_file(
|
|
803
|
+
venv_python: Path,
|
|
804
|
+
workspace_root: Path,
|
|
805
|
+
requirements_path: Path,
|
|
806
|
+
wheelhouse_dir: Path,
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Install a requirements.txt file (via `uv pip`). Optionally (re)build wheelhouse first."""
|
|
809
|
+
if not requirements_path.exists():
|
|
810
|
+
raise Exception(f"Requirements file not found: {requirements_path}")
|
|
811
|
+
|
|
812
|
+
if shutil.which("uv") is None:
|
|
813
|
+
raise Exception("Required command not found in PATH: uv")
|
|
814
|
+
|
|
815
|
+
# Installing requirements from wheelhouse (always offline)
|
|
816
|
+
pip_cmd: list[str] = [
|
|
817
|
+
"uv", "pip", "sync", "-p", str(venv_python),
|
|
818
|
+
"--offline", "--no-index",
|
|
819
|
+
"-f", str(wheelhouse_dir),
|
|
820
|
+
str(requirements_path),
|
|
821
|
+
]
|
|
822
|
+
|
|
823
|
+
_logger.info("Installing requirements from wheelhouse: %s", requirements_path)
|
|
824
|
+
p = subprocess.run(
|
|
825
|
+
pip_cmd,
|
|
826
|
+
cwd=str(workspace_root),
|
|
827
|
+
text=True,
|
|
828
|
+
capture_output=True,
|
|
829
|
+
)
|
|
830
|
+
_handle_process_output(p, err_msg=(
|
|
831
|
+
"Failed to install requirements from wheelhouse.\n"
|
|
832
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
833
|
+
f"{p.stdout}\n{p.stderr}"
|
|
834
|
+
))
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# -----------------------------
|
|
838
|
+
# Git operations
|
|
839
|
+
# -----------------------------
|
|
840
|
+
|
|
841
|
+
def _run(cmd: list[str], cwd: Optional[Path] = None) -> str:
|
|
842
|
+
# Log every git command we execute (stdout only; configured in main()).
|
|
843
|
+
if cmd and cmd[0] == "git":
|
|
844
|
+
_logger.info("git: %s (cwd=%s)", " ".join(cmd), str(cwd) if cwd else "<cwd>")
|
|
845
|
+
|
|
846
|
+
p = subprocess.run(
|
|
847
|
+
cmd,
|
|
848
|
+
cwd=str(cwd) if cwd else None,
|
|
849
|
+
text=True,
|
|
850
|
+
capture_output=True,
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
out = (p.stdout or "").strip()
|
|
854
|
+
err = (p.stderr or "").strip()
|
|
855
|
+
if cmd and cmd[0] == "git":
|
|
856
|
+
if out:
|
|
857
|
+
_logger.info("git stdout: %s", out)
|
|
858
|
+
if err:
|
|
859
|
+
_logger.info("git stderr: %s", err)
|
|
860
|
+
if p.returncode != 0:
|
|
861
|
+
raise Exception(f"Command failed: {' '.join(cmd)} {p.stdout} {p.stderr}")
|
|
862
|
+
return out
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def assert_clean_worktree(repo_dir: Path) -> None:
|
|
866
|
+
_logger.info("assert_clean_worktree: %s", repo_dir)
|
|
867
|
+
out = _run(["git", "status", "--porcelain"], cwd=repo_dir)
|
|
868
|
+
if out.strip():
|
|
869
|
+
raise Exception(
|
|
870
|
+
f"Local changes detected in repository: {repo_dir}\n"
|
|
871
|
+
"You must commit and push your local changes (or clean the working tree) before syncing.\n"
|
|
872
|
+
"Hint: `git status` to inspect, then commit/push or stash/clean as appropriate."
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _is_shallow_repo(repo_dir: Path) -> bool:
|
|
877
|
+
"""Return True if the repository is shallow.
|
|
878
|
+
|
|
879
|
+
We primarily rely on the presence of `.git/shallow` because it is stable
|
|
880
|
+
across git versions.
|
|
881
|
+
"""
|
|
882
|
+
return (repo_dir / ".git" / "shallow").exists()
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _ensure_full_origin_refspec(repo_dir: Path) -> None:
|
|
886
|
+
"""Ensure origin is configured to fetch all branches.
|
|
887
|
+
|
|
888
|
+
Repos cloned with `--single-branch` may have a restricted refspec. When the
|
|
889
|
+
user switches Odoo to a full clone/fetch, we widen origin's fetch refspec so
|
|
890
|
+
a subsequent `git fetch --all` can actually bring all remote branches.
|
|
891
|
+
"""
|
|
892
|
+
wildcard = "+refs/heads/*:refs/remotes/origin/*"
|
|
893
|
+
|
|
894
|
+
p = subprocess.run(
|
|
895
|
+
["git", "config", "--get-all", "remote.origin.fetch"],
|
|
896
|
+
cwd=str(repo_dir),
|
|
897
|
+
text=True,
|
|
898
|
+
capture_output=True,
|
|
899
|
+
)
|
|
900
|
+
existing = [ln.strip() for ln in (p.stdout or "").splitlines() if ln.strip()]
|
|
901
|
+
if wildcard in existing:
|
|
902
|
+
return
|
|
903
|
+
|
|
904
|
+
# Replace whatever refspec was there with a wildcard.
|
|
905
|
+
subprocess.run(
|
|
906
|
+
["git", "config", "--unset-all", "remote.origin.fetch"],
|
|
907
|
+
cwd=str(repo_dir),
|
|
908
|
+
text=True,
|
|
909
|
+
capture_output=True,
|
|
910
|
+
)
|
|
911
|
+
_run(["git", "config", "--add", "remote.origin.fetch", wildcard], cwd=repo_dir)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _unshallow_if_needed(repo_dir: Path) -> None:
|
|
915
|
+
"""Convert a shallow repo into a full-history repo (if needed)."""
|
|
916
|
+
if not _is_shallow_repo(repo_dir):
|
|
917
|
+
return
|
|
918
|
+
|
|
919
|
+
_logger.info("Repository is shallow; converting to full history: %s", repo_dir)
|
|
920
|
+
# `--unshallow` turns the repo into a full clone; safe because we check first.
|
|
921
|
+
_run(["git", "fetch", "--unshallow", "--tags", "origin"], cwd=repo_dir)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def ensure_repo(
|
|
925
|
+
repo_url: str,
|
|
926
|
+
dest: Path,
|
|
927
|
+
branch: Optional[str] = None,
|
|
928
|
+
depth: Optional[int] = None,
|
|
929
|
+
single_branch: bool = False,
|
|
930
|
+
fetch_all: bool = True,
|
|
931
|
+
) -> None:
|
|
932
|
+
"""
|
|
933
|
+
Ensure a git repository exists at `dest`.
|
|
934
|
+
|
|
935
|
+
- If the repo does not exist, it is cloned.
|
|
936
|
+
* If `branch` is provided, the clone will initially checkout that branch.
|
|
937
|
+
* If `single_branch` is True, only that branch will be fetched/kept.
|
|
938
|
+
* If `depth` is provided, the clone will be shallow (depth=N).
|
|
939
|
+
|
|
940
|
+
- If the repo exists, it is fetched/updated according to the chosen strategy:
|
|
941
|
+
* fetch_all=True -> `git fetch --all --prune`
|
|
942
|
+
* fetch_all=False -> fetch only `branch` from origin (optionally shallow)
|
|
943
|
+
"""
|
|
944
|
+
_logger.info("ensure_repo: %s -> %s (branch=%s, depth=%s, single_branch=%s, fetch_all=%s)",
|
|
945
|
+
repo_url, dest, branch, depth, single_branch, fetch_all)
|
|
946
|
+
|
|
947
|
+
if dest.exists() and (dest / ".git").exists():
|
|
948
|
+
assert_clean_worktree(dest)
|
|
949
|
+
|
|
950
|
+
if fetch_all:
|
|
951
|
+
# If the caller wants a full clone/fetch and the repo is currently shallow
|
|
952
|
+
# or restricted to a single branch, convert it.
|
|
953
|
+
if depth is None:
|
|
954
|
+
_ensure_full_origin_refspec(dest)
|
|
955
|
+
_unshallow_if_needed(dest)
|
|
956
|
+
_run(["git", "fetch", "--all", "--tags", "--prune"], cwd=dest)
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
# Fetch only the required branch (useful for shallow/single-branch workflows).
|
|
960
|
+
fetch_cmd: list[str] = ["git", "fetch", "--prune"]
|
|
961
|
+
if depth is not None:
|
|
962
|
+
fetch_cmd += ["--depth", str(depth)]
|
|
963
|
+
fetch_cmd += ["origin"]
|
|
964
|
+
if branch:
|
|
965
|
+
fetch_cmd += [branch]
|
|
966
|
+
_run(fetch_cmd, cwd=dest)
|
|
967
|
+
return
|
|
968
|
+
|
|
969
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
970
|
+
|
|
971
|
+
cmd: list[str] = ["git", "clone"]
|
|
972
|
+
if depth is not None:
|
|
973
|
+
cmd += ["--depth", str(depth)]
|
|
974
|
+
if branch is not None:
|
|
975
|
+
cmd += ["--branch", branch]
|
|
976
|
+
if single_branch:
|
|
977
|
+
cmd += ["--single-branch"]
|
|
978
|
+
cmd += [repo_url, str(dest)]
|
|
979
|
+
_run(cmd)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def checkout_branch(dest: Path, branch: str, fetch_all: bool = True, depth: Optional[int] = None) -> None:
|
|
983
|
+
"""
|
|
984
|
+
Checkout the requested `branch` in an existing repo.
|
|
985
|
+
|
|
986
|
+
- fetch_all=True (default): do a broad fetch (all remotes/branches + tags), then checkout.
|
|
987
|
+
This matches the previous behavior and is suitable for full clones (e.g. addons).
|
|
988
|
+
|
|
989
|
+
- fetch_all=False: fetch ONLY `origin/<branch>` (optionally shallow via depth),
|
|
990
|
+
then force local branch `<branch>` to match `origin/<branch>`.
|
|
991
|
+
This is intended for the Odoo repo where we want single-branch + shallow clones.
|
|
992
|
+
"""
|
|
993
|
+
_logger.info("checkout_branch: %s @ %s (fetch_all=%s, depth=%s)", dest, branch, fetch_all, depth)
|
|
994
|
+
assert_clean_worktree(dest)
|
|
995
|
+
|
|
996
|
+
if fetch_all:
|
|
997
|
+
# If the caller wants a full clone/fetch and the repo is currently shallow
|
|
998
|
+
# or restricted to a single branch, convert it.
|
|
999
|
+
if depth is None:
|
|
1000
|
+
_ensure_full_origin_refspec(dest)
|
|
1001
|
+
_unshallow_if_needed(dest)
|
|
1002
|
+
_run(["git", "fetch", "--all", "--tags", "--prune"], cwd=dest)
|
|
1003
|
+
|
|
1004
|
+
try:
|
|
1005
|
+
_run(["git", "rev-parse", "--verify", f"origin/{branch}"], cwd=dest)
|
|
1006
|
+
_run(["git", "checkout", "-B", branch, f"origin/{branch}"], cwd=dest)
|
|
1007
|
+
assert_clean_worktree(dest)
|
|
1008
|
+
_run(["git", "pull", "--ff-only"], cwd=dest)
|
|
1009
|
+
return
|
|
1010
|
+
except:
|
|
1011
|
+
pass
|
|
1012
|
+
|
|
1013
|
+
_run(["git", "checkout", branch], cwd=dest)
|
|
1014
|
+
return
|
|
1015
|
+
|
|
1016
|
+
# Narrow fetch: only the needed branch, optionally shallow.
|
|
1017
|
+
fetch_cmd: list[str] = ["git", "fetch", "--prune"]
|
|
1018
|
+
if depth is not None:
|
|
1019
|
+
fetch_cmd += ["--depth", str(depth)]
|
|
1020
|
+
fetch_cmd += ["origin", branch]
|
|
1021
|
+
_run(fetch_cmd, cwd=dest)
|
|
1022
|
+
|
|
1023
|
+
_run(["git", "rev-parse", "--verify", f"origin/{branch}"], cwd=dest)
|
|
1024
|
+
_run(["git", "checkout", "-B", branch, f"origin/{branch}"], cwd=dest)
|
|
1025
|
+
# Ensure the working tree exactly matches the remote branch (no pull, no extra refs).
|
|
1026
|
+
_run(["git", "reset", "--hard", f"origin/{branch}"], cwd=dest)
|
|
1027
|
+
assert_clean_worktree(dest)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
# -----------------------------
|
|
1031
|
+
# Odoo config generation
|
|
1032
|
+
# -----------------------------
|
|
1033
|
+
|
|
1034
|
+
def _join_addons_path(paths: Iterable[Path]) -> str:
|
|
1035
|
+
return ",".join(str(p) for p in paths)
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _format_conf_value(value: Any) -> str:
|
|
1039
|
+
# Render INI-parsed values into an Odoo .conf compatible scalar.
|
|
1040
|
+
if isinstance(value, bool):
|
|
1041
|
+
return "true" if value else "false"
|
|
1042
|
+
if isinstance(value, (list, tuple)):
|
|
1043
|
+
return ",".join(_format_conf_value(v) for v in value)
|
|
1044
|
+
return str(value)
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def render_odoo_conf(cfg: Dict[str, Any], layout: Layout, addon_paths: list[Path]) -> str:
|
|
1048
|
+
def _parse_addons_path_value(raw: str) -> list[str]:
|
|
1049
|
+
# Support either comma-separated or multi-line values.
|
|
1050
|
+
parts: list[str] = []
|
|
1051
|
+
for ln in raw.splitlines():
|
|
1052
|
+
ln = ln.strip()
|
|
1053
|
+
if not ln:
|
|
1054
|
+
continue
|
|
1055
|
+
for chunk in ln.split(","):
|
|
1056
|
+
chunk = chunk.strip()
|
|
1057
|
+
if chunk:
|
|
1058
|
+
parts.append(chunk)
|
|
1059
|
+
return parts
|
|
1060
|
+
|
|
1061
|
+
odoo_addons_candidates = [
|
|
1062
|
+
layout.odoo_dir / "addons",
|
|
1063
|
+
layout.odoo_dir / "odoo" / "addons",
|
|
1064
|
+
]
|
|
1065
|
+
|
|
1066
|
+
# Base addons_path always includes Odoo's addons plus every synced addon repository.
|
|
1067
|
+
base_paths: list[Path] = [*odoo_addons_candidates, *addon_paths]
|
|
1068
|
+
|
|
1069
|
+
# Extra addons_path entries from INI should EXTEND (append to) the computed base.
|
|
1070
|
+
extra_paths: list[Path] = []
|
|
1071
|
+
user_addons_raw = cfg.get("addons_path")
|
|
1072
|
+
if isinstance(user_addons_raw, str) and user_addons_raw.strip():
|
|
1073
|
+
for token in _parse_addons_path_value(user_addons_raw):
|
|
1074
|
+
p = Path(token).expanduser()
|
|
1075
|
+
if not p.is_absolute():
|
|
1076
|
+
p = (layout.root / p).resolve()
|
|
1077
|
+
else:
|
|
1078
|
+
p = p.resolve()
|
|
1079
|
+
extra_paths.append(p)
|
|
1080
|
+
|
|
1081
|
+
merged: list[str] = []
|
|
1082
|
+
seen: set[str] = set()
|
|
1083
|
+
for p in [*base_paths, *extra_paths]:
|
|
1084
|
+
s = str(p)
|
|
1085
|
+
if s in seen:
|
|
1086
|
+
continue
|
|
1087
|
+
seen.add(s)
|
|
1088
|
+
merged.append(s)
|
|
1089
|
+
merged_addons_path = ",".join(merged)
|
|
1090
|
+
|
|
1091
|
+
lines: list[str] = ["[options]"]
|
|
1092
|
+
|
|
1093
|
+
# Write every key from [config] (dynamic; no fixed schema), but treat addons_path specially.
|
|
1094
|
+
for key, value in cfg.items():
|
|
1095
|
+
if key in ("addons_path", "data_dir"):
|
|
1096
|
+
continue
|
|
1097
|
+
lines.append(f"{key} = {_format_conf_value(value)}")
|
|
1098
|
+
|
|
1099
|
+
# Always write merged addons_path.
|
|
1100
|
+
lines.append(f"addons_path = {merged_addons_path}")
|
|
1101
|
+
|
|
1102
|
+
# Always write data_dir from layout
|
|
1103
|
+
lines.append(f"data_dir = {layout.data_dir}")
|
|
1104
|
+
|
|
1105
|
+
return "\n".join(lines) + "\n"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
# -----------------------------
|
|
1109
|
+
# Script generation
|
|
1110
|
+
# -----------------------------
|
|
1111
|
+
|
|
1112
|
+
def write_run_sh(layout: Layout) -> None:
|
|
1113
|
+
content = """#!/usr/bin/env bash
|
|
1114
|
+
set -euo pipefail
|
|
1115
|
+
|
|
1116
|
+
# Resolve script directory
|
|
1117
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
1118
|
+
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
1119
|
+
|
|
1120
|
+
VENV_DIR="${ROOT_DIR}/venv"
|
|
1121
|
+
PY="${VENV_DIR}/bin/python"
|
|
1122
|
+
ODOO_BIN="${ROOT_DIR}/odoo/odoo-bin"
|
|
1123
|
+
CONF="${ROOT_DIR}/odoo-configs/odoo-server.conf"
|
|
1124
|
+
|
|
1125
|
+
if [[ ! -d "${VENV_DIR}" ]]; then
|
|
1126
|
+
echo "ERROR: required venv directory not found at ${VENV_DIR}" >&2
|
|
1127
|
+
exit 1
|
|
1128
|
+
fi
|
|
1129
|
+
if [[ ! -x "${PY}" ]]; then
|
|
1130
|
+
echo "ERROR: venv python not found/executable at ${PY}" >&2
|
|
1131
|
+
exit 1
|
|
1132
|
+
fi
|
|
1133
|
+
if [[ ! -f "${ODOO_BIN}" ]]; then
|
|
1134
|
+
echo "ERROR: odoo-bin not found at ${ODOO_BIN}" >&2
|
|
1135
|
+
exit 1
|
|
1136
|
+
fi
|
|
1137
|
+
|
|
1138
|
+
echo "INFO: Starting Odoo server using config ${CONF}. Passing through any extra arguments."
|
|
1139
|
+
exec "${PY}" "${ODOO_BIN}" -c "${CONF}" "$@"
|
|
1140
|
+
"""
|
|
1141
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1142
|
+
layout.run_sh.write_text(content, encoding="utf-8")
|
|
1143
|
+
|
|
1144
|
+
try:
|
|
1145
|
+
mode = layout.run_sh.stat().st_mode
|
|
1146
|
+
layout.run_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1147
|
+
except OSError:
|
|
1148
|
+
pass
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def write_instance_sh(layout: Layout) -> None:
|
|
1152
|
+
content = """#!/usr/bin/env bash
|
|
1153
|
+
set -euo pipefail
|
|
1154
|
+
|
|
1155
|
+
# Resolve script directory
|
|
1156
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
1157
|
+
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
1158
|
+
|
|
1159
|
+
VENV_DIR="${ROOT_DIR}/venv"
|
|
1160
|
+
PY="${VENV_DIR}/bin/python"
|
|
1161
|
+
ODOO_BIN="${ROOT_DIR}/odoo/odoo-bin"
|
|
1162
|
+
CONF="${ROOT_DIR}/odoo-configs/odoo-server.conf"
|
|
1163
|
+
|
|
1164
|
+
LOGS_DIR="${ROOT_DIR}/odoo-logs"
|
|
1165
|
+
LOG_FILE="${LOGS_DIR}/odoo-server.log"
|
|
1166
|
+
PID_FILE="${LOGS_DIR}/odoo-server.pid"
|
|
1167
|
+
|
|
1168
|
+
require_paths() {
|
|
1169
|
+
if [[ ! -d "${VENV_DIR}" ]]; then
|
|
1170
|
+
echo "ERROR: required venv directory not found at ${VENV_DIR}" >&2
|
|
1171
|
+
exit 1
|
|
1172
|
+
fi
|
|
1173
|
+
if [[ ! -x "${PY}" ]]; then
|
|
1174
|
+
echo "ERROR: venv python not found/executable at ${PY}" >&2
|
|
1175
|
+
exit 1
|
|
1176
|
+
fi
|
|
1177
|
+
if [[ ! -f "${ODOO_BIN}" ]]; then
|
|
1178
|
+
echo "ERROR: odoo-bin not found at ${ODOO_BIN}" >&2
|
|
1179
|
+
exit 1
|
|
1180
|
+
fi
|
|
1181
|
+
if [[ ! -f "${CONF}" ]]; then
|
|
1182
|
+
echo "ERROR: Odoo config not found at ${CONF}" >&2
|
|
1183
|
+
exit 1
|
|
1184
|
+
fi
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
is_running() {
|
|
1188
|
+
if [[ -f "${PID_FILE}" ]]; then
|
|
1189
|
+
local pid
|
|
1190
|
+
pid="$(cat "${PID_FILE}" 2>/dev/null || true)"
|
|
1191
|
+
if [[ -n "${pid}" ]] && kill -0 "${pid}" 2>/dev/null; then
|
|
1192
|
+
echo "${pid}"
|
|
1193
|
+
return 0
|
|
1194
|
+
fi
|
|
1195
|
+
fi
|
|
1196
|
+
return 1
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
start() {
|
|
1200
|
+
mkdir -p "${LOGS_DIR}"
|
|
1201
|
+
require_paths
|
|
1202
|
+
|
|
1203
|
+
local pid
|
|
1204
|
+
if pid="$(is_running)"; then
|
|
1205
|
+
echo "INFO: Odoo already running (PID=${pid})"
|
|
1206
|
+
return 0
|
|
1207
|
+
fi
|
|
1208
|
+
|
|
1209
|
+
echo "----- $(date -Is) START -----" >> "${LOG_FILE}"
|
|
1210
|
+
nohup "${PY}" "${ODOO_BIN}" -c "${CONF}" "$@" >> "${LOG_FILE}" 2>&1 &
|
|
1211
|
+
|
|
1212
|
+
pid=$!
|
|
1213
|
+
echo "${pid}" > "${PID_FILE}"
|
|
1214
|
+
echo "INFO: Started Odoo (PID=${pid}). Logging to ${LOG_FILE}"
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
stop() {
|
|
1218
|
+
mkdir -p "${LOGS_DIR}"
|
|
1219
|
+
|
|
1220
|
+
local pid
|
|
1221
|
+
if pid="$(is_running)"; then
|
|
1222
|
+
echo "INFO: Stopping Odoo (PID=${pid})"
|
|
1223
|
+
kill "${pid}" 2>/dev/null || true
|
|
1224
|
+
|
|
1225
|
+
# Wait up to ~30 seconds for a graceful shutdown
|
|
1226
|
+
for _ in {1..30}; do
|
|
1227
|
+
if kill -0 "${pid}" 2>/dev/null; then
|
|
1228
|
+
sleep 1
|
|
1229
|
+
else
|
|
1230
|
+
break
|
|
1231
|
+
fi
|
|
1232
|
+
done
|
|
1233
|
+
|
|
1234
|
+
if kill -0 "${pid}" 2>/dev/null; then
|
|
1235
|
+
echo "WARN: Odoo did not stop gracefully; sending SIGKILL" >&2
|
|
1236
|
+
kill -9 "${pid}" 2>/dev/null || true
|
|
1237
|
+
fi
|
|
1238
|
+
|
|
1239
|
+
rm -f "${PID_FILE}"
|
|
1240
|
+
echo "INFO: Stopped."
|
|
1241
|
+
return 0
|
|
1242
|
+
fi
|
|
1243
|
+
|
|
1244
|
+
# Cleanup stale PID file (if any)
|
|
1245
|
+
rm -f "${PID_FILE}"
|
|
1246
|
+
echo "INFO: Odoo not running."
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
status() {
|
|
1250
|
+
local pid
|
|
1251
|
+
if pid="$(is_running)"; then
|
|
1252
|
+
# Requirement: print PID if running
|
|
1253
|
+
echo "${pid}"
|
|
1254
|
+
return 0
|
|
1255
|
+
fi
|
|
1256
|
+
echo "NOT RUNNING" >&2
|
|
1257
|
+
return 1
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
cmd="${1:-}"
|
|
1261
|
+
shift || true
|
|
1262
|
+
|
|
1263
|
+
case "${cmd}" in
|
|
1264
|
+
start)
|
|
1265
|
+
start "$@"
|
|
1266
|
+
;;
|
|
1267
|
+
stop)
|
|
1268
|
+
stop
|
|
1269
|
+
;;
|
|
1270
|
+
restart)
|
|
1271
|
+
stop
|
|
1272
|
+
start "$@"
|
|
1273
|
+
;;
|
|
1274
|
+
status)
|
|
1275
|
+
status
|
|
1276
|
+
;;
|
|
1277
|
+
*)
|
|
1278
|
+
echo "Usage: $(basename "$0") {start|stop|restart|status} [odoo args...]" >&2
|
|
1279
|
+
exit 2
|
|
1280
|
+
;;
|
|
1281
|
+
esac
|
|
1282
|
+
"""
|
|
1283
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1284
|
+
layout.instance_sh.write_text(content, encoding="utf-8")
|
|
1285
|
+
|
|
1286
|
+
try:
|
|
1287
|
+
mode = layout.instance_sh.stat().st_mode
|
|
1288
|
+
layout.instance_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1289
|
+
except OSError:
|
|
1290
|
+
pass
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def write_run_bat(layout: Layout) -> None:
|
|
1294
|
+
content = r"""@echo off
|
|
1295
|
+
setlocal enabledelayedexpansion
|
|
1296
|
+
|
|
1297
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1298
|
+
set SCRIPT_DIR=%~dp0
|
|
1299
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1300
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1301
|
+
|
|
1302
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1303
|
+
set PY=%VENV_DIR%\Scripts\python.exe
|
|
1304
|
+
set ODOO_BIN=%ROOT_DIR%\odoo\odoo-bin
|
|
1305
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1306
|
+
|
|
1307
|
+
if not exist "%VENV_DIR%" (
|
|
1308
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1309
|
+
exit /b 1
|
|
1310
|
+
)
|
|
1311
|
+
if not exist "%PY%" (
|
|
1312
|
+
echo ERROR: venv python not found at %PY%
|
|
1313
|
+
exit /b 1
|
|
1314
|
+
)
|
|
1315
|
+
if not exist "%ODOO_BIN%" (
|
|
1316
|
+
echo ERROR: odoo-bin not found at %ODOO_BIN%
|
|
1317
|
+
exit /b 1
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
echo INFO: Starting Odoo server using config %CONF%. Passing through any extra arguments.
|
|
1321
|
+
"%PY%" "%ODOO_BIN%" -c "%CONF%" %*
|
|
1322
|
+
|
|
1323
|
+
endlocal
|
|
1324
|
+
"""
|
|
1325
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1326
|
+
layout.run_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def write_test_sh(layout: Layout) -> None:
|
|
1330
|
+
content = """#!/usr/bin/env bash
|
|
1331
|
+
set -euo pipefail
|
|
1332
|
+
|
|
1333
|
+
# Resolve script directory
|
|
1334
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
1335
|
+
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
1336
|
+
|
|
1337
|
+
VENV_DIR="${ROOT_DIR}/venv"
|
|
1338
|
+
PY="${VENV_DIR}/bin/python"
|
|
1339
|
+
ODOO_BIN="${ROOT_DIR}/odoo/odoo-bin"
|
|
1340
|
+
CONF="${ROOT_DIR}/odoo-configs/odoo-server.conf"
|
|
1341
|
+
|
|
1342
|
+
if [[ ! -d "${VENV_DIR}" ]]; then
|
|
1343
|
+
echo "ERROR: required venv directory not found at ${VENV_DIR}" >&2
|
|
1344
|
+
exit 1
|
|
1345
|
+
fi
|
|
1346
|
+
if [[ ! -x "${PY}" ]]; then
|
|
1347
|
+
echo "ERROR: venv python not found/executable at ${PY}" >&2
|
|
1348
|
+
exit 1
|
|
1349
|
+
fi
|
|
1350
|
+
if [[ ! -f "${ODOO_BIN}" ]]; then
|
|
1351
|
+
echo "ERROR: odoo-bin not found at ${ODOO_BIN}" >&2
|
|
1352
|
+
exit 1
|
|
1353
|
+
fi
|
|
1354
|
+
|
|
1355
|
+
echo "INFO: Running Odoo tests using config ${CONF}. Passing through any extra arguments."
|
|
1356
|
+
exec "${PY}" "${ODOO_BIN}" -c "${CONF}" --test-enable --stop-after-init "$@"
|
|
1357
|
+
"""
|
|
1358
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1359
|
+
layout.test_sh.write_text(content, encoding="utf-8")
|
|
1360
|
+
|
|
1361
|
+
try:
|
|
1362
|
+
mode = layout.test_sh.stat().st_mode
|
|
1363
|
+
layout.test_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1364
|
+
except OSError:
|
|
1365
|
+
pass
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def write_test_bat(layout: Layout) -> None:
|
|
1369
|
+
content = r"""@echo off
|
|
1370
|
+
setlocal enabledelayedexpansion
|
|
1371
|
+
|
|
1372
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1373
|
+
set SCRIPT_DIR=%~dp0
|
|
1374
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1375
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1376
|
+
|
|
1377
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1378
|
+
set PY=%VENV_DIR%\Scripts\python.exe
|
|
1379
|
+
set ODOO_BIN=%ROOT_DIR%\odoo\odoo-bin
|
|
1380
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1381
|
+
|
|
1382
|
+
if not exist "%VENV_DIR%" (
|
|
1383
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1384
|
+
exit /b 1
|
|
1385
|
+
)
|
|
1386
|
+
if not exist "%PY%" (
|
|
1387
|
+
echo ERROR: venv python not found at %PY%
|
|
1388
|
+
exit /b 1
|
|
1389
|
+
)
|
|
1390
|
+
if not exist "%ODOO_BIN%" (
|
|
1391
|
+
echo ERROR: odoo-bin not found at %ODOO_BIN%
|
|
1392
|
+
exit /b 1
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
echo INFO: Running Odoo tests using config %CONF%. Passing through any extra arguments.
|
|
1396
|
+
"%PY%" "%ODOO_BIN%" -c "%CONF%" --test-enable --stop-after-init %*
|
|
1397
|
+
|
|
1398
|
+
endlocal
|
|
1399
|
+
"""
|
|
1400
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1401
|
+
layout.test_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def write_shell_sh(layout: Layout) -> None:
|
|
1405
|
+
content = """#!/usr/bin/env bash
|
|
1406
|
+
set -euo pipefail
|
|
1407
|
+
|
|
1408
|
+
# Resolve script directory
|
|
1409
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
1410
|
+
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
1411
|
+
|
|
1412
|
+
VENV_DIR="${ROOT_DIR}/venv"
|
|
1413
|
+
PY="${VENV_DIR}/bin/python"
|
|
1414
|
+
ODOO_BIN="${ROOT_DIR}/odoo/odoo-bin"
|
|
1415
|
+
CONF="${ROOT_DIR}/odoo-configs/odoo-server.conf"
|
|
1416
|
+
|
|
1417
|
+
if [[ ! -d "${VENV_DIR}" ]]; then
|
|
1418
|
+
echo "ERROR: required venv directory not found at ${VENV_DIR}" >&2
|
|
1419
|
+
exit 1
|
|
1420
|
+
fi
|
|
1421
|
+
if [[ ! -x "${PY}" ]]; then
|
|
1422
|
+
echo "ERROR: venv python not found/executable at ${PY}" >&2
|
|
1423
|
+
exit 1
|
|
1424
|
+
fi
|
|
1425
|
+
if [[ ! -f "${ODOO_BIN}" ]]; then
|
|
1426
|
+
echo "ERROR: odoo-bin not found at ${ODOO_BIN}" >&2
|
|
1427
|
+
exit 1
|
|
1428
|
+
fi
|
|
1429
|
+
|
|
1430
|
+
echo "INFO: Starting Odoo shell using config ${CONF}. Passing through any extra arguments."
|
|
1431
|
+
exec "${PY}" "${ODOO_BIN}" shell -c "${CONF}" "$@"
|
|
1432
|
+
"""
|
|
1433
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1434
|
+
layout.shell_sh.write_text(content, encoding="utf-8")
|
|
1435
|
+
|
|
1436
|
+
try:
|
|
1437
|
+
mode = layout.shell_sh.stat().st_mode
|
|
1438
|
+
layout.shell_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1439
|
+
except OSError:
|
|
1440
|
+
pass
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
def write_shell_bat(layout: Layout) -> None:
|
|
1444
|
+
content = r"""@echo off
|
|
1445
|
+
setlocal enabledelayedexpansion
|
|
1446
|
+
|
|
1447
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1448
|
+
set SCRIPT_DIR=%~dp0
|
|
1449
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1450
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1451
|
+
|
|
1452
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1453
|
+
set PY=%VENV_DIR%\Scripts\python.exe
|
|
1454
|
+
set ODOO_BIN=%ROOT_DIR%\odoo\odoo-bin
|
|
1455
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1456
|
+
|
|
1457
|
+
if not exist "%VENV_DIR%" (
|
|
1458
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1459
|
+
exit /b 1
|
|
1460
|
+
)
|
|
1461
|
+
if not exist "%PY%" (
|
|
1462
|
+
echo ERROR: venv python not found at %PY%
|
|
1463
|
+
exit /b 1
|
|
1464
|
+
)
|
|
1465
|
+
if not exist "%ODOO_BIN%" (
|
|
1466
|
+
echo ERROR: odoo-bin not found at %ODOO_BIN%
|
|
1467
|
+
exit /b 1
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
echo INFO: Starting Odoo shell using config %CONF%. Passing through any extra arguments.
|
|
1471
|
+
"%PY%" "%ODOO_BIN%" shell -c "%CONF%" %*
|
|
1472
|
+
|
|
1473
|
+
endlocal
|
|
1474
|
+
"""
|
|
1475
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1476
|
+
layout.shell_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def write_initdb_sh(layout: Layout, db_name: str) -> None:
|
|
1480
|
+
content = f"""#!/usr/bin/env bash
|
|
1481
|
+
set -euo pipefail
|
|
1482
|
+
|
|
1483
|
+
# Resolve script directory
|
|
1484
|
+
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
1485
|
+
ROOT_DIR="$(cd "${{SCRIPT_DIR}}/.." && pwd)"
|
|
1486
|
+
|
|
1487
|
+
VENV_DIR="${{ROOT_DIR}}/venv"
|
|
1488
|
+
INITDB_BIN="${{VENV_DIR}}/bin/click-odoo-initdb"
|
|
1489
|
+
CONF="${{ROOT_DIR}}/odoo-configs/odoo-server.conf"
|
|
1490
|
+
|
|
1491
|
+
if [[ ! -d "${{VENV_DIR}}" ]]; then
|
|
1492
|
+
echo "ERROR: required venv directory not found at ${{VENV_DIR}}" >&2
|
|
1493
|
+
exit 1
|
|
1494
|
+
fi
|
|
1495
|
+
if [[ ! -x "${{INITDB_BIN}}" ]]; then
|
|
1496
|
+
echo "ERROR: click-odoo-initdb not found/executable at ${{INITDB_BIN}}" >&2
|
|
1497
|
+
exit 1
|
|
1498
|
+
fi
|
|
1499
|
+
if [[ ! -f "${{CONF}}" ]]; then
|
|
1500
|
+
echo "ERROR: Odoo config not found at ${{CONF}}" >&2
|
|
1501
|
+
exit 1
|
|
1502
|
+
fi
|
|
1503
|
+
|
|
1504
|
+
echo "INFO: Initializing Odoo database '{db_name}' (unless exists; no demo; no cache) using config ${{CONF}}. Passing through any extra arguments."
|
|
1505
|
+
exec "${{INITDB_BIN}}" -c "${{CONF}}" --no-demo --no-cache --unless-exists --log-level debug -n "{db_name}" "$@"
|
|
1506
|
+
"""
|
|
1507
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1508
|
+
layout.initdb_sh.write_text(content, encoding="utf-8")
|
|
1509
|
+
|
|
1510
|
+
try:
|
|
1511
|
+
mode = layout.initdb_sh.stat().st_mode
|
|
1512
|
+
layout.initdb_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1513
|
+
except OSError:
|
|
1514
|
+
pass
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def write_initdb_bat(layout: Layout, db_name: str) -> None:
|
|
1518
|
+
content = rf"""@echo off
|
|
1519
|
+
setlocal enabledelayedexpansion
|
|
1520
|
+
|
|
1521
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1522
|
+
set SCRIPT_DIR=%~dp0
|
|
1523
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1524
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1525
|
+
|
|
1526
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1527
|
+
set INITDB_BIN=%VENV_DIR%\Scripts\click-odoo-initdb.exe
|
|
1528
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1529
|
+
|
|
1530
|
+
if not exist "%VENV_DIR%" (
|
|
1531
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1532
|
+
exit /b 1
|
|
1533
|
+
)
|
|
1534
|
+
if not exist "%INITDB_BIN%" (
|
|
1535
|
+
echo ERROR: click-odoo-initdb not found at %INITDB_BIN%
|
|
1536
|
+
exit /b 1
|
|
1537
|
+
)
|
|
1538
|
+
if not exist "%CONF%" (
|
|
1539
|
+
echo ERROR: Odoo config not found at %CONF%
|
|
1540
|
+
exit /b 1
|
|
1541
|
+
)
|
|
1542
|
+
|
|
1543
|
+
echo INFO: Initializing Odoo database "{db_name}" (unless exists; no demo; no cache) using config %CONF%. Passing through any extra arguments.
|
|
1544
|
+
"%INITDB_BIN%" -c "%CONF%" --no-demo --no-cache --unless-exists --log-level debug -n "{db_name}" %*
|
|
1545
|
+
|
|
1546
|
+
endlocal
|
|
1547
|
+
"""
|
|
1548
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1549
|
+
layout.initdb_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def write_backup_sh(layout: Layout, db_name: str) -> None:
|
|
1553
|
+
content = f"""#!/usr/bin/env bash
|
|
1554
|
+
set -euo pipefail
|
|
1555
|
+
|
|
1556
|
+
# Resolve script directory
|
|
1557
|
+
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
1558
|
+
ROOT_DIR="$(cd "${{SCRIPT_DIR}}/.." && pwd)"
|
|
1559
|
+
|
|
1560
|
+
VENV_DIR="${{ROOT_DIR}}/venv"
|
|
1561
|
+
BACKUPS_DIR="${{ROOT_DIR}}/odoo-backups"
|
|
1562
|
+
BACKUP_BIN="${{VENV_DIR}}/bin/click-odoo-backupdb"
|
|
1563
|
+
CONF="${{ROOT_DIR}}/odoo-configs/odoo-server.conf"
|
|
1564
|
+
|
|
1565
|
+
TODAY=$(date +%Y%m%d)
|
|
1566
|
+
TIME=$(date +%H%M%S)
|
|
1567
|
+
BACKUP_FILENAME="{db_name}_${{TODAY}}_${{TIME}}.zip"
|
|
1568
|
+
FULL_BACKUP_PATH="${{BACKUPS_DIR}}/${{BACKUP_FILENAME}}"
|
|
1569
|
+
|
|
1570
|
+
if [[ ! -d "${{VENV_DIR}}" ]]; then
|
|
1571
|
+
echo "ERROR: required venv directory not found at ${{VENV_DIR}}" >&2
|
|
1572
|
+
exit 1
|
|
1573
|
+
fi
|
|
1574
|
+
if [[ ! -d "${{BACKUPS_DIR}}" ]]; then
|
|
1575
|
+
echo "ERROR: required odoo-backups directory not found at ${{BACKUPS_DIR}}" >&2
|
|
1576
|
+
exit 1
|
|
1577
|
+
fi
|
|
1578
|
+
if [[ ! -x "${{BACKUP_BIN}}" ]]; then
|
|
1579
|
+
echo "ERROR: click-odoo-backupdb not found/executable at ${{BACKUP_BIN}}" >&2
|
|
1580
|
+
exit 1
|
|
1581
|
+
fi
|
|
1582
|
+
if [[ ! -f "${{CONF}}" ]]; then
|
|
1583
|
+
echo "ERROR: Odoo config not found at ${{CONF}}" >&2
|
|
1584
|
+
exit 1
|
|
1585
|
+
fi
|
|
1586
|
+
|
|
1587
|
+
echo "INFO: Creating new backup '${{FULL_BACKUP_PATH}}' using config ${{CONF}}. Passing through any extra arguments."
|
|
1588
|
+
exec "${{BACKUP_BIN}}" -c "${{CONF}}" --format zip "{db_name}" "${{FULL_BACKUP_PATH}}" --log-level debug "$@"
|
|
1589
|
+
"""
|
|
1590
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1591
|
+
layout.backup_sh.write_text(content, encoding="utf-8")
|
|
1592
|
+
|
|
1593
|
+
try:
|
|
1594
|
+
mode = layout.backup_sh.stat().st_mode
|
|
1595
|
+
layout.backup_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1596
|
+
except OSError:
|
|
1597
|
+
pass
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def write_backup_bat(layout: Layout, db_name: str) -> None:
|
|
1601
|
+
content = rf"""@echo off
|
|
1602
|
+
setlocal enabledelayedexpansion
|
|
1603
|
+
|
|
1604
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1605
|
+
set SCRIPT_DIR=%~dp0
|
|
1606
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1607
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1608
|
+
|
|
1609
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1610
|
+
set BACKUPS_DIR=%ROOT_DIR%\odoo-backups
|
|
1611
|
+
set BACKUP_BIN=%VENV_DIR%\Scripts\click-odoo-backupdb.exe
|
|
1612
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1613
|
+
|
|
1614
|
+
if not exist "%VENV_DIR%" (
|
|
1615
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1616
|
+
exit /b 1
|
|
1617
|
+
)
|
|
1618
|
+
if not exist "%BACKUPS_DIR%" (
|
|
1619
|
+
echo ERROR: required odoo-backups directory not found at %BACKUPS_DIR%
|
|
1620
|
+
exit /b 1
|
|
1621
|
+
)
|
|
1622
|
+
if not exist "%BACKUP_BIN%" (
|
|
1623
|
+
echo ERROR: click-odoo-backupdb not found at %BACKUP_BIN%
|
|
1624
|
+
exit /b 1
|
|
1625
|
+
)
|
|
1626
|
+
if not exist "%CONF%" (
|
|
1627
|
+
echo ERROR: Odoo config not found at %CONF%
|
|
1628
|
+
exit /b 1
|
|
1629
|
+
)
|
|
1630
|
+
|
|
1631
|
+
REM Build timestamped filename (yyyyMMdd_HHmmss) via PowerShell for reliable formatting
|
|
1632
|
+
for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format yyyyMMdd"') do set TODAY=%%i
|
|
1633
|
+
for /f %%i in ('powershell -NoProfile -Command "Get-Date -Format HHmmss"') do set TIME=%%i
|
|
1634
|
+
|
|
1635
|
+
set BACKUP_FILENAME={db_name}_%TODAY%_%TIME%.zip
|
|
1636
|
+
set FULL_BACKUP_PATH=%BACKUPS_DIR%\%BACKUP_FILENAME%
|
|
1637
|
+
|
|
1638
|
+
echo INFO: Creating new backup "%FULL_BACKUP_PATH%" using config %CONF%. Passing through any extra arguments.
|
|
1639
|
+
"%BACKUP_BIN%" -c "%CONF%" --format zip "{db_name}" "%FULL_BACKUP_PATH%" --log-level debug %*
|
|
1640
|
+
|
|
1641
|
+
endlocal
|
|
1642
|
+
"""
|
|
1643
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1644
|
+
layout.backup_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
def write_restore_sh(layout: Layout, db_name: str) -> None:
|
|
1648
|
+
content = f"""#!/usr/bin/env bash
|
|
1649
|
+
set -euo pipefail
|
|
1650
|
+
|
|
1651
|
+
# Resolve script directory
|
|
1652
|
+
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
1653
|
+
ROOT_DIR="$(cd "${{SCRIPT_DIR}}/.." && pwd)"
|
|
1654
|
+
|
|
1655
|
+
VENV_DIR="${{ROOT_DIR}}/venv"
|
|
1656
|
+
RESTORE_BIN="${{VENV_DIR}}/bin/click-odoo-restoredb"
|
|
1657
|
+
CONF="${{ROOT_DIR}}/odoo-configs/odoo-server.conf"
|
|
1658
|
+
|
|
1659
|
+
if [[ ! -d "${{VENV_DIR}}" ]]; then
|
|
1660
|
+
echo "ERROR: required venv directory not found at ${{VENV_DIR}}" >&2
|
|
1661
|
+
exit 1
|
|
1662
|
+
fi
|
|
1663
|
+
if [[ ! -x "${{RESTORE_BIN}}" ]]; then
|
|
1664
|
+
echo "ERROR: click-odoo-restoredb not found/executable at ${{RESTORE_BIN}}" >&2
|
|
1665
|
+
exit 1
|
|
1666
|
+
fi
|
|
1667
|
+
if [[ ! -f "${{CONF}}" ]]; then
|
|
1668
|
+
echo "ERROR: Odoo config not found at ${{CONF}}" >&2
|
|
1669
|
+
exit 1
|
|
1670
|
+
fi
|
|
1671
|
+
|
|
1672
|
+
if [[ $# -lt 1 ]]; then
|
|
1673
|
+
echo "ERROR: missing restore source (backup file/path). Provide it as the first argument." >&2
|
|
1674
|
+
echo "Example: ./restore.sh /path/to/backup.zip" >&2
|
|
1675
|
+
exit 2
|
|
1676
|
+
fi
|
|
1677
|
+
|
|
1678
|
+
echo "INFO: Restoring Odoo database '{db_name}' using config ${{CONF}}. Passing through any extra arguments."
|
|
1679
|
+
exec "${{RESTORE_BIN}}" -c "${{CONF}}" --copy --neutralize --log-level debug "{db_name}" "$@"
|
|
1680
|
+
"""
|
|
1681
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1682
|
+
layout.restore_sh.write_text(content, encoding="utf-8")
|
|
1683
|
+
|
|
1684
|
+
try:
|
|
1685
|
+
mode = layout.restore_sh.stat().st_mode
|
|
1686
|
+
layout.restore_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1687
|
+
except OSError:
|
|
1688
|
+
pass
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
def write_restore_bat(layout: Layout, db_name: str) -> None:
|
|
1692
|
+
content = rf"""@echo off
|
|
1693
|
+
setlocal enabledelayedexpansion
|
|
1694
|
+
|
|
1695
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1696
|
+
set SCRIPT_DIR=%~dp0
|
|
1697
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1698
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1699
|
+
|
|
1700
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1701
|
+
set RESTORE_BIN=%VENV_DIR%\Scripts\click-odoo-restoredb.exe
|
|
1702
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1703
|
+
|
|
1704
|
+
if not exist "%VENV_DIR%" (
|
|
1705
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1706
|
+
exit /b 1
|
|
1707
|
+
)
|
|
1708
|
+
if not exist "%RESTORE_BIN%" (
|
|
1709
|
+
echo ERROR: click-odoo-restoredb not found at %RESTORE_BIN%
|
|
1710
|
+
exit /b 1
|
|
1711
|
+
)
|
|
1712
|
+
if not exist "%CONF%" (
|
|
1713
|
+
echo ERROR: Odoo config not found at %CONF%
|
|
1714
|
+
exit /b 1
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
if "%~1"=="" (
|
|
1718
|
+
echo ERROR: missing restore source ^(backup file/path^). Provide it as the first argument.
|
|
1719
|
+
echo Example: restore.bat C:\path\to\backup.zip
|
|
1720
|
+
exit /b 2
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
echo INFO: Restoring Odoo database "{db_name}" using config %CONF%. Passing through any extra arguments.
|
|
1724
|
+
"%RESTORE_BIN%" -c "%CONF%" --copy --neutralize --log-level debug "{db_name}" %*
|
|
1725
|
+
|
|
1726
|
+
endlocal
|
|
1727
|
+
"""
|
|
1728
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1729
|
+
layout.restore_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def write_restore_force_sh(layout: Layout, db_name: str) -> None:
|
|
1733
|
+
content = f"""#!/usr/bin/env bash
|
|
1734
|
+
set -euo pipefail
|
|
1735
|
+
|
|
1736
|
+
# Resolve script directory
|
|
1737
|
+
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
1738
|
+
ROOT_DIR="$(cd "${{SCRIPT_DIR}}/.." && pwd)"
|
|
1739
|
+
|
|
1740
|
+
VENV_DIR="${{ROOT_DIR}}/venv"
|
|
1741
|
+
RESTORE_BIN="${{VENV_DIR}}/bin/click-odoo-restoredb"
|
|
1742
|
+
CONF="${{ROOT_DIR}}/odoo-configs/odoo-server.conf"
|
|
1743
|
+
|
|
1744
|
+
if [[ ! -d "${{VENV_DIR}}" ]]; then
|
|
1745
|
+
echo "ERROR: required venv directory not found at ${{VENV_DIR}}" >&2
|
|
1746
|
+
exit 1
|
|
1747
|
+
fi
|
|
1748
|
+
if [[ ! -x "${{RESTORE_BIN}}" ]]; then
|
|
1749
|
+
echo "ERROR: click-odoo-restoredb not found/executable at ${{RESTORE_BIN}}" >&2
|
|
1750
|
+
exit 1
|
|
1751
|
+
fi
|
|
1752
|
+
if [[ ! -f "${{CONF}}" ]]; then
|
|
1753
|
+
echo "ERROR: Odoo config not found at ${{CONF}}" >&2
|
|
1754
|
+
exit 1
|
|
1755
|
+
fi
|
|
1756
|
+
|
|
1757
|
+
if [[ $# -lt 1 ]]; then
|
|
1758
|
+
echo "ERROR: missing restore source (backup file/path). Provide it as the first argument." >&2
|
|
1759
|
+
echo "Example: ./restore_force.sh /path/to/backup.zip" >&2
|
|
1760
|
+
exit 2
|
|
1761
|
+
fi
|
|
1762
|
+
|
|
1763
|
+
echo "INFO: Restoring Odoo database '{db_name}' using config ${{CONF}}. Passing through any extra arguments."
|
|
1764
|
+
exec "${{RESTORE_BIN}}" -c "${{CONF}}" --copy --neutralize --force --log-level debug "{db_name}" "$@"
|
|
1765
|
+
"""
|
|
1766
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1767
|
+
layout.restore_force_sh.write_text(content, encoding="utf-8")
|
|
1768
|
+
|
|
1769
|
+
try:
|
|
1770
|
+
mode = layout.restore_force_sh.stat().st_mode
|
|
1771
|
+
layout.restore_force_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1772
|
+
except OSError:
|
|
1773
|
+
pass
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def write_restore_force_bat(layout: Layout, db_name: str) -> None:
|
|
1777
|
+
content = rf"""@echo off
|
|
1778
|
+
setlocal enabledelayedexpansion
|
|
1779
|
+
|
|
1780
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1781
|
+
set SCRIPT_DIR=%~dp0
|
|
1782
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1783
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1784
|
+
|
|
1785
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1786
|
+
set RESTORE_BIN=%VENV_DIR%\Scripts\click-odoo-restoredb.exe
|
|
1787
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1788
|
+
|
|
1789
|
+
if not exist "%VENV_DIR%" (
|
|
1790
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1791
|
+
exit /b 1
|
|
1792
|
+
)
|
|
1793
|
+
if not exist "%RESTORE_BIN%" (
|
|
1794
|
+
echo ERROR: click-odoo-restoredb not found at %RESTORE_BIN%
|
|
1795
|
+
exit /b 1
|
|
1796
|
+
)
|
|
1797
|
+
if not exist "%CONF%" (
|
|
1798
|
+
echo ERROR: Odoo config not found at %CONF%
|
|
1799
|
+
exit /b 1
|
|
1800
|
+
)
|
|
1801
|
+
|
|
1802
|
+
if "%~1"=="" (
|
|
1803
|
+
echo ERROR: missing restore source ^(backup file/path^). Provide it as the first argument.
|
|
1804
|
+
echo Example: restore.bat C:\path\to\backup.zip
|
|
1805
|
+
exit /b 2
|
|
1806
|
+
)
|
|
1807
|
+
|
|
1808
|
+
echo INFO: Restoring Odoo database "{db_name}" using config %CONF%. Passing through any extra arguments.
|
|
1809
|
+
"%RESTORE_BIN%" -c "%CONF%" --copy --neutralize --force --log-level debug "{db_name}" %*
|
|
1810
|
+
|
|
1811
|
+
endlocal
|
|
1812
|
+
"""
|
|
1813
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1814
|
+
layout.restore_force_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1815
|
+
|
|
1816
|
+
|
|
1817
|
+
def write_update_sh(layout: Layout) -> None:
|
|
1818
|
+
content = f"""#!/usr/bin/env bash
|
|
1819
|
+
set -euo pipefail
|
|
1820
|
+
|
|
1821
|
+
# Resolve script directory
|
|
1822
|
+
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
1823
|
+
ROOT_DIR="$(cd "${{SCRIPT_DIR}}/.." && pwd)"
|
|
1824
|
+
|
|
1825
|
+
VENV_DIR="${{ROOT_DIR}}/venv"
|
|
1826
|
+
UPDATE_BIN="${{VENV_DIR}}/bin/click-odoo-update"
|
|
1827
|
+
CONF="${{ROOT_DIR}}/odoo-configs/odoo-server.conf"
|
|
1828
|
+
|
|
1829
|
+
if [[ ! -d "${{VENV_DIR}}" ]]; then
|
|
1830
|
+
echo "ERROR: required venv directory not found at ${{VENV_DIR}}" >&2
|
|
1831
|
+
exit 1
|
|
1832
|
+
fi
|
|
1833
|
+
if [[ ! -x "${{UPDATE_BIN}}" ]]; then
|
|
1834
|
+
echo "ERROR: click-odoo-update not found/executable at ${{UPDATE_BIN}}" >&2
|
|
1835
|
+
exit 1
|
|
1836
|
+
fi
|
|
1837
|
+
if [[ ! -f "${{CONF}}" ]]; then
|
|
1838
|
+
echo "ERROR: Odoo config not found at ${{CONF}}" >&2
|
|
1839
|
+
exit 1
|
|
1840
|
+
fi
|
|
1841
|
+
|
|
1842
|
+
echo "INFO: Updating Odoo addons using config ${{CONF}}. Passing through any extra arguments."
|
|
1843
|
+
exec "${{UPDATE_BIN}}" -c "${{CONF}}" --log-level debug "$@"
|
|
1844
|
+
"""
|
|
1845
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1846
|
+
layout.update_sh.write_text(content, encoding="utf-8")
|
|
1847
|
+
|
|
1848
|
+
try:
|
|
1849
|
+
mode = layout.update_sh.stat().st_mode
|
|
1850
|
+
layout.update_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1851
|
+
except OSError:
|
|
1852
|
+
pass
|
|
1853
|
+
|
|
1854
|
+
|
|
1855
|
+
def write_update_bat(layout: Layout) -> None:
|
|
1856
|
+
content = rf"""@echo off
|
|
1857
|
+
setlocal enabledelayedexpansion
|
|
1858
|
+
|
|
1859
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1860
|
+
set SCRIPT_DIR=%~dp0
|
|
1861
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1862
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1863
|
+
|
|
1864
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1865
|
+
set UPDATE_BIN=%VENV_DIR%\Scripts\click-odoo-update.exe
|
|
1866
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1867
|
+
|
|
1868
|
+
if not exist "%VENV_DIR%" (
|
|
1869
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1870
|
+
exit /b 1
|
|
1871
|
+
)
|
|
1872
|
+
if not exist "%UPDATE_BIN%" (
|
|
1873
|
+
echo ERROR: click-odoo-update not found at %UPDATE_BIN%
|
|
1874
|
+
exit /b 1
|
|
1875
|
+
)
|
|
1876
|
+
if not exist "%CONF%" (
|
|
1877
|
+
echo ERROR: Odoo config not found at %CONF%
|
|
1878
|
+
exit /b 1
|
|
1879
|
+
)
|
|
1880
|
+
|
|
1881
|
+
echo INFO: Updating Odoo addons using config %CONF%. Passing through any extra arguments.
|
|
1882
|
+
"%UPDATE_BIN%" -c "%CONF%" --log-level debug %*
|
|
1883
|
+
|
|
1884
|
+
endlocal
|
|
1885
|
+
"""
|
|
1886
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1887
|
+
layout.update_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1888
|
+
|
|
1889
|
+
|
|
1890
|
+
def write_update_all_sh(layout: Layout) -> None:
|
|
1891
|
+
content = f"""#!/usr/bin/env bash
|
|
1892
|
+
set -euo pipefail
|
|
1893
|
+
|
|
1894
|
+
# Resolve script directory
|
|
1895
|
+
SCRIPT_DIR="$(cd "$(dirname "${{BASH_SOURCE[0]}}")" && pwd)"
|
|
1896
|
+
ROOT_DIR="$(cd "${{SCRIPT_DIR}}/.." && pwd)"
|
|
1897
|
+
|
|
1898
|
+
VENV_DIR="${{ROOT_DIR}}/venv"
|
|
1899
|
+
UPDATE_BIN="${{VENV_DIR}}/bin/click-odoo-update"
|
|
1900
|
+
CONF="${{ROOT_DIR}}/odoo-configs/odoo-server.conf"
|
|
1901
|
+
|
|
1902
|
+
if [[ ! -d "${{VENV_DIR}}" ]]; then
|
|
1903
|
+
echo "ERROR: required venv directory not found at ${{VENV_DIR}}" >&2
|
|
1904
|
+
exit 1
|
|
1905
|
+
fi
|
|
1906
|
+
if [[ ! -x "${{UPDATE_BIN}}" ]]; then
|
|
1907
|
+
echo "ERROR: click-odoo-update not found/executable at ${{UPDATE_BIN}}" >&2
|
|
1908
|
+
exit 1
|
|
1909
|
+
fi
|
|
1910
|
+
if [[ ! -f "${{CONF}}" ]]; then
|
|
1911
|
+
echo "ERROR: Odoo config not found at ${{CONF}}" >&2
|
|
1912
|
+
exit 1
|
|
1913
|
+
fi
|
|
1914
|
+
|
|
1915
|
+
echo "INFO: Updating all Odoo addons using config ${{CONF}}. Passing through any extra arguments."
|
|
1916
|
+
exec "${{UPDATE_BIN}}" -c "${{CONF}}" --update-all --log-level debug "$@"
|
|
1917
|
+
"""
|
|
1918
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1919
|
+
layout.update_all_sh.write_text(content, encoding="utf-8")
|
|
1920
|
+
|
|
1921
|
+
try:
|
|
1922
|
+
mode = layout.update_all_sh.stat().st_mode
|
|
1923
|
+
layout.update_all_sh.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
1924
|
+
except OSError:
|
|
1925
|
+
pass
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def write_update_all_bat(layout: Layout) -> None:
|
|
1929
|
+
content = rf"""@echo off
|
|
1930
|
+
setlocal enabledelayedexpansion
|
|
1931
|
+
|
|
1932
|
+
REM Resolve ROOT directory (parent of this script directory)
|
|
1933
|
+
set SCRIPT_DIR=%~dp0
|
|
1934
|
+
if "%SCRIPT_DIR:~-1%"=="\" set SCRIPT_DIR=%SCRIPT_DIR:~0,-1%
|
|
1935
|
+
for %%I in ("%SCRIPT_DIR%\..") do set ROOT_DIR=%%~fI
|
|
1936
|
+
|
|
1937
|
+
set VENV_DIR=%ROOT_DIR%\venv
|
|
1938
|
+
set UPDATE_BIN=%VENV_DIR%\Scripts\click-odoo-update.exe
|
|
1939
|
+
set CONF=%ROOT_DIR%\odoo-configs\odoo-server.conf
|
|
1940
|
+
|
|
1941
|
+
if not exist "%VENV_DIR%" (
|
|
1942
|
+
echo ERROR: required venv directory not found at %VENV_DIR%
|
|
1943
|
+
exit /b 1
|
|
1944
|
+
)
|
|
1945
|
+
if not exist "%UPDATE_BIN%" (
|
|
1946
|
+
echo ERROR: click-odoo-update not found at %UPDATE_BIN%
|
|
1947
|
+
exit /b 1
|
|
1948
|
+
)
|
|
1949
|
+
if not exist "%CONF%" (
|
|
1950
|
+
echo ERROR: Odoo config not found at %CONF%
|
|
1951
|
+
exit /b 1
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
echo INFO: Updating Odoo addons using config %CONF%. Passing through any extra arguments.
|
|
1955
|
+
"%UPDATE_BIN%" -c "%CONF%" --update-all --log-level debug %*
|
|
1956
|
+
|
|
1957
|
+
endlocal
|
|
1958
|
+
"""
|
|
1959
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
1960
|
+
layout.update_all_bat.write_text(content.replace("\n", "\r\n"), encoding="utf-8")
|
|
1961
|
+
|
|
1962
|
+
|
|
1963
|
+
# -----------------------------
|
|
1964
|
+
# Main logic
|
|
1965
|
+
# -----------------------------
|
|
1966
|
+
|
|
1967
|
+
def sync_project(
|
|
1968
|
+
ini_path: Path,
|
|
1969
|
+
sync_odoo: bool,
|
|
1970
|
+
sync_addons: bool,
|
|
1971
|
+
root_override: Optional[Path] = None,
|
|
1972
|
+
dest_root_override: Optional[Path] = None,
|
|
1973
|
+
create_wheelhouse: bool = False,
|
|
1974
|
+
reuse_wheelhouse: bool = False,
|
|
1975
|
+
create_venv: bool = False,
|
|
1976
|
+
rebuild_venv: bool = False,
|
|
1977
|
+
clear_pip_wheel_cache: bool = False,
|
|
1978
|
+
no_configs: bool = False,
|
|
1979
|
+
no_scripts: bool = False,
|
|
1980
|
+
no_data_dir: bool = False,
|
|
1981
|
+
) -> None:
|
|
1982
|
+
root = (root_override or ini_path.parent).resolve()
|
|
1983
|
+
if root.exists() and not root.is_dir():
|
|
1984
|
+
raise Exception(f"ROOT exists but is not a directory: {root}")
|
|
1985
|
+
|
|
1986
|
+
# dest_root is used only for paths embedded in generated configs/scripts.
|
|
1987
|
+
# It does NOT need to exist on the build system.
|
|
1988
|
+
if dest_root_override is None:
|
|
1989
|
+
dest_root = root
|
|
1990
|
+
else:
|
|
1991
|
+
candidate = Path(dest_root_override).expanduser()
|
|
1992
|
+
if not candidate.is_absolute():
|
|
1993
|
+
# Interpret relative dest roots relative to the workspace ROOT for stability.
|
|
1994
|
+
candidate = root / candidate
|
|
1995
|
+
try:
|
|
1996
|
+
dest_root = candidate.resolve()
|
|
1997
|
+
except Exception:
|
|
1998
|
+
dest_root = candidate.absolute()
|
|
1999
|
+
|
|
2000
|
+
layout = Layout.from_root(root)
|
|
2001
|
+
dest_layout = Layout.from_root(dest_root)
|
|
2002
|
+
|
|
2003
|
+
# Runtime variables for INI evaluation:
|
|
2004
|
+
# - FS vars: used for repo/venv operations and include resolution (must exist on build host)
|
|
2005
|
+
# - DEST vars: used for [config] interpolation (paths embedded into generated files)
|
|
2006
|
+
fs_runtime_vars = {
|
|
2007
|
+
# Common workspace (filesystem) paths
|
|
2008
|
+
"ini_dir": str(ini_path.parent.resolve()),
|
|
2009
|
+
"root_dir": str(layout.root),
|
|
2010
|
+
"odoo_dir": str(layout.odoo_dir),
|
|
2011
|
+
"addons_dir": str(layout.addons_root),
|
|
2012
|
+
"backups_dir": str(layout.backups_dir),
|
|
2013
|
+
"configs_dir": str(layout.configs_dir),
|
|
2014
|
+
"config_path": str(layout.conf_path),
|
|
2015
|
+
"scripts_dir": str(layout.scripts_dir),
|
|
2016
|
+
"venv_python": str((layout.root / "venv") / ("Scripts/python.exe" if sys.platform.startswith("win") else "bin/python")),
|
|
2017
|
+
}
|
|
2018
|
+
dest_runtime_vars = {
|
|
2019
|
+
# Destination (deployment) paths
|
|
2020
|
+
"ini_dir": str(ini_path.parent.resolve()),
|
|
2021
|
+
"root_dir": str(dest_layout.root),
|
|
2022
|
+
"odoo_dir": str(dest_layout.odoo_dir),
|
|
2023
|
+
"addons_dir": str(dest_layout.addons_root),
|
|
2024
|
+
"backups_dir": str(dest_layout.backups_dir),
|
|
2025
|
+
"configs_dir": str(dest_layout.configs_dir),
|
|
2026
|
+
"config_path": str(dest_layout.conf_path),
|
|
2027
|
+
"scripts_dir": str(dest_layout.scripts_dir),
|
|
2028
|
+
"venv_python": str((dest_layout.root / "venv") / ("Scripts/python.exe" if sys.platform.startswith("win") else "bin/python")),
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
# Load config twice: filesystem vars for repos/venv, and destination vars for the [config] section.
|
|
2032
|
+
cfg_fs = load_project_config(ini_path, runtime_vars=fs_runtime_vars, include_runtime_vars=fs_runtime_vars)
|
|
2033
|
+
cfg_dest = load_project_config(ini_path, runtime_vars=dest_runtime_vars, include_runtime_vars=fs_runtime_vars)
|
|
2034
|
+
cfg = ProjectConfig(
|
|
2035
|
+
virtualenv=cfg_fs.virtualenv,
|
|
2036
|
+
odoo=cfg_fs.odoo,
|
|
2037
|
+
addons=cfg_fs.addons,
|
|
2038
|
+
config=cfg_dest.config,
|
|
2039
|
+
)
|
|
2040
|
+
|
|
2041
|
+
# If user overrides "data_dir" via [config] section, propagate changes to dest_layout->data_dir.
|
|
2042
|
+
if "data_dir" in cfg.config:
|
|
2043
|
+
cfg_data_dir_raw = cfg.config.get("data_dir")
|
|
2044
|
+
cfg_data_dir_path = Path(cfg_data_dir_raw.strip()).expanduser()
|
|
2045
|
+
if not cfg_data_dir_path.is_absolute():
|
|
2046
|
+
cfg_data_dir_path = dest_layout.root / cfg_data_dir_path
|
|
2047
|
+
try:
|
|
2048
|
+
cfg_data_dir = cfg_data_dir_path.resolve()
|
|
2049
|
+
except Exception:
|
|
2050
|
+
cfg_data_dir = cfg_data_dir_path.absolute()
|
|
2051
|
+
_logger.warning(f"data_dir override via [config] section: from={dest_layout.data_dir}, to={cfg_data_dir}")
|
|
2052
|
+
dest_layout = replace(dest_layout, data_dir=cfg_data_dir)
|
|
2053
|
+
|
|
2054
|
+
# We optionally create/ensure the venv early so we can use its Python for `uv pip compile` / installs.
|
|
2055
|
+
venv_py: Optional[Path] = None
|
|
2056
|
+
venv_enabled = create_venv or rebuild_venv
|
|
2057
|
+
|
|
2058
|
+
# Validate combinations (defensive; CLI also enforces these).
|
|
2059
|
+
if reuse_wheelhouse and not venv_enabled:
|
|
2060
|
+
raise Exception("--reuse-wheelhouse requires --create-venv (or --rebuild-venv).")
|
|
2061
|
+
if create_wheelhouse and reuse_wheelhouse:
|
|
2062
|
+
raise Exception('--create-wheelhouse can not be used with --reuse-wheelhouse')
|
|
2063
|
+
|
|
2064
|
+
if venv_enabled or create_wheelhouse:
|
|
2065
|
+
venv_dir = layout.root / "venv"
|
|
2066
|
+
|
|
2067
|
+
if (rebuild_venv or create_wheelhouse) and venv_dir.exists():
|
|
2068
|
+
_logger.info("Rebuilding venv: removing %s", venv_dir)
|
|
2069
|
+
_rmtree(venv_dir)
|
|
2070
|
+
|
|
2071
|
+
# Wheelhouse handling: either reuse, or rebuild from scratch.
|
|
2072
|
+
if reuse_wheelhouse:
|
|
2073
|
+
if not layout.wheelhouse_dir.exists() or not layout.wheelhouse_dir.is_dir():
|
|
2074
|
+
raise Exception(f"--reuse-wheelhouse set but wheelhouse dir not found: {layout.wheelhouse_dir}")
|
|
2075
|
+
else:
|
|
2076
|
+
if layout.wheelhouse_dir.exists():
|
|
2077
|
+
_logger.info("Rebuilding wheelhouse: removing %s", layout.wheelhouse_dir)
|
|
2078
|
+
_rmtree(layout.wheelhouse_dir)
|
|
2079
|
+
layout.wheelhouse_dir.mkdir(parents=True, exist_ok=True)
|
|
2080
|
+
|
|
2081
|
+
# Create venv (requirements are installed later from a single lock file).
|
|
2082
|
+
require_venv(
|
|
2083
|
+
layout=layout,
|
|
2084
|
+
python_version=cfg.virtualenv.python_version,
|
|
2085
|
+
reuse_wheelhouse=reuse_wheelhouse,
|
|
2086
|
+
managed_python=cfg.virtualenv.managed_python,
|
|
2087
|
+
)
|
|
2088
|
+
venv_py = venv_dir / ("Scripts/python.exe" if sys.platform.startswith("win") else "bin/python")
|
|
2089
|
+
if not venv_py.exists():
|
|
2090
|
+
raise Exception(f"venv python not found at expected path: {venv_py}")
|
|
2091
|
+
|
|
2092
|
+
if reuse_wheelhouse and (sync_odoo or sync_addons):
|
|
2093
|
+
_logger.warning(
|
|
2094
|
+
"--reuse-wheelhouse is set together with repo sync targets; "
|
|
2095
|
+
"dependency lock/wheelhouse rebuild will be skipped. "
|
|
2096
|
+
"If requirements changed, re-run without --reuse-wheelhouse."
|
|
2097
|
+
)
|
|
2098
|
+
else:
|
|
2099
|
+
if sync_odoo or sync_addons:
|
|
2100
|
+
_logger.info(
|
|
2101
|
+
"Repo sync selected, but venv/wheelhouse provisioning is disabled; "
|
|
2102
|
+
"skipping venv/wheelhouse. Use --create-venv or --create-wheelhouse to enable."
|
|
2103
|
+
)
|
|
2104
|
+
else:
|
|
2105
|
+
_logger.info(
|
|
2106
|
+
"No sync target selected; regenerating config and helper scripts only (skipping venv/repo operations)."
|
|
2107
|
+
)
|
|
2108
|
+
|
|
2109
|
+
layout.configs_dir.mkdir(parents=True, exist_ok=True)
|
|
2110
|
+
layout.addons_root.mkdir(parents=True, exist_ok=True)
|
|
2111
|
+
layout.scripts_dir.mkdir(parents=True, exist_ok=True)
|
|
2112
|
+
layout.backups_dir.mkdir(parents=True, exist_ok=True)
|
|
2113
|
+
if not no_data_dir:
|
|
2114
|
+
dest_layout.data_dir.mkdir(parents=True, exist_ok=True)
|
|
2115
|
+
|
|
2116
|
+
# Sync repositories first, collect all requirements, then compile + install once.
|
|
2117
|
+
req_files: list[Path] = []
|
|
2118
|
+
|
|
2119
|
+
if sync_odoo:
|
|
2120
|
+
if cfg.odoo.shallow_clone:
|
|
2121
|
+
# Shallow + single branch (enabled only when [odoo] shallow_clone=true).
|
|
2122
|
+
ensure_repo(
|
|
2123
|
+
cfg.odoo.repo,
|
|
2124
|
+
layout.odoo_dir,
|
|
2125
|
+
branch=cfg.odoo.branch,
|
|
2126
|
+
depth=1,
|
|
2127
|
+
single_branch=True,
|
|
2128
|
+
fetch_all=False,
|
|
2129
|
+
)
|
|
2130
|
+
checkout_branch(layout.odoo_dir, cfg.odoo.branch, fetch_all=False, depth=1)
|
|
2131
|
+
else:
|
|
2132
|
+
# Full clone/fetch (default).
|
|
2133
|
+
ensure_repo(
|
|
2134
|
+
cfg.odoo.repo,
|
|
2135
|
+
layout.odoo_dir,
|
|
2136
|
+
branch=cfg.odoo.branch,
|
|
2137
|
+
depth=None,
|
|
2138
|
+
single_branch=False,
|
|
2139
|
+
fetch_all=True,
|
|
2140
|
+
)
|
|
2141
|
+
checkout_branch(layout.odoo_dir, cfg.odoo.branch, fetch_all=True, depth=None)
|
|
2142
|
+
|
|
2143
|
+
odoo_req = layout.odoo_dir / "requirements.txt"
|
|
2144
|
+
if not odoo_req.exists():
|
|
2145
|
+
raise Exception(f"Odoo requirements file not found: {odoo_req}")
|
|
2146
|
+
req_files.append(odoo_req)
|
|
2147
|
+
else:
|
|
2148
|
+
# If we're provisioning python but not syncing repos, use whatever is already present in the workspace.
|
|
2149
|
+
if venv_py is not None:
|
|
2150
|
+
odoo_req = layout.odoo_dir / "requirements.txt"
|
|
2151
|
+
if odoo_req.exists():
|
|
2152
|
+
req_files.append(odoo_req)
|
|
2153
|
+
|
|
2154
|
+
if sync_addons:
|
|
2155
|
+
if not cfg.addons:
|
|
2156
|
+
_logger.info("No [addons.*] sections configured; skipping addons sync.")
|
|
2157
|
+
for addon_name, spec in cfg.addons.items():
|
|
2158
|
+
dest = layout.addons_root / addon_name
|
|
2159
|
+
|
|
2160
|
+
if spec.shallow_clone:
|
|
2161
|
+
# Shallow + single branch (enabled only when [addons.<name>] shallow_clone=true).
|
|
2162
|
+
ensure_repo(
|
|
2163
|
+
spec.repo,
|
|
2164
|
+
dest,
|
|
2165
|
+
branch=spec.branch,
|
|
2166
|
+
depth=1,
|
|
2167
|
+
single_branch=True,
|
|
2168
|
+
fetch_all=False,
|
|
2169
|
+
)
|
|
2170
|
+
checkout_branch(dest, spec.branch, fetch_all=False, depth=1)
|
|
2171
|
+
else:
|
|
2172
|
+
# Full clone/fetch (default).
|
|
2173
|
+
ensure_repo(
|
|
2174
|
+
spec.repo,
|
|
2175
|
+
dest,
|
|
2176
|
+
branch=spec.branch,
|
|
2177
|
+
depth=None,
|
|
2178
|
+
single_branch=False,
|
|
2179
|
+
fetch_all=True,
|
|
2180
|
+
)
|
|
2181
|
+
checkout_branch(dest, spec.branch, fetch_all=True, depth=None)
|
|
2182
|
+
|
|
2183
|
+
addon_req = dest / "requirements.txt"
|
|
2184
|
+
if addon_req.exists():
|
|
2185
|
+
req_files.append(addon_req)
|
|
2186
|
+
else:
|
|
2187
|
+
# If we're provisioning python but not syncing repos, use existing addon requirements (if present).
|
|
2188
|
+
if venv_py is not None and cfg.addons:
|
|
2189
|
+
for addon_name in cfg.addons.keys():
|
|
2190
|
+
dest = layout.addons_root / addon_name
|
|
2191
|
+
addon_req = dest / "requirements.txt"
|
|
2192
|
+
if addon_req.exists():
|
|
2193
|
+
req_files.append(addon_req)
|
|
2194
|
+
|
|
2195
|
+
# Compile and install a single lock file from all synced repos + base requirements.
|
|
2196
|
+
# In --reuse-wheelhouse mode we skip compilation + wheel build and only install offline from existing lock/wheels.
|
|
2197
|
+
if venv_py is not None:
|
|
2198
|
+
# The generated scripts assume ROOT/odoo exists.
|
|
2199
|
+
if not layout.odoo_dir.exists() or not layout.odoo_dir.is_dir():
|
|
2200
|
+
raise Exception(
|
|
2201
|
+
f"Odoo directory not found: {layout.odoo_dir}. "
|
|
2202
|
+
"Run with --sync-odoo/--sync-all first (or ensure ROOT/odoo exists)."
|
|
2203
|
+
)
|
|
2204
|
+
|
|
2205
|
+
lock_path = layout.wheelhouse_dir / "all-requirements.lock.txt"
|
|
2206
|
+
build_constraints_path = layout.wheelhouse_dir / "build-constraints.txt"
|
|
2207
|
+
|
|
2208
|
+
if reuse_wheelhouse:
|
|
2209
|
+
# Reuse existing wheelhouse (offline-only mode)
|
|
2210
|
+
if not layout.wheelhouse_dir.exists() or not layout.wheelhouse_dir.is_dir():
|
|
2211
|
+
raise Exception(f"Wheelhouse directory not found: {layout.wheelhouse_dir}")
|
|
2212
|
+
if not any(layout.wheelhouse_dir.glob("*.whl")):
|
|
2213
|
+
raise Exception(f"Wheelhouse looks empty (no .whl files): {layout.wheelhouse_dir}")
|
|
2214
|
+
|
|
2215
|
+
if not lock_path.exists():
|
|
2216
|
+
raise Exception(
|
|
2217
|
+
f"--reuse-wheelhouse set but lock file not found: {lock_path} "
|
|
2218
|
+
"(expected existing wheelhouse from a previous run)"
|
|
2219
|
+
)
|
|
2220
|
+
|
|
2221
|
+
if cfg.virtualenv.build_constraints and not build_constraints_path.is_file():
|
|
2222
|
+
raise Exception(
|
|
2223
|
+
f"--reuse-wheelhouse and build_constraints set in INI but build_constraints file not found: {build_constraints_path} "
|
|
2224
|
+
"(expected existing wheelhouse from a previous run)"
|
|
2225
|
+
)
|
|
2226
|
+
|
|
2227
|
+
pip_install_requirements_file(
|
|
2228
|
+
venv_python=venv_py,
|
|
2229
|
+
workspace_root=layout.root,
|
|
2230
|
+
wheelhouse_dir=layout.wheelhouse_dir,
|
|
2231
|
+
requirements_path=lock_path,
|
|
2232
|
+
)
|
|
2233
|
+
else:
|
|
2234
|
+
# Write build constraints to file
|
|
2235
|
+
if cfg.virtualenv.build_constraints:
|
|
2236
|
+
build_constraints_path.write_text(
|
|
2237
|
+
"\n".join(cfg.virtualenv.build_constraints).rstrip("\n") + "\n", encoding="utf-8")
|
|
2238
|
+
|
|
2239
|
+
# We need Odoo requirements to produce a correct lock.
|
|
2240
|
+
odoo_req = layout.odoo_dir / "requirements.txt"
|
|
2241
|
+
if not odoo_req.exists():
|
|
2242
|
+
raise Exception(f"Odoo requirements file not found: {odoo_req}")
|
|
2243
|
+
|
|
2244
|
+
base_requirements = list(_DEFAULT_REQUIREMENTS)
|
|
2245
|
+
if cfg.virtualenv.requirements:
|
|
2246
|
+
base_requirements.extend(cfg.virtualenv.requirements)
|
|
2247
|
+
|
|
2248
|
+
compile_all_requirements_lock(
|
|
2249
|
+
venv_python=venv_py,
|
|
2250
|
+
workspace_root=layout.root,
|
|
2251
|
+
wheelhouse_dir=layout.wheelhouse_dir,
|
|
2252
|
+
requirement_files=req_files,
|
|
2253
|
+
base_requirements=base_requirements,
|
|
2254
|
+
requirements_ignore=cfg.virtualenv.requirements_ignore,
|
|
2255
|
+
output_lock_path=lock_path,
|
|
2256
|
+
build_constraints_path=build_constraints_path,
|
|
2257
|
+
)
|
|
2258
|
+
|
|
2259
|
+
build_wheelhouse_from_requirements(
|
|
2260
|
+
venv_python=venv_py,
|
|
2261
|
+
workspace_root=layout.root,
|
|
2262
|
+
requirements_path=lock_path,
|
|
2263
|
+
wheelhouse_dir=layout.wheelhouse_dir,
|
|
2264
|
+
build_constraints_path=build_constraints_path,
|
|
2265
|
+
clear_pip_wheel_cache=clear_pip_wheel_cache,
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
if venv_enabled:
|
|
2269
|
+
pip_install_requirements_file(
|
|
2270
|
+
venv_python=venv_py,
|
|
2271
|
+
workspace_root=layout.root,
|
|
2272
|
+
wheelhouse_dir=layout.wheelhouse_dir,
|
|
2273
|
+
requirements_path=lock_path,
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
if venv_enabled:
|
|
2277
|
+
# Install Odoo itself in editable mode (so local source changes are reflected).
|
|
2278
|
+
_logger.info("Installing Odoo in editable mode: %s", layout.odoo_dir)
|
|
2279
|
+
cmd = [
|
|
2280
|
+
str(venv_py), "-m", "pip", "install",
|
|
2281
|
+
"--no-deps",
|
|
2282
|
+
"--no-build-isolation",
|
|
2283
|
+
"-e", str(layout.odoo_dir),
|
|
2284
|
+
]
|
|
2285
|
+
p = subprocess.run(
|
|
2286
|
+
cmd,
|
|
2287
|
+
cwd=str(layout.root),
|
|
2288
|
+
text=True,
|
|
2289
|
+
capture_output=True,
|
|
2290
|
+
)
|
|
2291
|
+
_handle_process_output(p, err_msg=(
|
|
2292
|
+
"Failed to install Odoo in editable mode.\n"
|
|
2293
|
+
f"Command: {' '.join(p.args if isinstance(p.args, list) else [str(p.args)])}\n"
|
|
2294
|
+
f"{p.stdout}\n{p.stderr}"
|
|
2295
|
+
))
|
|
2296
|
+
|
|
2297
|
+
# Generate config (unless disabled).
|
|
2298
|
+
if not no_configs:
|
|
2299
|
+
addon_paths: list[Path] = [dest_layout.addons_root / name for name in cfg.addons.keys()]
|
|
2300
|
+
conf_text = render_odoo_conf(cfg.config, dest_layout, addon_paths)
|
|
2301
|
+
layout.conf_path.write_text(conf_text, encoding="utf-8")
|
|
2302
|
+
else:
|
|
2303
|
+
_logger.info("Skipping config generation (--no-configs).")
|
|
2304
|
+
|
|
2305
|
+
is_windows = sys.platform.startswith("win")
|
|
2306
|
+
|
|
2307
|
+
# Generate helper scripts (unless disabled).
|
|
2308
|
+
if not no_scripts:
|
|
2309
|
+
if is_windows:
|
|
2310
|
+
write_run_bat(layout)
|
|
2311
|
+
write_test_bat(layout)
|
|
2312
|
+
write_shell_bat(layout)
|
|
2313
|
+
write_update_bat(layout)
|
|
2314
|
+
write_update_all_bat(layout)
|
|
2315
|
+
else:
|
|
2316
|
+
write_run_sh(layout)
|
|
2317
|
+
write_instance_sh(layout)
|
|
2318
|
+
write_test_sh(layout)
|
|
2319
|
+
write_shell_sh(layout)
|
|
2320
|
+
write_update_sh(layout)
|
|
2321
|
+
write_update_all_sh(layout)
|
|
2322
|
+
|
|
2323
|
+
db_name = cfg.config.get("db_name")
|
|
2324
|
+
if not isinstance(db_name, str) or not db_name.strip():
|
|
2325
|
+
_logger.warning(
|
|
2326
|
+
"Missing or invalid 'db_name' in [config] (expected non-empty string)."
|
|
2327
|
+
"Database scripts (initdb/backup/restore/restore-force) will NOT be generated."
|
|
2328
|
+
)
|
|
2329
|
+
else:
|
|
2330
|
+
if is_windows:
|
|
2331
|
+
write_initdb_bat(layout, db_name.strip())
|
|
2332
|
+
write_backup_bat(layout, db_name.strip())
|
|
2333
|
+
write_restore_bat(layout, db_name.strip())
|
|
2334
|
+
write_restore_force_bat(layout, db_name.strip())
|
|
2335
|
+
else:
|
|
2336
|
+
write_initdb_sh(layout, db_name.strip())
|
|
2337
|
+
write_backup_sh(layout, db_name.strip())
|
|
2338
|
+
write_restore_sh(layout, db_name.strip())
|
|
2339
|
+
write_restore_force_sh(layout, db_name.strip())
|
|
2340
|
+
else:
|
|
2341
|
+
_logger.info("Skipping script generation (--no-scripts).")
|
|
2342
|
+
|
|
2343
|
+
synced: list[str] = []
|
|
2344
|
+
if sync_odoo:
|
|
2345
|
+
synced.append("odoo")
|
|
2346
|
+
if sync_addons:
|
|
2347
|
+
synced.append("addons")
|
|
2348
|
+
|
|
2349
|
+
print("OK")
|
|
2350
|
+
if synced:
|
|
2351
|
+
synced_label = ", ".join(synced)
|
|
2352
|
+
else:
|
|
2353
|
+
synced_label = "none"
|
|
2354
|
+
|
|
2355
|
+
generated: list[str] = []
|
|
2356
|
+
if not no_configs:
|
|
2357
|
+
generated.append("configs")
|
|
2358
|
+
if not no_scripts:
|
|
2359
|
+
generated.append("scripts")
|
|
2360
|
+
if generated:
|
|
2361
|
+
synced_label = f"{synced_label} (generated: {', '.join(generated)})"
|
|
2362
|
+
else:
|
|
2363
|
+
synced_label = f"{synced_label} (no configs and scripts generated)"
|
|
2364
|
+
|
|
2365
|
+
print(f" Synced: {synced_label}")
|
|
2366
|
+
print(f" ROOT: {layout.root}")
|
|
2367
|
+
if dest_layout.root != layout.root:
|
|
2368
|
+
print(f" DEST_ROOT: {dest_layout.root}")
|
|
2369
|
+
print(f" Odoo: {layout.odoo_dir}")
|
|
2370
|
+
print(f" Addons: {layout.addons_root}")
|
|
2371
|
+
print(f" Backups: {layout.backups_dir}")
|
|
2372
|
+
if no_data_dir:
|
|
2373
|
+
print(f" Data: SKIPPED (--no-data-dir)")
|
|
2374
|
+
else:
|
|
2375
|
+
print(f" Data: {dest_layout.data_dir}")
|
|
2376
|
+
if no_configs:
|
|
2377
|
+
print(f" Config: SKIPPED (--no-configs) [{layout.conf_path}]")
|
|
2378
|
+
else:
|
|
2379
|
+
print(f" Config: {layout.conf_path}")
|
|
2380
|
+
if venv_py is not None:
|
|
2381
|
+
print(f" Venv: {layout.root / 'venv'}")
|
|
2382
|
+
lock_path = layout.wheelhouse_dir / "all-requirements.lock.txt"
|
|
2383
|
+
if lock_path.exists():
|
|
2384
|
+
print(f" Requirements: {lock_path}")
|
|
2385
|
+
print(f" Wheelhouse: {layout.wheelhouse_dir}")
|
|
2386
|
+
if cfg.virtualenv.build_constraints:
|
|
2387
|
+
bc = layout.wheelhouse_dir / "build-constraints.txt"
|
|
2388
|
+
if bc.exists():
|
|
2389
|
+
print(f" Build Constraints: {bc}")
|
|
2390
|
+
|
|
2391
|
+
if no_scripts:
|
|
2392
|
+
print(" Scripts: SKIPPED (--no-scripts)")
|
|
2393
|
+
else:
|
|
2394
|
+
print(f" Scripts:")
|
|
2395
|
+
if is_windows:
|
|
2396
|
+
print(f" - run: {layout.run_bat}")
|
|
2397
|
+
print(f" - test: {layout.test_bat}")
|
|
2398
|
+
print(f" - shell: {layout.shell_bat}")
|
|
2399
|
+
print(f" - initdb: {layout.initdb_bat}")
|
|
2400
|
+
print(f" - backup: {layout.backup_bat}")
|
|
2401
|
+
print(f" - restore: {layout.restore_bat}")
|
|
2402
|
+
print(f" - restore_force: {layout.restore_force_bat}")
|
|
2403
|
+
print(f" - update: {layout.update_bat}")
|
|
2404
|
+
print(f" - update-all: {layout.update_all_bat}")
|
|
2405
|
+
else:
|
|
2406
|
+
print(f" - run: {layout.run_sh}")
|
|
2407
|
+
print(f" - test: {layout.test_sh}")
|
|
2408
|
+
print(f" - shell: {layout.shell_sh}")
|
|
2409
|
+
print(f" - initdb: {layout.initdb_sh}")
|
|
2410
|
+
print(f" - backup: {layout.backup_sh}")
|
|
2411
|
+
print(f" - restore: {layout.restore_sh}")
|
|
2412
|
+
print(f" - restore_force: {layout.restore_force_sh}")
|
|
2413
|
+
print(f" - update: {layout.update_sh}")
|
|
2414
|
+
print(f" - update-all: {layout.update_all_sh}")
|
|
2415
|
+
|
|
2416
|
+
|
|
2417
|
+
# -----------------------------
|
|
2418
|
+
# CLI
|
|
2419
|
+
# -----------------------------
|
|
2420
|
+
|
|
2421
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2422
|
+
epilog = """If no options are specified, odk-env only regenerates configs and helper scripts.
|
|
2423
|
+
|
|
2424
|
+
ROOT selection:
|
|
2425
|
+
By default, ROOT is the directory containing the INI.
|
|
2426
|
+
Use --root to override where the workspace (repos/venv/configs/scripts) is created.
|
|
2427
|
+
Use --dest-root to override the ROOT path embedded into generated configs/scripts (deployment root).
|
|
2428
|
+
|
|
2429
|
+
Examples:
|
|
2430
|
+
odk-env /path/to/odoo-project.ini --sync-all --create-venv
|
|
2431
|
+
odk-env /path/to/odoo-project.ini --sync-all --create-wheelhouse
|
|
2432
|
+
odk-env /path/to/odoo-project.ini --sync-all --create-venv --root /path/to/workspace-root
|
|
2433
|
+
odk-env /path/to/odoo-project.ini --rebuild-venv --reuse-wheelhouse
|
|
2434
|
+
odk-env /path/to/odoo-project.ini --rebuild-venv --reuse-wheelhouse --root /path/to/workspace-root
|
|
2435
|
+
"""
|
|
2436
|
+
|
|
2437
|
+
parser = argparse.ArgumentParser(
|
|
2438
|
+
prog="odk-env",
|
|
2439
|
+
description=f"odk-env {__version__}",
|
|
2440
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2441
|
+
epilog=epilog,
|
|
2442
|
+
)
|
|
2443
|
+
|
|
2444
|
+
parser.add_argument(
|
|
2445
|
+
"--version",
|
|
2446
|
+
action="version",
|
|
2447
|
+
version=f"odk-env {__version__}",
|
|
2448
|
+
help="Show the program version and exit.",
|
|
2449
|
+
)
|
|
2450
|
+
|
|
2451
|
+
parser.add_argument(
|
|
2452
|
+
"ini",
|
|
2453
|
+
metavar="INI",
|
|
2454
|
+
help="Path to odoo-project.ini (default ROOT is its directory; override with --root)",
|
|
2455
|
+
)
|
|
2456
|
+
|
|
2457
|
+
parser.add_argument(
|
|
2458
|
+
"--root",
|
|
2459
|
+
metavar="ROOT",
|
|
2460
|
+
default=None,
|
|
2461
|
+
help="Override workspace ROOT directory (default: directory containing INI).",
|
|
2462
|
+
)
|
|
2463
|
+
|
|
2464
|
+
parser.add_argument(
|
|
2465
|
+
"--dest-root",
|
|
2466
|
+
dest="dest_root",
|
|
2467
|
+
metavar="DEST_ROOT",
|
|
2468
|
+
default=None,
|
|
2469
|
+
help=(
|
|
2470
|
+
"Override DEST_ROOT used for paths embedded in generated configs/scripts. "
|
|
2471
|
+
"This does not change where the workspace is created (see --root). "
|
|
2472
|
+
"DEST_ROOT does not need to exist on the build machine. "
|
|
2473
|
+
"Default: same as ROOT."
|
|
2474
|
+
),
|
|
2475
|
+
)
|
|
2476
|
+
|
|
2477
|
+
target = parser.add_mutually_exclusive_group()
|
|
2478
|
+
target.add_argument("--sync-odoo", dest="odoo", action="store_true", help="Sync only Odoo repository")
|
|
2479
|
+
target.add_argument("--sync-addons", dest="addons", action="store_true", help="Sync only addon repositories")
|
|
2480
|
+
target.add_argument("--sync-all", dest="all", action="store_true", help="Sync Odoo + addons")
|
|
2481
|
+
|
|
2482
|
+
parser.add_argument(
|
|
2483
|
+
"--create-wheelhouse",
|
|
2484
|
+
action="store_true",
|
|
2485
|
+
help=(
|
|
2486
|
+
"Create/refresh ROOT/wheelhouse (and all-requirements.lock.txt). "
|
|
2487
|
+
"Ensures ROOT/venv exists so wheels can be built, but does NOT install project requirements into the venv "
|
|
2488
|
+
"unless --create-venv/--rebuild-venv is also set."
|
|
2489
|
+
),
|
|
2490
|
+
)
|
|
2491
|
+
parser.add_argument(
|
|
2492
|
+
"--create-venv",
|
|
2493
|
+
action="store_true",
|
|
2494
|
+
help=(
|
|
2495
|
+
"Enable virtualenv provisioning (create/update ROOT/venv + wheelhouse). "
|
|
2496
|
+
"Without this flag, OPM will not touch venv/wheelhouse."
|
|
2497
|
+
),
|
|
2498
|
+
)
|
|
2499
|
+
parser.add_argument(
|
|
2500
|
+
"--rebuild-venv",
|
|
2501
|
+
action="store_true",
|
|
2502
|
+
help="Delete ROOT/venv and recreate it (implies --create-venv).",
|
|
2503
|
+
)
|
|
2504
|
+
parser.add_argument(
|
|
2505
|
+
"--reuse-wheelhouse",
|
|
2506
|
+
action="store_true",
|
|
2507
|
+
help=(
|
|
2508
|
+
"Reuse existing ROOT/wheelhouse (and all-requirements.lock.txt) and install offline only. "
|
|
2509
|
+
"Skips lock compilation and wheelhouse build. Requires --create-venv."
|
|
2510
|
+
),
|
|
2511
|
+
)
|
|
2512
|
+
parser.add_argument(
|
|
2513
|
+
"--clear-pip-wheel-cache",
|
|
2514
|
+
action="store_true",
|
|
2515
|
+
help="Remove all items from the pip's wheel cache.",
|
|
2516
|
+
)
|
|
2517
|
+
|
|
2518
|
+
parser.add_argument(
|
|
2519
|
+
"--no-configs",
|
|
2520
|
+
action="store_true",
|
|
2521
|
+
help="Do not (re)generate config files (e.g. ROOT/odoo-configs/odoo-server.conf).",
|
|
2522
|
+
)
|
|
2523
|
+
parser.add_argument(
|
|
2524
|
+
"--no-scripts",
|
|
2525
|
+
action="store_true",
|
|
2526
|
+
help="Do not (re)generate helper scripts under ROOT/odoo-scripts/.",
|
|
2527
|
+
)
|
|
2528
|
+
parser.add_argument(
|
|
2529
|
+
"--no-data-dir",
|
|
2530
|
+
action="store_true",
|
|
2531
|
+
help="Do not generate odoo data folder.",
|
|
2532
|
+
)
|
|
2533
|
+
|
|
2534
|
+
return parser
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
def _validate_root_override(parser: argparse.ArgumentParser, raw_root: str) -> Path:
|
|
2538
|
+
"""Validate and normalize the --root override.
|
|
2539
|
+
|
|
2540
|
+
- Expands '~'
|
|
2541
|
+
- Resolves to an absolute path
|
|
2542
|
+
- Ensures it exists and is a directory
|
|
2543
|
+
|
|
2544
|
+
Returns the normalized Path.
|
|
2545
|
+
"""
|
|
2546
|
+
_logger.info('CLI --root provided: %s', raw_root)
|
|
2547
|
+
|
|
2548
|
+
candidate = Path(raw_root).expanduser()
|
|
2549
|
+
|
|
2550
|
+
# Resolve to an absolute path for consistent workspace layout and logging.
|
|
2551
|
+
try:
|
|
2552
|
+
resolved = candidate.resolve()
|
|
2553
|
+
except Exception:
|
|
2554
|
+
# Fallback: make it absolute without resolving symlinks.
|
|
2555
|
+
resolved = candidate.absolute()
|
|
2556
|
+
|
|
2557
|
+
if not resolved.exists():
|
|
2558
|
+
parser.error(f'--root path does not exist: {resolved}')
|
|
2559
|
+
if not resolved.is_dir():
|
|
2560
|
+
parser.error(f'--root path is not a directory: {resolved}')
|
|
2561
|
+
|
|
2562
|
+
_logger.info('Validated --root: %s', resolved)
|
|
2563
|
+
return resolved
|
|
2564
|
+
|
|
2565
|
+
|
|
2566
|
+
def main() -> None:
|
|
2567
|
+
# Standard logging to stdout only.
|
|
2568
|
+
logging.basicConfig(
|
|
2569
|
+
level=logging.INFO,
|
|
2570
|
+
stream=sys.stdout,
|
|
2571
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
2572
|
+
)
|
|
2573
|
+
|
|
2574
|
+
parser = build_parser()
|
|
2575
|
+
|
|
2576
|
+
if len(sys.argv) == 1:
|
|
2577
|
+
parser.print_help()
|
|
2578
|
+
raise SystemExit(2)
|
|
2579
|
+
|
|
2580
|
+
args = parser.parse_args()
|
|
2581
|
+
clear_pip_wheel_cache = bool(getattr(args, 'clear_pip_wheel_cache', False))
|
|
2582
|
+
reuse_wheelhouse = bool(getattr(args, 'reuse_wheelhouse', False))
|
|
2583
|
+
rebuild_venv = bool(getattr(args, 'rebuild_venv', False))
|
|
2584
|
+
create_venv = bool(getattr(args, 'create_venv', False)) or rebuild_venv
|
|
2585
|
+
create_wheelhouse = bool(getattr(args, 'create_wheelhouse', False))
|
|
2586
|
+
no_configs = bool(getattr(args, 'no_configs', False))
|
|
2587
|
+
no_scripts = bool(getattr(args, 'no_scripts', False))
|
|
2588
|
+
no_data_dir = bool(getattr(args, 'no_data_dir', False))
|
|
2589
|
+
if reuse_wheelhouse and not create_venv:
|
|
2590
|
+
parser.error('--reuse-wheelhouse requires --create-venv (or --rebuild-venv)')
|
|
2591
|
+
if create_wheelhouse and reuse_wheelhouse:
|
|
2592
|
+
parser.error('--create-wheelhouse can not be used with --reuse-wheelhouse')
|
|
2593
|
+
|
|
2594
|
+
ini_path = Path(args.ini).expanduser().resolve()
|
|
2595
|
+
if not ini_path.exists():
|
|
2596
|
+
parser.error(f'INI file does not exist: {ini_path}')
|
|
2597
|
+
if not ini_path.is_file():
|
|
2598
|
+
parser.error(f'INI path is not a file: {ini_path}')
|
|
2599
|
+
|
|
2600
|
+
root_override: Optional[Path] = None
|
|
2601
|
+
if args.root:
|
|
2602
|
+
root_override = _validate_root_override(parser, args.root)
|
|
2603
|
+
else:
|
|
2604
|
+
_logger.info('Workspace ROOT default (INI directory): %s', ini_path.parent.resolve())
|
|
2605
|
+
|
|
2606
|
+
dest_root_override: Optional[Path] = None
|
|
2607
|
+
if getattr(args, 'dest_root', None):
|
|
2608
|
+
# NOTE: DEST_ROOT does not need to exist on this machine.
|
|
2609
|
+
dest_root_override = Path(args.dest_root).expanduser()
|
|
2610
|
+
|
|
2611
|
+
if args.all:
|
|
2612
|
+
sync_odoo, sync_addons = True, True
|
|
2613
|
+
elif args.odoo:
|
|
2614
|
+
sync_odoo, sync_addons = True, False
|
|
2615
|
+
elif args.addons:
|
|
2616
|
+
sync_odoo, sync_addons = False, True
|
|
2617
|
+
else:
|
|
2618
|
+
# No sync target selected -> only regenerate configs + helper scripts.
|
|
2619
|
+
sync_odoo, sync_addons = False, False
|
|
2620
|
+
|
|
2621
|
+
ini_path = Path(args.ini).resolve()
|
|
2622
|
+
sync_project(
|
|
2623
|
+
ini_path,
|
|
2624
|
+
sync_odoo=sync_odoo,
|
|
2625
|
+
sync_addons=sync_addons,
|
|
2626
|
+
root_override=root_override,
|
|
2627
|
+
dest_root_override=dest_root_override,
|
|
2628
|
+
create_wheelhouse=create_wheelhouse,
|
|
2629
|
+
reuse_wheelhouse=reuse_wheelhouse,
|
|
2630
|
+
create_venv=create_venv,
|
|
2631
|
+
rebuild_venv=rebuild_venv,
|
|
2632
|
+
clear_pip_wheel_cache=clear_pip_wheel_cache,
|
|
2633
|
+
no_configs=no_configs,
|
|
2634
|
+
no_scripts=no_scripts,
|
|
2635
|
+
no_data_dir=no_data_dir,
|
|
2636
|
+
)
|
|
2637
|
+
|
|
2638
|
+
|
|
2639
|
+
if __name__ == "__main__":
|
|
2640
|
+
main()
|