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.

618 lines
20 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 tempfile
  8. import time
  9. from itertools import chain
  10. from logging import getLogger
  11. from pathlib import Path
  12. from shutil import which
  13. from invoke import exceptions, task
  14. from invoke.util import yaml
  15. PROJECT_ROOT = Path(__file__).parent.absolute()
  16. SRC_PATH = PROJECT_ROOT / "odoo" / "custom" / "src"
  17. UID_ENV = {"GID": str(os.getgid()), "UID": str(os.getuid()), "UMASK": "27"}
  18. SERVICES_WAIT_TIME = int(os.environ.get("SERVICES_WAIT_TIME", 4))
  19. ODOO_VERSION = float(
  20. yaml.safe_load((PROJECT_ROOT / "common.yaml").read_text())["services"]["odoo"][
  21. "build"
  22. ]["args"]["ODOO_VERSION"]
  23. )
  24. _logger = getLogger(__name__)
  25. def _override_docker_command(service, command, file, orig_file=None):
  26. # Read config from main file
  27. if orig_file:
  28. with open(orig_file, "r") as fd:
  29. orig_docker_config = yaml.safe_load(fd.read())
  30. docker_compose_file_version = orig_docker_config.get("version")
  31. else:
  32. docker_compose_file_version = "2.4"
  33. docker_config = {
  34. "version": docker_compose_file_version,
  35. "services": {service: {"command": command}},
  36. }
  37. docker_config_yaml = yaml.dump(docker_config)
  38. file.write(docker_config_yaml)
  39. file.flush()
  40. def _remove_auto_reload(file, orig_file):
  41. with open(orig_file, "r") as fd:
  42. orig_docker_config = yaml.safe_load(fd.read())
  43. odoo_command = orig_docker_config["services"]["odoo"]["command"]
  44. new_odoo_command = []
  45. for flag in odoo_command:
  46. if flag.startswith("--dev"):
  47. flag = flag.replace("reload,", "")
  48. new_odoo_command.append(flag)
  49. _override_docker_command("odoo", new_odoo_command, file, orig_file=orig_file)
  50. def _get_cwd_addon(file):
  51. cwd = Path(file)
  52. manifest_file = False
  53. while PROJECT_ROOT < cwd:
  54. manifest_file = (cwd / "__manifest__.py").exists() or (
  55. cwd / "__openerp__.py"
  56. ).exists()
  57. if manifest_file:
  58. return cwd.stem
  59. cwd = cwd.parent
  60. if cwd == PROJECT_ROOT:
  61. return None
  62. @task
  63. def write_code_workspace_file(c, cw_path=None):
  64. """Generate code-workspace file definition.
  65. Some other tasks will call this one when needed, and since you cannot specify
  66. the file name there, if you want a specific one, you should call this task
  67. before.
  68. Most times you just can forget about this task and let it be run automatically
  69. whenever needed.
  70. If you don't define a workspace name, this task will reuse the 1st
  71. `doodba.*.code-workspace` file found inside the current directory.
  72. If none is found, it will default to `doodba.$(basename $PWD).code-workspace`.
  73. If you define it manually, remember to use the same prefix and suffix if you
  74. want it git-ignored by default.
  75. Example: `--cw-path doodba.my-custom-name.code-workspace`
  76. """
  77. root_name = f"doodba.{PROJECT_ROOT.name}"
  78. root_var = "${workspaceFolder:%s}" % root_name
  79. if not cw_path:
  80. try:
  81. cw_path = next(PROJECT_ROOT.glob("doodba.*.code-workspace"))
  82. except StopIteration:
  83. cw_path = f"{root_name}.code-workspace"
  84. if not Path(cw_path).is_absolute():
  85. cw_path = PROJECT_ROOT / cw_path
  86. cw_config = {}
  87. try:
  88. with open(cw_path) as cw_fd:
  89. cw_config = json.load(cw_fd)
  90. except (FileNotFoundError, json.decoder.JSONDecodeError):
  91. pass # Nevermind, we start with a new config
  92. # Static settings
  93. cw_config.setdefault("settings", {})
  94. cw_config["settings"].update({"search.followSymlinks": False})
  95. # Launch configurations
  96. debugpy_configuration = {
  97. "name": "Attach Python debugger to running container",
  98. "type": "python",
  99. "request": "attach",
  100. "pathMappings": [],
  101. "port": int(ODOO_VERSION) * 1000 + 899,
  102. # HACK https://github.com/microsoft/vscode-python/issues/14820
  103. "host": "0.0.0.0",
  104. }
  105. firefox_configuration = {
  106. "type": "firefox",
  107. "request": "launch",
  108. "reAttach": True,
  109. "name": "Connect to firefox debugger",
  110. "url": f"http://localhost:{ODOO_VERSION:.0f}069/?debug=assets",
  111. "reloadOnChange": {
  112. "watch": f"{root_var}/odoo/custom/src/**/*.{'{js,css,scss,less}'}"
  113. },
  114. "skipFiles": ["**/lib/**"],
  115. "pathMappings": [],
  116. }
  117. chrome_executable = which("chrome") or which("chromium")
  118. chrome_configuration = {
  119. "type": "chrome",
  120. "request": "launch",
  121. "name": "Connect to chrome debugger",
  122. "url": f"http://localhost:{ODOO_VERSION:.0f}069/?debug=assets",
  123. "skipFiles": ["**/lib/**"],
  124. "trace": True,
  125. "pathMapping": {},
  126. }
  127. if chrome_executable:
  128. chrome_configuration["runtimeExecutable"] = chrome_executable
  129. cw_config["launch"] = {
  130. "compounds": [
  131. {
  132. "name": "Start Odoo and debug Python",
  133. "configurations": ["Attach Python debugger to running container"],
  134. "preLaunchTask": "Start Odoo in debug mode",
  135. },
  136. {
  137. "name": "Test and debug current module",
  138. "configurations": ["Attach Python debugger to running container"],
  139. "preLaunchTask": "Run Odoo Tests in debug mode for current module",
  140. "internalConsoleOptions": "openOnSessionStart",
  141. },
  142. ],
  143. "configurations": [
  144. debugpy_configuration,
  145. firefox_configuration,
  146. chrome_configuration,
  147. ],
  148. }
  149. # Configure folders and debuggers
  150. debugpy_configuration["pathMappings"].append(
  151. {
  152. "localRoot": "${workspaceFolder:odoo}/",
  153. "remoteRoot": "/opt/odoo/custom/src/odoo",
  154. }
  155. )
  156. cw_config["folders"] = []
  157. for subrepo in SRC_PATH.glob("*"):
  158. if not subrepo.is_dir():
  159. continue
  160. if (subrepo / ".git").exists() and subrepo.name != "odoo":
  161. cw_config["folders"].append(
  162. {"path": str(subrepo.relative_to(PROJECT_ROOT))}
  163. )
  164. for addon in chain(subrepo.glob("*"), subrepo.glob("addons/*")):
  165. if (addon / "__manifest__.py").is_file() or (
  166. addon / "__openerp__.py"
  167. ).is_file():
  168. if subrepo.name == "odoo":
  169. local_path = "${workspaceFolder:%s}/addons/%s/" % (
  170. subrepo.name,
  171. addon.name,
  172. )
  173. else:
  174. local_path = "${workspaceFolder:%s}/%s" % (subrepo.name, addon.name)
  175. debugpy_configuration["pathMappings"].append(
  176. {
  177. "localRoot": local_path,
  178. "remoteRoot": f"/opt/odoo/auto/addons/{addon.name}/",
  179. }
  180. )
  181. url = f"http://localhost:{ODOO_VERSION:.0f}069/{addon.name}/static/"
  182. path = "${workspaceFolder:%s}/%s/static/" % (
  183. subrepo.name,
  184. addon.relative_to(subrepo),
  185. )
  186. firefox_configuration["pathMappings"].append({"url": url, "path": path})
  187. chrome_configuration["pathMapping"][url] = path
  188. cw_config["tasks"] = {
  189. "version": "2.0.0",
  190. "tasks": [
  191. {
  192. "label": "Start Odoo",
  193. "type": "process",
  194. "command": "invoke",
  195. "args": ["start", "--detach"],
  196. "presentation": {
  197. "echo": True,
  198. "reveal": "silent",
  199. "focus": False,
  200. "panel": "shared",
  201. "showReuseMessage": True,
  202. "clear": False,
  203. },
  204. "problemMatcher": [],
  205. "options": {"statusbar": {"label": "$(play-circle) Start Odoo"}},
  206. },
  207. {
  208. "label": "Run Odoo Tests for current module",
  209. "type": "process",
  210. "command": "invoke",
  211. "args": ["test", "--cur-file", "${file}"],
  212. "presentation": {
  213. "echo": True,
  214. "reveal": "always",
  215. "focus": True,
  216. "panel": "shared",
  217. "showReuseMessage": True,
  218. "clear": False,
  219. },
  220. "problemMatcher": [],
  221. "options": {"statusbar": {"label": "$(beaker) Test module"}},
  222. },
  223. {
  224. "label": "Run Odoo Tests in debug mode for current module",
  225. "type": "process",
  226. "command": "invoke",
  227. "args": [
  228. "test",
  229. "--cur-file",
  230. "${file}",
  231. "--debugpy",
  232. ],
  233. "presentation": {
  234. "echo": True,
  235. "reveal": "silent",
  236. "focus": False,
  237. "panel": "shared",
  238. "showReuseMessage": True,
  239. "clear": False,
  240. },
  241. "problemMatcher": [],
  242. "options": {"statusbar": {"hide": True}},
  243. },
  244. {
  245. "label": "Start Odoo in debug mode",
  246. "type": "process",
  247. "command": "invoke",
  248. "args": ["start", "--detach", "--debugpy"],
  249. "presentation": {
  250. "echo": True,
  251. "reveal": "silent",
  252. "focus": False,
  253. "panel": "shared",
  254. "showReuseMessage": True,
  255. "clear": False,
  256. },
  257. "problemMatcher": [],
  258. "options": {"statusbar": {"hide": True}},
  259. },
  260. {
  261. "label": "Stop Odoo",
  262. "type": "process",
  263. "command": "invoke",
  264. "args": ["stop"],
  265. "presentation": {
  266. "echo": True,
  267. "reveal": "silent",
  268. "focus": False,
  269. "panel": "shared",
  270. "showReuseMessage": True,
  271. "clear": False,
  272. },
  273. "problemMatcher": [],
  274. "options": {"statusbar": {"label": "$(stop-circle) Stop Odoo"}},
  275. },
  276. {
  277. "label": "Restart Odoo",
  278. "type": "process",
  279. "command": "invoke",
  280. "args": ["restart"],
  281. "presentation": {
  282. "echo": True,
  283. "reveal": "silent",
  284. "focus": False,
  285. "panel": "shared",
  286. "showReuseMessage": True,
  287. "clear": False,
  288. },
  289. "problemMatcher": [],
  290. "options": {"statusbar": {"label": "$(history) Restart Odoo"}},
  291. },
  292. ],
  293. }
  294. # Sort project folders
  295. cw_config["folders"].sort(key=lambda x: x["path"])
  296. # Put Odoo folder just before private and top folder and map to debugpy
  297. odoo = SRC_PATH / "odoo"
  298. if odoo.is_dir():
  299. cw_config["folders"].append({"path": str(odoo.relative_to(PROJECT_ROOT))})
  300. # HACK https://github.com/microsoft/vscode/issues/95963 put private second to last
  301. private = SRC_PATH / "private"
  302. if private.is_dir():
  303. cw_config["folders"].append({"path": str(private.relative_to(PROJECT_ROOT))})
  304. # HACK https://github.com/microsoft/vscode/issues/37947 put top folder last
  305. cw_config["folders"].append({"path": ".", "name": root_name})
  306. with open(cw_path, "w") as cw_fd:
  307. json.dump(cw_config, cw_fd, indent=2)
  308. cw_fd.write("\n")
  309. @task
  310. def develop(c):
  311. """Set up a basic development environment."""
  312. # Prepare environment
  313. Path(PROJECT_ROOT, "odoo", "auto", "addons").mkdir(parents=True, exist_ok=True)
  314. with c.cd(str(PROJECT_ROOT)):
  315. c.run("git init")
  316. c.run("ln -sf devel.yaml docker-compose.yml")
  317. write_code_workspace_file(c)
  318. c.run("pre-commit install")
  319. @task(develop)
  320. def git_aggregate(c):
  321. """Download odoo & addons git code.
  322. Executes git-aggregator from within the doodba container.
  323. """
  324. with c.cd(str(PROJECT_ROOT)):
  325. c.run(
  326. "docker-compose --file setup-devel.yaml run --rm odoo",
  327. env=UID_ENV,
  328. )
  329. write_code_workspace_file(c)
  330. for git_folder in SRC_PATH.glob("*/.git/.."):
  331. action = (
  332. "install"
  333. if (git_folder / ".pre-commit-config.yaml").is_file()
  334. else "uninstall"
  335. )
  336. with c.cd(str(git_folder)):
  337. c.run(f"pre-commit {action}")
  338. @task(develop)
  339. def img_build(c, pull=True):
  340. """Build docker images."""
  341. cmd = "docker-compose build"
  342. if pull:
  343. cmd += " --pull"
  344. with c.cd(str(PROJECT_ROOT)):
  345. c.run(cmd, env=UID_ENV)
  346. @task(develop)
  347. def img_pull(c):
  348. """Pull docker images."""
  349. with c.cd(str(PROJECT_ROOT)):
  350. c.run("docker-compose pull")
  351. @task(develop)
  352. def lint(c, verbose=False):
  353. """Lint & format source code."""
  354. cmd = "pre-commit run --show-diff-on-failure --all-files --color=always"
  355. if verbose:
  356. cmd += " --verbose"
  357. with c.cd(str(PROJECT_ROOT)):
  358. c.run(cmd)
  359. @task(develop)
  360. def start(c, detach=True, debugpy=False):
  361. """Start environment."""
  362. cmd = "docker-compose up"
  363. with tempfile.NamedTemporaryFile(
  364. mode="w",
  365. suffix=".yaml",
  366. ) as tmp_docker_compose_file:
  367. if debugpy:
  368. # Remove auto-reload
  369. cmd = (
  370. "docker-compose -f docker-compose.yml "
  371. f"-f {tmp_docker_compose_file.name} up"
  372. )
  373. _remove_auto_reload(
  374. tmp_docker_compose_file,
  375. orig_file=PROJECT_ROOT / "docker-compose.yml",
  376. )
  377. if detach:
  378. cmd += " --detach"
  379. with c.cd(str(PROJECT_ROOT)):
  380. result = c.run(
  381. cmd,
  382. pty=True,
  383. env=dict(
  384. UID_ENV,
  385. DOODBA_DEBUGPY_ENABLE=str(int(debugpy)),
  386. ),
  387. )
  388. if not ("Recreating" in result.stdout or "Starting" in result.stdout):
  389. restart(c)
  390. _logger.info("Waiting for services to spin up...")
  391. time.sleep(SERVICES_WAIT_TIME)
  392. @task(
  393. develop,
  394. help={
  395. "modules": "Comma-separated list of modules to install.",
  396. "core": "Install all core addons. Default: False",
  397. "extra": "Install all extra addons. Default: False",
  398. "private": "Install all private addons. Default: False",
  399. },
  400. )
  401. def install(c, modules=None, core=False, extra=False, private=False):
  402. """Install Odoo addons
  403. By default, installs addon from directory being worked on,
  404. unless other options are specified.
  405. """
  406. if not (modules or core or extra or private):
  407. cur_module = _get_cwd_addon(Path.cwd())
  408. if not cur_module:
  409. raise exceptions.ParseError(
  410. msg="Odoo addon to install not found. "
  411. "You must provide at least one option for modules"
  412. " or be in a subdirectory of one."
  413. " See --help for details."
  414. )
  415. modules = cur_module
  416. cmd = "docker-compose run --rm odoo addons init"
  417. if core:
  418. cmd += " --core"
  419. if extra:
  420. cmd += " --extra"
  421. if private:
  422. cmd += " --private"
  423. if modules:
  424. cmd += f" -w {modules}"
  425. with c.cd(str(PROJECT_ROOT)):
  426. c.run(
  427. cmd,
  428. env=UID_ENV,
  429. pty=True,
  430. )
  431. def _test_in_debug_mode(c, odoo_command):
  432. with tempfile.NamedTemporaryFile(
  433. mode="w", suffix=".yaml"
  434. ) as tmp_docker_compose_file:
  435. cmd = (
  436. "docker-compose -f docker-compose.yml "
  437. f"-f {tmp_docker_compose_file.name} up -d"
  438. )
  439. _override_docker_command(
  440. "odoo",
  441. odoo_command,
  442. file=tmp_docker_compose_file,
  443. orig_file=Path(str(PROJECT_ROOT), "docker-compose.yml"),
  444. )
  445. with c.cd(str(PROJECT_ROOT)):
  446. c.run(
  447. cmd,
  448. env=dict(
  449. UID_ENV,
  450. DOODBA_DEBUGPY_ENABLE="1",
  451. ),
  452. )
  453. _logger.info("Waiting for services to spin up...")
  454. time.sleep(SERVICES_WAIT_TIME)
  455. @task(
  456. develop,
  457. help={
  458. "modules": "Comma-separated list of modules to test.",
  459. "debugpy": "Whether or not to run tests in a VSCode debugging session. "
  460. "Default: False",
  461. "cur-file": "Path to the current file."
  462. " Addon name will be obtained from there to run tests",
  463. "mode": "Mode in which tests run. Options: ['init'(default), 'update']",
  464. },
  465. )
  466. def test(c, modules=None, debugpy=False, cur_file=None, mode="init"):
  467. """Run Odoo tests
  468. By default, tests addon from directory being worked on,
  469. unless other options are specified.
  470. NOTE: Odoo must be restarted manually after this to go back to normal mode
  471. """
  472. if not modules:
  473. cur_module = _get_cwd_addon(cur_file or Path.cwd())
  474. if not cur_module:
  475. raise exceptions.ParseError(
  476. msg="Odoo addon to test not found. "
  477. "You must provide at least one option for modules/file "
  478. "or be in a subdirectory of one. "
  479. "See --help for details."
  480. )
  481. else:
  482. modules = cur_module
  483. odoo_command = ["odoo", "--test-enable", "--stop-after-init", "--workers=0"]
  484. if mode == "init":
  485. odoo_command.append("-i")
  486. elif mode == "update":
  487. odoo_command.append("-u")
  488. else:
  489. raise exceptions.ParseError(
  490. msg="Available modes are 'init' or 'update'. See --help for details."
  491. )
  492. odoo_command.append(modules)
  493. if debugpy:
  494. _test_in_debug_mode(c, odoo_command)
  495. else:
  496. cmd = ["docker-compose", "run", "--rm", "odoo"]
  497. cmd.extend(odoo_command)
  498. with c.cd(str(PROJECT_ROOT)):
  499. c.run(
  500. " ".join(cmd),
  501. env=UID_ENV,
  502. pty=True,
  503. )
  504. @task(
  505. develop,
  506. help={"purge": "Remove all related containers, networks images and volumes"},
  507. )
  508. def stop(c, purge=False):
  509. """Stop and (optionally) purge environment."""
  510. cmd = "docker-compose"
  511. if purge:
  512. cmd += " down --remove-orphans --rmi local --volumes"
  513. else:
  514. cmd += " stop"
  515. with c.cd(str(PROJECT_ROOT)):
  516. c.run(cmd)
  517. @task(
  518. develop,
  519. help={
  520. "dbname": "The DB that will be DESTROYED and recreated. Default: 'devel'.",
  521. "modules": "Comma-separated list of modules to install. Default: 'base'.",
  522. },
  523. )
  524. def resetdb(c, modules="base", dbname="devel"):
  525. """Reset the specified database with the specified modules.
  526. Uses click-odoo-initdb behind the scenes, which has a caching system that
  527. makes DB resets quicker. See its docs for more info.
  528. """
  529. with c.cd(str(PROJECT_ROOT)):
  530. c.run("docker-compose stop odoo", pty=True)
  531. _run = "docker-compose run --rm -l traefik.enable=false odoo"
  532. c.run(
  533. f"{_run} click-odoo-dropdb {dbname}",
  534. env=UID_ENV,
  535. warn=True,
  536. pty=True,
  537. )
  538. c.run(
  539. f"{_run} click-odoo-initdb -n {dbname} -m {modules}",
  540. env=UID_ENV,
  541. pty=True,
  542. )
  543. @task(develop)
  544. def restart(c, quick=True):
  545. """Restart odoo container(s)."""
  546. cmd = "docker-compose restart"
  547. if quick:
  548. cmd = f"{cmd} -t0"
  549. cmd = f"{cmd} odoo odoo_proxy"
  550. with c.cd(str(PROJECT_ROOT)):
  551. c.run(cmd, env=UID_ENV)
  552. @task(
  553. develop,
  554. help={
  555. "container": "Names of the containers from which logs will be obtained."
  556. " You can specify a single one, or several comma-separated names."
  557. " Default: None (show logs for all containers)"
  558. },
  559. )
  560. def logs(c, tail=10, follow=True, container=None):
  561. """Obtain last logs of current environment."""
  562. cmd = "docker-compose logs"
  563. if follow:
  564. cmd += " -f"
  565. if tail:
  566. cmd += f" --tail {tail}"
  567. if container:
  568. cmd += f" {container.replace(',', ' ')}"
  569. with c.cd(str(PROJECT_ROOT)):
  570. c.run(cmd)