bl-odoo 0.2.7__py3-none-any.whl → 0.3.1__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.
bl/__main__.py CHANGED
@@ -12,15 +12,19 @@ def run():
12
12
  parser = argparse.ArgumentParser(
13
13
  description="Process a project specification.", formatter_class=argparse.ArgumentDefaultsHelpFormatter
14
14
  )
15
- parser.add_argument(
16
- "-f", "--freeze", const=True, default=None, nargs="?", type=Path, help="Freeze the current state of modules"
17
- )
18
- parser.add_argument(
15
+
16
+ parent_parser = argparse.ArgumentParser(add_help=False)
17
+ parent_parser.add_argument(
19
18
  "-c", "--config", type=Path, help="Path to the project specification file.", default="spec.yaml"
20
19
  )
21
- parser.add_argument("-z", "--frozen", type=Path, help="Path to the frozen specification file.")
22
- parser.add_argument("-j", "--concurrency", type=int, default=28, help="Number of concurrent tasks.")
23
- parser.add_argument("-w", "--workdir", type=Path, help="Working directory. Defaults to config directory.")
20
+ parent_parser.add_argument("-z", "--frozen", type=Path, help="Path to the frozen specification file.")
21
+ parent_parser.add_argument("-j", "--concurrency", type=int, default=28, help="Number of concurrent tasks.")
22
+ parent_parser.add_argument("-w", "--workdir", type=Path, help="Working directory. Defaults to config directory.")
23
+
24
+ sub = parser.add_subparsers(help="subcommand help", dest="command")
25
+ build = sub.add_parser("build", parents=[parent_parser], help="build help")
26
+ freeze = sub.add_parser("freeze", parents=[parent_parser], help="freeze help")
27
+
24
28
  args = parser.parse_args()
25
29
 
26
30
  project_spec = load_spec_file(args.config, args.frozen, args.workdir)
@@ -28,9 +32,9 @@ def run():
28
32
  sys.exit(1)
29
33
 
30
34
  try:
31
- if args.freeze:
35
+ if args.command == "freeze":
32
36
  asyncio.run(freeze_project(project_spec, args.freeze, concurrency=args.concurrency))
33
- else:
37
+ elif args.command == "build":
34
38
  asyncio.run(process_project(project_spec, concurrency=args.concurrency))
35
39
  except Exception:
36
40
  sys.exit(1)
bl/freezer.py CHANGED
@@ -1,13 +1,11 @@
1
1
  import asyncio
2
2
  import yaml
3
- from operator import countOf
4
3
  from pathlib import Path
5
- from typing import TextIO
6
4
 
7
5
  from rich.console import Console
8
6
  from rich.live import Live
9
7
  from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskID, TextColumn
10
- from bl.spec_parser import ModuleSpec, ProjectSpec
8
+ from bl.types import RepoInfo, ProjectSpec
11
9
  from bl.utils import get_local_ref, get_module_path, run_git
12
10
 
13
11
  console = Console()
@@ -18,7 +16,7 @@ async def freeze_spec(
18
16
  progress: Progress,
19
17
  task_id: TaskID,
20
18
  module_name: str,
21
- module_spec: ModuleSpec,
19
+ module_spec: RepoInfo,
22
20
  workdir: Path,
23
21
  ):
24
22
  result = {module_name: {}}
bl/spec_parser.py CHANGED
@@ -1,18 +1,13 @@
1
1
  import re
2
2
  import warnings
3
+ from dataclasses import dataclass
3
4
  from enum import Enum
4
5
  from pathlib import Path
5
- from typing import Any, Dict, List, Optional
6
+ from typing import Any, Dict, List, Optional, Type
6
7
 
7
8
  import yaml
8
9
 
9
-
10
- class OriginType(Enum):
11
- """Type of origin reference."""
12
-
13
- BRANCH = "branch"
14
- PR = "pr"
15
- REF = "ref"
10
+ from bl.types import RepoInfo, OriginType, ProjectSpec, RefspecInfo
16
11
 
17
12
 
18
13
  def make_remote_merge_from_src(src: str) -> tuple[dict, list]:
@@ -52,60 +47,33 @@ def get_origin_type(origin_value: str) -> OriginType:
52
47
  return OriginType.BRANCH
53
48
 
54
49
 
55
- class RefspecInfo:
56
- """A git refspec with its remote, type and optional frozen sha."""
57
-
58
- def __init__(
59
- self,
60
- remote: str,
61
- ref_str: str,
62
- type: OriginType,
63
- ref_name: Optional[str],
64
- ):
65
- self.remote = remote
66
- self.refspec = ref_str
67
- """ The refspec string (branch name, PR ref, or commit hash). """
68
- self.type = type
69
- self.ref_name = ref_name
70
-
71
- def __repr__(self) -> str:
72
- return f"RefspecInfo(remote={self.remote!r}, origin={self.refspec!r}, type={self.type.value})"
73
-
74
-
75
- class ModuleSpec:
76
- """Represents the specification for a set of modules."""
77
-
78
- def __init__(
79
- self,
80
- modules: List[str],
81
- remotes: Optional[Dict[str, str]] = {},
82
- origins: Optional[List[RefspecInfo]] = [],
83
- shell_commands: Optional[List[str]] = [],
84
- patch_globs_to_apply: Optional[List[str]] = None,
85
- target_folder: Optional[str] = None,
86
- frozen_modules: Optional[Dict[str, Dict[str, str]]] = None,
87
- ):
88
- self.modules = modules
89
- self.remotes = remotes
90
- self.refspec_info = origins
91
- self.shell_commands = shell_commands
92
- self.patch_globs_to_apply = patch_globs_to_apply
93
- self.frozen_modules = frozen_modules
94
- self.target_folder = None
50
+ def parse_remote_refspec_from_parts(parts: List[str], frozen_repo: Dict[str, Dict[str, str]]):
51
+ if len(parts) == 2:
52
+ parts.insert(1, "")
53
+ else:
54
+ warnings.warn(
55
+ "Deprecated src format: use <url> <sha> format for the src property",
56
+ DeprecationWarning,
57
+ )
58
+ remote_key, _, ref_spec = parts
59
+ ref_type = get_origin_type(ref_spec)
95
60
 
96
- def __repr__(self) -> str:
97
- return f"ModuleSpec(modules={self.modules}, remotes={self.remotes}, origins={self.refspec_info})"
61
+ ref_name = None
62
+ remote_freezes = frozen_repo.get(remote_key, {})
98
63
 
64
+ if ref_spec in remote_freezes:
65
+ ref_type = OriginType.REF
66
+ ref_name = ref_spec
67
+ ref_spec = remote_freezes.get(ref_name)
99
68
 
100
- class ProjectSpec:
101
- """Represents the overall project specification from the YAML file."""
69
+ return RefspecInfo(remote_key, ref_spec, ref_type, ref_name)
102
70
 
103
- def __init__(self, specs: Dict[str, ModuleSpec], workdir: Path = Path(".")):
104
- self.specs = specs
105
- self.workdir = workdir
106
71
 
107
- def __repr__(self) -> str:
108
- return f"ProjectSpec(specs={self.specs}, workdir={self.workdir})"
72
+ def get_with_syntax_check(name, data, key: str, type: Type):
73
+ result = data.get(key, type())
74
+ if not isinstance(result, type):
75
+ raise Exception(f"Key {key} not of proper syntax should be {str(type)} in {name} description")
76
+ return result
109
77
 
110
78
 
111
79
  def load_spec_file(config: Path, frozen: Path, workdir: Path) -> Optional[ProjectSpec]:
@@ -123,6 +91,7 @@ def load_spec_file(config: Path, frozen: Path, workdir: Path) -> Optional[Projec
123
91
  config = config.resolve()
124
92
  # If the file is not in the current directory, check inside the odoo subdirectory
125
93
  odoo_config = config.parent / "odoo" / config.name
94
+ # TODO(franz): should use rich console for prettiness
126
95
  if not odoo_config.exists():
127
96
  print(f"Error: Neither '{config}' nor '{odoo_config}' exists.")
128
97
  return None
@@ -151,22 +120,21 @@ def load_spec_file(config: Path, frozen: Path, workdir: Path) -> Optional[Projec
151
120
  except yaml.YAMLError as e:
152
121
  print(f"Error parsing frozen YAML file '{frozen_path}': {e}")
153
122
 
154
- specs: Dict[str, ModuleSpec] = {}
155
- for section_name, section_data in data.items():
156
- modules = section_data.get("modules", [])
157
- src = section_data.get("src")
158
- remotes = section_data.get("remotes") or {}
159
- merges = section_data.get("merges") or []
160
- shell_commands = section_data.get("shell_command_after") or None
161
- patch_globs_to_apply = section_data.get("patch_globs") or None
162
-
163
- frozen_for_section_raw = frozen_mapping.get(section_name)
164
- frozen_for_section: Optional[Dict[str, Dict[str, str]]] = (
165
- frozen_for_section_raw if isinstance(frozen_for_section_raw, dict) else None
166
- )
123
+ repos: Dict[str, RepoInfo] = {}
124
+ for repo_name, repo_data in data.items():
125
+ modules = get_with_syntax_check(repo_name, repo_data, "modules", list)
126
+ src = get_with_syntax_check(repo_name, repo_data, "src", str)
127
+ remotes = get_with_syntax_check(repo_name, repo_data, "remotes", dict)
128
+ merges = get_with_syntax_check(repo_name, repo_data, "merges", list)
129
+ shell_commands = get_with_syntax_check(repo_name, repo_data, "shell_command_after", list)
130
+ patch_globs_to_apply = get_with_syntax_check(repo_name, repo_data, "patch_globs", list)
131
+ target_folder = get_with_syntax_check(repo_name, repo_data, "target_folder", str)
132
+ locales = get_with_syntax_check(repo_name, repo_data, "locales", list)
133
+
134
+ frozen_repo = frozen_mapping.get(repo_name, {})
167
135
 
168
136
  # Parse merges into RefspecInfo objects
169
- origins: List[RefspecInfo] = []
137
+ refspec_infos: List[RefspecInfo] = []
170
138
  if src:
171
139
  # If src is defined, create a remote and merge entry from it
172
140
  src_remotes, src_merges = make_remote_merge_from_src(src)
@@ -175,51 +143,17 @@ def load_spec_file(config: Path, frozen: Path, workdir: Path) -> Optional[Projec
175
143
 
176
144
  for merge_entry in merges:
177
145
  parts = merge_entry.split(" ", 2)
178
- if len(parts) == 2:
179
- remote_key, ref_spec = parts
180
-
181
- # Determine type: PR if matches refs/pull/{pr_id}/head pattern, otherwise branch
182
- ref_type = get_origin_type(ref_spec)
183
-
184
- ref_name = None
185
- if frozen_for_section:
186
- remote_freezes = frozen_for_section.get(remote_key) or {}
187
- ref_name = ref_spec
188
- ref_type = OriginType.REF
189
- frozen_ref = remote_freezes.get(ref_spec)
190
- ref_spec = frozen_ref or ref_spec
191
-
192
- origins.append(
193
- RefspecInfo(
194
- remote_key,
195
- ref_spec,
196
- ref_type,
197
- ref_name,
198
- )
199
- )
200
- elif len(parts) == 3:
201
- warnings.warn(
202
- "Deprecated src format: use <url> <sha> format for the src property",
203
- DeprecationWarning,
204
- )
205
- remote_key, _, ref_spec = parts
206
- ref_type = get_origin_type(ref_spec)
207
-
208
- ref_name = None
209
- if frozen_for_section:
210
- remote_freezes = frozen_for_section.get(remote_key) or {}
211
- ref_name = ref_spec
212
- ref_spec = remote_freezes.get(ref_spec)
213
-
214
- origins.append(RefspecInfo(remote_key, ref_spec, ref_type, ref_name))
215
-
216
- specs[section_name] = ModuleSpec(
146
+ refspec_info = parse_remote_refspec_from_parts(parts, frozen_repo)
147
+ refspec_infos.append(refspec_info)
148
+
149
+ repos[repo_name] = RepoInfo(
217
150
  modules,
218
151
  remotes,
219
- origins,
152
+ refspec_infos,
220
153
  shell_commands,
221
154
  patch_globs_to_apply,
222
- frozen_modules=frozen_for_section or None,
155
+ target_folder,
156
+ locales,
223
157
  )
224
158
 
225
- return ProjectSpec(specs, workdir)
159
+ return ProjectSpec(repos, workdir)
bl/spec_processor.py CHANGED
@@ -1,13 +1,10 @@
1
1
  import asyncio
2
- import hashlib
3
- from logging import root
4
2
  import os
5
- from posix import link
6
3
  import warnings
7
- import shutil
8
4
  from pathlib import Path
9
- from typing import Any, Dict, List, Optional
5
+ from typing import Dict, List
10
6
 
7
+ from rich import progress
11
8
  from rich.console import Console
12
9
  from rich.live import Live
13
10
  from rich.progress import BarColumn, MofNCompleteColumn, Progress, SpinnerColumn, TaskID, TextColumn
@@ -16,10 +13,17 @@ from typing_extensions import deprecated
16
13
 
17
14
  from bl.utils import english_env, get_local_ref, get_module_path, run_git
18
15
 
19
- from .spec_parser import ModuleSpec, OriginType, ProjectSpec, RefspecInfo
16
+ from bl.types import CloneFlags, CloneInfo, OriginType, ProjectSpec, RefspecInfo, RepoInfo
20
17
 
21
18
  console = Console()
22
19
 
20
+ # TODO(franz): it's a bit better now but better keep an eye on it
21
+ # TODO(franz): Error handling should be watch carefully because if
22
+ # we don't exit on some error code due to the fact that git resolve to
23
+ # the parent repo we could activate sparse checkout on a parent folder
24
+ # should probably make a function that handles the error in a unified manner
25
+ # and crash if the error is on a vital part of the process
26
+
23
27
 
24
28
  def rich_warning(message, category, filename, lineno, file=None, line=None):
25
29
  console.print(f"[yellow]Warning:[/] {category.__name__}: {message}\n[dim]{filename}:{lineno}[/]")
@@ -29,24 +33,44 @@ warnings.showwarning = rich_warning
29
33
  warnings.simplefilter("default", DeprecationWarning)
30
34
 
31
35
 
32
- # for single branch we should clone shallow but for other we should clone
33
- # with tree:0 filter and because this avoid confusing fetch for git to have the history
34
- # before fetching
35
- def create_clone_args(name: str, ref_spec_info: RefspecInfo, remote_url: str, shallow: bool) -> List[str]:
36
+ def check_path_is_repo(module_path: Path):
37
+ # TODO(franz): add check for .git folder
38
+ return not module_path.exists() or not module_path.is_dir()
39
+
40
+
41
+ def clone_info_from_repo(name: str, repo_info: RepoInfo):
42
+ flags = CloneFlags.SHALLOW if name == "odoo" or len(repo_info.refspec_info) == 1 else 0
43
+ flags |= CloneFlags.SPARSE if name != "odoo" or len(repo_info.locales) > 0 else 0
44
+ root_refspec_info = repo_info.refspec_info[0]
45
+ remote_url = repo_info.remotes.get(root_refspec_info.remote)
46
+
47
+ return CloneInfo(
48
+ remote_url,
49
+ flags,
50
+ root_refspec_info,
51
+ )
52
+
53
+
54
+ # for single branch we should clone shallow but for other we should clone deep
55
+ # this allows us to get merge-base to work and git can then merge by pulling the minimum
56
+ # amount of data
57
+ def create_clone_args(clone_info: CloneInfo) -> List[str]:
36
58
  """Creates git clone arguments based on the base origin."""
37
59
  args = [
38
60
  "clone",
39
61
  "--filter=tree:0",
40
62
  ]
41
63
 
42
- if name == "odoo" or shallow:
64
+ if clone_info.clone_flags & CloneFlags.SHALLOW:
43
65
  args += [
44
66
  "--depth",
45
67
  "1",
46
68
  ]
47
- else:
69
+ if clone_info.clone_flags & CloneFlags.SPARSE:
48
70
  args += ["--sparse"]
49
71
 
72
+ ref_spec_info = clone_info.root_refspec_info
73
+
50
74
  if ref_spec_info.type == OriginType.REF:
51
75
  args += [
52
76
  "--revision",
@@ -61,7 +85,7 @@ def create_clone_args(name: str, ref_spec_info: RefspecInfo, remote_url: str, sh
61
85
  ]
62
86
 
63
87
  args += [
64
- remote_url,
88
+ clone_info.url,
65
89
  ]
66
90
 
67
91
  return args
@@ -74,22 +98,37 @@ def normalize_merge_result(ret: int, out: str, err: str):
74
98
  return ret, err
75
99
 
76
100
 
77
- class SpecProcessor:
101
+ class RepoProcessor:
78
102
  """
79
103
  Processes a ProjectSpec by concurrently cloning and merging modules.
80
104
  """
81
105
 
82
- def __init__(self, workdir: Path, concurrency: int = 4):
106
+ def __init__(
107
+ self,
108
+ workdir: Path,
109
+ name: str,
110
+ semaphore: asyncio.Semaphore,
111
+ repo_info: RepoInfo,
112
+ progress: Progress,
113
+ count_progress: Progress,
114
+ count_task: TaskID,
115
+ concurrency: int,
116
+ ):
83
117
  self.workdir = workdir
118
+ self.name = name
119
+ self.semaphore = semaphore
120
+ self.repo_info = repo_info
121
+ self.progress = progress
122
+ self.count_progress = count_progress
123
+ self.count_task = count_task
84
124
  self.concurrency = concurrency
85
- self.semaphore = asyncio.Semaphore(concurrency)
86
125
 
87
126
  @deprecated(
88
127
  "run_shell_commands is deprecated if used to apply patches. Use patch_globs properties in spec.yaml instead."
89
128
  )
90
- async def run_shell_commands(self, progress: Progress, task_id: TaskID, spec: ModuleSpec, module_path: Path) -> int:
91
- for cmd in spec.shell_commands:
92
- progress.update(task_id, status=f"Running shell command: {cmd}...")
129
+ async def run_shell_commands(self, repo_info: RepoInfo, module_path: Path) -> int:
130
+ for cmd in repo_info.shell_commands:
131
+ self.progress.update(self.task_id, status=f"Running shell command: {cmd}...")
93
132
  proc = await asyncio.create_subprocess_shell(
94
133
  cmd,
95
134
  cwd=str(module_path),
@@ -102,128 +141,74 @@ class SpecProcessor:
102
141
  # This is a sanity check because people usually put "git am" commands
103
142
  # in shell_commands, so we abort any ongoing git am
104
143
  await run_git("am", "--abort", cwd=str(module_path))
105
- progress.update(
106
- task_id,
144
+ self.progress.update(
145
+ self.task_id,
107
146
  status=f"[red]Shell command failed: {cmd}\nError: {stderr.decode().strip()}",
108
147
  )
109
148
  return -1
110
149
  return 0
111
150
 
112
- async def fetch_local_ref(
113
- self,
114
- origin: RefspecInfo,
115
- local_ref: str,
116
- module_path: Path,
117
- ) -> tuple[int, str, str]:
118
- return await run_git(
119
- "fetch",
120
- origin.remote,
121
- f"{origin.refspec}:{local_ref}",
122
- cwd=module_path,
123
- )
124
-
125
- async def clone_base_repo_ref(
126
- self, name: str, ref_spec_info: RefspecInfo, remote_url: str, module_path: Path, shallow: bool
127
- ) -> tuple[int, str, str]:
128
- args = create_clone_args(name, ref_spec_info, remote_url, shallow)
129
-
130
- ret, out, err = await run_git(
131
- *args,
132
- str(module_path),
133
- )
134
-
135
- # if it's a ref we need to manually create a base branch because we cannot
136
- # merge in a detached head
137
- local_ref = get_local_ref(ref_spec_info)
138
- ret, out, err = await run_git(
139
- "checkout",
140
- "-b",
141
- local_ref,
142
- cwd=str(module_path),
143
- )
144
-
145
- return ret, out, err
146
-
147
- async def try_merge(
148
- self,
149
- progress: Progress,
150
- task_id: TaskID,
151
- remote_url: str,
152
- local_ref: str,
153
- module_path: Path,
154
- origin: RefspecInfo,
155
- ) -> tuple[int, str]:
156
- # Merge
157
- # I think the idea would be to not fetch shallow but fetch treeless and do a merge-base
158
- # then fetch the required data and then merge
159
- progress.update(task_id, status=f"Merging {local_ref}", advance=0.1)
160
- ret, out, err = await run_git("merge", "--no-edit", local_ref, cwd=module_path)
161
- ret, err = normalize_merge_result(ret, out, err)
162
-
163
- if "CONFLICT" in err:
164
- progress.update(task_id, status=f"[red]Merge conflict in {origin.refspec}: {err}")
165
- # In case of conflict, we might want to abort the merge
166
- await run_git("merge", "--abort", cwd=module_path)
167
- return ret, err
168
-
169
151
  async def setup_new_repo(
170
152
  self,
171
- progress: Progress,
172
- task_id: TaskID,
173
- spec: ModuleSpec,
174
- name: str,
175
- root_refspec_info: RefspecInfo,
176
- remote_url: str,
153
+ clone_info: CloneInfo,
177
154
  module_path: Path,
178
155
  ) -> int:
179
- progress.update(
180
- task_id,
181
- status=(f"Cloning {root_refspec_info.remote}/{root_refspec_info.refspec}"),
156
+ root_refspec_info = clone_info.root_refspec_info
157
+ remote = root_refspec_info.remote
158
+ root_refspec = root_refspec_info.refspec
159
+
160
+ self.progress.update(
161
+ self.task_id,
162
+ status=(f"Cloning {remote}/{root_refspec}"),
182
163
  )
183
164
 
184
- # Clone shallowly with blobless filter and no checkout
185
- # We don't use the cache yet for simplicity, but we follow the optimized command
186
- # User --revision for specific commit checkout if needed
187
- shallow_clone = len(spec.refspec_info) == 1
188
- ret, out, err = await self.clone_base_repo_ref(name, root_refspec_info, remote_url, module_path, shallow_clone)
165
+ clone_args = create_clone_args(clone_info)
166
+ ret, out, err = await run_git(*clone_args, module_path)
189
167
 
190
168
  if ret != 0:
191
169
  status_message = (
192
- f"[red]Clone failed {root_refspec_info.remote}({remote_url})/{root_refspec_info.refspec}"
170
+ f"[red]Clone failed {root_refspec_info.remote}({clone_info.url})/{root_refspec_info.refspec}"
193
171
  + f" -> {module_path}:\n{err}"
194
172
  )
195
- progress.update(task_id, status=status_message)
173
+ self.progress.update(self.task_id, status=status_message)
196
174
  return ret
197
175
 
198
- async def reset_repo_for_work(
199
- self, progress: Progress, task_id: TaskID, spec: ModuleSpec, root_refspec_info: RefspecInfo, module_path: Path
200
- ) -> int:
176
+ local_ref = get_local_ref(root_refspec_info)
177
+ ret, out, err = await run_git("checkout", "-b", local_ref, cwd=module_path)
178
+
179
+ return 0
180
+
181
+ async def reset_repo_for_work(self, module_path: Path) -> int:
182
+ # TODO(franz): we should test if the folder is a git repo or not
183
+
201
184
  ret, out, err = await run_git("status", "--porcelain", cwd=module_path)
202
185
 
203
186
  if out != "":
204
- progress.update(task_id, status=f"[red]Repo is dirty:\n{out}")
187
+ self.progress.update(self.task_id, status=f"[red]Repo is dirty:\n{out}")
188
+ return ret
189
+ if ret != 0:
190
+ self.progress.update(self.task_id, status="[red]Repo does not exist")
205
191
  return ret
206
192
  # Reset all the local origin to their remote origins
207
- progress.update(
208
- task_id,
193
+ repo_info = self.repo_info
194
+ root_refspec_info = repo_info.refspec_info[0]
195
+
196
+ self.progress.update(
197
+ self.task_id,
209
198
  status=(f"Resetting existing repository for {root_refspec_info.remote}/{root_refspec_info.refspec}"),
210
199
  )
211
200
 
212
201
  s_ret, s_out, s_err = await run_git("rev-parse", "--is-shallow-repository", cwd=module_path)
213
- if len(spec.refspec_info) > 1 and s_out == "true":
202
+ if len(repo_info.refspec_info) > 1 and s_out == "true":
214
203
  await run_git("fetch", "--unshallow", cwd=module_path)
215
204
 
216
- reset_target = f"{root_refspec_info.remote}/{root_refspec_info.refspec}"
205
+ reset_target = get_local_ref(root_refspec_info)
217
206
  ret, out, err = await run_git("reset", "--hard", reset_target, cwd=module_path)
218
207
  if ret != 0:
219
- progress.update(task_id, status=f"[red]Reset failed: {err}")
208
+ self.progress.update(self.task_id, status=f"[red]Reset failed: {err}")
220
209
  return ret
221
210
 
222
- for refspec_info in spec.refspec_info[1:]:
223
- local_ref = get_local_ref(refspec_info)
224
- # This is probably the best thing but for now this works good enough
225
- # TODO(franz): find something better
226
- ret, out, err = await run_git("branch", "-d", local_ref, cwd=module_path)
211
+ return 0
227
212
 
228
213
  def link_all_modules(self, module_list: List[str], module_path: Path) -> tuple[int, str]:
229
214
  links_path = self.workdir / "links"
@@ -247,9 +232,7 @@ class SpecProcessor:
247
232
 
248
233
  async def merge_spec_into_tree(
249
234
  self,
250
- progress: Progress,
251
- task_id: TaskID,
252
- spec: ModuleSpec,
235
+ spec: RepoInfo,
253
236
  refspec_info: RefspecInfo,
254
237
  root_refspec_info: RefspecInfo,
255
238
  module_path: Path,
@@ -260,11 +243,23 @@ class SpecProcessor:
260
243
  local_ref = get_local_ref(refspec_info)
261
244
  remote_ref = refspec_info.refspec
262
245
 
263
- ret, err = await self.try_merge(progress, task_id, remote_url, local_ref, module_path, refspec_info)
246
+ # Merge
247
+ # I think the idea would be to not fetch shallow but fetch treeless and do a merge-base
248
+ # then fetch the required data and then merge
249
+ self.progress.update(self.task_id, status=f"Merging {local_ref}", advance=0.1)
250
+ ret, out, err = await run_git("merge", "--no-edit", local_ref, cwd=module_path)
251
+ ret, err = normalize_merge_result(ret, out, err)
252
+
253
+ if "CONFLICT" in err:
254
+ self.progress.update(self.task_id, status=f"[red]Merge conflict {local_ref} in {remote_ref}: {err}")
255
+ # In case of conflict, we might want to abort the merge
256
+ await run_git("merge", "--abort", cwd=module_path)
257
+ return ret, err
258
+
264
259
  if ret != 0:
260
+ self.progress.update(self.task_id, status=f"[red]Merge error {local_ref} in {remote_ref}: {err}")
265
261
  return ret, err
266
262
 
267
- progress.advance(task_id)
268
263
  return 0, ""
269
264
 
270
265
  def get_refspec_by_remote(self, refspec_info_list: List[RefspecInfo]) -> Dict[str, List[RefspecInfo]]:
@@ -293,7 +288,7 @@ class SpecProcessor:
293
288
 
294
289
  return ret, out, err
295
290
 
296
- def filter_non_link_module(self, spec: ModuleSpec):
291
+ def filter_non_link_module(self, spec: RepoInfo):
297
292
  result = []
298
293
  base_path_links = self.workdir / "links"
299
294
  for module in spec.modules:
@@ -307,153 +302,170 @@ class SpecProcessor:
307
302
  )
308
303
  return result
309
304
 
310
- async def process_module(
311
- self, name: str, spec: ModuleSpec, progress: Progress, count_progress: Progress, count_task: TaskID
312
- ) -> int:
313
- """Processes a single ModuleSpec."""
314
- total_steps = len(spec.refspec_info) + 1 if spec.refspec_info else 1
305
+ async def setup_odoo_sparse(self, module_spec: RepoInfo, module_path: Path):
306
+ list_modules = module_spec.modules
307
+
308
+ await run_git("sparse-checkout", "init", "--no-cone", cwd=module_path)
309
+ included_po = [f"{locale}.po" for locale in module_spec.locales]
310
+ included_modules = [f"/addons/{module}/*" for module in list_modules]
311
+ await run_git(
312
+ "sparse-checkout",
313
+ "set",
314
+ "/*",
315
+ "!/addons/*",
316
+ *included_modules,
317
+ "!*.po",
318
+ *included_po,
319
+ cwd=module_path,
320
+ )
315
321
 
316
- symlink_modules = self.filter_non_link_module(spec)
322
+ async def setup_sparse_checkout(self, symlink_modules: List[str], module_path: Path):
323
+ # 2. Sparse Checkout setup
324
+ if self.name != "odoo":
325
+ self.progress.update(self.task_id, status="Configuring sparse checkout...")
326
+ await run_git("sparse-checkout", "init", "--cone", cwd=module_path)
327
+ if symlink_modules:
328
+ await run_git("sparse-checkout", "set", *self.repo_info.modules, cwd=module_path)
329
+ elif len(self.repo_info.locales) > 0:
330
+ # TODO(franz): We should still set sparse if there is no locales but there is a module list
331
+ self.progress.update(self.task_id, status="Configuring sparse odoo checkout...")
332
+ await self.setup_odoo_sparse(self.repo_info, module_path)
333
+
334
+ async def process_repo(self) -> int:
335
+ """Processes a single ModuleSpec."""
336
+ symlink_modules = self.filter_non_link_module(self.repo_info)
337
+ module_path = get_module_path(self.workdir, self.name, self.repo_info)
317
338
 
318
339
  async with self.semaphore:
319
- task_id = progress.add_task(f"[cyan]{name}", status="Waiting...", total=total_steps)
320
340
  try:
321
- if not spec.refspec_info:
322
- progress.update(task_id, status="[yellow]No origins defined", completed=1)
341
+ self.task_id = self.progress.add_task(
342
+ f"[cyan]{self.name}", status="Waiting...", total=len(self.repo_info.refspec_info) + 1
343
+ )
344
+ if not self.repo_info.refspec_info:
345
+ self.progress.update(self.task_id, status="[yellow]No origins defined", completed=1)
323
346
  return -1
324
347
 
325
- module_path = get_module_path(self.workdir, name, spec)
326
-
327
- # 1. Initialize with first origin
328
- root_refspec_info = spec.refspec_info[0]
329
- remote_url = spec.remotes.get(root_refspec_info.remote) or root_refspec_info.remote
330
-
331
- if not module_path.exists() or not module_path.is_dir():
332
- await self.setup_new_repo(progress, task_id, spec, name, root_refspec_info, remote_url, module_path)
348
+ # TODO(franz) the shallow and sparseness of repo should be unify
349
+ # so that we don't have all those stupid conditions
350
+ if check_path_is_repo(module_path):
351
+ clone_info = clone_info_from_repo(self.name, self.repo_info)
352
+ ret = await self.setup_new_repo(clone_info, module_path)
333
353
  else:
334
- await self.reset_repo_for_work(progress, task_id, spec, root_refspec_info, module_path)
335
-
336
- if name != "odoo":
337
- # We don't do sparse checkout for odoo because the odoo repo does not work at
338
- # all like the other repos (modules are in addons/ and src/addons/) instead of
339
- # at the root of the repo
354
+ ret = await self.reset_repo_for_work(module_path)
340
355
 
341
- # TODO(franz): there is probably a way to make it work, but for now we skip it
342
- # this is probably a good way to gain performance
356
+ if ret != 0:
357
+ return -1
343
358
 
344
- # 2. Sparse Checkout setup
345
- progress.update(task_id, status="Configuring sparse checkout...")
346
- await run_git("sparse-checkout", "init", "--cone", cwd=module_path)
347
- if symlink_modules:
348
- await run_git("sparse-checkout", "set", *spec.modules, cwd=module_path)
359
+ await self.setup_sparse_checkout(symlink_modules, module_path)
349
360
 
350
361
  checkout_target = "merged"
351
362
 
352
363
  await run_git("checkout", "-b", checkout_target, cwd=module_path)
353
- progress.advance(task_id)
364
+ self.progress.advance(self.task_id)
354
365
 
355
- for remote, remote_url in spec.remotes.items():
366
+ for remote, remote_url in self.repo_info.remotes.items():
356
367
  await run_git("remote", "add", remote, remote_url, cwd=module_path)
357
368
  await run_git("config", f"remote.{remote}.partialCloneFilter", "tree:0", cwd=module_path)
358
369
  await run_git("config", f"remote.{remote}.promisor", "true", cwd=module_path)
359
370
 
360
- # TODO(franz) fetch and merge should be done separately
361
- # fetch can be done in parallel by git with -j X and putting several refspec as parameters
362
- # to git fetch
363
- refspec_by_remote: Dict[str, List[RefspecInfo]] = self.get_refspec_by_remote(spec.refspec_info[1:])
371
+ refspec_by_remote: Dict[str, List[RefspecInfo]] = self.get_refspec_by_remote(
372
+ self.repo_info.refspec_info
373
+ )
364
374
 
375
+ # TODO(franz): right now we fetch everything so when the repo is just cloned
376
+ # we fetch the base branch twice. Since we fetch with multi this is probably not
377
+ # a big issue but it could be better
365
378
  for remote, refspec_list in refspec_by_remote.items():
366
- progress.update(task_id, status=f"Fetching multi from {remote}")
379
+ self.progress.update(self.task_id, status=f"Fetching multi from {remote}")
367
380
  await self.fetch_multi(remote, refspec_list, module_path)
368
381
 
369
382
  # 4. Fetch and Merge remaining origins
370
- for refspec_info in spec.refspec_info[1:]:
383
+ for refspec_info in self.repo_info.refspec_info[1:]:
371
384
  ret, err = await self.merge_spec_into_tree(
372
- progress, task_id, spec, refspec_info, root_refspec_info, module_path
385
+ self.repo_info, refspec_info, self.repo_info.refspec_info[0], module_path
373
386
  )
374
387
  if ret != 0:
375
- progress.update(task_id, status=f"[purple]Merge failed from {refspec_info.refspec}: {err}")
376
388
  return -1
389
+ self.progress.advance(self.task_id)
377
390
 
378
- if spec.shell_commands:
379
- ret = await self.run_shell_commands(progress, task_id, spec, module_path)
391
+ if self.repo_info.shell_commands:
392
+ ret = await self.run_shell_commands(self.repo_info, module_path)
380
393
  if ret != 0:
381
394
  return ret
382
395
 
383
- if spec.patch_globs_to_apply:
384
- for glob in spec.patch_globs_to_apply:
385
- progress.update(task_id, status=f"Applying patches: {glob}...", advance=0.1)
396
+ if self.repo_info.patch_globs_to_apply:
397
+ for glob in self.repo_info.patch_globs_to_apply:
398
+ self.progress.update(self.task_id, status=f"Applying patches: {glob}...", advance=0.1)
386
399
  ret, out, err = await run_git("am", glob, cwd=module_path)
387
400
  if ret != 0:
388
401
  await run_git("am", "--abort", cwd=module_path)
389
- progress.update(task_id, status=f"[red]Applying patches failed: {err}")
402
+ self.progress.update(self.task_id, status=f"[red]Applying patches failed: {err}")
390
403
  return ret
391
404
 
392
- progress.update(task_id, status="Linking directory")
393
- if name != "odoo":
405
+ self.progress.update(self.task_id, status="Linking directory")
406
+ if self.name != "odoo":
394
407
  ret, err = self.link_all_modules(symlink_modules, module_path)
395
408
  if ret != 0:
396
- progress.update(task_id, status=f"[red]Could not link modules: {err}")
409
+ self.progress.update(self.task_id, status=f"[red]Could not link modules: {err}")
397
410
  return ret
398
411
 
399
- progress.update(task_id, status="[green]Complete")
400
- progress.remove_task(task_id)
401
- count_progress.advance(count_task)
412
+ self.progress.update(self.task_id, status="[green]Complete", advance=1)
413
+ self.progress.remove_task(self.task_id)
414
+ self.count_progress.advance(self.count_task)
402
415
 
403
416
  except Exception as e:
404
- progress.update(task_id, status=f"[red]Error: {str(e)}")
417
+ self.progress.update(self.task_id, status=f"[red]Error: {str(e)}")
418
+ raise e
405
419
  return -1
406
420
 
407
421
  return 0
408
422
 
409
- async def process_project(self, project_spec: ProjectSpec) -> None:
410
- """Processes all modules in a ProjectSpec."""
411
- (self.workdir / "external-src").mkdir(parents=True, exist_ok=True)
412
-
413
- task_list_progress = Progress(
414
- SpinnerColumn(),
415
- TextColumn("[progress.description]{task.description}"),
416
- BarColumn(),
417
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
418
- TextColumn("{task.fields[status]}", table_column=Column(ratio=2)),
419
- )
420
423
 
421
- task_count_progress = Progress(
422
- TextColumn("[progress.description]{task.description}"),
423
- BarColumn(),
424
- MofNCompleteColumn(),
425
- )
426
- count_task = task_count_progress.add_task("Processing Modules", total=len(project_spec.specs))
424
+ async def process_project(project_spec: ProjectSpec, concurrency: int) -> None:
425
+ """Processes all modules in a ProjectSpec."""
426
+ (project_spec.workdir / "external-src").mkdir(parents=True, exist_ok=True)
427
427
 
428
- progress_table = Table.grid()
429
- progress_table.add_row(
430
- task_list_progress,
431
- )
432
- progress_table.add_row(
433
- task_count_progress,
434
- )
428
+ task_list_progress = Progress(
429
+ SpinnerColumn(),
430
+ TextColumn("[progress.description]{task.description}"),
431
+ BarColumn(),
432
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
433
+ TextColumn("{task.fields[status]}", table_column=Column(ratio=2)),
434
+ )
435
435
 
436
- with Live(progress_table, console=console, refresh_per_second=10):
437
- tasks = []
438
- for name, spec in project_spec.specs.items():
439
- tasks.append(
440
- self.process_module(
441
- name,
442
- spec,
443
- task_list_progress,
444
- task_count_progress,
445
- count_task,
446
- )
447
- )
436
+ task_count_progress = Progress(
437
+ TextColumn("[progress.description]{task.description}"),
438
+ BarColumn(),
439
+ MofNCompleteColumn(),
440
+ )
441
+ count_task = task_count_progress.add_task("Processing Modules", total=len(project_spec.repos))
448
442
 
449
- # this should error if a task crashes
450
- return_codes = await asyncio.gather(*tasks)
451
- if any(return_codes):
452
- raise Exception()
443
+ progress_table = Table.grid()
444
+ progress_table.add_row(
445
+ task_list_progress,
446
+ )
447
+ progress_table.add_row(
448
+ task_count_progress,
449
+ )
453
450
 
451
+ semaphore = asyncio.Semaphore(concurrency)
452
+ with Live(progress_table, console=console, refresh_per_second=10):
453
+ tasks = []
454
+ for name, repo_info in project_spec.repos.items():
455
+ total_steps = len(repo_info.refspec_info) + 1
456
+ repo_processor = RepoProcessor(
457
+ project_spec.workdir,
458
+ name,
459
+ semaphore,
460
+ repo_info,
461
+ task_list_progress,
462
+ task_count_progress,
463
+ count_task,
464
+ concurrency,
465
+ )
466
+ tasks.append(repo_processor.process_repo())
454
467
 
455
- async def process_project(project_spec: ProjectSpec, concurrency: int = 4) -> None:
456
- """Helper function to run the SpecProcessor."""
457
- processor = SpecProcessor(project_spec.workdir, concurrency)
458
- # project_spec.specs = {name: spec for name, spec in project_spec.specs.items() if name == "sale-workflow"}
459
- return await processor.process_project(project_spec)
468
+ # this should error if a task crashes
469
+ return_codes = await asyncio.gather(*tasks)
470
+ if any(return_codes):
471
+ raise Exception()
bl/types.py ADDED
@@ -0,0 +1,86 @@
1
+ from pathlib import Path
2
+ from dataclasses import dataclass
3
+ from enum import Enum, IntEnum
4
+ from typing import Dict, List, Optional
5
+
6
+
7
+ class OriginType(Enum):
8
+ """Type of origin reference."""
9
+
10
+ BRANCH = "branch"
11
+ PR = "pr"
12
+ REF = "ref"
13
+
14
+
15
+ @dataclass
16
+ class Remote:
17
+ name: str
18
+ url: str
19
+
20
+
21
+ class CloneFlags(IntEnum):
22
+ SHALLOW = 1
23
+ SPARSE = 2
24
+
25
+
26
+ class RefspecInfo:
27
+ """A git refspec with its remote, type and optional frozen sha."""
28
+
29
+ def __init__(
30
+ self,
31
+ remote: str,
32
+ ref_str: str,
33
+ type: OriginType,
34
+ ref_name: Optional[str],
35
+ ):
36
+ self.remote = remote
37
+ self.refspec = ref_str
38
+ """ The refspec string (branch name, PR ref, or commit hash). """
39
+ self.type = type
40
+ self.ref_name = ref_name
41
+
42
+ def __repr__(self) -> str:
43
+ return f"RefspecInfo(remote={self.remote!r}, origin={self.refspec!r}, type={self.type.value})"
44
+
45
+
46
+ @dataclass
47
+ class CloneInfo:
48
+ url: str
49
+ clone_flags: int
50
+ root_refspec_info: RefspecInfo
51
+
52
+
53
+ class RepoInfo:
54
+ """Represents the specification for a set of modules."""
55
+
56
+ def __init__(
57
+ self,
58
+ modules: List[str],
59
+ remotes: List[str] = {},
60
+ refspecs: List[RefspecInfo] = [],
61
+ shell_commands: List[str] = [],
62
+ patch_globs_to_apply: List[str] = [],
63
+ target_folder: Optional[str] = None,
64
+ locales: List[str] = [],
65
+ ):
66
+ self.modules = modules
67
+ self.remotes = remotes
68
+ self.refspec_info = refspecs
69
+ self.shell_commands = shell_commands
70
+ self.patch_globs_to_apply = patch_globs_to_apply
71
+ self.target_folder = target_folder
72
+ self.locales = locales
73
+
74
+ def __repr__(self) -> str:
75
+ return f"ModuleSpec(modules={self.modules}, remotes={self.remotes}, origins={self.refspec_info})"
76
+
77
+
78
+ class ProjectSpec:
79
+ """Represents the overall project specification from the YAML file."""
80
+
81
+ def __init__(self, repos: Dict[str, RepoInfo], workdir: Path = Path(".")):
82
+ self.repos = repos
83
+ self.workdir = workdir
84
+
85
+ def __repr__(self) -> str:
86
+ return f"ProjectSpec(specs={self.repos}, workdir={self.workdir})"
bl/utils.py CHANGED
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
  from typing import Optional
5
5
 
6
6
  import warnings
7
- from bl.spec_parser import ModuleSpec, OriginType, RefspecInfo
7
+ from bl.types import RepoInfo, OriginType, RefspecInfo
8
8
 
9
9
 
10
10
  english_env = os.environ.copy()
@@ -12,16 +12,16 @@ english_env = os.environ.copy()
12
12
  english_env["LANG"] = "en_US.UTF-8"
13
13
 
14
14
 
15
- def get_module_path(workdir: Path, module_name: str, module_spec: ModuleSpec) -> Path:
15
+ def get_module_path(workdir: Path, module_name: str, module_spec: RepoInfo) -> Path:
16
16
  """Returns the path to the module directory."""
17
- if module_name == "odoo" and module_spec.target_folder is None:
17
+ if module_name == "odoo" and not module_spec.target_folder:
18
18
  warnings.warn(
19
19
  "importing 'odoo' without a 'target_folder' "
20
20
  + "property is deprecated. Use target_folder: 'src/' in spec.yaml.",
21
21
  DeprecationWarning,
22
22
  )
23
23
  return workdir / "src/"
24
- elif module_spec.target_folder is not None:
24
+ elif module_spec.target_folder:
25
25
  return workdir / module_spec.target_folder
26
26
  else:
27
27
  return workdir / "external-src" / module_name
@@ -36,6 +36,8 @@ async def run_git(*args: str, cwd: Optional[Path] = None) -> tuple[int, str, str
36
36
  """Executes a git command asynchronously."""
37
37
  proc = await asyncio.create_subprocess_exec(
38
38
  "git",
39
+ "--git-dir",
40
+ ".git/",
39
41
  *args,
40
42
  stdout=asyncio.subprocess.PIPE,
41
43
  stderr=asyncio.subprocess.PIPE,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bl-odoo
3
- Version: 0.2.7
3
+ Version: 0.3.1
4
4
  Summary: A command-line tool for managing Odoo dependencies.
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,13 @@
1
+ bl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ bl/__main__.py,sha256=ypl02Cl-9NF9823xjdHVQa3aA9lHtnKAJFrtwmP9vbc,1643
3
+ bl/freezer.py,sha256=wUYNd0zKU-0OGdIXSLJol-_xJmxSSkXvzV_VbF2HJyg,2512
4
+ bl/spec_parser.py,sha256=dVvaK4fhtut5_vI3sm7g5oGk0hc_vFAvDfnVDVTf_64,5457
5
+ bl/spec_processor.py,sha256=KsgNX-IAizLZ_22WcFKJAeCJKd98clDZb4qQ0kclqOs,17893
6
+ bl/types.py,sha256=h14FXDVCrYRxY7lYTEu8jhdrEHr1PvSNyZRIHm33CTk,2158
7
+ bl/utils.py,sha256=ZAdvXjNqDhICoZU8IZ5QQnmA5ILdYFbjaEeieyuo2GI,1622
8
+ bl_odoo-0.3.1.dist-info/licenses/LICENSE,sha256=GTVQl3vH6ht70wJXKC0yMT8CmXKHxv_YyO_utAgm7EA,1065
9
+ bl_odoo-0.3.1.dist-info/METADATA,sha256=6-dmdSbQqzfSmhA-wbIer6Pj36rtRretVUQ88TFSzIA,391
10
+ bl_odoo-0.3.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
11
+ bl_odoo-0.3.1.dist-info/entry_points.txt,sha256=fmdGhYYJlP-XByamgaZdM0bo3JK4LJFswU_Nilq6SSw,39
12
+ bl_odoo-0.3.1.dist-info/top_level.txt,sha256=1o4tN3wszdw7U5SnGgdF5P2sTYA0Schf0vKFy9_2D6A,3
13
+ bl_odoo-0.3.1.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- bl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- bl/__main__.py,sha256=v1d-voJ7N1QBLGJJh8JdrTxXtzf0JFHQv4RUBxlCkcg,1428
3
- bl/freezer.py,sha256=SXqfTXTys7tu-m94TftUjDLt7usNMkYoO10ZX62fRJg,2577
4
- bl/spec_parser.py,sha256=23_J5qf-20uSrKOu3drT0u2I1c9UwOGvp2Y7Ql8K5GE,7461
5
- bl/spec_processor.py,sha256=_acOuo_gfAZQEaSSDtZa3DAnTEBfGGVTke9wedS3Owo,17572
6
- bl/utils.py,sha256=d6pmkwlMLU4jm94JMisd6LT31YJ_oyqgX50O3g5yzq4,1610
7
- bl_odoo-0.2.7.dist-info/licenses/LICENSE,sha256=GTVQl3vH6ht70wJXKC0yMT8CmXKHxv_YyO_utAgm7EA,1065
8
- bl_odoo-0.2.7.dist-info/METADATA,sha256=H2jetZWnqXRa3w-rnYHHRaBmBN44_w9kkZdSy9WuKgA,391
9
- bl_odoo-0.2.7.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
10
- bl_odoo-0.2.7.dist-info/entry_points.txt,sha256=fmdGhYYJlP-XByamgaZdM0bo3JK4LJFswU_Nilq6SSw,39
11
- bl_odoo-0.2.7.dist-info/top_level.txt,sha256=1o4tN3wszdw7U5SnGgdF5P2sTYA0Schf0vKFy9_2D6A,3
12
- bl_odoo-0.2.7.dist-info/RECORD,,