odoo-runbot 1.0.0b2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- odoo_runbot/__about__.py +3 -0
- odoo_runbot/__init__.py +12 -0
- odoo_runbot/_odoo_internal.py +136 -0
- odoo_runbot/cli.py +234 -0
- odoo_runbot/runbot_env.py +365 -0
- odoo_runbot/runbot_init.py +145 -0
- odoo_runbot/runbot_init_branch_resolver.py +169 -0
- odoo_runbot/runbot_run.py +153 -0
- odoo_runbot/runbot_run_logging.py +159 -0
- odoo_runbot/runbot_run_test.py +112 -0
- odoo_runbot-1.0.0b2.dist-info/METADATA +64 -0
- odoo_runbot-1.0.0b2.dist-info/RECORD +14 -0
- odoo_runbot-1.0.0b2.dist-info/WHEEL +4 -0
- odoo_runbot-1.0.0b2.dist-info/entry_points.txt +2 -0
odoo_runbot/__about__.py
ADDED
odoo_runbot/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""This lib is a CLI using typer.
|
|
2
|
+
|
|
3
|
+
I tried to separate the code by feature part.
|
|
4
|
+
|
|
5
|
+
- [odoo_runbot.cli][] Contains the entrypoint, All the rich display is done here. (Other than log)
|
|
6
|
+
- [odoo_runbot.runbot_env][] Contains il a python way the env in wich the runbot is running.
|
|
7
|
+
- [odoo_runbot.runbot_init][] Contains all the code to execute to initialize the Odoo to run test
|
|
8
|
+
- [odoo_runbot.runbot_init_branch_resolver][] Contains the working branch resolving (Pull other add-ons from git)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__import__("os").environ["TZ"] = "UTC"
|
|
12
|
+
__import__("os").environ["WITHOUT_DEMO"] = "False"
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import logging
|
|
5
|
+
import typing
|
|
6
|
+
|
|
7
|
+
if typing.TYPE_CHECKING:
|
|
8
|
+
import pathlib
|
|
9
|
+
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
from xmlrunner import XMLTestRunner
|
|
13
|
+
from xmlrunner.result import _XMLTestResult
|
|
14
|
+
|
|
15
|
+
_logger = logging.getLogger("odoo_runbot.odoo_internal")
|
|
16
|
+
try:
|
|
17
|
+
import odoo
|
|
18
|
+
|
|
19
|
+
except ModuleNotFoundError:
|
|
20
|
+
from unittest import mock
|
|
21
|
+
|
|
22
|
+
odoo = mock.MagicMock()
|
|
23
|
+
|
|
24
|
+
__all__ = ["odoo", "patch_odoo_test_suite", "run_odoo_and_stop", "setup_odoo"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _RunProto(typing.Protocol):
|
|
28
|
+
def run(self, result): ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _UpdateProto(typing.Protocol):
|
|
32
|
+
def update(self, other): ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextlib.contextmanager
|
|
36
|
+
def patch_odoo_test_suite(output_dir: pathlib.Path) -> typing.Generator[None]:
|
|
37
|
+
"""Patch odoo test suite and Odoo test result.
|
|
38
|
+
|
|
39
|
+
- In Odoo 12 and Odoo 13, The test result is the native unittest.TestResult
|
|
40
|
+
- In Odoo 14 and Odoo 15. The test result extend unittest.TestResult and add `update` function. So we call it
|
|
41
|
+
- In 16.0 and more the OdooTestResult don't extend unittest.TestResult.
|
|
42
|
+
I need to patch the `update` function to add result of the `_XmlTestResult`
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
output_dir: Te patch where to store the xml result
|
|
46
|
+
|
|
47
|
+
Returns: Nothing, but unpatched function at the end
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
odoo_suite_class, odoo_result_class, apply_update = _try_find_stuff_to_patch()
|
|
51
|
+
if not odoo_suite_class:
|
|
52
|
+
msg = f"Odoo version {odoo.release.version} not supported"
|
|
53
|
+
raise ValueError(msg)
|
|
54
|
+
output_dir.mkdir(exist_ok=True, parents=True)
|
|
55
|
+
|
|
56
|
+
_logger.info("Create test result in %s", output_dir.absolute())
|
|
57
|
+
_unpatched_run = odoo_suite_class.run
|
|
58
|
+
patch_odoo_suite_run = f"{odoo_suite_class.__module__}.{odoo_suite_class.__name__}.run"
|
|
59
|
+
_logger.info("Patch %s", patch_odoo_suite_run)
|
|
60
|
+
patch_odoo_suite_update = None
|
|
61
|
+
if odoo_result_class:
|
|
62
|
+
_unpatched_update = odoo_result_class.update
|
|
63
|
+
patch_odoo_suite_update = f"{odoo_result_class.__module__}.{odoo_result_class.__name__}.update"
|
|
64
|
+
_logger.info("Patch %s", patch_odoo_suite_update)
|
|
65
|
+
|
|
66
|
+
def patched_suite_run(self, result):
|
|
67
|
+
# Suite run method will be called by the XMLTestRunner,
|
|
68
|
+
# so we need to run the original run method
|
|
69
|
+
with patch.object(self, "run", lambda result: _unpatched_run(self, result)):
|
|
70
|
+
# Override : XMLTestRunner to run the tests and generate XML reports
|
|
71
|
+
xml_results = XMLTestRunner(output=str(output_dir.absolute()), verbosity=2).run(self)
|
|
72
|
+
if apply_update:
|
|
73
|
+
result.update(xml_results)
|
|
74
|
+
return result
|
|
75
|
+
return xml_results
|
|
76
|
+
|
|
77
|
+
def patched_suite_update(self, other):
|
|
78
|
+
# Adapt _XMLTestResult to OdooTestResult
|
|
79
|
+
if isinstance(other, _XMLTestResult):
|
|
80
|
+
self.failures_count += len(other.failures)
|
|
81
|
+
self.errors_count += len(other.errors)
|
|
82
|
+
self.skipped += len(other.skipped)
|
|
83
|
+
self.testsRun += other.testsRun
|
|
84
|
+
else:
|
|
85
|
+
_unpatched_update(self, other)
|
|
86
|
+
|
|
87
|
+
with patch(patch_odoo_suite_run, patched_suite_run):
|
|
88
|
+
if odoo_result_class:
|
|
89
|
+
with patch(patch_odoo_suite_update, patched_suite_update):
|
|
90
|
+
yield
|
|
91
|
+
else:
|
|
92
|
+
yield
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _try_find_stuff_to_patch() -> tuple[type[_RunProto] | None, type[_UpdateProto] | None, bool]:
|
|
96
|
+
apply_update = True
|
|
97
|
+
if "12.0" <= odoo.release.version <= "13.0":
|
|
98
|
+
from odoo.tests.common import OdooSuite as odoo_suite_class # noqa: N813
|
|
99
|
+
|
|
100
|
+
odoo_result_class = None
|
|
101
|
+
apply_update = False
|
|
102
|
+
elif "14.0" <= odoo.release.version <= "15.0":
|
|
103
|
+
from odoo.tests.common import OdooSuite as odoo_suite_class # noqa: N813
|
|
104
|
+
|
|
105
|
+
odoo_result_class = None
|
|
106
|
+
elif "16.0" <= odoo.release.version <= "18.0":
|
|
107
|
+
from odoo.tests.result import OdooTestResult as odoo_result_class # noqa: N813
|
|
108
|
+
from odoo.tests.suite import OdooSuite as odoo_suite_class # noqa: N813
|
|
109
|
+
else:
|
|
110
|
+
return None, None, False
|
|
111
|
+
|
|
112
|
+
return odoo_suite_class, odoo_result_class, apply_update
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run_odoo_and_stop(preload: list[str] | None = None) -> int:
|
|
116
|
+
preload = preload or odoo.tools.config["db_name"].split(",")
|
|
117
|
+
return odoo.service.server.start(preload=preload, stop=True)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def setup_odoo() -> None:
|
|
121
|
+
odoo.cli.server.check_root_user()
|
|
122
|
+
odoo.cli.server.check_postgres_user()
|
|
123
|
+
odoo.cli.server.report_configuration()
|
|
124
|
+
odoo.netsvc.init_logger()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_config(server_args: list[str]) -> None:
|
|
128
|
+
__import__("os").environ["TZ"] = "UTC"
|
|
129
|
+
_logger.info("Start Odoo with args=%s", server_args)
|
|
130
|
+
# Hard set value
|
|
131
|
+
# In test mode we don't care about loading language
|
|
132
|
+
# But il you do multiple step, you load multiple time the registry.
|
|
133
|
+
# And in odoo.modules.loadings.load_module they `pop` this key, idkw
|
|
134
|
+
# So in test mode, we force this option key to None
|
|
135
|
+
odoo.tools.config.options["load_language"] = None
|
|
136
|
+
odoo.tools.config.parse_config(server_args)
|
odoo_runbot/cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console, ConsoleOptions, Group, RenderResult
|
|
9
|
+
from rich.panel import Panel
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from typer import Typer
|
|
12
|
+
from typing_extensions import Annotated
|
|
13
|
+
|
|
14
|
+
from . import runbot_init
|
|
15
|
+
from .runbot_env import RunbotEnvironment, RunbotStepConfig, RunbotToolConfig, StepAction
|
|
16
|
+
from .runbot_run import StepRunner
|
|
17
|
+
|
|
18
|
+
app = Typer()
|
|
19
|
+
|
|
20
|
+
env: RunbotEnvironment = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def rich_force_colors() -> bool:
|
|
24
|
+
"""In Gitlab CI runner there is no tty, and no color.
|
|
25
|
+
|
|
26
|
+
This function force the color even if there is no TTY
|
|
27
|
+
See Also:
|
|
28
|
+
https://github.com/nf-core/tools/pull/760/files
|
|
29
|
+
https://github.com/Textualize/rich/issues/343
|
|
30
|
+
Returns:
|
|
31
|
+
True if in CI/CD Job or if color is forced
|
|
32
|
+
"""
|
|
33
|
+
return bool(
|
|
34
|
+
os.getenv("CI")
|
|
35
|
+
or os.getenv("GITHUB_ACTIONS")
|
|
36
|
+
or os.getenv("FORCE_COLOR")
|
|
37
|
+
or os.getenv("PY_COLORS")
|
|
38
|
+
or os.getenv("COLORTERM") == "truecolor"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
console = Console(width=150, force_terminal=rich_force_colors())
|
|
43
|
+
|
|
44
|
+
_logger = logging.getLogger("odoo_runbot")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def _callback(workdir: Path = None, *, verbose: bool = False) -> None:
|
|
49
|
+
global env # noqa: PLW0603
|
|
50
|
+
env = RunbotEnvironment(dict(os.environ), workdir=workdir, verbose=verbose)
|
|
51
|
+
env.setup_logging_for_runbot(console)
|
|
52
|
+
env.print_info()
|
|
53
|
+
if not env.check_ok():
|
|
54
|
+
raise TypeError(300)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("init")
|
|
58
|
+
def init_runbot() -> None:
|
|
59
|
+
"""Init the current project to run test
|
|
60
|
+
- Find external addons depedency, install them if needed (git clone + pip install)
|
|
61
|
+
- Init database, and wait postgresql is ready
|
|
62
|
+
- Create basic config file for Odoo using $ODOO_RC
|
|
63
|
+
"""
|
|
64
|
+
current_project_key = f"ADDONS_LOCAL_{env.abs_curr_dir.name.upper()}"
|
|
65
|
+
project_config = RunbotToolConfig.from_env(env)
|
|
66
|
+
_logger.info(
|
|
67
|
+
"Add current project %s=%s ? %s",
|
|
68
|
+
current_project_key,
|
|
69
|
+
str(env.abs_curr_dir),
|
|
70
|
+
project_config.include_current_project,
|
|
71
|
+
)
|
|
72
|
+
if project_config.include_current_project:
|
|
73
|
+
env.environ[current_project_key] = str(env.abs_curr_dir)
|
|
74
|
+
info, addons = runbot_init.show_addons(env)
|
|
75
|
+
console.print(info)
|
|
76
|
+
runbot_init.install_addons(addons)
|
|
77
|
+
console.print(runbot_init.init_database(env))
|
|
78
|
+
console.print(runbot_init.init_config(env))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.command("run", help="""Run the steps after initializing the project""")
|
|
82
|
+
def run_runbot(
|
|
83
|
+
step_name: Annotated[str, typer.Option(envvar="RUNBOT_STEP")] = "all",
|
|
84
|
+
only_action: Annotated[StepAction, typer.Option()] = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Run the steps after initializing the project
|
|
87
|
+
|
|
88
|
+
You can filter the step you want to run with the `step_name` argument.
|
|
89
|
+
|
|
90
|
+
warning:
|
|
91
|
+
If no step is run, then mangono-runbot will exit with code 100.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
step_name: The step to run, if None, are "all" then no filter is applied
|
|
95
|
+
only_action: Choose wich action to only run
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
if not Path(env.ODOO_RC).exists():
|
|
99
|
+
_logger.error("[red] Please run `mangono-runbot init config` to create your odoo config file")
|
|
100
|
+
raise typer.Abort
|
|
101
|
+
|
|
102
|
+
project_config = RunbotToolConfig.from_env(env)
|
|
103
|
+
steps_to_run = []
|
|
104
|
+
for step in project_config.steps:
|
|
105
|
+
if only_action is not None and step.action != only_action:
|
|
106
|
+
_logger.debug("Filter step %s, only want action=%s", step.name, only_action.name)
|
|
107
|
+
continue
|
|
108
|
+
if step_name and step_name not in (None, "all", step.name):
|
|
109
|
+
_logger.info("Skip warmup %s", step.name)
|
|
110
|
+
continue
|
|
111
|
+
steps_to_run.append(step)
|
|
112
|
+
|
|
113
|
+
step_runner = StepRunner(env)
|
|
114
|
+
step_runner.setup_warning_filter(project_config.warning_filters)
|
|
115
|
+
step_runner.setup_odoo()
|
|
116
|
+
for step in steps_to_run:
|
|
117
|
+
console.print(RichStep(step))
|
|
118
|
+
rc = step_runner.execute(step)
|
|
119
|
+
if rc:
|
|
120
|
+
console.print(f"[red] Step {step.name} {bool_to_emoji(False)}") # noqa: FBT003
|
|
121
|
+
raise typer.Exit(rc)
|
|
122
|
+
console.print(f"[green] Step {step.name} {bool_to_emoji(True)}") # noqa: FBT003
|
|
123
|
+
|
|
124
|
+
if not step_runner.has_run:
|
|
125
|
+
raise typer.Exit(100)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def bool_to_emoji(v: bool) -> str: # noqa: FBT001
|
|
129
|
+
return ":heavy_check_mark:" if v else ":x:"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
ASCII_ART_MANGONO = """
|
|
133
|
+
[medium_spring_green] % % [/]
|
|
134
|
+
[medium_spring_green] %%% %%% %%%%%%% [/]
|
|
135
|
+
[medium_spring_green] %%%% %%% %%% %%%[/]
|
|
136
|
+
[medium_spring_green]%%%% %%% %% %%%[/]
|
|
137
|
+
[medium_spring_green] %%%% %%% %%% %%%[/]
|
|
138
|
+
[medium_spring_green] %%% %%% %%%%%%% [/]
|
|
139
|
+
[medium_spring_green] % % [/]
|
|
140
|
+
|
|
141
|
+
@@@ @@@@ @@@@ @@@ @@ @@@@@@ @@@@@@ @@@ @@ @@@@@@ [medium_spring_green]%%%[/]
|
|
142
|
+
@@@@ @@@@ @@@@ @@@@ @@ @@ @@@ @@@ @@@@ @@@@ @@ @@@ @@@ [medium_spring_green] %%%[/]
|
|
143
|
+
@@@@@ @@@@@ @@@ @@ @@ @@ @@ @@@ @@@@@@@@@ @@@ @@ @@ @@ @@@ @@@ [medium_spring_green] %%%%[/]
|
|
144
|
+
@@@ @@@ @@@ @@@@@@@@ @@ @@@@ @@ @@ @@@ @@@@ @@ @@@@ @@@ @@@ [medium_spring_green] %%%[/]
|
|
145
|
+
@@@ @@@ @@@ @@ @@@ @@ @@@ @@@@@@ @@@@@@ @@ @@@ @@@@@@ [medium_spring_green]%%%[/]
|
|
146
|
+
|
|
147
|
+
@ @ @@ @ @@ @ @ @@@ @ @@ @@ @@@ @ @ [medium_spring_green]%%%%%%%%%[/]
|
|
148
|
+
[medium_spring_green]%%%%%%%%%[/]
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@app.command("diag")
|
|
153
|
+
def diag_print() -> None:
|
|
154
|
+
console.print(ASCII_ART_MANGONO)
|
|
155
|
+
console.print("Main Author: Alexis Pasquier")
|
|
156
|
+
t_warn = Table(
|
|
157
|
+
"Name",
|
|
158
|
+
"Action",
|
|
159
|
+
"Message Filter",
|
|
160
|
+
"Wanted Category",
|
|
161
|
+
title="py.warnings Filters",
|
|
162
|
+
)
|
|
163
|
+
project_config = RunbotToolConfig.from_env(env)
|
|
164
|
+
if project_config.warning_filters:
|
|
165
|
+
for log_filter in project_config.warning_filters:
|
|
166
|
+
t_warn.add_row(
|
|
167
|
+
log_filter.name,
|
|
168
|
+
log_filter.action,
|
|
169
|
+
f"r'{log_filter.message}'",
|
|
170
|
+
log_filter.category,
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
t_warn.add_row("[DEFAULT] No py.warnings allowed", "always", ".*", "Warnings", "*")
|
|
174
|
+
console.print(t_warn)
|
|
175
|
+
table = Table(
|
|
176
|
+
"Step",
|
|
177
|
+
"Module",
|
|
178
|
+
"Run tests",
|
|
179
|
+
"Activate Coverage",
|
|
180
|
+
"Tags to test",
|
|
181
|
+
"Logger filter",
|
|
182
|
+
title="Steps",
|
|
183
|
+
)
|
|
184
|
+
for step in project_config.steps:
|
|
185
|
+
table.add_row(
|
|
186
|
+
step.name,
|
|
187
|
+
(step.modules and ",".join(step.modules)) or str(step.modules),
|
|
188
|
+
step.action.name,
|
|
189
|
+
bool_to_emoji(step.action == StepAction.TESTS and step.coverage),
|
|
190
|
+
",".join(step.test_tags),
|
|
191
|
+
",".join([f.name for f in step.log_filters]),
|
|
192
|
+
)
|
|
193
|
+
console.print(table)
|
|
194
|
+
|
|
195
|
+
for step in project_config.steps:
|
|
196
|
+
console.print(RichStep(step))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class RichStep:
|
|
200
|
+
def __init__(self, step: RunbotStepConfig) -> None:
|
|
201
|
+
self.step = step
|
|
202
|
+
|
|
203
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
204
|
+
t_log = Table("Name", "Regex", "Match", "logger", title="Log Filters", width=146)
|
|
205
|
+
if self.step.log_filters:
|
|
206
|
+
for log_filter in self.step.log_filters:
|
|
207
|
+
match_txt = f"Between {log_filter.min_match} and {log_filter.max_match}"
|
|
208
|
+
if log_filter.min_match == log_filter.max_match:
|
|
209
|
+
match_txt = f"Exactly {log_filter.min_match}"
|
|
210
|
+
t_log.add_row(
|
|
211
|
+
log_filter.name,
|
|
212
|
+
f"r'{log_filter.regex}'",
|
|
213
|
+
match_txt,
|
|
214
|
+
log_filter.logger + " (and all child logger)",
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
t_log.add_row("[DEFAULT] No log allowed", ".*", "Exactly 0", "odoo (and all child logger)")
|
|
218
|
+
|
|
219
|
+
yield Panel(
|
|
220
|
+
Group(
|
|
221
|
+
f"Install : {self.step.modules}",
|
|
222
|
+
f"Action : {self.step.action.name}",
|
|
223
|
+
f"Activate Coverage: {bool_to_emoji(self.step.action == StepAction.TESTS and self.step.coverage)}",
|
|
224
|
+
f"Allow warnings: {bool_to_emoji(self.step.allow_warnings)}",
|
|
225
|
+
f"Test Tags: {self.step.test_tags}",
|
|
226
|
+
t_log,
|
|
227
|
+
),
|
|
228
|
+
style="green" if self.step.action == StepAction.TESTS else "dodger_blue2",
|
|
229
|
+
title=self.step.name,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
app()
|