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.
@@ -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-
@@ -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
+ [![PyPI Version](https://img.shields.io/pypi/v/odoo-project)](https://pypi.org/project/odoo-project/ "Latest version on PyPI")
4
+ ![PyPI Status](https://img.shields.io/pypi/status/odoo-project)
5
+ ![Python Versions](https://img.shields.io/pypi/pyversions/odoo-project)
6
+ [![PyPI Versions from Framework Classifiers](https://img.shields.io/pypi/frameworkversions/odoo/odoo-project?color=A24689)](https://www.odoo.com/documentation/20.0/developer/reference.html "Odoo 20.0 developer docs")
7
+ [![Github Pages](https://img.shields.io/badge/github-pages-blue?logo=github)](https://odoo-ru.github.io/odoo-project/ "Documentation")
8
+ [![Coverage](https://coveralls.io/repos/github/odoo-ru/odoo-project/badge.svg?branch=main)](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,6 @@
1
+ from odoo_project.module import OdooModule, OdooModuleManifest
2
+
3
+ __all__ = [
4
+ 'OdooModule',
5
+ 'OdooModuleManifest',
6
+ ]
@@ -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,13 @@
1
+ from pathlib import Path
2
+
3
+
4
+ def import_odoo():
5
+ # ruff:ignore[import-outside-top-level]
6
+ import odoo
7
+
8
+ return odoo
9
+
10
+
11
+ def odoo_core_modules_path():
12
+ odoo = import_odoo()
13
+ return Path(odoo.__path__[0]) / 'addons'
@@ -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
+