odoo-addons-path 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,4 @@
1
+ from .cli import app
2
+ from .main import get_addons_path
3
+
4
+ __all__ = ["app", "get_addons_path"]
@@ -0,0 +1,87 @@
1
+ import glob
2
+ from pathlib import Path
3
+ from typing import Annotated
4
+
5
+ import typer
6
+
7
+ from .main import get_addons_path
8
+
9
+
10
+ def _parse_paths(values: list[str] | None) -> list[Path]:
11
+ if not values:
12
+ return []
13
+ paths: list[Path] = []
14
+ for value in values:
15
+ for p_str in value.split(","):
16
+ p_str = p_str.strip()
17
+ if not p_str:
18
+ continue
19
+ p = Path(p_str).expanduser()
20
+ if "*" in str(p) or "?" in str(p) or "[" in str(p):
21
+ paths.extend(Path(g) for g in sorted(glob.glob(str(p), recursive=True)))
22
+ else:
23
+ paths.append(p)
24
+ return paths
25
+
26
+
27
+ app = typer.Typer()
28
+
29
+
30
+ @app.command()
31
+ def main(
32
+ codebase: Annotated[
33
+ Path,
34
+ typer.Argument(
35
+ envvar="CODEBASE",
36
+ help="Path to the Odoo project. Can also be set via the CODEBASE environment variable.",
37
+ exists=True,
38
+ file_okay=False,
39
+ dir_okay=True,
40
+ resolve_path=True,
41
+ ),
42
+ ] = Path("./"),
43
+ addons_dir: Annotated[
44
+ list[str] | None,
45
+ typer.Option(
46
+ help=(
47
+ "Paths that are addon directories (contain Odoo modules) or "
48
+ "paths that contain addon directories (repositories with multiple Odoo modules). "
49
+ "Globs and comma-separated values are supported."
50
+ ),
51
+ ),
52
+ ] = None,
53
+ odoo_dir: Annotated[
54
+ str | None,
55
+ typer.Option(
56
+ help="Path containing the Odoo source code.",
57
+ ),
58
+ ] = None,
59
+ verbose: Annotated[
60
+ bool,
61
+ typer.Option(
62
+ "--verbose",
63
+ "-v",
64
+ ),
65
+ ] = False,
66
+ ):
67
+ """
68
+ Return addons_path constructor
69
+ """
70
+ odoo_dir_path = None
71
+ if odoo_dir:
72
+ odoo_dir_path = Path(odoo_dir).expanduser()
73
+ if not odoo_dir_path.exists():
74
+ typer.secho(f"Odoo dir {odoo_dir} not found.", fg=typer.colors.RED)
75
+ raise typer.Exit(1)
76
+
77
+ paths = _parse_paths(addons_dir)
78
+
79
+ addons_path = get_addons_path(
80
+ codebase=codebase,
81
+ addons_dir=paths,
82
+ odoo_dir=odoo_dir_path,
83
+ verbose=verbose,
84
+ )
85
+
86
+ if not verbose:
87
+ typer.echo(addons_path)
@@ -0,0 +1,246 @@
1
+ from abc import ABC, abstractmethod
2
+ from pathlib import Path
3
+ from typing import Any, Optional
4
+
5
+ import yaml
6
+
7
+
8
+ class CodeBaseDetector(ABC):
9
+ _next_detector: Optional["CodeBaseDetector"] = None
10
+
11
+ def set_next(self, detector: "CodeBaseDetector") -> "CodeBaseDetector":
12
+ self._next_detector = detector
13
+ return detector
14
+
15
+ @abstractmethod
16
+ def detect(self, codebase: Path) -> tuple[str, dict[str, Any]] | None:
17
+ if self._next_detector:
18
+ return self._next_detector.detect(codebase)
19
+ return None
20
+
21
+
22
+ class TrobzDetector(CodeBaseDetector):
23
+ def detect(self, codebase: Path) -> tuple[str, dict[str, Any]] | None:
24
+ if (codebase / ".trobz").is_dir():
25
+ addons_dirs = []
26
+ for item in (codebase / "addons").iterdir():
27
+ if item.is_dir():
28
+ addons_dirs.append(item)
29
+ return (
30
+ "Trobz",
31
+ {
32
+ "addons_dirs": addons_dirs,
33
+ "addons_dir": [codebase / "project"],
34
+ "odoo_dir": [
35
+ codebase / "odoo/addons",
36
+ codebase / "odoo/odoo/addons",
37
+ ],
38
+ },
39
+ )
40
+ return super().detect(codebase)
41
+
42
+
43
+ class DoodbaDetector(CodeBaseDetector):
44
+ """
45
+ ┌─ root/
46
+ │ └── odoo/
47
+ │ └── custom/
48
+ │ └── src/
49
+ │ ├── odoo/ # Odoo core addons
50
+ │ │ └── addons/
51
+ │ ├── private/
52
+ │ │ └── addon4/
53
+ │ └── submodule/
54
+ │ └── addon1/
55
+ """
56
+
57
+ def detect(self, codebase: Path) -> tuple[str, dict[str, Any]] | None:
58
+ copier_answers_file = codebase / ".copier-answers.yml"
59
+ if not copier_answers_file.is_file():
60
+ return super().detect(codebase)
61
+ with open(copier_answers_file) as f:
62
+ try:
63
+ answers = yaml.safe_load(f)
64
+ if "doodba" in answers.get("_src_path", ""):
65
+ addons_dirs = []
66
+ path = codebase / "odoo" / "custom" / "src"
67
+ if path.is_dir():
68
+ for item in path.iterdir():
69
+ if item.is_dir() and item.name not in ("odoo", "private"):
70
+ addons_dirs.append(item)
71
+ return (
72
+ "Doodba",
73
+ {
74
+ "addons_dirs": addons_dirs,
75
+ "addons_dir": [codebase / "odoo/custom/src/private"],
76
+ "odoo_dir": [
77
+ codebase / "odoo/custom/src/odoo/addons",
78
+ codebase / "odoo/custom/src/odoo/odoo/addons",
79
+ ],
80
+ },
81
+ )
82
+ except yaml.YAMLError:
83
+ pass
84
+ return super().detect(codebase)
85
+
86
+
87
+ class C2CDetector(CodeBaseDetector):
88
+ """
89
+ Supports both legacy and new C2C project structures:
90
+
91
+ Legacy Layout:
92
+ ┌─ odoo/
93
+ │ ├── Dockerfile
94
+ │ ├── src/ # Odoo core source
95
+ │ │ ├── addons/
96
+ │ │ └── odoo/
97
+ │ │ └── addons/
98
+ │ ├── external-src/
99
+ │ │ └── custom-repo/
100
+ │ └── local-src/
101
+ │ └── addon2/
102
+
103
+ New Layout:
104
+ ┌─ root/
105
+ ├── Dockerfile
106
+ ├── odoo/
107
+ │ ├── addons/
108
+ │ │ └── addon1/
109
+ │ ├── dev-src/
110
+ │ │ └── addon2/
111
+ │ ├── paid-modules/
112
+ │ │ └── addon3/
113
+ │ └── external-src/
114
+ │ ├── custom-repo/
115
+ │ └── addon4/
116
+ """
117
+
118
+ def _find_docker_file(self, codebase: Path) -> Path | None:
119
+ for path in [codebase / "odoo" / "Dockerfile", codebase / "Dockerfile"]:
120
+ if path.is_file():
121
+ return path
122
+ return None
123
+
124
+ def _is_c2c_dockerfile(self, docker_file: Path) -> bool:
125
+ try:
126
+ with open(docker_file) as f:
127
+ content = f.read()
128
+ return "LABEL maintainer='Camptocamp'" in content or 'LABEL maintainer="Camptocamp"' in content
129
+ except (FileNotFoundError, PermissionError, OSError):
130
+ return False
131
+
132
+ def _collect_external_src_dirs(self, codebase: Path) -> list[Path]:
133
+ addons_dirs = []
134
+ external_src_dir = codebase / "odoo" / "external-src"
135
+ if external_src_dir.is_dir():
136
+ for item in external_src_dir.iterdir():
137
+ if item.is_dir():
138
+ addons_dirs.append(item)
139
+ return addons_dirs
140
+
141
+ def _detect_legacy_layout(self, codebase: Path) -> bool:
142
+ odoo_src_dir = codebase / "odoo" / "src"
143
+ return odoo_src_dir.is_dir()
144
+
145
+ def _get_legacy_config(self, codebase: Path, addons_dirs: list[Path]) -> dict[str, Any]:
146
+ return {
147
+ "addons_dirs": addons_dirs,
148
+ "addons_dir": [codebase / "odoo/local-src"],
149
+ "odoo_dir": [
150
+ codebase / "odoo/src/addons",
151
+ codebase / "odoo/src/odoo/addons",
152
+ ],
153
+ }
154
+
155
+ def _get_new_config(self, codebase: Path, addons_dirs: list[Path]) -> dict[str, Any]:
156
+ addons_dir_paths = []
157
+ odoo_dir_paths = []
158
+
159
+ for dir_name in ["dev-src", "paid-modules"]:
160
+ dir_path = codebase / "odoo" / dir_name
161
+ if dir_path.is_dir():
162
+ addons_dir_paths.append(dir_path)
163
+
164
+ odoo_addons_dir = codebase / "odoo" / "addons"
165
+ if odoo_addons_dir.is_dir():
166
+ odoo_dir_paths.append(odoo_addons_dir)
167
+
168
+ return {
169
+ "addons_dirs": addons_dirs,
170
+ "addons_dir": addons_dir_paths,
171
+ "odoo_dir": odoo_dir_paths,
172
+ }
173
+
174
+ def detect(self, codebase: Path) -> tuple[str, dict[str, Any]] | None:
175
+ docker_file = self._find_docker_file(codebase)
176
+ if not docker_file or not self._is_c2c_dockerfile(docker_file):
177
+ return super().detect(codebase)
178
+
179
+ addons_dirs = self._collect_external_src_dirs(codebase)
180
+
181
+ if self._detect_legacy_layout(codebase):
182
+ return "Camptocamp (Legacy)", self._get_legacy_config(codebase, addons_dirs)
183
+ else:
184
+ return "Camptocamp", self._get_new_config(codebase, addons_dirs)
185
+
186
+
187
+ class OdooShDetector(CodeBaseDetector):
188
+ """
189
+ Follow Odoo.sh mode
190
+ src/
191
+ ├── enterprise/ # Odoo Enterprise
192
+ ├── odoo/ # Odoo core
193
+ └── themes/
194
+ ├── user/ # user's submodule
195
+ │ └── OCA/
196
+ """
197
+
198
+ def detect(self, codebase: Path) -> tuple[str, dict[str, Any]] | None:
199
+ if (
200
+ (codebase / "enterprise").is_dir()
201
+ and (codebase / "odoo").is_dir()
202
+ and (codebase / "themes").is_dir()
203
+ and (codebase / "user").is_dir()
204
+ ):
205
+ addons_dirs = [codebase / "enterprise", codebase / "themes"]
206
+ for item in (codebase / "user").iterdir():
207
+ if item.is_dir():
208
+ addons_dirs.append(item)
209
+ return (
210
+ "odoo.sh",
211
+ {
212
+ "addons_dirs": addons_dirs,
213
+ "addons_dir": [codebase / "project"],
214
+ "odoo_dir": [
215
+ codebase / "odoo/addons",
216
+ codebase / "odoo/odoo/addons",
217
+ ],
218
+ },
219
+ )
220
+ return super().detect(codebase)
221
+
222
+
223
+ class GenericDetector(CodeBaseDetector):
224
+ """
225
+ A fallback detector that looks for any folder that contains __manifest__.py
226
+ """
227
+
228
+ def detect(self, codebase: Path) -> tuple[str, dict[str, Any]] | None:
229
+ manifest_files = []
230
+ for manifest_file in codebase.glob("**/__manifest__.py"):
231
+ # ignore setup folder in a module
232
+ if "setup" in manifest_file.parts:
233
+ continue
234
+ # ignore folder in a same folder
235
+ str_manifest_file = str(manifest_file)
236
+ if str_manifest_file.count("__manifest__.py") > 1:
237
+ continue
238
+ manifest_files.append(str_manifest_file)
239
+ if not manifest_files:
240
+ return super().detect(codebase)
241
+
242
+ addons_dirs = set()
243
+ for manifest_file in manifest_files:
244
+ addons_dirs.add(Path(manifest_file).parent.parent)
245
+
246
+ return "fallback", {"addons_dirs": list(addons_dirs)}
@@ -0,0 +1,100 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+
5
+ from odoo_addons_path.detector import C2CDetector, DoodbaDetector, GenericDetector, OdooShDetector, TrobzDetector
6
+
7
+
8
+ def _add_to_path(path_list: list[str], dirs_to_add: list[Path], is_sorted: bool = False):
9
+ if is_sorted:
10
+ dirs_to_add = sorted(dirs_to_add, key=lambda p: p.name)
11
+ for d in dirs_to_add:
12
+ if d.is_dir():
13
+ resolved_path = str(d.resolve())
14
+ if resolved_path not in path_list:
15
+ path_list.append(resolved_path)
16
+
17
+
18
+ def _detect_codebase_layout(codebase: Path, verbose: bool = False) -> dict:
19
+ trobz = TrobzDetector()
20
+ c2c = C2CDetector()
21
+ odoo_sh = OdooShDetector()
22
+ doodba = DoodbaDetector()
23
+ fallback = GenericDetector()
24
+ trobz.set_next(c2c).set_next(odoo_sh).set_next(doodba).set_next(fallback)
25
+ res = trobz.detect(codebase)
26
+ if not res:
27
+ typer.secho("No codebase layout detected", fg=typer.colors.RED)
28
+ raise typer.Exit(1)
29
+ detector_name, detected_paths = res
30
+ if verbose:
31
+ typer.echo(f"Codebase layout: {detector_name}")
32
+ return detected_paths
33
+
34
+
35
+ def _process_paths(
36
+ all_paths: dict[str, list[str]],
37
+ detected_paths: dict,
38
+ addons_dir: list[Path] | None,
39
+ odoo_dir: Path | None,
40
+ ):
41
+ if odoo_dir:
42
+ _add_to_path(
43
+ all_paths["odoo_dir"],
44
+ [odoo_dir / "addons", odoo_dir / "odoo" / "addons"],
45
+ )
46
+ if detected_paths.get("odoo_dir"):
47
+ _add_to_path(all_paths["odoo_dir"], detected_paths["odoo_dir"])
48
+
49
+ all_addon_paths_to_process = (
50
+ (addons_dir or []) + detected_paths.get("addons_dirs", []) + detected_paths.get("addons_dir", [])
51
+ )
52
+
53
+ result = []
54
+
55
+ for p in all_addon_paths_to_process:
56
+ if not p.is_dir():
57
+ continue
58
+ manifests = p.glob("**/__manifest__.py")
59
+ for manifest in sorted(manifests):
60
+ repo_path = manifest.parent.parent
61
+ if repo_path not in result:
62
+ result.append(repo_path)
63
+
64
+ _add_to_path(
65
+ all_paths["addon_repositories"],
66
+ result,
67
+ is_sorted=not bool(addons_dir),
68
+ )
69
+
70
+
71
+ def get_addons_path(
72
+ codebase: Path,
73
+ addons_dir: list[Path] | None = None,
74
+ odoo_dir: Path | None = None,
75
+ verbose: bool = False,
76
+ ) -> str:
77
+ all_paths: dict[str, list[str]] = {
78
+ "odoo_dir": [],
79
+ "addon_repositories": [],
80
+ }
81
+
82
+ detected_paths = {}
83
+ if not addons_dir:
84
+ detected_paths = _detect_codebase_layout(codebase, verbose)
85
+
86
+ _process_paths(all_paths, detected_paths, addons_dir, odoo_dir)
87
+
88
+ result = [path for paths in all_paths.values() for path in paths]
89
+ addons_path = ",".join(result)
90
+
91
+ if verbose:
92
+ for category, paths in all_paths.items():
93
+ if paths:
94
+ typer.echo(f"\n# {category}")
95
+ for path in paths:
96
+ typer.echo(path)
97
+ typer.echo("\n# addons_path")
98
+ typer.echo(addons_path)
99
+
100
+ return addons_path
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: odoo-addons-path
3
+ Version: 1.0.0
4
+ Summary: Create the ultimate Odoo addons_path constructor
5
+ Project-URL: Repository, https://github.com/trobz/odoo-addons-path
6
+ Author-email: trisdoan <doanminhtri8183@gmail.com>
7
+ Keywords: python
8
+ Requires-Python: <4.0,>=3.10
9
+ Requires-Dist: pyyaml
10
+ Requires-Dist: typer>=0.19.2
11
+ Description-Content-Type: text/markdown
12
+
13
+ # odoo-addons-path
14
+
15
+ A tool to auto-detect and construct Odoo `addons_path` for various project layouts.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install odoo-addons-path
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ### As a CLI tool
26
+
27
+ ```bash
28
+ odoo-addons-path /path/to/your/odoo/project
29
+ ```
30
+
31
+ #### Example: This will find addon directories that inside '18.0' directories
32
+ ```bash
33
+ odoo-addons-path --addons-dir "./tests/data/repo-version-module/*/18.0"
34
+ ```
35
+
36
+ #### Example: List of repo directories
37
+ ```bash
38
+ odoo-addons-path --verbose --addons-dir "./tests/data/c2c-new/odoo/external-src/, ./tests/data/c2c/odoo/external-src/"
39
+ ```
40
+
41
+ **Note:** The path to the codebase can also be set via the `CODEBASE` environment variable.
42
+
43
+ ### As a library
44
+
45
+ ```python
46
+ from pathlib import Path
47
+ from odoo_addons_path import get_addons_path
48
+
49
+ addons_path = get_addons_path(Path("/path/to/your/odoo/project"))
50
+ print(addons_path)
51
+ ```
52
+
53
+ ## Codebase layouts supported
54
+
55
+ There are several out-of-the-box supported layouts:
56
+
57
+ - `c2c`
58
+ - `doodba`
59
+ - `odoo.sh`
60
+ - `trobz`
61
+
62
+ For more details on the layouts, please refer to the `tests/data/` directory.
@@ -0,0 +1,8 @@
1
+ odoo_addons_path/__init__.py,sha256=9KUyE7AUseiDPwHNZ3v35BA9KYzsyPuAMV1hbo9baKI,93
2
+ odoo_addons_path/cli.py,sha256=C6wgoGx7jfCeQWYlvNO1gQKJD4ghMyRqFRjvviutIzc,2234
3
+ odoo_addons_path/detector.py,sha256=RTFVpTCvsN8La-NnYch0lTAFArkbnV1ZZ_BNsiFOzE0,8658
4
+ odoo_addons_path/main.py,sha256=M61uCoeSdQYqhvm6Fc15WO3Kh2TaZkp5sUman_pzPtc,2965
5
+ odoo_addons_path-1.0.0.dist-info/METADATA,sha256=-9MTSqnyem65crtXG7YuUtBnjsp2jhxRJu9KR5lwipQ,1445
6
+ odoo_addons_path-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ odoo_addons_path-1.0.0.dist-info/entry_points.txt,sha256=m4J4eCgDvGLRCbp0yrKdSpj9QIlUOubSMan7cnvx2-g,62
8
+ odoo_addons_path-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ odoo-addons-path = odoo_addons_path.cli:app