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.
@@ -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()