You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1040 lines
34 KiB

  1. """Doodba child project tasks.
  2. This file is to be executed with https://www.pyinvoke.org/ in Python 3.6+.
  3. Contains common helpers to develop using this child project.
  4. """
  5. import json
  6. import os
  7. import stat
  8. import tempfile
  9. import time
  10. from datetime import datetime
  11. from itertools import chain
  12. from logging import getLogger
  13. from pathlib import Path
  14. from shutil import which
  15. from invoke import exceptions, task
  16. try:
  17. import yaml
  18. except ImportError:
  19. from invoke.util import yaml
  20. PROJECT_ROOT = Path(__file__).parent.absolute()
  21. SRC_PATH = PROJECT_ROOT / "odoo" / "custom" / "src"
  22. UID_ENV = {
  23. "GID": os.environ.get("DOODBA_GID", str(os.getgid())),
  24. "UID": os.environ.get("DOODBA_UID", str(os.getuid())),
  25. "DOODBA_UMASK": os.environ.get("DOODBA_UMASK", "27"),
  26. }
  27. UID_ENV.update(
  28. {
  29. "DOODBA_GITAGGREGATE_GID": os.environ.get(
  30. "DOODBA_GITAGGREGATE_GID", UID_ENV["GID"]
  31. ),
  32. "DOODBA_GITAGGREGATE_UID": os.environ.get(
  33. "DOODBA_GITAGGREGATE_UID", UID_ENV["UID"]
  34. ),
  35. }
  36. )
  37. SERVICES_WAIT_TIME = int(os.environ.get("SERVICES_WAIT_TIME", 4))
  38. ODOO_VERSION = float(
  39. yaml.safe_load((PROJECT_ROOT / "common.yaml").read_text())["services"]["odoo"][
  40. "build"
  41. ]["args"]["ODOO_VERSION"]
  42. )
  43. _logger = getLogger(__name__)
  44. def _override_docker_command(service, command, file, orig_file=None):
  45. # Read config from main file
  46. if orig_file:
  47. with open(orig_file, "r") as fd:
  48. orig_docker_config = yaml.safe_load(fd.read())
  49. docker_compose_file_version = orig_docker_config.get("version")
  50. else:
  51. docker_compose_file_version = "2.4"
  52. docker_config = {
  53. "version": docker_compose_file_version,
  54. "services": {service: {"command": command}},
  55. }
  56. docker_config_yaml = yaml.dump(docker_config)
  57. file.write(docker_config_yaml)
  58. file.flush()
  59. def _remove_auto_reload(file, orig_file):
  60. with open(orig_file, "r") as fd:
  61. orig_docker_config = yaml.safe_load(fd.read())
  62. odoo_command = orig_docker_config["services"]["odoo"]["command"]
  63. new_odoo_command = []
  64. for flag in odoo_command:
  65. if flag.startswith("--dev"):
  66. flag = flag.replace("reload,", "")
  67. new_odoo_command.append(flag)
  68. _override_docker_command("odoo", new_odoo_command, file, orig_file=orig_file)
  69. def _get_cwd_addon(file):
  70. cwd = Path(file).resolve()
  71. manifest_file = False
  72. while PROJECT_ROOT < cwd:
  73. manifest_file = (cwd / "__manifest__.py").exists() or (
  74. cwd / "__openerp__.py"
  75. ).exists()
  76. if manifest_file:
  77. return cwd.stem
  78. cwd = cwd.parent
  79. if cwd == PROJECT_ROOT:
  80. return None
  81. @task
  82. def write_code_workspace_file(c, cw_path=None):
  83. """Generate code-workspace file definition.
  84. Some other tasks will call this one when needed, and since you cannot specify
  85. the file name there, if you want a specific one, you should call this task
  86. before.
  87. Most times you just can forget about this task and let it be run automatically
  88. whenever needed.
  89. If you don't define a workspace name, this task will reuse the 1st
  90. `doodba.*.code-workspace` file found inside the current directory.
  91. If none is found, it will default to `doodba.$(basename $PWD).code-workspace`.
  92. If you define it manually, remember to use the same prefix and suffix if you
  93. want it git-ignored by default.
  94. Example: `--cw-path doodba.my-custom-name.code-workspace`
  95. """
  96. root_name = f"doodba.{PROJECT_ROOT.name}"
  97. root_var = "${workspaceFolder:%s}" % root_name
  98. if not cw_path:
  99. try:
  100. cw_path = next(PROJECT_ROOT.glob("doodba.*.code-workspace"))
  101. except StopIteration:
  102. cw_path = f"{root_name}.code-workspace"
  103. if not Path(cw_path).is_absolute():
  104. cw_path = PROJECT_ROOT / cw_path
  105. cw_config = {}
  106. try:
  107. with open(cw_path) as cw_fd:
  108. cw_config = json.load(cw_fd)
  109. except (FileNotFoundError, json.decoder.JSONDecodeError):
  110. pass # Nevermind, we start with a new config
  111. # Static settings
  112. cw_config.setdefault("settings", {})
  113. cw_config["settings"].update(
  114. {
  115. "python.autoComplete.extraPaths": [f"{str(SRC_PATH)}/odoo"],
  116. "python.linting.flake8Enabled": True,
  117. "python.linting.ignorePatterns": [f"{str(SRC_PATH)}/odoo/**/*.py"],
  118. "python.linting.pylintArgs": [
  119. f"--init-hook=\"import sys;sys.path.append('{str(SRC_PATH)}/odoo')\"",
  120. "--load-plugins=pylint_odoo",
  121. ],
  122. "python.linting.pylintEnabled": True,
  123. "python.pythonPath": "python%s" % (2 if ODOO_VERSION < 11 else 3),
  124. "restructuredtext.confPath": "",
  125. "search.followSymlinks": False,
  126. "search.useIgnoreFiles": False,
  127. # Language-specific configurations
  128. "[python]": {"editor.defaultFormatter": "ms-python.python"},
  129. "[json]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},
  130. "[jsonc]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},
  131. "[markdown]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},
  132. "[yaml]": {"editor.defaultFormatter": "esbenp.prettier-vscode"},
  133. "[xml]": {"editor.formatOnSave": False},
  134. }
  135. )
  136. # Launch configurations
  137. debugpy_configuration = {
  138. "name": "Attach Python debugger to running container",
  139. "type": "python",
  140. "request": "attach",
  141. "pathMappings": [],
  142. "port": int(ODOO_VERSION) * 1000 + 899,
  143. # HACK https://github.com/microsoft/vscode-python/issues/14820
  144. "host": "0.0.0.0",
  145. }
  146. firefox_configuration = {
  147. "type": "firefox",
  148. "request": "launch",
  149. "reAttach": True,
  150. "name": "Connect to firefox debugger",
  151. "url": f"http://localhost:{ODOO_VERSION:.0f}069/?debug=assets",
  152. "reloadOnChange": {
  153. "watch": f"{root_var}/odoo/custom/src/**/*.{'{js,css,scss,less}'}"
  154. },
  155. "skipFiles": ["**/lib/**"],
  156. "pathMappings": [],
  157. }
  158. chrome_executable = which("chrome") or which("chromium")
  159. chrome_configuration = {
  160. "type": "chrome",
  161. "request": "launch",
  162. "name": "Connect to chrome debugger",
  163. "url": f"http://localhost:{ODOO_VERSION:.0f}069/?debug=assets",
  164. "skipFiles": ["**/lib/**"],
  165. "trace": True,
  166. "pathMapping": {},
  167. }
  168. if chrome_executable:
  169. chrome_configuration["runtimeExecutable"] = chrome_executable
  170. cw_config["launch"] = {
  171. "compounds": [
  172. {
  173. "name": "Start Odoo and debug Python",
  174. "configurations": ["Attach Python debugger to running container"],
  175. "preLaunchTask": "Start Odoo in debug mode",
  176. },
  177. {
  178. "name": "Test and debug current module",
  179. "configurations": ["Attach Python debugger to running container"],
  180. "preLaunchTask": "Run Odoo Tests in debug mode for current module",
  181. "internalConsoleOptions": "openOnSessionStart",
  182. },
  183. ],
  184. "configurations": [
  185. debugpy_configuration,
  186. firefox_configuration,
  187. chrome_configuration,
  188. ],
  189. }
  190. # Configure folders and debuggers
  191. debugpy_configuration["pathMappings"].append(
  192. {
  193. "localRoot": "${workspaceFolder:odoo}/",
  194. "remoteRoot": "/opt/odoo/custom/src/odoo",
  195. }
  196. )
  197. cw_config["folders"] = []
  198. for subrepo in SRC_PATH.glob("*"):
  199. if not subrepo.is_dir():
  200. continue
  201. if (subrepo / ".git").exists() and subrepo.name != "odoo":
  202. cw_config["folders"].append(
  203. {"path": str(subrepo.relative_to(PROJECT_ROOT))}
  204. )
  205. for addon in chain(subrepo.glob("*"), subrepo.glob("addons/*")):
  206. if (addon / "__manifest__.py").is_file() or (
  207. addon / "__openerp__.py"
  208. ).is_file():
  209. if subrepo.name == "odoo":
  210. local_path = "${workspaceFolder:%s}/addons/%s/" % (
  211. subrepo.name,
  212. addon.name,
  213. )
  214. else:
  215. local_path = "${workspaceFolder:%s}/%s" % (subrepo.name, addon.name)
  216. debugpy_configuration["pathMappings"].append(
  217. {
  218. "localRoot": local_path,
  219. "remoteRoot": f"/opt/odoo/auto/addons/{addon.name}/",
  220. }
  221. )
  222. url = f"http://localhost:{ODOO_VERSION:.0f}069/{addon.name}/static/"
  223. path = "${workspaceFolder:%s}/%s/static/" % (
  224. subrepo.name,
  225. addon.relative_to(subrepo),
  226. )
  227. firefox_configuration["pathMappings"].append({"url": url, "path": path})
  228. chrome_configuration["pathMapping"][url] = path
  229. cw_config["tasks"] = {
  230. "version": "2.0.0",
  231. "tasks": [
  232. {
  233. "label": "Start Odoo",
  234. "type": "process",
  235. "command": "invoke",
  236. "args": ["start", "--detach"],
  237. "presentation": {
  238. "echo": True,
  239. "reveal": "silent",
  240. "focus": False,
  241. "panel": "shared",
  242. "showReuseMessage": True,
  243. "clear": False,
  244. },
  245. "problemMatcher": [],
  246. "options": {"statusbar": {"label": "$(play-circle) Start Odoo"}},
  247. },
  248. {
  249. "label": "Install current module",
  250. "type": "process",
  251. "command": "invoke",
  252. "args": ["install", "--cur-file", "${file}", "restart"],
  253. "presentation": {
  254. "echo": True,
  255. "reveal": "always",
  256. "focus": True,
  257. "panel": "shared",
  258. "showReuseMessage": True,
  259. "clear": False,
  260. },
  261. "problemMatcher": [],
  262. "options": {
  263. "statusbar": {"label": "$(symbol-property) Install module"}
  264. },
  265. },
  266. {
  267. "label": "Run Odoo Tests for current module",
  268. "type": "process",
  269. "command": "invoke",
  270. "args": ["test", "--cur-file", "${file}"],
  271. "presentation": {
  272. "echo": True,
  273. "reveal": "always",
  274. "focus": True,
  275. "panel": "shared",
  276. "showReuseMessage": True,
  277. "clear": False,
  278. },
  279. "problemMatcher": [],
  280. "options": {"statusbar": {"label": "$(beaker) Test module"}},
  281. },
  282. {
  283. "label": "Run Odoo Tests in debug mode for current module",
  284. "type": "process",
  285. "command": "invoke",
  286. "args": [
  287. "test",
  288. "--cur-file",
  289. "${file}",
  290. "--debugpy",
  291. ],
  292. "presentation": {
  293. "echo": True,
  294. "reveal": "silent",
  295. "focus": False,
  296. "panel": "shared",
  297. "showReuseMessage": True,
  298. "clear": False,
  299. },
  300. "problemMatcher": [],
  301. "options": {"statusbar": {"hide": True}},
  302. },
  303. {
  304. "label": "Start Odoo in debug mode",
  305. "type": "process",
  306. "command": "invoke",
  307. "args": ["start", "--detach", "--debugpy"],
  308. "presentation": {
  309. "echo": True,
  310. "reveal": "silent",
  311. "focus": False,
  312. "panel": "shared",
  313. "showReuseMessage": True,
  314. "clear": False,
  315. },
  316. "problemMatcher": [],
  317. "options": {"statusbar": {"hide": True}},
  318. },
  319. {
  320. "label": "Stop Odoo",
  321. "type": "process",
  322. "command": "invoke",
  323. "args": ["stop"],
  324. "presentation": {
  325. "echo": True,
  326. "reveal": "silent",
  327. "focus": False,
  328. "panel": "shared",
  329. "showReuseMessage": True,
  330. "clear": False,
  331. },
  332. "problemMatcher": [],
  333. "options": {"statusbar": {"label": "$(stop-circle) Stop Odoo"}},
  334. },
  335. {
  336. "label": "Restart Odoo",
  337. "type": "process",
  338. "command": "invoke",
  339. "args": ["restart"],
  340. "presentation": {
  341. "echo": True,
  342. "reveal": "silent",
  343. "focus": False,
  344. "panel": "shared",
  345. "showReuseMessage": True,
  346. "clear": False,
  347. },
  348. "problemMatcher": [],
  349. "options": {"statusbar": {"label": "$(history) Restart Odoo"}},
  350. },
  351. {
  352. "label": "See container logs",
  353. "type": "process",
  354. "command": "invoke",
  355. "args": ["logs"],
  356. "presentation": {
  357. "echo": True,
  358. "reveal": "always",
  359. "focus": False,
  360. "panel": "shared",
  361. "showReuseMessage": True,
  362. "clear": False,
  363. },
  364. "problemMatcher": [],
  365. "options": {
  366. "statusbar": {"label": "$(list-selection) See container logs"}
  367. },
  368. },
  369. ],
  370. }
  371. # Sort project folders
  372. cw_config["folders"].sort(key=lambda x: x["path"])
  373. # Put Odoo folder just before private and top folder and map to debugpy
  374. odoo = SRC_PATH / "odoo"
  375. if odoo.is_dir():
  376. cw_config["folders"].append({"path": str(odoo.relative_to(PROJECT_ROOT))})
  377. # HACK https://github.com/microsoft/vscode/issues/95963 put private second to last
  378. private = SRC_PATH / "private"
  379. if private.is_dir():
  380. cw_config["folders"].append({"path": str(private.relative_to(PROJECT_ROOT))})
  381. # HACK https://github.com/microsoft/vscode/issues/37947 put top folder last
  382. cw_config["folders"].append({"path": ".", "name": root_name})
  383. with open(cw_path, "w") as cw_fd:
  384. json.dump(cw_config, cw_fd, indent=2)
  385. cw_fd.write("\n")
  386. @task
  387. def develop(c):
  388. """Set up a basic development environment."""
  389. # Prepare environment
  390. auto = Path(PROJECT_ROOT, "odoo", "auto")
  391. addons = auto / "addons"
  392. addons.mkdir(parents=True, exist_ok=True)
  393. # Allow others writing, for podman support
  394. auto.chmod(0o777)
  395. addons.chmod(0o777)
  396. with c.cd(str(PROJECT_ROOT)):
  397. c.run("git init")
  398. c.run("ln -sf devel.yaml docker-compose.yml")
  399. write_code_workspace_file(c)
  400. c.run("pre-commit install")
  401. @task(develop)
  402. def git_aggregate(c):
  403. """Download odoo & addons git code.
  404. Executes git-aggregator from within the doodba container.
  405. """
  406. with c.cd(str(PROJECT_ROOT)):
  407. c.run(
  408. "docker-compose --file setup-devel.yaml run --rm -T odoo",
  409. env=UID_ENV,
  410. )
  411. write_code_workspace_file(c)
  412. for git_folder in SRC_PATH.glob("*/.git/.."):
  413. action = (
  414. "install"
  415. if (git_folder / ".pre-commit-config.yaml").is_file()
  416. else "uninstall"
  417. )
  418. with c.cd(str(git_folder)):
  419. c.run(f"pre-commit {action}")
  420. @task(develop)
  421. def closed_prs(c):
  422. """Test closed PRs from repos.yaml"""
  423. with c.cd(str(PROJECT_ROOT / "odoo/custom/src")):
  424. cmd = "gitaggregate -c {} show-closed-prs".format("repos.yaml")
  425. c.run(cmd, env=UID_ENV, pty=True)
  426. @task()
  427. def img_build(c, pull=True):
  428. """Build docker images."""
  429. cmd = "docker-compose build"
  430. if pull:
  431. cmd += " --pull"
  432. with c.cd(str(PROJECT_ROOT)):
  433. c.run(cmd, env=UID_ENV, pty=True)
  434. @task()
  435. def img_pull(c):
  436. """Pull docker images."""
  437. with c.cd(str(PROJECT_ROOT)):
  438. c.run("docker-compose pull", pty=True)
  439. @task()
  440. def lint(c, verbose=False):
  441. """Lint & format source code."""
  442. cmd = "pre-commit run --show-diff-on-failure --all-files --color=always"
  443. if verbose:
  444. cmd += " --verbose"
  445. with c.cd(str(PROJECT_ROOT)):
  446. c.run(cmd)
  447. @task()
  448. def start(c, detach=True, debugpy=False):
  449. """Start environment."""
  450. cmd = "docker-compose up"
  451. with tempfile.NamedTemporaryFile(
  452. mode="w",
  453. suffix=".yaml",
  454. ) as tmp_docker_compose_file:
  455. if debugpy:
  456. # Remove auto-reload
  457. cmd = (
  458. "docker-compose -f docker-compose.yml "
  459. f"-f {tmp_docker_compose_file.name} up"
  460. )
  461. _remove_auto_reload(
  462. tmp_docker_compose_file,
  463. orig_file=PROJECT_ROOT / "docker-compose.yml",
  464. )
  465. if detach:
  466. cmd += " --detach"
  467. with c.cd(str(PROJECT_ROOT)):
  468. result = c.run(
  469. cmd,
  470. pty=True,
  471. env=dict(
  472. UID_ENV,
  473. DOODBA_DEBUGPY_ENABLE=str(int(debugpy)),
  474. ),
  475. )
  476. if not (
  477. "Recreating" in result.stdout
  478. or "Starting" in result.stdout
  479. or "Creating" in result.stdout
  480. ):
  481. restart(c)
  482. _logger.info("Waiting for services to spin up...")
  483. time.sleep(SERVICES_WAIT_TIME)
  484. @task(
  485. help={
  486. "modules": "Comma-separated list of modules to install.",
  487. "core": "Install all core addons. Default: False",
  488. "extra": "Install all extra addons. Default: False",
  489. "private": "Install all private addons. Default: False",
  490. "enterprise": "Install all enterprise addons. Default: False",
  491. "cur-file": "Path to the current file."
  492. " Addon name will be obtained from there to install.",
  493. },
  494. )
  495. def install(
  496. c,
  497. modules=None,
  498. cur_file=None,
  499. core=False,
  500. extra=False,
  501. private=False,
  502. enterprise=False,
  503. ):
  504. """Install Odoo addons
  505. By default, installs addon from directory being worked on,
  506. unless other options are specified.
  507. """
  508. if not (modules or core or extra or private or enterprise):
  509. cur_module = _get_cwd_addon(cur_file or Path.cwd())
  510. if not cur_module:
  511. raise exceptions.ParseError(
  512. msg="Odoo addon to install not found. "
  513. "You must provide at least one option for modules"
  514. " or be in a subdirectory of one."
  515. " See --help for details."
  516. )
  517. modules = cur_module
  518. cmd = "docker-compose run --rm odoo addons init"
  519. if core:
  520. cmd += " --core"
  521. if extra:
  522. cmd += " --extra"
  523. if private:
  524. cmd += " --private"
  525. if enterprise:
  526. cmd += " --enterprise"
  527. if modules:
  528. cmd += f" -w {modules}"
  529. with c.cd(str(PROJECT_ROOT)):
  530. c.run("docker-compose stop odoo")
  531. c.run(
  532. cmd,
  533. env=UID_ENV,
  534. pty=True,
  535. )
  536. @task(
  537. help={
  538. "modules": "Comma-separated list of modules to uninstall.",
  539. },
  540. )
  541. def uninstall(
  542. c,
  543. modules=None,
  544. cur_file=None,
  545. ):
  546. """Uninstall Odoo addons
  547. By default, uninstalls addon from directory being worked on,
  548. unless other options are specified.
  549. """
  550. if not modules:
  551. cur_module = _get_cwd_addon(cur_file or Path.cwd())
  552. if not cur_module:
  553. raise exceptions.ParseError(
  554. msg="Odoo addon to uninstall not found. "
  555. "You must provide at least one option for modules"
  556. " or be in a subdirectory of one."
  557. " See --help for details."
  558. )
  559. modules = cur_module
  560. cmd = (
  561. f"docker-compose run --rm odoo click-odoo-uninstall -m {modules or cur_module}"
  562. )
  563. with c.cd(str(PROJECT_ROOT)):
  564. c.run(
  565. cmd,
  566. env=UID_ENV,
  567. pty=True,
  568. )
  569. def _get_module_dependencies(
  570. c, modules=None, core=False, extra=False, private=False, enterprise=False
  571. ):
  572. """Returns a list of the addons' dependencies
  573. By default, refers to the addon from directory being worked on,
  574. unless other options are specified.
  575. """
  576. # Get list of dependencies for addon
  577. cmd = "docker-compose run --rm odoo addons list --dependencies"
  578. if core:
  579. cmd += " --core"
  580. if extra:
  581. cmd += " --extra"
  582. if private:
  583. cmd += " --private"
  584. if enterprise:
  585. cmd += " --enterprise"
  586. if modules:
  587. cmd += f" -w {modules}"
  588. with c.cd(str(PROJECT_ROOT)):
  589. dependencies = c.run(
  590. cmd,
  591. env=UID_ENV,
  592. hide="stdout",
  593. ).stdout.splitlines()[-1]
  594. return dependencies
  595. def _test_in_debug_mode(c, odoo_command):
  596. with tempfile.NamedTemporaryFile(
  597. mode="w", suffix=".yaml"
  598. ) as tmp_docker_compose_file:
  599. cmd = (
  600. "docker-compose -f docker-compose.yml "
  601. f"-f {tmp_docker_compose_file.name} up -d"
  602. )
  603. _override_docker_command(
  604. "odoo",
  605. odoo_command,
  606. file=tmp_docker_compose_file,
  607. orig_file=Path(str(PROJECT_ROOT), "docker-compose.yml"),
  608. )
  609. with c.cd(str(PROJECT_ROOT)):
  610. c.run(
  611. cmd,
  612. env=dict(
  613. UID_ENV,
  614. DOODBA_DEBUGPY_ENABLE="1",
  615. ),
  616. pty=True,
  617. )
  618. _logger.info("Waiting for services to spin up...")
  619. time.sleep(SERVICES_WAIT_TIME)
  620. def _get_module_list(
  621. c,
  622. modules=None,
  623. core=False,
  624. extra=False,
  625. private=False,
  626. enterprise=False,
  627. only_installable=True,
  628. ):
  629. """Returns a list of addons according to the passed parameters.
  630. By default, refers to the addon from directory being worked on,
  631. unless other options are specified.
  632. """
  633. # Get list of dependencies for addon
  634. cmd = "docker-compose run --rm odoo addons list"
  635. if core:
  636. cmd += " --core"
  637. if extra:
  638. cmd += " --extra"
  639. if private:
  640. cmd += " --private"
  641. if enterprise:
  642. cmd += " --enterprise"
  643. if modules:
  644. cmd += f" -w {modules}"
  645. if only_installable:
  646. cmd += " --installable"
  647. with c.cd(str(PROJECT_ROOT)):
  648. module_list = c.run(
  649. cmd,
  650. env=UID_ENV,
  651. pty=True,
  652. hide="stdout",
  653. ).stdout.splitlines()[-1]
  654. return module_list
  655. @task(
  656. help={
  657. "modules": "Comma-separated list of modules to test.",
  658. "core": "Test all core addons. Default: False",
  659. "extra": "Test all extra addons. Default: False",
  660. "private": "Test all private addons. Default: False",
  661. "enterprise": "Test all enterprise addons. Default: False",
  662. "skip": "List of addons to skip. Default: []",
  663. "debugpy": "Whether or not to run tests in a VSCode debugging session. "
  664. "Default: False",
  665. "cur-file": "Path to the current file."
  666. " Addon name will be obtained from there to run tests",
  667. "mode": "Mode in which tests run. Options: ['init'(default), 'update']",
  668. "db_filter": "DB_FILTER regex to pass to the test container Set to ''"
  669. " to disable. Default: '^devel$'",
  670. },
  671. )
  672. def test(
  673. c,
  674. modules=None,
  675. core=False,
  676. extra=False,
  677. private=False,
  678. enterprise=False,
  679. skip="",
  680. debugpy=False,
  681. cur_file=None,
  682. mode="init",
  683. db_filter="^devel$",
  684. ):
  685. """Run Odoo tests
  686. By default, tests addon from directory being worked on,
  687. unless other options are specified.
  688. NOTE: Odoo must be restarted manually after this to go back to normal mode
  689. """
  690. if not (modules or core or extra or private or enterprise):
  691. cur_module = _get_cwd_addon(cur_file or Path.cwd())
  692. if not cur_module:
  693. raise exceptions.ParseError(
  694. msg="Odoo addon to install not found. "
  695. "You must provide at least one option for modules"
  696. " or be in a subdirectory of one."
  697. " See --help for details."
  698. )
  699. modules = cur_module
  700. else:
  701. modules = _get_module_list(c, modules, core, extra, private, enterprise)
  702. odoo_command = ["odoo", "--test-enable", "--stop-after-init", "--workers=0"]
  703. if mode == "init":
  704. odoo_command.append("-i")
  705. elif mode == "update":
  706. odoo_command.append("-u")
  707. else:
  708. raise exceptions.ParseError(
  709. msg="Available modes are 'init' or 'update'. See --help for details."
  710. )
  711. # Skip test in some modules
  712. modules_list = modules.split(",")
  713. for m_to_skip in skip.split(","):
  714. if not m_to_skip:
  715. continue
  716. if m_to_skip not in modules:
  717. _logger.warn(
  718. "%s not found in the list of addons to test: %s" % (m_to_skip, modules)
  719. )
  720. modules_list.remove(m_to_skip)
  721. modules = ",".join(modules_list)
  722. odoo_command.append(modules)
  723. if ODOO_VERSION >= 12:
  724. # Limit tests to explicit list
  725. # Filter spec format (comma-separated)
  726. # [-][tag][/module][:class][.method]
  727. odoo_command.extend(["--test-tags", "/" + ",/".join(modules_list)])
  728. if debugpy:
  729. _test_in_debug_mode(c, odoo_command)
  730. else:
  731. cmd = ["docker-compose", "run", "--rm"]
  732. if db_filter:
  733. cmd.extend(["-e", "DB_FILTER='%s'" % db_filter])
  734. cmd.append("odoo")
  735. cmd.extend(odoo_command)
  736. with c.cd(str(PROJECT_ROOT)):
  737. c.run(
  738. " ".join(cmd),
  739. env=UID_ENV,
  740. pty=True,
  741. )
  742. @task(
  743. help={"purge": "Remove all related containers, networks images and volumes"},
  744. )
  745. def stop(c, purge=False):
  746. """Stop and (optionally) purge environment."""
  747. cmd = "docker-compose down --remove-orphans"
  748. if purge:
  749. cmd += " --rmi local --volumes"
  750. with c.cd(str(PROJECT_ROOT)):
  751. c.run(cmd, pty=True)
  752. @task(
  753. help={
  754. "dbname": "The DB that will be DESTROYED and recreated. Default: 'devel'.",
  755. "modules": "Comma-separated list of modules to install. Default: 'base'.",
  756. "core": "Install all core addons. Default: False",
  757. "extra": "Install all extra addons. Default: False",
  758. "private": "Install all private addons. Default: False",
  759. "enterprise": "Install all enterprise addons. Default: False",
  760. "populate": "Run preparedb task right after (only available for v11+)."
  761. " Default: True",
  762. "dependencies": "Install only the dependencies of the specified addons."
  763. "Default: False",
  764. },
  765. )
  766. def resetdb(
  767. c,
  768. modules=None,
  769. core=False,
  770. extra=False,
  771. private=False,
  772. enterprise=False,
  773. dbname="devel",
  774. populate=True,
  775. dependencies=False,
  776. ):
  777. """Reset the specified database with the specified modules.
  778. Uses click-odoo-initdb behind the scenes, which has a caching system that
  779. makes DB resets quicker. See its docs for more info.
  780. """
  781. if dependencies:
  782. modules = _get_module_dependencies(c, modules, core, extra, private, enterprise)
  783. elif core or extra or private or enterprise:
  784. modules = _get_module_list(c, modules, core, extra, private, enterprise)
  785. else:
  786. modules = modules or "base"
  787. with c.cd(str(PROJECT_ROOT)):
  788. c.run("docker-compose stop odoo", pty=True)
  789. _run = "docker-compose run --rm -l traefik.enable=false odoo"
  790. c.run(
  791. f"{_run} click-odoo-dropdb {dbname}",
  792. env=UID_ENV,
  793. warn=True,
  794. pty=True,
  795. )
  796. c.run(
  797. f"{_run} click-odoo-initdb -n {dbname} -m {modules}",
  798. env=UID_ENV,
  799. pty=True,
  800. )
  801. if populate and ODOO_VERSION < 11:
  802. _logger.warn(
  803. "Skipping populate task as it is not available in v%s" % ODOO_VERSION
  804. )
  805. populate = False
  806. if populate:
  807. preparedb(c)
  808. @task()
  809. def preparedb(c):
  810. """Run the `preparedb` script inside the container
  811. Populates the DB with some helpful config
  812. """
  813. if ODOO_VERSION < 11:
  814. raise exceptions.PlatformError(
  815. "The preparedb script is not available for Doodba environments bellow v11."
  816. )
  817. with c.cd(str(PROJECT_ROOT)):
  818. c.run(
  819. "docker-compose run --rm -l traefik.enable=false odoo preparedb",
  820. env=UID_ENV,
  821. pty=True,
  822. )
  823. @task()
  824. def restart(c, quick=True):
  825. """Restart odoo container(s)."""
  826. cmd = "docker-compose restart"
  827. if quick:
  828. cmd = f"{cmd} -t0"
  829. cmd = f"{cmd} odoo odoo_proxy"
  830. with c.cd(str(PROJECT_ROOT)):
  831. c.run(cmd, env=UID_ENV, pty=True)
  832. @task(
  833. help={
  834. "container": "Names of the containers from which logs will be obtained."
  835. " You can specify a single one, or several comma-separated names."
  836. " Default: None (show logs for all containers)"
  837. },
  838. )
  839. def logs(c, tail=10, follow=True, container=None):
  840. """Obtain last logs of current environment."""
  841. cmd = "docker-compose logs"
  842. if follow:
  843. cmd += " -f"
  844. if tail:
  845. cmd += f" --tail {tail}"
  846. if container:
  847. cmd += f" {container.replace(',', ' ')}"
  848. with c.cd(str(PROJECT_ROOT)):
  849. c.run(cmd, pty=True)
  850. @task
  851. def after_update(c):
  852. """Execute some actions after a copier update or init"""
  853. # Make custom build scripts executable
  854. if ODOO_VERSION < 11:
  855. files = (
  856. Path(PROJECT_ROOT, "odoo", "custom", "build.d", "20-update-pg-repos"),
  857. Path(PROJECT_ROOT, "odoo", "custom", "build.d", "10-fix-certs"),
  858. )
  859. for script_file in files:
  860. # Ignore if, for some reason, the file didn't end up in the generated
  861. # project despite of the correct version (e.g. Copier exclusions)
  862. if not script_file.exists():
  863. continue
  864. cur_stat = script_file.stat()
  865. # Like chmod ug+x
  866. script_file.chmod(cur_stat.st_mode | stat.S_IXUSR | stat.S_IXGRP)
  867. else:
  868. # Remove version-specific build scripts if the copier update didn't
  869. # HACK: https://github.com/copier-org/copier/issues/461
  870. files = (
  871. Path(PROJECT_ROOT, "odoo", "custom", "build.d", "20-update-pg-repos"),
  872. Path(PROJECT_ROOT, "odoo", "custom", "build.d", "10-fix-certs"),
  873. )
  874. for script_file in files:
  875. # missing_ok argument would take care of this, but it was only added for
  876. # Python 3.8
  877. if script_file.exists():
  878. script_file.unlink()
  879. @task(
  880. help={
  881. "source_db": "The source DB name. Default: 'devel'.",
  882. "destination_db": "The destination DB name. Default: '[SOURCE_DB_NAME]-[CURRENT_DATE]'",
  883. },
  884. )
  885. def snapshot(
  886. c,
  887. source_db="devel",
  888. destination_db=None,
  889. ):
  890. """Snapshot current database and filestore.
  891. Uses click-odoo-copydb behind the scenes to make a snapshot.
  892. """
  893. if not destination_db:
  894. destination_db = "%s-%s" % (
  895. source_db,
  896. datetime.now().strftime("%Y_%m_%d-%H_%M"),
  897. )
  898. with c.cd(str(PROJECT_ROOT)):
  899. cur_state = c.run("docker-compose stop odoo db", pty=True).stdout
  900. _logger.info("Snapshoting current %s DB to %s" % (source_db, destination_db))
  901. _run = "docker-compose run --rm -l traefik.enable=false odoo"
  902. c.run(
  903. f"{_run} click-odoo-copydb {source_db} {destination_db}",
  904. env=UID_ENV,
  905. pty=True,
  906. )
  907. if "Stopping" in cur_state:
  908. # Restart services if they were previously active
  909. c.run("docker-compose start odoo db", pty=True)
  910. @task(
  911. help={
  912. "snapshot_name": "The snapshot name. If not provided,"
  913. "the script will try to find the last snapshot"
  914. " that starts with the destination_db name",
  915. "destination_db": "The destination DB name. Default: 'devel'",
  916. },
  917. )
  918. def restore_snapshot(
  919. c,
  920. snapshot_name=None,
  921. destination_db="devel",
  922. ):
  923. """Restore database and filestore snapshot.
  924. Uses click-odoo-copydb behind the scenes to restore a DB snapshot.
  925. """
  926. with c.cd(str(PROJECT_ROOT)):
  927. cur_state = c.run("docker-compose stop odoo db", pty=True).stdout
  928. if not snapshot_name:
  929. # List DBs
  930. res = c.run(
  931. "docker-compose run --rm -e LOG_LEVEL=WARNING odoo psql -tc"
  932. " 'SELECT datname FROM pg_database;'",
  933. env=UID_ENV,
  934. hide="stdout",
  935. )
  936. db_list = []
  937. for db in res.stdout.splitlines():
  938. # Parse and filter DB List
  939. if not db.lstrip().startswith(destination_db):
  940. continue
  941. db_name = db.lstrip()
  942. try:
  943. db_date = datetime.strptime(
  944. db_name.lstrip(destination_db + "-"), "%Y_%m_%d-%H_%M"
  945. )
  946. db_list.append((db_name, db_date))
  947. except ValueError:
  948. continue
  949. snapshot_name = max(db_list, key=lambda x: x[1])[0]
  950. if not snapshot_name:
  951. raise exceptions.PlatformError(
  952. "No snapshot found for destination_db %s" % destination_db
  953. )
  954. _logger.info("Restoring snapshot %s to %s" % (snapshot_name, destination_db))
  955. _run = "docker-compose run --rm -l traefik.enable=false odoo"
  956. c.run(
  957. f"{_run} click-odoo-dropdb {destination_db}",
  958. env=UID_ENV,
  959. warn=True,
  960. pty=True,
  961. )
  962. c.run(
  963. f"{_run} click-odoo-copydb {snapshot_name} {destination_db}",
  964. env=UID_ENV,
  965. pty=True,
  966. )
  967. if "Stopping" in cur_state:
  968. # Restart services if they were previously active
  969. c.run("docker-compose start odoo db", pty=True)