bl-odoo 0.1.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.
- bl_odoo-0.1.1/LICENSE +21 -0
- bl_odoo-0.1.1/PKG-INFO +20 -0
- bl_odoo-0.1.1/README.md +3 -0
- bl_odoo-0.1.1/bl/__init__.py +0 -0
- bl_odoo-0.1.1/bl/__main__.py +18 -0
- bl_odoo-0.1.1/bl/spec_parser.py +183 -0
- bl_odoo-0.1.1/bl/spec_processor.py +417 -0
- bl_odoo-0.1.1/bl_odoo.egg-info/PKG-INFO +20 -0
- bl_odoo-0.1.1/bl_odoo.egg-info/SOURCES.txt +13 -0
- bl_odoo-0.1.1/bl_odoo.egg-info/dependency_links.txt +1 -0
- bl_odoo-0.1.1/bl_odoo.egg-info/entry_points.txt +2 -0
- bl_odoo-0.1.1/bl_odoo.egg-info/requires.txt +7 -0
- bl_odoo-0.1.1/bl_odoo.egg-info/top_level.txt +1 -0
- bl_odoo-0.1.1/pyproject.toml +44 -0
- bl_odoo-0.1.1/setup.cfg +4 -0
bl_odoo-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Your Name
|
|
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.
|
bl_odoo-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bl-odoo
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A command-line tool for managing Odoo dependencies.
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: rich
|
|
12
|
+
Requires-Dist: typer
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: ruff; extra == "dev"
|
|
15
|
+
Requires-Dist: kalong; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# bl
|
|
19
|
+
|
|
20
|
+
A new Python project.
|
bl_odoo-0.1.1/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from bl.spec_parser import load_spec_file
|
|
3
|
+
from bl.spec_processor import process_project
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run():
|
|
9
|
+
# Example usage:
|
|
10
|
+
file_name = sys.argv[1] if len(sys.argv) > 1 else "spec.yaml"
|
|
11
|
+
spec_file = file_name
|
|
12
|
+
project_spec = load_spec_file(spec_file)
|
|
13
|
+
if project_spec is not None:
|
|
14
|
+
asyncio.run(process_project(project_spec, workdir=Path("."), concurrency=16))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
run()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import re
|
|
3
|
+
from typing import List, Dict, Any, Optional
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_remote_merge_from_src(src: str) -> str:
|
|
8
|
+
"""
|
|
9
|
+
Creates a remote and merge entry from the src string.
|
|
10
|
+
"""
|
|
11
|
+
remotes = {}
|
|
12
|
+
merges = []
|
|
13
|
+
|
|
14
|
+
parts = src.split(" ", 1)
|
|
15
|
+
remotes["origin"] = parts[0]
|
|
16
|
+
merges.append(f"origin {parts[1]}")
|
|
17
|
+
|
|
18
|
+
return remotes, merges
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_origin_type(origin_value: str) -> OriginType:
|
|
22
|
+
"""
|
|
23
|
+
Determines the origin type based on the origin value.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
origin_value: The origin string to evaluate.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The corresponding OriginType.
|
|
30
|
+
"""
|
|
31
|
+
# Pattern to match GitHub PR references: refs/pull/{pr_id}/head
|
|
32
|
+
pr_pattern = re.compile(r"^refs/pull/\d+/head$")
|
|
33
|
+
# Pattern to match that matches git reference hashes (40 hex characters)
|
|
34
|
+
ref_pattern = re.compile(r"^[a-z0-9]{40}$")
|
|
35
|
+
|
|
36
|
+
if pr_pattern.match(origin_value):
|
|
37
|
+
return OriginType.PR
|
|
38
|
+
elif ref_pattern.match(origin_value):
|
|
39
|
+
return OriginType.REF
|
|
40
|
+
else:
|
|
41
|
+
return OriginType.BRANCH
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class OriginType(Enum):
|
|
45
|
+
"""Type of origin reference."""
|
|
46
|
+
|
|
47
|
+
BRANCH = "branch"
|
|
48
|
+
PR = "pr"
|
|
49
|
+
REF = "ref"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ModuleOrigin:
|
|
53
|
+
"""Represents an origin reference for a module."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
remote: str,
|
|
58
|
+
origin: str,
|
|
59
|
+
type: OriginType,
|
|
60
|
+
):
|
|
61
|
+
self.remote = remote
|
|
62
|
+
self.origin = origin
|
|
63
|
+
self.type = type
|
|
64
|
+
|
|
65
|
+
def __repr__(self) -> str:
|
|
66
|
+
return f"ModuleOrigin(remote={self.remote!r}, origin={self.origin!r}, type={self.type.value})"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ModuleSpec:
|
|
70
|
+
"""Represents the specification for a set of modules."""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
modules: List[str],
|
|
75
|
+
remotes: Optional[Dict[str, str]] = None,
|
|
76
|
+
origins: Optional[List[ModuleOrigin]] = None,
|
|
77
|
+
shell_commands: Optional[List[str]] = None,
|
|
78
|
+
patch_globs_to_apply: Optional[List[str]] = None,
|
|
79
|
+
target_folder: Optional[str] = None,
|
|
80
|
+
):
|
|
81
|
+
self.modules = modules
|
|
82
|
+
self.remotes = remotes
|
|
83
|
+
self.origins = origins
|
|
84
|
+
self.shell_commands = shell_commands
|
|
85
|
+
self.patch_globs_to_apply = patch_globs_to_apply
|
|
86
|
+
self.target_folder = None
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return f"ModuleSpec(modules={self.modules}, remotes={self.remotes}, origins={self.origins})"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ProjectSpec:
|
|
93
|
+
"""Represents the overall project specification from the YAML file."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, specs: Dict[str, ModuleSpec]):
|
|
96
|
+
self.specs = specs
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
return f"ProjectSpec(specs={self.specs})"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def load_spec_file(file_path: str) -> Optional[ProjectSpec]:
|
|
103
|
+
"""
|
|
104
|
+
Loads and parses the project specification from a YAML file.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
file_path: The path to the YAML specification file.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A ProjectSpec object if successful, None otherwise.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
with open(file_path, "r") as f:
|
|
114
|
+
data: Dict[str, Any] = yaml.safe_load(f)
|
|
115
|
+
|
|
116
|
+
specs: Dict[str, ModuleSpec] = {}
|
|
117
|
+
for section_name, section_data in data.items():
|
|
118
|
+
modules = section_data.get("modules", [])
|
|
119
|
+
src = section_data.get("src")
|
|
120
|
+
remotes = section_data.get("remotes") or {}
|
|
121
|
+
merges = section_data.get("merges") or []
|
|
122
|
+
shell_commands = section_data.get("shell_command_after") or None
|
|
123
|
+
patch_globs_to_apply = section_data.get("patch_globs") or None
|
|
124
|
+
|
|
125
|
+
# Parse merges into ModuleOrigin objects
|
|
126
|
+
origins: List[ModuleOrigin] = []
|
|
127
|
+
if src:
|
|
128
|
+
# If src is defined, create a remote and merge entry from it
|
|
129
|
+
src_remotes, src_merges = make_remote_merge_from_src(src)
|
|
130
|
+
remotes.update(src_remotes)
|
|
131
|
+
merges = src_merges + merges
|
|
132
|
+
|
|
133
|
+
for merge_entry in merges:
|
|
134
|
+
parts = merge_entry.split(" ", 2)
|
|
135
|
+
if len(parts) == 2:
|
|
136
|
+
remote_key = parts[0]
|
|
137
|
+
origin_value = parts[1]
|
|
138
|
+
|
|
139
|
+
# Determine type: PR if matches refs/pull/{pr_id}/head pattern, otherwise branch
|
|
140
|
+
origin_type = get_origin_type(origin_value)
|
|
141
|
+
|
|
142
|
+
origins.append(ModuleOrigin(remote_key, origin_value, origin_type))
|
|
143
|
+
if len(parts) == 3 and remote_key not in remotes:
|
|
144
|
+
remote_key = parts[0]
|
|
145
|
+
origin_value = parts[2]
|
|
146
|
+
|
|
147
|
+
origin_type = get_origin_type(origin_value)
|
|
148
|
+
|
|
149
|
+
origins.append(ModuleOrigin(remote_key, origin_value, origin_type))
|
|
150
|
+
|
|
151
|
+
specs[section_name] = ModuleSpec(
|
|
152
|
+
modules,
|
|
153
|
+
remotes if remotes else None,
|
|
154
|
+
origins if origins else None,
|
|
155
|
+
shell_commands,
|
|
156
|
+
patch_globs_to_apply,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
return ProjectSpec(specs)
|
|
160
|
+
|
|
161
|
+
except FileNotFoundError:
|
|
162
|
+
print(f"Error: The file '{file_path}' was not found.")
|
|
163
|
+
return None
|
|
164
|
+
except yaml.YAMLError as e:
|
|
165
|
+
print(f"Error parsing YAML file '{file_path}': {e}")
|
|
166
|
+
return None
|
|
167
|
+
except Exception as e:
|
|
168
|
+
print(f"An unexpected error occurred while processing '{file_path}': {e}")
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
# Example usage:
|
|
174
|
+
spec_file = "spec.yaml"
|
|
175
|
+
project_spec = load_spec_file(spec_file)
|
|
176
|
+
|
|
177
|
+
if project_spec:
|
|
178
|
+
print("Successfully loaded project specification:")
|
|
179
|
+
# You can access the data like this:
|
|
180
|
+
# print(project_spec.specs['odoo'].modules)
|
|
181
|
+
# print(project_spec.specs['server-ux'].remotes)
|
|
182
|
+
# print(project_spec.specs['server-ux'].origins)
|
|
183
|
+
print([spec for name, spec in project_spec.specs.items() if name == "queue"])
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import hashlib
|
|
4
|
+
import warnings
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict, Optional, Any
|
|
7
|
+
from typing_extensions import deprecated
|
|
8
|
+
from rich.progress import MofNCompleteColumn, Progress, SpinnerColumn, TextColumn, BarColumn, TaskID
|
|
9
|
+
from rich.live import Live
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from .spec_parser import ProjectSpec, ModuleSpec, ModuleOrigin, OriginType
|
|
12
|
+
|
|
13
|
+
BASE_DEPTH_VALUE = 10000
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def rich_warning(message, category, filename, lineno, file=None, line=None):
|
|
19
|
+
console.print(f"[yellow]Warning:[/] {category.__name__}: {message}\n[dim]{filename}:{lineno}[/]")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
warnings.showwarning = rich_warning
|
|
23
|
+
warnings.simplefilter("default", DeprecationWarning)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
english_env = os.environ.copy()
|
|
27
|
+
# Ensure git outputs in English for consistent parsing
|
|
28
|
+
english_env["LANG"] = "en_US.UTF-8"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def create_clone_args(base_origin: ModuleOrigin, remote_url: str) -> List[str]:
|
|
32
|
+
"""Creates git clone arguments based on the base origin."""
|
|
33
|
+
args = [
|
|
34
|
+
"clone",
|
|
35
|
+
"--no-checkout",
|
|
36
|
+
"--filter=blob:none",
|
|
37
|
+
"--depth",
|
|
38
|
+
"1",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
if base_origin.type == OriginType.REF:
|
|
42
|
+
args += [
|
|
43
|
+
"--revision",
|
|
44
|
+
base_origin.origin,
|
|
45
|
+
]
|
|
46
|
+
else:
|
|
47
|
+
args += [
|
|
48
|
+
"--origin",
|
|
49
|
+
base_origin.remote,
|
|
50
|
+
"--single-branch",
|
|
51
|
+
"--branch",
|
|
52
|
+
base_origin.origin,
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
args += [
|
|
56
|
+
remote_url,
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
return args
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_local_ref(origin: ModuleOrigin) -> str:
|
|
63
|
+
"""Generates a local reference name for a given origin."""
|
|
64
|
+
if origin.type == OriginType.PR:
|
|
65
|
+
pr_id = origin.origin.split("/")[-2]
|
|
66
|
+
return f"pr/{pr_id}"
|
|
67
|
+
else:
|
|
68
|
+
return f"loc-{origin.origin}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SpecProcessor:
|
|
72
|
+
"""
|
|
73
|
+
Processes a ProjectSpec by concurrently cloning and merging modules.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self, workdir: Path, concurrency: int = 4):
|
|
77
|
+
self.workdir = workdir
|
|
78
|
+
self.modules_dir = workdir / "external-src"
|
|
79
|
+
self.cache_dir = workdir / "_cache"
|
|
80
|
+
self.concurrency = concurrency
|
|
81
|
+
self.semaphore = asyncio.Semaphore(concurrency)
|
|
82
|
+
|
|
83
|
+
def _get_cache_path(self, remote_url: str) -> Path:
|
|
84
|
+
"""Returns a unique cache path for a given remote URL."""
|
|
85
|
+
url_hash = hashlib.sha256(remote_url.encode()).hexdigest()[:12]
|
|
86
|
+
return self.cache_dir / url_hash
|
|
87
|
+
|
|
88
|
+
def get_module_path(self, module_name: str, module_spec: ModuleSpec) -> Path:
|
|
89
|
+
"""Returns the path to the module directory."""
|
|
90
|
+
if module_name == "odoo" and module_spec.target_folder is None:
|
|
91
|
+
console.print(
|
|
92
|
+
"[yellow]Warning:[/] importing 'odoo' without a target_folder "
|
|
93
|
+
+ "property is deprecated. Use target_folder: 'src/' in spec.yaml."
|
|
94
|
+
)
|
|
95
|
+
return self.workdir / "src/"
|
|
96
|
+
else:
|
|
97
|
+
return self.workdir / "external-src" / module_name
|
|
98
|
+
|
|
99
|
+
async def run_git(self, *args: str, cwd: Optional[Path] = None) -> tuple[int, str, str]:
|
|
100
|
+
"""Executes a git command asynchronously."""
|
|
101
|
+
proc = await asyncio.create_subprocess_exec(
|
|
102
|
+
"git",
|
|
103
|
+
*args,
|
|
104
|
+
stdout=asyncio.subprocess.PIPE,
|
|
105
|
+
stderr=asyncio.subprocess.PIPE,
|
|
106
|
+
cwd=str(cwd) if cwd else None,
|
|
107
|
+
env=english_env,
|
|
108
|
+
)
|
|
109
|
+
stdout, stderr = await proc.communicate()
|
|
110
|
+
returncode = proc.returncode if proc.returncode is not None else -1
|
|
111
|
+
return returncode, stdout.decode().strip(), stderr.decode().strip()
|
|
112
|
+
|
|
113
|
+
@deprecated(
|
|
114
|
+
"run_shell_commands is deprecated if used to apply patches. Use patch_globs properties in spec.yaml instead."
|
|
115
|
+
)
|
|
116
|
+
async def run_shell_commands(
|
|
117
|
+
self, progress: Progress, task_id: TaskID, spec: ModuleSpec, module_path: Path
|
|
118
|
+
) -> None:
|
|
119
|
+
for cmd in spec.shell_commands:
|
|
120
|
+
progress.update(task_id, status=f"Running shell command: {cmd}...")
|
|
121
|
+
proc = await asyncio.create_subprocess_shell(
|
|
122
|
+
cmd,
|
|
123
|
+
cwd=str(module_path),
|
|
124
|
+
stdout=asyncio.subprocess.PIPE,
|
|
125
|
+
stderr=asyncio.subprocess.PIPE,
|
|
126
|
+
env=english_env,
|
|
127
|
+
)
|
|
128
|
+
stdout, stderr = await proc.communicate()
|
|
129
|
+
if proc.returncode != 0:
|
|
130
|
+
# This is a sanity check because people usually put "git am" commands
|
|
131
|
+
# in shell_commands, so we abort any ongoing git am
|
|
132
|
+
await self.run_git("am", "--abort", cwd=str(module_path))
|
|
133
|
+
progress.update(
|
|
134
|
+
task_id,
|
|
135
|
+
status=f"[red]Shell command failed: {cmd}\nError: {stderr.decode().strip()}",
|
|
136
|
+
)
|
|
137
|
+
return -1
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
async def deepen_fetch(
|
|
141
|
+
self,
|
|
142
|
+
remote_url: str,
|
|
143
|
+
origin: str,
|
|
144
|
+
local_ref: str,
|
|
145
|
+
module_path: Path,
|
|
146
|
+
depth: str,
|
|
147
|
+
) -> tuple[int, str, str]:
|
|
148
|
+
await self.run_git(
|
|
149
|
+
"fetch",
|
|
150
|
+
"--deepen",
|
|
151
|
+
depth,
|
|
152
|
+
remote_url,
|
|
153
|
+
f"{origin}:{local_ref}",
|
|
154
|
+
cwd=module_path,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
async def try_merge(
|
|
158
|
+
self,
|
|
159
|
+
progress: Progress,
|
|
160
|
+
task_id: TaskID,
|
|
161
|
+
remote_url: str,
|
|
162
|
+
local_ref: str,
|
|
163
|
+
module_path: Path,
|
|
164
|
+
origin: ModuleOrigin,
|
|
165
|
+
base_origin: ModuleOrigin,
|
|
166
|
+
) -> bool:
|
|
167
|
+
# Merge
|
|
168
|
+
for i in range(2):
|
|
169
|
+
progress.update(
|
|
170
|
+
task_id, status=f"Merging {local_ref} into {base_origin.origin} (attempt {i + 2})...", advance=0.1
|
|
171
|
+
)
|
|
172
|
+
ret, out, err = await self.run_git("merge", "--no-edit", local_ref, cwd=module_path)
|
|
173
|
+
|
|
174
|
+
if "CONFLICT" in out:
|
|
175
|
+
progress.update(task_id, status=f"[purple]Merge conflict while merging {origin.origin}")
|
|
176
|
+
return ret, out, err
|
|
177
|
+
|
|
178
|
+
if ret != 0:
|
|
179
|
+
await self.run_git("merge", "--abort", cwd=module_path)
|
|
180
|
+
|
|
181
|
+
depth = str(BASE_DEPTH_VALUE ** (i + 1))
|
|
182
|
+
progress.update(task_id, status=f"[yellow]Deepening fetch to depth {depth}...")
|
|
183
|
+
fetch_origin = origin.origin
|
|
184
|
+
await self.deepen_fetch(
|
|
185
|
+
remote_url,
|
|
186
|
+
fetch_origin,
|
|
187
|
+
local_ref,
|
|
188
|
+
module_path,
|
|
189
|
+
depth,
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
return ret, out, err
|
|
193
|
+
|
|
194
|
+
ret, out, err = await self.run_git(
|
|
195
|
+
"fetch",
|
|
196
|
+
"--unshallow",
|
|
197
|
+
remote_url,
|
|
198
|
+
f"{origin.origin}:{local_ref}",
|
|
199
|
+
cwd=module_path,
|
|
200
|
+
)
|
|
201
|
+
if ret != 0:
|
|
202
|
+
progress.update(task_id, status=f"[red]epen fetch failed while merging {local_ref}: {err}")
|
|
203
|
+
return ret, out, err
|
|
204
|
+
|
|
205
|
+
ret, out, err = await self.run_git("merge", "--no-edit", local_ref, cwd=module_path)
|
|
206
|
+
if ret != 0:
|
|
207
|
+
progress.update(task_id, status=f"[red]Merge conflict in {origin.origin}: {err}")
|
|
208
|
+
# In case of conflict, we might want to abort the merge
|
|
209
|
+
await self.run_git("merge", "--abort", cwd=module_path)
|
|
210
|
+
|
|
211
|
+
return ret, out, err
|
|
212
|
+
|
|
213
|
+
async def process_module(
|
|
214
|
+
self, name: str, spec: ModuleSpec, progress: Progress, count_progress: Progress, count_task: TaskID
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Processes a single ModuleSpec."""
|
|
217
|
+
total_steps = len(spec.origins) if spec.origins else 1
|
|
218
|
+
|
|
219
|
+
async with self.semaphore:
|
|
220
|
+
task_id = progress.add_task(f"[cyan]{name}", status="Waiting...", total=total_steps)
|
|
221
|
+
try:
|
|
222
|
+
if not spec.origins:
|
|
223
|
+
progress.update(task_id, status="[yellow]No origins defined", completed=1)
|
|
224
|
+
return -1
|
|
225
|
+
|
|
226
|
+
module_path = self.get_module_path(name, spec)
|
|
227
|
+
|
|
228
|
+
# 1. Initialize with first origin
|
|
229
|
+
base_origin = spec.origins[0]
|
|
230
|
+
remote_url = (spec.remotes or {}).get(base_origin.remote) or base_origin.remote
|
|
231
|
+
|
|
232
|
+
if not module_path.exists() or not module_path.is_dir():
|
|
233
|
+
progress.update(task_id, status=f"Cloning {base_origin.origin}...")
|
|
234
|
+
|
|
235
|
+
# Clone shallowly with blobless filter and no checkout
|
|
236
|
+
# We don't use the cache yet for simplicity, but we follow the optimized command
|
|
237
|
+
# User --revision for specific commit checkout if needed
|
|
238
|
+
if name == "odoo":
|
|
239
|
+
ret, out, err = await self.run_git(
|
|
240
|
+
"clone",
|
|
241
|
+
"--filter=blob:none",
|
|
242
|
+
"--depth",
|
|
243
|
+
"1",
|
|
244
|
+
"--origin",
|
|
245
|
+
base_origin.remote,
|
|
246
|
+
"--single-branch",
|
|
247
|
+
"--branch",
|
|
248
|
+
base_origin.origin,
|
|
249
|
+
remote_url,
|
|
250
|
+
module_path,
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
args = create_clone_args(base_origin, remote_url)
|
|
254
|
+
|
|
255
|
+
ret, out, err = await self.run_git(
|
|
256
|
+
*args,
|
|
257
|
+
str(module_path),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
for name, url in (spec.remotes or {}).items():
|
|
261
|
+
if name != "origin":
|
|
262
|
+
await self.run_git("remote", "add", name, url, cwd=module_path)
|
|
263
|
+
await self.run_git("config", f"remote.{name}.promisor", "true", cwd=module_path)
|
|
264
|
+
await self.run_git(
|
|
265
|
+
"config", f"remote.{name}.partialclonefilter", "blob:none", cwd=module_path
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if ret != 0:
|
|
269
|
+
progress.update(task_id, status=f"[red]Clone failed why cloning base branch: {err}")
|
|
270
|
+
return ret
|
|
271
|
+
else:
|
|
272
|
+
ret, out, err = await self.run_git("status", "--porcelain", cwd=module_path)
|
|
273
|
+
|
|
274
|
+
if out != "":
|
|
275
|
+
progress.update(task_id, status=f"[red]Repo is dirty:\n{out}")
|
|
276
|
+
return ret
|
|
277
|
+
# Reset all the local origin to their remote origins
|
|
278
|
+
progress.update(
|
|
279
|
+
task_id,
|
|
280
|
+
status=(
|
|
281
|
+
f"Resetting existing repository for {base_origin.origin}"
|
|
282
|
+
+ " to {base_origin.remote}/{base_origin.origin}..."
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
ret, out, err = await self.run_git(
|
|
286
|
+
"reset",
|
|
287
|
+
"--hard",
|
|
288
|
+
f"{base_origin.remote}/{base_origin.origin}",
|
|
289
|
+
cwd=module_path,
|
|
290
|
+
)
|
|
291
|
+
if ret != 0:
|
|
292
|
+
progress.update(task_id, status=f"[red]Reset failed: {err}")
|
|
293
|
+
return ret
|
|
294
|
+
|
|
295
|
+
for origin in spec.origins[1:]:
|
|
296
|
+
local_ref = _get_local_ref(origin)
|
|
297
|
+
# This is probably the best thing but for now this works good enough
|
|
298
|
+
# TODO(franz): find something better
|
|
299
|
+
ret, out, err = await self.run_git("branch", "-d", local_ref, cwd=module_path)
|
|
300
|
+
|
|
301
|
+
if name != "odoo":
|
|
302
|
+
# We don't do sparse checkout for odoo because the odoo repo does not work at
|
|
303
|
+
# all like the other repos (modules are in addons/ and src/addons/) instead of
|
|
304
|
+
# at the root of the repo
|
|
305
|
+
|
|
306
|
+
# TODO(franz): there is probably a way to make it work, but for now we skip it
|
|
307
|
+
# this is probably a good way to gain performance
|
|
308
|
+
|
|
309
|
+
# 2. Sparse Checkout setup
|
|
310
|
+
progress.update(task_id, status="Configuring sparse checkout...")
|
|
311
|
+
await self.run_git("sparse-checkout", "init", "--cone", cwd=module_path)
|
|
312
|
+
if spec.modules:
|
|
313
|
+
await self.run_git("sparse-checkout", "set", *spec.modules, cwd=module_path)
|
|
314
|
+
|
|
315
|
+
# 3. Checkout base
|
|
316
|
+
await self.run_git("checkout", base_origin.origin, cwd=module_path)
|
|
317
|
+
progress.advance(task_id)
|
|
318
|
+
|
|
319
|
+
# 4. Fetch and Merge remaining origins
|
|
320
|
+
for origin in spec.origins[1:]:
|
|
321
|
+
progress.update(
|
|
322
|
+
task_id,
|
|
323
|
+
status=(
|
|
324
|
+
f"Merging {origin.remote}/{origin.origin}"
|
|
325
|
+
+ f" into {base_origin.remote}/{base_origin.origin}..."
|
|
326
|
+
),
|
|
327
|
+
advance=0.1,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
remote_url = (spec.remotes or {}).get(origin.remote) or origin.remote
|
|
331
|
+
|
|
332
|
+
local_ref = _get_local_ref(origin)
|
|
333
|
+
|
|
334
|
+
ret, out, err = await self.run_git(
|
|
335
|
+
"fetch", "--depth", "1", remote_url, f"{origin.origin}:{local_ref}", cwd=module_path
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if ret != 0:
|
|
339
|
+
# Should not necessarily crash
|
|
340
|
+
progress.update(task_id, status=f"[red]Fetch failed for {origin.origin}")
|
|
341
|
+
return ret
|
|
342
|
+
|
|
343
|
+
# Try to merge
|
|
344
|
+
ret, out, err = await self.try_merge(
|
|
345
|
+
progress, task_id, remote_url, local_ref, module_path, origin, base_origin
|
|
346
|
+
)
|
|
347
|
+
if ret != 0:
|
|
348
|
+
return ret
|
|
349
|
+
|
|
350
|
+
progress.advance(task_id)
|
|
351
|
+
|
|
352
|
+
if spec.shell_commands:
|
|
353
|
+
ret = await self.run_shell_commands(progress, task_id, spec, module_path)
|
|
354
|
+
if ret != 0:
|
|
355
|
+
return ret
|
|
356
|
+
|
|
357
|
+
if spec.patch_globs_to_apply:
|
|
358
|
+
for glob in spec.patch_globs_to_apply:
|
|
359
|
+
progress.update(task_id, status=f"Applying patches: {glob}...", advance=0.1)
|
|
360
|
+
ret, out, err = await self.run_git("am", glob, cwd=module_path)
|
|
361
|
+
if ret != 0:
|
|
362
|
+
await self.run_git("am", "--abort", cwd=module_path)
|
|
363
|
+
progress.update(task_id, status=f"[red]Applying patches failed: {err}")
|
|
364
|
+
return ret
|
|
365
|
+
|
|
366
|
+
progress.update(task_id, status="[green]Complete")
|
|
367
|
+
progress.remove_task(task_id)
|
|
368
|
+
count_progress.advance(count_task)
|
|
369
|
+
|
|
370
|
+
except Exception as e:
|
|
371
|
+
progress.update(task_id, status=f"[red]Error: {str(e)}")
|
|
372
|
+
|
|
373
|
+
async def process_project(self, project_spec: ProjectSpec) -> None:
|
|
374
|
+
"""Processes all modules in a ProjectSpec."""
|
|
375
|
+
self.modules_dir.mkdir(parents=True, exist_ok=True)
|
|
376
|
+
|
|
377
|
+
task_list_progress = Progress(
|
|
378
|
+
SpinnerColumn(),
|
|
379
|
+
TextColumn("[progress.description]{task.description}"),
|
|
380
|
+
BarColumn(),
|
|
381
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
382
|
+
TextColumn("{task.fields[status]}"),
|
|
383
|
+
console=console,
|
|
384
|
+
refresh_per_second=10,
|
|
385
|
+
)
|
|
386
|
+
task_count_progress = Progress(
|
|
387
|
+
MofNCompleteColumn(),
|
|
388
|
+
console=console,
|
|
389
|
+
refresh_per_second=10,
|
|
390
|
+
)
|
|
391
|
+
task_list_progress.start()
|
|
392
|
+
task_count_progress.start()
|
|
393
|
+
count_task = task_count_progress.add_task("Processing Modules", total=len(project_spec.specs))
|
|
394
|
+
try:
|
|
395
|
+
tasks = []
|
|
396
|
+
for name, spec in project_spec.specs.items():
|
|
397
|
+
tasks.append(
|
|
398
|
+
self.process_module(
|
|
399
|
+
name,
|
|
400
|
+
spec,
|
|
401
|
+
task_list_progress,
|
|
402
|
+
task_count_progress,
|
|
403
|
+
count_task,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
await asyncio.gather(*tasks)
|
|
408
|
+
finally:
|
|
409
|
+
task_list_progress.stop()
|
|
410
|
+
task_count_progress.stop()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
async def process_project(project_spec: ProjectSpec, workdir: Path, concurrency: int = 4) -> None:
|
|
414
|
+
"""Helper function to run the SpecProcessor."""
|
|
415
|
+
processor = SpecProcessor(workdir, concurrency)
|
|
416
|
+
# project_spec.specs = {name: spec for name, spec in project_spec.specs.items() if name == "sale-workflow"}
|
|
417
|
+
await processor.process_project(project_spec)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bl-odoo
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: A command-line tool for managing Odoo dependencies.
|
|
5
|
+
Author-email: Your Name <your.email@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
11
|
+
Requires-Dist: rich
|
|
12
|
+
Requires-Dist: typer
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: ruff; extra == "dev"
|
|
15
|
+
Requires-Dist: kalong; extra == "dev"
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# bl
|
|
19
|
+
|
|
20
|
+
A new Python project.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
bl/__init__.py
|
|
5
|
+
bl/__main__.py
|
|
6
|
+
bl/spec_parser.py
|
|
7
|
+
bl/spec_processor.py
|
|
8
|
+
bl_odoo.egg-info/PKG-INFO
|
|
9
|
+
bl_odoo.egg-info/SOURCES.txt
|
|
10
|
+
bl_odoo.egg-info/dependency_links.txt
|
|
11
|
+
bl_odoo.egg-info/entry_points.txt
|
|
12
|
+
bl_odoo.egg-info/requires.txt
|
|
13
|
+
bl_odoo.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bl
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "bl-odoo"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "A command-line tool for managing Odoo dependencies."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name="Your Name", email="your.email@example.com" },
|
|
7
|
+
]
|
|
8
|
+
dependencies = [
|
|
9
|
+
"pyyaml>=6.0.3",
|
|
10
|
+
"rich",
|
|
11
|
+
"typer",
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
license = "MIT"
|
|
16
|
+
license-files = ["LICENSE"]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"ruff",
|
|
21
|
+
"kalong",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
bl = "bl.__main__:run"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools>=61.0"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools]
|
|
32
|
+
packages = ["bl"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.package-data]
|
|
35
|
+
"bl" = ["py.typed"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 120
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I"]
|
|
42
|
+
|
|
43
|
+
[tool.mypy]
|
|
44
|
+
strict = true
|
bl_odoo-0.1.1/setup.cfg
ADDED