trobz-deploy 0.9.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.
File without changes
trobz_deploy/cli.py ADDED
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from trobz_deploy.command.configure import configure
6
+ from trobz_deploy.command.status import status
7
+ from trobz_deploy.command.update import update
8
+
9
+
10
+ @click.group()
11
+ @click.option(
12
+ "--config",
13
+ default="deploy.yml",
14
+ show_default=True,
15
+ metavar="FILE",
16
+ help="Path to the configuration file.",
17
+ )
18
+ @click.option(
19
+ "--verbose",
20
+ is_flag=True,
21
+ default=False,
22
+ help="Print each remote command and its output as it runs.",
23
+ )
24
+ @click.pass_context
25
+ def cli(ctx: click.Context, config: str, verbose: bool) -> None:
26
+ """Deploy and manage applications on remote servers over SSH."""
27
+ ctx.ensure_object(dict)
28
+ ctx.obj["config"] = config
29
+ ctx.obj["verbose"] = verbose
30
+
31
+
32
+ cli.add_command(configure)
33
+ cli.add_command(update)
34
+ cli.add_command(status)
File without changes
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import click
6
+
7
+ from trobz_deploy.utils.config import load_config, resolve_options
8
+ from trobz_deploy.utils.executor import Executor, ExecutorError
9
+ from trobz_deploy.utils.render import render_unit
10
+ from trobz_deploy.utils.venv import setup_odoo_venv, setup_package_venv, setup_python_venv
11
+
12
+
13
+ def _is_git_repo(executor: Executor, path: str) -> bool:
14
+ try:
15
+ executor.run(f"test -d {path}/.git")
16
+ except ExecutorError:
17
+ return False
18
+ else:
19
+ return True
20
+
21
+
22
+ @click.command()
23
+ @click.argument("instance_name")
24
+ @click.argument("ssh_host", required=False)
25
+ @click.argument("repo_url", required=False)
26
+ @click.option(
27
+ "--type",
28
+ "deploy_type",
29
+ type=click.Choice(["odoo", "python", "service"]),
30
+ default=None,
31
+ help="Deployment type (auto-detected from instance name prefix if omitted).",
32
+ )
33
+ @click.option(
34
+ "-p",
35
+ "--port",
36
+ "ssh_port",
37
+ type=int,
38
+ default=None,
39
+ help="SSH port on the remote host.",
40
+ )
41
+ @click.option(
42
+ "--force",
43
+ is_flag=True,
44
+ default=False,
45
+ help="Re-run setup steps even if the instance directory already exists.",
46
+ )
47
+ @click.option(
48
+ "--repo-subdir",
49
+ "repo_subdir",
50
+ default=None,
51
+ help="Subdirectory within the repo to use as the service root (for monorepos).",
52
+ )
53
+ @click.option(
54
+ "--repo-branch",
55
+ "repo_branch",
56
+ default=None,
57
+ help="Git branch to clone and track (defaults to the repository's default branch).",
58
+ )
59
+ @click.pass_context
60
+ def configure( # noqa: C901
61
+ ctx: click.Context,
62
+ instance_name: str,
63
+ ssh_host: str | None,
64
+ repo_url: str | None,
65
+ deploy_type: str | None,
66
+ ssh_port: int | None,
67
+ force: bool,
68
+ repo_subdir: str | None,
69
+ repo_branch: str | None,
70
+ ) -> None:
71
+ """Configure a new deployment instance."""
72
+ cfg = load_config(ctx.obj["config"], instance_name)
73
+ try:
74
+ opts = resolve_options(
75
+ cfg,
76
+ instance_name,
77
+ ssh_host=ssh_host,
78
+ ssh_port=ssh_port,
79
+ repo_url=repo_url,
80
+ repo_branch=repo_branch,
81
+ deploy_type=deploy_type,
82
+ repo_subdir=repo_subdir,
83
+ )
84
+ except ValueError as exc:
85
+ raise click.ClickException(click.style(str(exc), fg="red")) from exc
86
+
87
+ eff_ssh_host: str | None = opts.get("ssh_host")
88
+ eff_ssh_port: int | None = opts.get("ssh_port")
89
+ eff_repo_url: str | None = opts.get("repo_url")
90
+ eff_repo_branch: str | None = opts.get("repo_branch")
91
+ eff_type: str = opts["type"]
92
+ _req = opts.get("requirements")
93
+ eff_requirements: list[str] = ([_req] if isinstance(_req, str) else _req) if _req else []
94
+
95
+ if eff_type == "python" and eff_requirements and eff_repo_url:
96
+ msg = click.style(
97
+ "requirements and repo_url are mutually exclusive for python type.",
98
+ fg="red",
99
+ )
100
+ raise click.ClickException(msg)
101
+
102
+ executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port)
103
+ home_dir = executor.capture("echo $HOME")
104
+ instance_path = f"{home_dir}/{instance_name}"
105
+ eff_repo_subdir: str | None = opts.get("repo_subdir")
106
+ service_path = f"{instance_path}/{eff_repo_subdir}" if eff_repo_subdir else instance_path
107
+
108
+ # Step 2: Set up instance directory
109
+ if eff_type == "python" and eff_requirements:
110
+ # Package mode: create directory directly, no git clone
111
+ try:
112
+ executor.run(f"test -d {instance_path}")
113
+ if not force:
114
+ msg = click.style(
115
+ f"Instance directory already exists: ~/{instance_name}\nUse --force to re-run setup.",
116
+ fg="yellow",
117
+ )
118
+ raise click.ClickException(msg)
119
+ click.secho("\nDirectory exists, skipping mkdir (--force).", fg="yellow")
120
+ except ExecutorError:
121
+ click.secho(f"\nCreating instance directory ~/{instance_name}…", fg="green")
122
+ executor.run(f"mkdir -p {instance_path}")
123
+ else:
124
+ if not eff_repo_url:
125
+ msg = click.style(
126
+ "repo_url is required. Provide it as an argument or set it in deploy.yml.",
127
+ fg="red",
128
+ )
129
+ raise click.ClickException(msg)
130
+
131
+ # Repo mode: clone
132
+ if _is_git_repo(executor, instance_path):
133
+ if not force:
134
+ msg = click.style(
135
+ f"Instance directory already exists: ~/{instance_name}\n"
136
+ "Use --force to skip cloning and re-run setup.",
137
+ fg="yellow",
138
+ )
139
+ raise click.ClickException(msg)
140
+ click.secho("\nDirectory exists, skipping clone (--force).", fg="yellow")
141
+ else:
142
+ click.secho(f"\nCloning {eff_repo_url} into ~/{instance_name}…", fg="green")
143
+ try:
144
+ clone_cmd = f"git clone {eff_repo_url} $HOME/{instance_name}"
145
+ if eff_repo_branch:
146
+ clone_cmd += f" --branch {eff_repo_branch}"
147
+ executor.run(clone_cmd)
148
+ except ExecutorError as exc:
149
+ msg = click.style(f"Git clone failed: {exc}", fg="red")
150
+ raise click.ClickException(msg) from exc
151
+
152
+ if eff_type == "odoo":
153
+ executor.run(
154
+ "if [ -f addons/repos.yaml ]; then cd addons/ && gitaggregate -c repos.yaml; fi",
155
+ cwd=instance_path,
156
+ )
157
+
158
+ # Step 3: Set up environment
159
+ click.secho(f"\nSetting up {eff_type} environment…", fg="green")
160
+ try:
161
+ if eff_type == "odoo":
162
+ setup_odoo_venv(executor, instance_path)
163
+ elif eff_type == "python":
164
+ if eff_requirements:
165
+ setup_package_venv(executor, instance_path, eff_requirements, force=force)
166
+ else:
167
+ setup_python_venv(executor, service_path, force=force)
168
+ executor.run(
169
+ "if [ -f .env.example ] && [ ! -f .env ]; then cp .env.example .env; fi",
170
+ cwd=service_path,
171
+ )
172
+ else: # service
173
+ build_cmd: str | None = opts.get("build")
174
+ if not build_cmd:
175
+ msg = click.style(
176
+ "build command is required for service type. Set it in deploy.yml.",
177
+ fg="red",
178
+ )
179
+ raise click.ClickException(msg)
180
+ executor.run(build_cmd, cwd=service_path)
181
+ except ExecutorError as exc:
182
+ raise click.ClickException(click.style(str(exc), fg="red")) from exc
183
+
184
+ # Step 4: Install systemd unit
185
+ click.secho("\nInstalling systemd unit…", fg="green")
186
+ unit_instance_path = instance_path if eff_requirements else service_path
187
+ venv_path = f"{unit_instance_path}/.venv"
188
+
189
+ template_vars: dict[str, Any] = {
190
+ "instance_name": instance_name,
191
+ "instance_path": unit_instance_path,
192
+ }
193
+ if eff_type == "odoo":
194
+ template_vars["venv_path"] = venv_path
195
+ odoo_addons_path = executor.capture("which odoo-addons-path")
196
+ template_vars["odoo_addons_path"] = odoo_addons_path
197
+ else:
198
+ exec_start: str = opts.get("exec_start", "")
199
+ if not exec_start and not eff_requirements:
200
+ res = executor.capture(
201
+ "if [ -f server.py ]; then echo server.py; fi",
202
+ cwd=service_path,
203
+ )
204
+ if res == "server.py":
205
+ exec_start = "python server.py"
206
+ if not exec_start:
207
+ msg = click.style(
208
+ "exec_start is required for service or python type. Set it in deploy.yml.",
209
+ fg="red",
210
+ )
211
+ raise click.ClickException(msg)
212
+ if eff_type == "python":
213
+ template_vars["venv_path"] = venv_path
214
+ template_vars["exec_start"] = exec_start
215
+
216
+ try:
217
+ unit_content = render_unit(eff_type, **template_vars)
218
+ except Exception as exc:
219
+ msg = click.style(f"Template rendering failed: {exc}", fg="red")
220
+ raise click.ClickException(msg) from exc
221
+
222
+ unit_dir = "$HOME/.config/systemd/user"
223
+ unit_path = f"{unit_dir}/{instance_name}.service"
224
+ try:
225
+ executor.run(f"mkdir -p {unit_dir}")
226
+ executor.write_file(unit_content, unit_path)
227
+ executor.run("loginctl enable-linger")
228
+ executor.run("systemctl --user daemon-reload")
229
+ executor.run(f"systemctl --user enable --now {instance_name}")
230
+ except ExecutorError as exc:
231
+ raise click.ClickException(click.style(str(exc), fg="red")) from exc
232
+
233
+ click.secho(f"\nInstance {instance_name!r} configured successfully.", fg="green")
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from trobz_deploy.utils.config import load_config
6
+ from trobz_deploy.utils.executor import Executor, ExecutorError
7
+
8
+
9
+ @click.command()
10
+ @click.argument("instance_name")
11
+ @click.argument("ssh_host", required=False)
12
+ @click.option(
13
+ "-p",
14
+ "--port",
15
+ "ssh_port",
16
+ type=int,
17
+ default=None,
18
+ help="SSH port on the remote host.",
19
+ )
20
+ @click.pass_context
21
+ def status(
22
+ ctx: click.Context,
23
+ instance_name: str,
24
+ ssh_host: str | None,
25
+ ssh_port: int | None,
26
+ ) -> None:
27
+ """Show status of a deployment instance."""
28
+ cfg = load_config(ctx.obj["config"], instance_name)
29
+
30
+ # Resolve ssh_host/ssh_port: CLI arg > config value
31
+ eff_ssh_host: str | None = ssh_host if ssh_host is not None else cfg.get("ssh_host")
32
+ eff_ssh_port: int | None = ssh_port if ssh_port is not None else cfg.get("ssh_port")
33
+
34
+ executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port)
35
+ instance_path = f"$HOME/{instance_name}"
36
+
37
+ # Step 2: Verify instance directory exists
38
+ try:
39
+ executor.run(f"test -d {instance_path}")
40
+ except ExecutorError:
41
+ msg = f"Instance directory not found: ~/{instance_name}"
42
+ raise click.ClickException(msg) from None
43
+
44
+ # Step 3: Git info
45
+ try:
46
+ remote_url = executor.capture("git remote get-url origin", cwd=instance_path)
47
+ branch = executor.capture("git rev-parse --abbrev-ref HEAD", cwd=instance_path)
48
+ commit = executor.capture("git rev-parse --short HEAD", cwd=instance_path)
49
+ except ExecutorError as exc:
50
+ msg = f"Failed to get git info: {exc}"
51
+ raise click.ClickException(msg) from exc
52
+
53
+ # Step 4: systemd unit status
54
+ unit_line = "unknown"
55
+ try:
56
+ raw = executor.capture(
57
+ f"systemctl --user show {instance_name} --property=ActiveState,SubState,ActiveEnterTimestamp"
58
+ )
59
+ props: dict[str, str] = {}
60
+ for line in raw.splitlines():
61
+ if "=" in line:
62
+ key, _, val = line.partition("=")
63
+ props[key.strip()] = val.strip()
64
+
65
+ active = props.get("ActiveState", "unknown")
66
+ sub = props.get("SubState", "unknown")
67
+ ts = props.get("ActiveEnterTimestamp", "")
68
+ # Timestamp format: "Mon 2026-03-09 08:12:03 UTC"
69
+ since = " ".join(ts.split()[1:3]) if ts else ""
70
+ unit_line = f"{active} ({sub})"
71
+ if since:
72
+ unit_line += f" since {since}"
73
+ except ExecutorError:
74
+ pass
75
+
76
+ click.echo(f"Instance: {instance_name}")
77
+ click.echo(f"Remote: {remote_url}")
78
+ click.echo(f"Branch: {branch} ({commit})")
79
+ click.echo(f"Unit: {unit_line}")
@@ -0,0 +1,221 @@
1
+ from __future__ import annotations
2
+
3
+ import click
4
+
5
+ from trobz_deploy.utils.addons import get_addons_path
6
+ from trobz_deploy.utils.config import load_config, resolve_options
7
+ from trobz_deploy.utils.executor import Executor, ExecutorError
8
+ from trobz_deploy.utils.venv import setup_python_deps, upgrade_package
9
+
10
+
11
+ @click.command()
12
+ @click.argument("instance_name")
13
+ @click.argument("ssh_host", required=False)
14
+ @click.option(
15
+ "--type",
16
+ "deploy_type",
17
+ type=click.Choice(["odoo", "python", "service"]),
18
+ default=None,
19
+ help="Deployment type (auto-detected from instance name prefix if omitted).",
20
+ )
21
+ @click.option(
22
+ "--db",
23
+ default=None,
24
+ help="Override the target database name (Odoo only).",
25
+ )
26
+ @click.option(
27
+ "-p",
28
+ "--port",
29
+ "ssh_port",
30
+ type=int,
31
+ default=None,
32
+ help="SSH port on the remote host.",
33
+ )
34
+ @click.option(
35
+ "--ignore-hooks",
36
+ "ignore_hooks",
37
+ is_flag=True,
38
+ default=False,
39
+ help="Skip all hook execution.",
40
+ )
41
+ @click.option(
42
+ "--repo-subdir",
43
+ "repo_subdir",
44
+ default=None,
45
+ help="Subdirectory within the repo to use as the service root (for monorepos).",
46
+ )
47
+ @click.option(
48
+ "--repo-branch",
49
+ "repo_branch",
50
+ default=None,
51
+ help="Git branch to pull (defaults to the currently checked-out branch).",
52
+ )
53
+ @click.option(
54
+ "--watch",
55
+ "watch",
56
+ is_flag=True,
57
+ default=False,
58
+ help="Stream service logs with journalctl after a successful update.",
59
+ )
60
+ @click.pass_context
61
+ def update( # noqa: C901
62
+ ctx: click.Context,
63
+ instance_name: str,
64
+ ssh_host: str | None,
65
+ deploy_type: str | None,
66
+ db: str | None,
67
+ ssh_port: int | None,
68
+ ignore_hooks: bool,
69
+ repo_subdir: str | None,
70
+ repo_branch: str | None,
71
+ watch: bool,
72
+ ) -> None:
73
+ """Update an existing deployment instance."""
74
+ cfg = load_config(ctx.obj["config"], instance_name)
75
+ try:
76
+ opts = resolve_options(
77
+ cfg,
78
+ instance_name,
79
+ ssh_host=ssh_host,
80
+ ssh_port=ssh_port,
81
+ deploy_type=deploy_type,
82
+ db=db,
83
+ repo_subdir=repo_subdir,
84
+ repo_branch=repo_branch,
85
+ )
86
+ except ValueError as exc:
87
+ raise click.ClickException(click.style(str(exc), fg="red")) from exc
88
+
89
+ eff_ssh_host: str | None = opts.get("ssh_host")
90
+ eff_ssh_port: int | None = opts.get("ssh_port")
91
+ eff_type: str = opts["type"]
92
+ eff_db: str = opts.get("db", instance_name)
93
+ eff_repo_branch: str | None = opts.get("repo_branch")
94
+ _req = opts.get("requirements")
95
+ eff_requirements: list[str] = ([_req] if isinstance(_req, str) else _req) if _req else []
96
+ hooks: dict = opts.get("hooks", {})
97
+
98
+ executor = Executor(eff_ssh_host, ctx.obj["verbose"], ssh_port=eff_ssh_port)
99
+ home_dir = executor.capture("echo $HOME")
100
+ instance_path = f"{home_dir}/{instance_name}"
101
+ eff_repo_subdir: str | None = opts.get("repo_subdir")
102
+ service_path = f"{instance_path}/{eff_repo_subdir}" if eff_repo_subdir else instance_path
103
+
104
+ def run_hooks(hook_name: str) -> bool:
105
+ """Execute all commands for *hook_name*. Returns True if all succeeded."""
106
+ if ignore_hooks:
107
+ return True
108
+ for cmd in hooks.get(hook_name, []):
109
+ try:
110
+ executor.run(cmd, cwd=instance_path)
111
+ except ExecutorError as exc:
112
+ click.secho(
113
+ f"Hook {hook_name!r} failed: {exc}",
114
+ fg="red",
115
+ err=True,
116
+ )
117
+ return False
118
+ return True
119
+
120
+ # Step 2: pre-update hooks (non-blocking)
121
+ click.secho("\nRunning pre-update hooks…", fg="green")
122
+ run_hooks("pre-update")
123
+
124
+ # Step 3: pre-update-required hooks (blocking on failure)
125
+ click.secho("\nRunning pre-update-required hooks…", fg="green")
126
+ if not run_hooks("pre-update-required"):
127
+ run_hooks("pre-update-fail")
128
+ msg = click.style(
129
+ "pre-update-required hook failed. Update aborted.",
130
+ fg="red",
131
+ )
132
+ raise click.ClickException(msg)
133
+
134
+ # Step 4: pre-update-success
135
+ run_hooks("pre-update-success")
136
+
137
+ # Step 5+6: Pull/upgrade code and update dependencies
138
+ if eff_type == "python" and eff_requirements:
139
+ # Package mode: upgrade pip package directly, no git pull
140
+ click.secho("\nUpgrading package…", fg="green")
141
+ try:
142
+ upgrade_package(executor, instance_path, eff_requirements)
143
+ except ExecutorError as exc:
144
+ run_hooks("post-update")
145
+ run_hooks("post-update-fail")
146
+ msg = click.style(f"Package upgrade failed: {exc}", fg="red")
147
+ raise click.ClickException(msg) from exc
148
+ else:
149
+ # Repo mode: git pull then update deps
150
+ try:
151
+ executor.run(f"test -d {instance_path}/.git")
152
+ except ExecutorError:
153
+ msg = click.style(
154
+ f"Instance directory not found or not a git repo: ~/{instance_name}",
155
+ fg="red",
156
+ )
157
+ raise click.ClickException(msg) from None
158
+
159
+ click.secho("\nPulling latest code…", fg="green")
160
+ try:
161
+ if eff_repo_branch:
162
+ executor.run(
163
+ f"git fetch origin && git checkout {eff_repo_branch} && git pull",
164
+ cwd=instance_path,
165
+ )
166
+ else:
167
+ executor.run("git pull", cwd=instance_path)
168
+ except ExecutorError as exc:
169
+ run_hooks("post-update")
170
+ run_hooks("post-update-fail")
171
+ msg = click.style(f"git pull failed: {exc}", fg="red")
172
+ raise click.ClickException(msg) from exc
173
+
174
+ click.secho("\nUpdating dependencies…", fg="green")
175
+ try:
176
+ if eff_type == "odoo":
177
+ executor.run(
178
+ "if [ -f addons/repos.yaml ]; then cd addons/ && gitaggregate -c repos.yaml; fi",
179
+ cwd=instance_path,
180
+ )
181
+ executor.run("odoo-venv update .venv --backup --yes", cwd=instance_path)
182
+ elif eff_type == "python":
183
+ setup_python_deps(executor, service_path)
184
+ else: # service
185
+ build_cmd: str | None = opts.get("build")
186
+ if build_cmd:
187
+ executor.run(build_cmd, cwd=service_path)
188
+ except ExecutorError as exc:
189
+ run_hooks("post-update")
190
+ run_hooks("post-update-fail")
191
+ msg = click.style(f"Dependency update failed: {exc}", fg="red")
192
+ raise click.ClickException(msg) from exc
193
+
194
+ # Step 7: Apply changes
195
+ click.secho("\nApplying changes…", fg="green")
196
+ try:
197
+ if eff_type == "odoo":
198
+ addons_path = get_addons_path(executor, instance_path)
199
+ executor.run(
200
+ f".venv/bin/click-odoo-update -d {eff_db} --addons-path={addons_path}",
201
+ cwd=instance_path,
202
+ )
203
+ executor.run(f"systemctl --user restart {instance_name}")
204
+ except ExecutorError as exc:
205
+ run_hooks("post-update")
206
+ run_hooks("post-update-fail")
207
+ msg = click.style(f"Restart/upgrade failed: {exc}", fg="red")
208
+ raise click.ClickException(msg) from exc
209
+
210
+ # Step 8: post-update hooks
211
+ run_hooks("post-update")
212
+ run_hooks("post-update-success")
213
+
214
+ click.secho(f"\nInstance {instance_name!r} updated successfully.", fg="green")
215
+
216
+ if watch:
217
+ click.secho("\nWatching service logs (Ctrl+C to stop)…", fg="cyan")
218
+ try:
219
+ executor.stream(f"journalctl --user -u {instance_name} -f")
220
+ except KeyboardInterrupt:
221
+ click.echo()
File without changes
@@ -0,0 +1,15 @@
1
+ [Unit]
2
+ Description=Odoo instance {{ instance_name }}
3
+ After=network.target postgresql.service
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory={{ instance_path }}
8
+ ExecStart=bash -c "{{ venv_path }}/bin/python {{ venv_path }}/bin/odoo \
9
+ --config {{ instance_path }}/config/odoo.conf \
10
+ --addons-path $({{ odoo_addons_path }} {{ instance_path }})"
11
+ Restart=on-failure
12
+ RestartSec=5s
13
+
14
+ [Install]
15
+ WantedBy=default.target
@@ -0,0 +1,14 @@
1
+ [Unit]
2
+ Description=Python service {{ instance_name }}
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory={{ instance_path }}
8
+ EnvironmentFile=-{{ instance_path }}/.env
9
+ ExecStart={{ venv_path }}/bin/{{ exec_start }}
10
+ Restart=on-failure
11
+ RestartSec=5s
12
+
13
+ [Install]
14
+ WantedBy=default.target
@@ -0,0 +1,13 @@
1
+ [Unit]
2
+ Description={{ instance_name }}
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=simple
7
+ WorkingDirectory={{ instance_path }}
8
+ ExecStart={{ exec_start }}
9
+ Restart=on-failure
10
+ RestartSec=5s
11
+
12
+ [Install]
13
+ WantedBy=default.target
File without changes
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from trobz_deploy.utils.executor import Executor
4
+
5
+
6
+ def get_addons_path(executor: Executor, instance_path: str) -> str:
7
+ """Run ``odoo-addons-path`` in *instance_path* and return the result."""
8
+ return executor.capture("odoo-addons-path", cwd=instance_path)