odoo-project 0.1__tar.gz
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_project-0.1/.gitignore +27 -0
- odoo_project-0.1/LICENSE +21 -0
- odoo_project-0.1/PKG-INFO +15 -0
- odoo_project-0.1/README.md +38 -0
- odoo_project-0.1/odoo_project/__init__.py +6 -0
- odoo_project-0.1/odoo_project/config.py +18 -0
- odoo_project-0.1/odoo_project/module.py +250 -0
- odoo_project-0.1/odoo_project/module_set.py +44 -0
- odoo_project-0.1/odoo_project/project.py +224 -0
- odoo_project-0.1/odoo_project/utils.py +13 -0
- odoo_project-0.1/pyproject.toml +57 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store/
|
|
3
|
+
|
|
4
|
+
# editors
|
|
5
|
+
/.idea/
|
|
6
|
+
|
|
7
|
+
# direnv
|
|
8
|
+
.envrc
|
|
9
|
+
|
|
10
|
+
# Python
|
|
11
|
+
__pycache__/
|
|
12
|
+
/.venv
|
|
13
|
+
/dist/
|
|
14
|
+
/.python-version
|
|
15
|
+
|
|
16
|
+
# coverage
|
|
17
|
+
/.coverage
|
|
18
|
+
/coverage.*
|
|
19
|
+
|
|
20
|
+
# docs
|
|
21
|
+
/docs/_build/
|
|
22
|
+
/site/
|
|
23
|
+
|
|
24
|
+
# project
|
|
25
|
+
/odoo_*.zip
|
|
26
|
+
/test_*
|
|
27
|
+
/test-
|
odoo_project-0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitry Voronin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: odoo-project
|
|
3
|
+
Version: 0.1
|
|
4
|
+
Summary: Object models for Odoo modules and projects
|
|
5
|
+
Project-URL: repository, https://github.com/odoo-ru/odoo-project
|
|
6
|
+
Project-URL: documentation, https://odoo-ru.github.io/odoo-project/
|
|
7
|
+
Author-email: Dmitry Voronin <voronin.odoo@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Framework :: Odoo :: 20.0
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Requires-Python: >=3.12
|
|
14
|
+
Requires-Dist: attrs>=26.1.0
|
|
15
|
+
Requires-Dist: tomlkit>=0.15.2
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Odoo Project
|
|
2
|
+
============
|
|
3
|
+
[](https://pypi.org/project/odoo-project/ "Latest version on PyPI")
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
[](https://www.odoo.com/documentation/20.0/developer/reference.html "Odoo 20.0 developer docs")
|
|
7
|
+
[](https://odoo-ru.github.io/odoo-project/ "Documentation")
|
|
8
|
+
[](https://coveralls.io/github/odoo-ru/odoo-project?branch=main "Coverage")
|
|
9
|
+
|
|
10
|
+
Model Objects for Odoo modules and projects.
|
|
11
|
+
|
|
12
|
+
Install
|
|
13
|
+
-------
|
|
14
|
+
```console
|
|
15
|
+
uv add odoo-project
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Usage
|
|
19
|
+
-----
|
|
20
|
+
```python
|
|
21
|
+
from odoo_project import OdooModule
|
|
22
|
+
|
|
23
|
+
module = OdooModule('./hr_payroll')
|
|
24
|
+
```
|
|
25
|
+
See [documentation](https://odoo-ru.github.io/odoo-project/).
|
|
26
|
+
|
|
27
|
+
Development
|
|
28
|
+
-----------
|
|
29
|
+
```console
|
|
30
|
+
git clone git@github.com:odoo-ru/odoo-project.git
|
|
31
|
+
|
|
32
|
+
cd odoo-project
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run tests:
|
|
36
|
+
```console
|
|
37
|
+
uv run pytest
|
|
38
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
import tomlkit
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(order=True)
|
|
7
|
+
class Config:
|
|
8
|
+
project_module_dirs: list[str] = field(default_factory=list)
|
|
9
|
+
third_party_module_dirs: list[str] = field(default_factory=list)
|
|
10
|
+
include_modules: list[str] = field(default_factory=list)
|
|
11
|
+
exclude_modules: list[str] = field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_config(path):
|
|
15
|
+
with path.open() as config_file:
|
|
16
|
+
config = tomlkit.load(config_file)
|
|
17
|
+
|
|
18
|
+
return Config(**config.unwrap())
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from collections.abc import Iterable
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from attr import Factory
|
|
7
|
+
from attrs import define
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AutoInstallTypeError(TypeError):
|
|
11
|
+
def __init__(self, value):
|
|
12
|
+
super().__init__(f'Invalid type of manifest auto_install: {type(value)}')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CacheModuleMeta(type):
|
|
16
|
+
def __init__(cls, what, bases, dict):
|
|
17
|
+
super().__init__(what, bases, dict)
|
|
18
|
+
cls._modules_cache = {}
|
|
19
|
+
|
|
20
|
+
def __call__(cls, path, *args, cache_module=True, **kwargs):
|
|
21
|
+
if isinstance(path, str):
|
|
22
|
+
path = Path(path)
|
|
23
|
+
|
|
24
|
+
path = path.resolve()
|
|
25
|
+
|
|
26
|
+
if cache_module and path in cls._modules_cache:
|
|
27
|
+
return cls._modules_cache[path]
|
|
28
|
+
|
|
29
|
+
instance = super().__call__(path, *args, **kwargs)
|
|
30
|
+
if cache_module:
|
|
31
|
+
cls._modules_cache[path] = instance
|
|
32
|
+
return instance
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
README_FILE_NAMES = ['README.rst', 'README.md', 'README.txt', 'README']
|
|
36
|
+
STATIC_DESCRIPTION_ICON = 'static/description/icon.png'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@define(slots=True)
|
|
40
|
+
class OdooModuleManifest:
|
|
41
|
+
"""
|
|
42
|
+
A wrapper around the values of an Odoo module manifest file.
|
|
43
|
+
|
|
44
|
+
Designed for convenient access to manifest values
|
|
45
|
+
without computing certain fields as Odoo does.
|
|
46
|
+
It exposes all known manifest fields as class attributes.
|
|
47
|
+
Missing values are automatically populated with defaults, empty lists, empty dicts, or `None`.
|
|
48
|
+
"""
|
|
49
|
+
name: str
|
|
50
|
+
version: str = '1.0'
|
|
51
|
+
summary: str | None = None
|
|
52
|
+
description: str | None = None
|
|
53
|
+
website: str | None = None
|
|
54
|
+
license: str = 'LGPL-3'
|
|
55
|
+
category: str = 'Uncategorized'
|
|
56
|
+
sequence: int = 100
|
|
57
|
+
icon: str | None = None
|
|
58
|
+
|
|
59
|
+
author: str | None = None
|
|
60
|
+
contributors: str | None = None
|
|
61
|
+
maintainer: str | None = None
|
|
62
|
+
|
|
63
|
+
installable: bool = True
|
|
64
|
+
application: bool = False
|
|
65
|
+
depends: list[str] = Factory(list)
|
|
66
|
+
auto_install: bool | list[str] = False
|
|
67
|
+
external_dependencies: dict | None = Factory(dict)
|
|
68
|
+
|
|
69
|
+
data: list[str] = Factory(list)
|
|
70
|
+
demo: list[str] = Factory(list)
|
|
71
|
+
assets: dict[str, list[str]] = Factory(dict)
|
|
72
|
+
|
|
73
|
+
pre_init_hook: str | None = None
|
|
74
|
+
post_init_hook: str | None = None
|
|
75
|
+
uninstall_hook: str | None = None
|
|
76
|
+
post_load: str | None = None
|
|
77
|
+
|
|
78
|
+
# Odoo 20 modules fields
|
|
79
|
+
iap_paid_service: bool = False
|
|
80
|
+
# l10n_* modules
|
|
81
|
+
countries: list[str] = Factory(list)
|
|
82
|
+
# theme_* + hr
|
|
83
|
+
images: list[str] = Factory(list)
|
|
84
|
+
# theme_* + website
|
|
85
|
+
configurator_snippets: dict[str, list[str]] = Factory(dict)
|
|
86
|
+
# theme_*
|
|
87
|
+
configurator_snippets_addons: dict[str, dict] = Factory(dict)
|
|
88
|
+
theme_customizations: dict[str, dict] = Factory(dict)
|
|
89
|
+
# l10n_cr modules. Incorrect field
|
|
90
|
+
url: str | None = None
|
|
91
|
+
# some hr, some pos and other modules
|
|
92
|
+
other_files: list[str] = Factory(list)
|
|
93
|
+
# account
|
|
94
|
+
kpi_providers: list[str] = Factory(list)
|
|
95
|
+
# web, api_doc, auth_signup
|
|
96
|
+
bootstrap: bool = False
|
|
97
|
+
# website
|
|
98
|
+
new_page_templates: dict[str, dict] = Factory(dict)
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_path(cls, path: Path):
|
|
102
|
+
"""Read values from directory `__manifest__.py` file."""
|
|
103
|
+
with (path / '__manifest__.py').open() as manifest_file:
|
|
104
|
+
# TODO keep file content
|
|
105
|
+
manifest_dict = ast.literal_eval(manifest_file.read())
|
|
106
|
+
return cls(**manifest_dict)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Use attrs.define to enable cached_property with slots.
|
|
110
|
+
# https://discuss.python.org/t/extensible-cached-property/105188
|
|
111
|
+
# https://github.com/python-attrs/attrs/pull/1200
|
|
112
|
+
@define(
|
|
113
|
+
slots=True,
|
|
114
|
+
init=False,
|
|
115
|
+
repr=False,
|
|
116
|
+
eq=False,
|
|
117
|
+
)
|
|
118
|
+
class OdooModule(metaclass=CacheModuleMeta):
|
|
119
|
+
"""
|
|
120
|
+
Represents an Odoo module directory.
|
|
121
|
+
|
|
122
|
+
Designed to work with module files, including the manifest, data, and translations.
|
|
123
|
+
The implementation is independent of the Odoo version, does not import `odoo`,
|
|
124
|
+
and does not rely on Odoo code.
|
|
125
|
+
"""
|
|
126
|
+
path: Path
|
|
127
|
+
"""Module dir path"""
|
|
128
|
+
name: str
|
|
129
|
+
"""Module dir name <small>aka</small> Technical name"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, path: Path):
|
|
132
|
+
if not is_module(path):
|
|
133
|
+
raise ValueError(path)
|
|
134
|
+
|
|
135
|
+
self.path = path
|
|
136
|
+
self.name = path.name
|
|
137
|
+
|
|
138
|
+
def __fspath__(self):
|
|
139
|
+
return str(self.path)
|
|
140
|
+
|
|
141
|
+
def __truediv__(self, path: Path | str) -> Path:
|
|
142
|
+
"""Returns the path relative to the module directory."""
|
|
143
|
+
return self.path / path
|
|
144
|
+
|
|
145
|
+
def __repr__(self):
|
|
146
|
+
return f'OdooModule({str(self.path)!r})'
|
|
147
|
+
|
|
148
|
+
def __eq__(self, other):
|
|
149
|
+
return self.path == other.path
|
|
150
|
+
|
|
151
|
+
def __hash__(self):
|
|
152
|
+
return hash(self.path)
|
|
153
|
+
|
|
154
|
+
@cached_property
|
|
155
|
+
def manifest(self) -> OdooModuleManifest:
|
|
156
|
+
"""Returns parsed manifest object"""
|
|
157
|
+
return OdooModuleManifest.from_path(self.path)
|
|
158
|
+
|
|
159
|
+
@cached_property
|
|
160
|
+
def readme_path(self) -> Path | None:
|
|
161
|
+
"""
|
|
162
|
+
Returns first of existing files or `None`:
|
|
163
|
+
|
|
164
|
+
1. README.rst
|
|
165
|
+
2. README.md
|
|
166
|
+
3. README.txt
|
|
167
|
+
4. README
|
|
168
|
+
"""
|
|
169
|
+
for readme_file_name in README_FILE_NAMES:
|
|
170
|
+
readme_file_path = self / readme_file_name
|
|
171
|
+
if readme_file_path.is_file():
|
|
172
|
+
return readme_file_path
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
@cached_property
|
|
176
|
+
def icon_path(self) -> str:
|
|
177
|
+
"""
|
|
178
|
+
Returns first available option:
|
|
179
|
+
|
|
180
|
+
1. Manifest icon value if it is specified.
|
|
181
|
+
2. `/<module>/static/description/icon.png` if file exists.
|
|
182
|
+
3. Fallback to `/base/static/description/icon.png`.
|
|
183
|
+
"""
|
|
184
|
+
if self.manifest.icon:
|
|
185
|
+
return self.manifest.icon
|
|
186
|
+
|
|
187
|
+
if (self / STATIC_DESCRIPTION_ICON).is_file():
|
|
188
|
+
return f'/{self.name}/{STATIC_DESCRIPTION_ICON}'
|
|
189
|
+
|
|
190
|
+
return f'/base/{STATIC_DESCRIPTION_ICON}'
|
|
191
|
+
|
|
192
|
+
@cached_property
|
|
193
|
+
def dependencies(self) -> frozenset[str]:
|
|
194
|
+
"""
|
|
195
|
+
Returns manifest `depends` wrapped into `frozenset`.
|
|
196
|
+
Adds `base` if it's empty.
|
|
197
|
+
"""
|
|
198
|
+
if not self.manifest.depends and self.name != 'base':
|
|
199
|
+
return frozenset({'base'})
|
|
200
|
+
return frozenset(self.manifest.depends)
|
|
201
|
+
|
|
202
|
+
@cached_property
|
|
203
|
+
def is_auto_install(self) -> bool:
|
|
204
|
+
"""
|
|
205
|
+
Returns True if manifest `auto_install` is True or Iterable.
|
|
206
|
+
"""
|
|
207
|
+
if isinstance(self.manifest.auto_install, bool):
|
|
208
|
+
return self.manifest.auto_install
|
|
209
|
+
if isinstance(self.manifest.auto_install, Iterable):
|
|
210
|
+
return True
|
|
211
|
+
raise AutoInstallTypeError(self.manifest.auto_install)
|
|
212
|
+
|
|
213
|
+
@cached_property
|
|
214
|
+
def auto_install_depends(self) -> frozenset[str]:
|
|
215
|
+
"""
|
|
216
|
+
Return frozenset depends on manifest `auto_install` value.
|
|
217
|
+
If it's True returns `self.dependencies`.
|
|
218
|
+
If it's False returns empty set.
|
|
219
|
+
If it's list returns frozenset of its values.
|
|
220
|
+
"""
|
|
221
|
+
if self.manifest.auto_install is False:
|
|
222
|
+
return frozenset()
|
|
223
|
+
if self.manifest.auto_install is True:
|
|
224
|
+
return self.dependencies
|
|
225
|
+
if isinstance(self.manifest.auto_install, Iterable):
|
|
226
|
+
return frozenset(self.manifest.auto_install)
|
|
227
|
+
raise AutoInstallTypeError(self.manifest.auto_install)
|
|
228
|
+
|
|
229
|
+
def is_installable(self, serie):
|
|
230
|
+
adopted_version = adapt_version(self.manifest.version, serie)
|
|
231
|
+
return self.manifest.installable and adopted_version.startswith(serie + '.')
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
SERIE_LENGTH = 2
|
|
235
|
+
FULL_VERSION_MIN_LENGTH = 4
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def adapt_version(version, serie):
|
|
239
|
+
"""
|
|
240
|
+
Adapt module version by prefix Odoo major version.
|
|
241
|
+
Odoo 20: adapt_version at odoo/modules/module.py:589:1
|
|
242
|
+
"""
|
|
243
|
+
version_parts = version.split('.')
|
|
244
|
+
if len(version_parts) < FULL_VERSION_MIN_LENGTH and '.'.join(version_parts[:SERIE_LENGTH]) != serie:
|
|
245
|
+
return f'{serie}.{version}'
|
|
246
|
+
return version
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def is_module(path: Path):
|
|
250
|
+
return (path / '__manifest__.py').is_file()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# What is the correct (or best) way to subclass the Python set class, adding a new instance variable?
|
|
5
|
+
# https://stackoverflow.com/questions/798442
|
|
6
|
+
def wrap(method):
|
|
7
|
+
@functools.wraps(method)
|
|
8
|
+
def wrapped_method(*args, **kwargs):
|
|
9
|
+
return OdooModuleSet(method(*args, **kwargs))
|
|
10
|
+
return wrapped_method
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OdooModuleSet(set):
|
|
14
|
+
# @cached_property
|
|
15
|
+
@property
|
|
16
|
+
def names_list(self):
|
|
17
|
+
return sorted(self.names)
|
|
18
|
+
|
|
19
|
+
# @cached_property
|
|
20
|
+
@property
|
|
21
|
+
def names(self):
|
|
22
|
+
return {module.name for module in self}
|
|
23
|
+
|
|
24
|
+
def depends(self):
|
|
25
|
+
return set().union(*[module.dependencies for module in self]) - self.names
|
|
26
|
+
|
|
27
|
+
def sorted(self):
|
|
28
|
+
return sorted(self, key=lambda m: m.name)
|
|
29
|
+
|
|
30
|
+
for method in [
|
|
31
|
+
'__or__',
|
|
32
|
+
'__and__',
|
|
33
|
+
'__sub__',
|
|
34
|
+
'difference',
|
|
35
|
+
'difference_update',
|
|
36
|
+
'intersection_update',
|
|
37
|
+
'symmetric_difference',
|
|
38
|
+
'symmetric_difference_update',
|
|
39
|
+
'intersection',
|
|
40
|
+
'union',
|
|
41
|
+
'copy',
|
|
42
|
+
]:
|
|
43
|
+
# ruff:ignore[exec-builtin]
|
|
44
|
+
exec(f'{method} = wrap(set.{method})')
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from collections.abc import Container, Iterable
|
|
3
|
+
from functools import cached_property, lru_cache
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from odoo_project.config import Config, read_config
|
|
7
|
+
from odoo_project.module import OdooModule, is_module
|
|
8
|
+
from odoo_project.module_set import OdooModuleSet
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OdooProject:
|
|
12
|
+
def __init__(self, path, **options):
|
|
13
|
+
self.path = Path(path).resolve()
|
|
14
|
+
|
|
15
|
+
config_dict = dataclasses.asdict(self.project_file_config)
|
|
16
|
+
config_dict.update(options)
|
|
17
|
+
self.project_config = Config(**config_dict)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def project_file_config(self):
|
|
21
|
+
return read_config(self.path / 'odoo-project.toml')
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def project_module_paths(self):
|
|
25
|
+
return [(self.path / path) for path in self.project_config.project_module_dirs]
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def third_party_module_paths(self):
|
|
29
|
+
return [(self.path / path) for path in self.project_config.third_party_module_dirs]
|
|
30
|
+
|
|
31
|
+
@cached_property
|
|
32
|
+
def modules_paths(self):
|
|
33
|
+
# In fact core_modules_path is os.path.join(odoo.tools.config['root_path'], 'addons').
|
|
34
|
+
# We don't want to import odoo. It's long one second almost.
|
|
35
|
+
|
|
36
|
+
# ruff:ignore[import-outside-top-level]
|
|
37
|
+
import importlib
|
|
38
|
+
odoo_module_spec = importlib.util.find_spec('odoo')
|
|
39
|
+
odoo_module_path = odoo_module_spec.submodule_search_locations[0]
|
|
40
|
+
core_modules_path = Path(odoo_module_path, 'addons').resolve()
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
self.project_module_paths
|
|
44
|
+
+ self.third_party_module_paths
|
|
45
|
+
+ [core_modules_path]
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def collect_modules(self, paths: Iterable[Path], exclude: Container[str] = ()):
|
|
49
|
+
modules = OdooModuleSet()
|
|
50
|
+
for module_path in paths:
|
|
51
|
+
for subdir in module_path.iterdir():
|
|
52
|
+
if subdir.name not in exclude and is_module(subdir):
|
|
53
|
+
modules.add(OdooModule(subdir))
|
|
54
|
+
return modules
|
|
55
|
+
|
|
56
|
+
# ruff:ignore[cached-instance-method]
|
|
57
|
+
@lru_cache(maxsize=1024)
|
|
58
|
+
def module(self, module_name):
|
|
59
|
+
for modules_path in self.modules_paths:
|
|
60
|
+
module_path = modules_path / module_name
|
|
61
|
+
if is_module(module_path):
|
|
62
|
+
# TODO Cache?
|
|
63
|
+
return OdooModule(module_path)
|
|
64
|
+
msg = f'No module found: {module_name}'
|
|
65
|
+
raise LookupError(msg)
|
|
66
|
+
|
|
67
|
+
def find_modules(self, module_names):
|
|
68
|
+
return OdooModuleSet(self.module(module_name) for module_name in module_names)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def required_modules(self) -> OdooModuleSet:
|
|
72
|
+
return (
|
|
73
|
+
self.collect_modules(self.project_module_paths, exclude=self.project_config.exclude_modules)
|
|
74
|
+
| self.find_modules(self.project_config.include_modules)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def expand_dependencies(self, modules):
|
|
78
|
+
modules_to_check_depends = modules
|
|
79
|
+
|
|
80
|
+
while True:
|
|
81
|
+
module_depends_names = modules_to_check_depends.depends()
|
|
82
|
+
if module_depends_names:
|
|
83
|
+
modules_to_check_depends = self.find_modules(module_depends_names)
|
|
84
|
+
modules |= modules_to_check_depends
|
|
85
|
+
else:
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
return modules
|
|
89
|
+
|
|
90
|
+
def auto_install_modules(self, modules: OdooModuleSet):
|
|
91
|
+
auto_install_modules = OdooModuleSet()
|
|
92
|
+
for module in self.collect_modules(self.modules_paths, exclude=modules.names):
|
|
93
|
+
if module.is_auto_install and set(module.depends) <= modules.names:
|
|
94
|
+
auto_install_modules.add(module)
|
|
95
|
+
return auto_install_modules
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def installed_modules(self):
|
|
99
|
+
modules = self.expand_dependencies(self.required_modules)
|
|
100
|
+
|
|
101
|
+
while True:
|
|
102
|
+
auto_install_modules = self.auto_install_modules(modules)
|
|
103
|
+
if auto_install_modules:
|
|
104
|
+
modules |= auto_install_modules
|
|
105
|
+
else:
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
return modules
|
|
109
|
+
|
|
110
|
+
def topologic_dependencies(self, modules: OdooModuleSet):
|
|
111
|
+
result = []
|
|
112
|
+
visited = set()
|
|
113
|
+
|
|
114
|
+
def visit(modd):
|
|
115
|
+
if modd not in visited:
|
|
116
|
+
visited.add(modd)
|
|
117
|
+
if modd in modules:
|
|
118
|
+
for depend_module in self.find_modules(modd.depends).sorted():
|
|
119
|
+
visit(depend_module)
|
|
120
|
+
result.append(modd)
|
|
121
|
+
|
|
122
|
+
for module in modules.sorted():
|
|
123
|
+
visit(module)
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
# @lru_cache
|
|
128
|
+
def installed_modules_list(self, *, inversed=False):
|
|
129
|
+
modules = self.topologic_dependencies(self.installed_modules)
|
|
130
|
+
if inversed:
|
|
131
|
+
modules = modules.reverse()
|
|
132
|
+
return modules
|
|
133
|
+
|
|
134
|
+
# =======================================================================
|
|
135
|
+
def module_graph(self, module_name):
|
|
136
|
+
processed_modules = set()
|
|
137
|
+
|
|
138
|
+
def recursive_module_graph(module):
|
|
139
|
+
edges = []
|
|
140
|
+
processed_modules.add(module)
|
|
141
|
+
for dependency_module_name in module.depends:
|
|
142
|
+
dependency_module = self.module(dependency_module_name)
|
|
143
|
+
edges.append((module, dependency_module))
|
|
144
|
+
if dependency_module not in processed_modules:
|
|
145
|
+
edges.extend(self.module_graph(dependency_module))
|
|
146
|
+
return edges
|
|
147
|
+
|
|
148
|
+
return recursive_module_graph(self.module(module_name))
|
|
149
|
+
|
|
150
|
+
# ============= create db ====================
|
|
151
|
+
def cache_key(self, module_names, timestamp):
|
|
152
|
+
raise NotImplementedError
|
|
153
|
+
|
|
154
|
+
# Env
|
|
155
|
+
@cached_property
|
|
156
|
+
def env(self):
|
|
157
|
+
# Keep ref on generator force close generator later
|
|
158
|
+
return self._get_env('soma', self.installed_modules)
|
|
159
|
+
|
|
160
|
+
def _get_env(self, db_name, installed_modules: OdooModule | OdooModuleSet):
|
|
161
|
+
# ruff:ignore[unsorted-imports]
|
|
162
|
+
# ruff:ignore[import-outside-top-level]
|
|
163
|
+
from unittest import mock
|
|
164
|
+
# ruff:ignore[import-outside-top-level]
|
|
165
|
+
import odoo
|
|
166
|
+
# ruff:ignore[import-outside-top-level]
|
|
167
|
+
from odoo_project.database_mock import CursorMock, FakeDatabase
|
|
168
|
+
|
|
169
|
+
CursorMock.databases[db_name] = FakeDatabase(installed_modules)
|
|
170
|
+
|
|
171
|
+
with (
|
|
172
|
+
mock.patch('odoo.sql_db.Cursor', CursorMock),
|
|
173
|
+
odoo.registry(db_name).cursor() as cr,
|
|
174
|
+
):
|
|
175
|
+
return odoo.api.Environment(cr, odoo.SUPERUSER_ID, {})
|
|
176
|
+
|
|
177
|
+
# ========== Translation ==========
|
|
178
|
+
def search_translation(self, tupl, lang=None):
|
|
179
|
+
for module in self.installed_modules_list():
|
|
180
|
+
translations = module.translations(lang or self.lang)
|
|
181
|
+
if tupl in translations:
|
|
182
|
+
return translations[tupl]
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
def code_translation(self, file_path, value, lineno=None):
|
|
186
|
+
return self.search_translation(('code', file_path, lineno, value))
|
|
187
|
+
|
|
188
|
+
def selection(self, model_name, field_name, value, lang=None):
|
|
189
|
+
field = self.env[model_name]._fields[field_name]
|
|
190
|
+
for selection_value, _selection_string in field.selection:
|
|
191
|
+
if value == selection_value:
|
|
192
|
+
break
|
|
193
|
+
else:
|
|
194
|
+
msg = 'Invalid selection value'
|
|
195
|
+
raise ValueError(msg)
|
|
196
|
+
return self.search_translation(('selection', f'{model_name},{field_name}', '0', _selection_string), lang=lang)
|
|
197
|
+
|
|
198
|
+
def constraint(self, model, value):
|
|
199
|
+
return self.search_translation(('constraint', model, '0', value))
|
|
200
|
+
|
|
201
|
+
def sql_constraint(self, model, value):
|
|
202
|
+
return self.search_translation(('sql_constraint', model, '0', value))
|
|
203
|
+
|
|
204
|
+
def model_translation(self, model_name, field_name, xml_id, origin, lang=None):
|
|
205
|
+
return self.search_translation(('model', f'{model_name},{field_name}', xml_id, origin), lang=lang)
|
|
206
|
+
|
|
207
|
+
def model_description(self, model_name, lang=None):
|
|
208
|
+
model = self.env[model_name]
|
|
209
|
+
if not lang:
|
|
210
|
+
# ruff:ignore[private-member-access]
|
|
211
|
+
return model._description
|
|
212
|
+
# ruff:ignore[private-member-access]
|
|
213
|
+
xml_id = f'{model._original_module}.model_{model_name.replace(".", "_")}'
|
|
214
|
+
xml_id = None
|
|
215
|
+
# ruff:ignore[private-member-access]
|
|
216
|
+
return self.model_translation('ir.model', 'name', xml_id, model._description, lang=lang)
|
|
217
|
+
|
|
218
|
+
def field_description(self, model_name, field_name, lang=None):
|
|
219
|
+
field = self.env[model_name]._fields[field_name]
|
|
220
|
+
if not lang:
|
|
221
|
+
return field.string
|
|
222
|
+
# ruff:ignore[private-member-access]
|
|
223
|
+
xml_id = f'{field._module}.field_{model_name.replace(".", "_")}_{field_name}'
|
|
224
|
+
return self.model_translation('ir.model.fields', 'field_description', xml_id, field.string, lang=lang)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "odoo-project"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Object models for Odoo modules and projects"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Dmitry Voronin", email = "voronin.odoo@gmail.com" }
|
|
7
|
+
]
|
|
8
|
+
urls.repository = "https://github.com/odoo-ru/odoo-project"
|
|
9
|
+
urls.documentation = "https://odoo-ru.github.io/odoo-project/"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Framework :: Odoo :: 20.0",
|
|
15
|
+
]
|
|
16
|
+
requires-python = ">=3.12"
|
|
17
|
+
dependencies = [
|
|
18
|
+
"attrs>=26.1.0",
|
|
19
|
+
"tomlkit>=0.15.2",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"ipython>=9.17.1",
|
|
25
|
+
"manifestoo>=1.1",
|
|
26
|
+
"mkdocstrings[python]>=1.0.8",
|
|
27
|
+
"pytest>=9.1.1",
|
|
28
|
+
"pytest-cov>=7.1.0",
|
|
29
|
+
"pytest-xdist>=3.8.0",
|
|
30
|
+
"sphinx>=9.1.1",
|
|
31
|
+
"sphinx-autobuild>=2025.8.25",
|
|
32
|
+
"zensical>=0.0.72",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
odoo-project = "odoo_project:main"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.version]
|
|
43
|
+
source = "vcs"
|
|
44
|
+
raw-options = { local_scheme = "no-local-version" }
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = [
|
|
48
|
+
"odoo_project",
|
|
49
|
+
"README.md",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
[tool.uv]
|
|
53
|
+
preview-features = ["centralized-project-envs"]
|
|
54
|
+
|
|
55
|
+
[tool.uv.sources]
|
|
56
|
+
odoo = { path = "odoo_20.0.20261018.zip" }
|
|
57
|
+
|