odoo-wt 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
odoo_wt/app_config.py ADDED
@@ -0,0 +1,41 @@
1
+ import json
2
+ import datetime
3
+ import os
4
+ from pathlib import Path
5
+
6
+ # --- CONFIGURATION ---
7
+ CONFIG_FILE = Path.home() / ".config" / "odoo-wt.json"
8
+ LOG_FILE = Path.home() / ".config" / "odoo-wt-logs.jsonl"
9
+
10
+ def append_log(action: str, details: dict = None):
11
+ if details is None: details = {}
12
+ entry = {"timestamp": datetime.datetime.now().isoformat(), "action": action, "details": details}
13
+ try:
14
+ with open(LOG_FILE, "a") as f:
15
+ f.write(json.dumps(entry) + "\n")
16
+ except: pass
17
+
18
+ def load_config():
19
+ default_config = {
20
+ "wt_root": str(Path.home() / "repos" / "Odoo" / "wt"),
21
+ "env_root": str(Path.home() / ".envs"),
22
+ "suffix": "pian",
23
+ "remote_name": "odoo-dev",
24
+ "community_dir": "odoo",
25
+ "enterprise_dir": "enterprise",
26
+ "default_tab": "tab-create",
27
+ "ignored_versions": [],
28
+ "ignored_suffixes": []
29
+ }
30
+ if CONFIG_FILE.exists():
31
+ try:
32
+ with open(CONFIG_FILE, "r") as f:
33
+ default_config.update(json.load(f))
34
+ except:
35
+ pass
36
+ return default_config
37
+
38
+ def save_config(config):
39
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
40
+ with open(CONFIG_FILE, "w") as f:
41
+ json.dump(config, f, indent=4)
odoo_wt/cli_main.py ADDED
@@ -0,0 +1,50 @@
1
+ import sys
2
+ import os
3
+ from .app_config import load_config, CONFIG_FILE
4
+ from .system_discovery import discover_system_data
5
+ from .setup_wizard import WizardApp
6
+ from .main_tui import OdooWtApp
7
+
8
+ def main():
9
+ if not CONFIG_FILE.exists():
10
+ app = WizardApp()
11
+ config = app.run()
12
+ if not config:
13
+ return
14
+ else:
15
+ config = load_config()
16
+
17
+ # CLI direct creation mode
18
+ if len(sys.argv) > 1:
19
+ branch = sys.argv[1]
20
+ v = branch.split("-")[0] if "-" in branch else "master"
21
+ # We manually build the data for DeployScreen
22
+ data = {"action": "create", "version": v, "desc": branch, "suffix": ""}
23
+ # In CLI mode, we still need to boot OdooWtApp to push the DeployScreen
24
+ # or we just boot a minimal app to host the DeployScreen.
25
+ # For now, let's just launch the full app and it will handle it if we modify init.
26
+ # But easier: just boot the app normally.
27
+ v_list, s_list, worktrees = discover_system_data(config["wt_root"], config["suffix"])
28
+ app = OdooWtApp(config, v_list, s_list, worktrees)
29
+ # We don't have an easy way to auto-start deployment from CLI yet,
30
+ # let's just open the app for now.
31
+ app.run()
32
+ else:
33
+ v_list, s_list, worktrees = discover_system_data(config["wt_root"], config["suffix"])
34
+ app = OdooWtApp(config, v_list, s_list, worktrees)
35
+ data = app.run()
36
+
37
+ # Handle deployment completion actions
38
+ if data and isinstance(data, dict):
39
+ action = data.get("action")
40
+ if action == "terminal":
41
+ target = data["path"]
42
+ os.chdir(target)
43
+ shell = os.environ.get("SHELL", "/bin/bash")
44
+ os.execv(shell, [shell])
45
+ elif action == "vscode":
46
+ target = data["path"]
47
+ os.system(f"code {target}")
48
+
49
+ if __name__ == "__main__":
50
+ main()
@@ -0,0 +1,290 @@
1
+ import asyncio
2
+ import os
3
+ import json
4
+ from pathlib import Path
5
+ from textual import on, work
6
+ from textual.binding import Binding
7
+ from textual.screen import ModalScreen, Screen
8
+ from textual.widgets import Label, Button, ProgressBar, RichLog
9
+ from textual.containers import Vertical, Horizontal, VerticalScroll
10
+
11
+ from .app_config import append_log
12
+ from .system_discovery import get_remote, check_local
13
+
14
+ async def run_cmd_stream(cmd, cwd, log_widget, prefix=""):
15
+ process = await asyncio.create_subprocess_exec(
16
+ *cmd,
17
+ cwd=cwd,
18
+ stdout=asyncio.subprocess.PIPE,
19
+ stderr=asyncio.subprocess.STDOUT
20
+ )
21
+ while True:
22
+ line = await process.stdout.readline()
23
+ if not line:
24
+ break
25
+ text = line.decode('utf-8', errors='replace').rstrip()
26
+ log_widget.write(f"{prefix}{text}")
27
+ await process.wait()
28
+ return process.returncode == 0
29
+
30
+ class DeleteConfirmScreen(ModalScreen[bool]):
31
+ def __init__(self, wt_name: str):
32
+ super().__init__()
33
+ self.wt_name = wt_name
34
+ self.step = 1
35
+
36
+ def on_key(self, event) -> None:
37
+ if event.key == "left":
38
+ self.focus_previous()
39
+ elif event.key == "right":
40
+ self.focus_next()
41
+
42
+ def compose(self):
43
+ with Vertical(id="delete-dialog"):
44
+ yield Label(f"Delete worktree '{self.wt_name}'? (1/3)", id="del-msg")
45
+ with Horizontal(classes="del-btn-row", id="btn-container"):
46
+ yield Button("Yes, delete", variant="error", id="btn-yes")
47
+ yield Button("Cancel", variant="primary", id="btn-cancel")
48
+
49
+ @on(Button.Pressed, "#btn-yes")
50
+ async def on_yes(self):
51
+ append_log("Delete Confirm Step", {"step": self.step, "worktree": self.wt_name})
52
+ self.step += 1
53
+ if self.step > 3:
54
+ self.dismiss(True)
55
+ return
56
+ msg = self.query_one("#del-msg", Label)
57
+ container = self.query_one("#btn-container", Horizontal)
58
+ await container.query_children().remove()
59
+ if self.step == 2:
60
+ msg.update(f"Are you SURE? (2/3)")
61
+ await container.mount(Button("Cancel", variant="primary", id="btn-cancel"))
62
+ await container.mount(Button("Yes, delete", variant="error", id="btn-yes"))
63
+ elif self.step == 3:
64
+ msg.update(f"Final warning: NUKE '{self.wt_name}'? (3/3)")
65
+ await container.mount(Button("Yes, delete", variant="error", id="btn-yes"))
66
+ await container.mount(Button("Cancel", variant="primary", id="btn-cancel"))
67
+ self.query_one("#btn-cancel").focus()
68
+
69
+ @on(Button.Pressed, "#btn-cancel")
70
+ def on_cancel(self):
71
+ append_log("Delete Cancelled", {"worktree": self.wt_name})
72
+ self.dismiss(False)
73
+
74
+
75
+
76
+ from textual.binding import Binding
77
+ class DeployScreen(Screen):
78
+ BINDINGS = [
79
+ Binding("t", "terminal", "Terminal", show=False),
80
+ Binding("v", "vscode", "VS Code", show=False),
81
+ Binding("b", "back", "Back", show=False),
82
+ Binding("x", "exit", "Exit", show=False),
83
+ ]
84
+
85
+ def action_terminal(self):
86
+ if not self.query_one("#success-footer").has_class("hidden"):
87
+ self.on_terminal()
88
+
89
+ def action_vscode(self):
90
+ if not self.query_one("#success-footer").has_class("hidden"):
91
+ self.on_vscode()
92
+
93
+ def action_back(self):
94
+ if not self.query_one("#success-footer").has_class("hidden"):
95
+ self.on_back()
96
+
97
+ def action_exit(self):
98
+ if not self.query_one("#success-footer").has_class("hidden"):
99
+ self.on_exit()
100
+
101
+ def __init__(self, data, config):
102
+ super().__init__()
103
+ self.data = data
104
+ self.config = config
105
+
106
+ def compose(self):
107
+ with VerticalScroll(id="deploy-logs-scroll"):
108
+ with Vertical(classes="log-box"):
109
+ with Horizontal(classes="log-header"):
110
+ yield Label("Community", classes="log-title")
111
+ yield ProgressBar(id="prog-odoo", show_eta=False)
112
+ yield RichLog(id="log-odoo", markup=False, highlight=False)
113
+
114
+ with Vertical(classes="log-box"):
115
+ with Horizontal(classes="log-header"):
116
+ yield Label("Enterprise", classes="log-title")
117
+ yield ProgressBar(id="prog-ent", show_eta=False)
118
+ yield RichLog(id="log-ent", markup=False, highlight=False)
119
+
120
+ with Vertical(classes="log-uv-box", id="uv-box"):
121
+ with Horizontal(classes="log-header"):
122
+ yield Label("UV Env", classes="log-title")
123
+ yield ProgressBar(id="prog-uv", show_eta=False)
124
+ yield RichLog(id="log-uv", markup=False, highlight=False)
125
+
126
+ with Vertical(id="success-footer", classes="hidden"):
127
+ yield Label("", id="success-message", classes="success-msg")
128
+ with Horizontal(classes="success-btn-row"):
129
+ yield Button("(T)erminal", variant="success", id="btn-terminal")
130
+ yield Button("(V)S Code", variant="primary", id="btn-vscode")
131
+ yield Button("(B)ack to Tool", variant="warning", id="btn-back")
132
+ yield Button("E(x)it", variant="error", id="btn-exit")
133
+
134
+ def on_mount(self):
135
+ self.run_deployment()
136
+
137
+ @work(exclusive=True)
138
+ async def run_deployment(self):
139
+ wt_root = Path(self.config["wt_root"])
140
+ dev_remote = self.config.get("remote_name", "odoo-dev")
141
+ comm_dir = self.config.get("community_dir", "odoo")
142
+ ent_dir = self.config.get("enterprise_dir", "enterprise")
143
+
144
+ clean_desc = self.data["desc"].strip().replace(" ", "_")
145
+ parts = [p for p in [self.data["version"], clean_desc, self.data["suffix"]] if p]
146
+ branch_name = "-".join(parts)
147
+ append_log("Deployment Started", {"branch": branch_name, "version": self.data["version"]})
148
+ target_dir = wt_root / branch_name
149
+ base_odoo = wt_root / "master" / comm_dir
150
+ base_ent = wt_root / "master" / ent_dir
151
+ base_v = self.data["version"] or "master"
152
+
153
+ target_dir.mkdir(parents=True, exist_ok=True)
154
+
155
+ async def deploy_repo(repo, dest, log_id, prog_id):
156
+ log = self.query_one(f"#{log_id}", RichLog)
157
+ prog = self.query_one(f"#{prog_id}", ProgressBar)
158
+ prog.update(total=3)
159
+
160
+ remote = await asyncio.to_thread(get_remote, repo)
161
+ log.write(f"Detected base remote: {remote}")
162
+ prog.advance(1)
163
+
164
+ log.write(f"Fetching '{branch_name}' from '{dev_remote}'...")
165
+ success = await run_cmd_stream(["git", "fetch", dev_remote, f"{branch_name}:{branch_name}", "--force"], repo, log)
166
+ prog.advance(1)
167
+
168
+ if success:
169
+ log.write("Fetch successful. Creating worktree...")
170
+ append_log("Branch Strategy Selected", {"repo": dest, "strategy": "remote_pull", "remote": dev_remote, "branch": branch_name})
171
+ await run_cmd_stream(["git", "worktree", "add", str(target_dir / dest), branch_name], repo, log)
172
+ else:
173
+ log.write(f"Branch not found on '{dev_remote}'. Fetching '{base_v}' from '{remote}'...")
174
+ await run_cmd_stream(["git", "fetch", remote, base_v], repo, log)
175
+
176
+ is_local = await asyncio.to_thread(check_local, repo, branch_name)
177
+ if is_local:
178
+ log.write("Branch exists locally. Creating worktree...")
179
+ append_log("Branch Strategy Selected", {"repo": dest, "strategy": "local_checkout", "branch": branch_name})
180
+ await run_cmd_stream(["git", "worktree", "add", str(target_dir / dest), branch_name], repo, log)
181
+ else:
182
+ log.write(f"Creating new branch from {remote}/{base_v}...")
183
+ append_log("Branch Strategy Selected", {"repo": dest, "strategy": "new_branch", "base": f"{remote}/{base_v}", "branch": branch_name})
184
+ await run_cmd_stream(["git", "worktree", "add", "-b", branch_name, str(target_dir / dest), f"{remote}/{base_v}"], repo, log)
185
+ await run_cmd_stream(["git", "branch", "--set-upstream-to", f"{remote}/{base_v}", branch_name], repo, log)
186
+
187
+ prog.advance(1)
188
+ log.write("✅ Done.")
189
+
190
+ async def setup_uv():
191
+ log_uv = self.query_one("#log-uv", RichLog)
192
+ prog_uv = self.query_one("#prog-uv", ProgressBar)
193
+ prog_uv.update(total=4)
194
+
195
+ env_root = Path(self.config["env_root"])
196
+ env_root.mkdir(parents=True, exist_ok=True)
197
+ target_env = env_root / base_v
198
+
199
+ if not target_env.exists():
200
+ log_uv.write(f"Initializing UV environment for {base_v}...")
201
+ await run_cmd_stream(["uv", "venv", str(target_env), "--python", "3.12"], env_root, log_uv)
202
+ prog_uv.advance(1)
203
+
204
+ # Use base folder requirements to avoid waiting for worktree creation
205
+ base_req = wt_root / "master" / comm_dir / "requirements.txt"
206
+ if base_req.exists():
207
+ log_uv.write(f"Installing requirements from base '{comm_dir}'...")
208
+ await run_cmd_stream([
209
+ "uv", "pip", "install", "-r", str(base_req),
210
+ "--python", str(target_env / "bin" / "python")
211
+ ], env_root, log_uv)
212
+ prog_uv.advance(1)
213
+ else:
214
+ log_uv.write(f"UV environment '{base_v}' already exists.")
215
+ prog_uv.advance(2)
216
+
217
+ # Wait for target_dir to definitely exist (though we create it at start)
218
+ # but more importantly, we need to wait for odoo/enterprise worktrees
219
+ # to finish before we can reliably symlink .venv into the target_dir
220
+ # actually target_dir is just a folder, we can symlink now!
221
+ venv_symlink = target_dir / ".venv"
222
+ if not venv_symlink.exists():
223
+ try:
224
+ os.symlink(target_env, venv_symlink)
225
+ log_uv.write("Created .venv symlink.")
226
+ except Exception as e:
227
+ log_uv.write(f"Failed to create symlink: {e}")
228
+ prog_uv.advance(1)
229
+ log_uv.write("✅ Done.")
230
+ return True
231
+
232
+ await asyncio.gather(
233
+ deploy_repo(base_odoo, comm_dir, "log-odoo", "prog-odoo"),
234
+ deploy_repo(base_ent, ent_dir, "log-ent", "prog-ent"),
235
+ setup_uv()
236
+ )
237
+
238
+ append_log("Deployment Success", {"branch": branch_name, "path": str(target_dir)})
239
+ self.target_dir = target_dir
240
+ self.show_success_footer()
241
+
242
+ def show_success_footer(self):
243
+ msg = self.query_one("#success-message", Label)
244
+ msg.update(f"SUCCESS! Worktree ready at: {self.target_dir}\nWhat would you like to do next?")
245
+ self.query_one("#success-footer").remove_class("hidden")
246
+ self.query_one("#btn-terminal").focus()
247
+
248
+ @on(Button.Pressed, "#btn-terminal")
249
+ def on_terminal(self):
250
+ append_log("Deployment Complete", {"choice": "terminal"})
251
+ self.app.exit({"action": "terminal", "path": str(self.target_dir)})
252
+
253
+ @on(Button.Pressed, "#btn-vscode")
254
+ def on_vscode(self):
255
+ append_log("Deployment Complete", {"choice": "vscode"})
256
+ self.app.exit({"action": "vscode", "path": str(self.target_dir)})
257
+
258
+ @on(Button.Pressed, "#btn-back")
259
+ def on_back(self):
260
+ append_log("Deployment Complete", {"choice": "back"})
261
+ self.dismiss()
262
+
263
+ @on(Button.Pressed, "#btn-exit")
264
+ def on_exit(self):
265
+ append_log("Deployment Complete", {"choice": "exit"})
266
+ self.app.exit()
267
+
268
+ class LogDetailScreen(ModalScreen[None]):
269
+ def __init__(self, ts, action, details):
270
+ super().__init__()
271
+ self.ts = ts
272
+ self.action = action
273
+ self.details = details
274
+
275
+ def compose(self):
276
+ with Vertical(id="log-detail-dialog"):
277
+ yield Label(f"[{self.ts}] {self.action}", classes="log-detail-title")
278
+ try:
279
+ parsed = json.loads(self.details)
280
+ pretty_details = json.dumps(parsed, indent=4)
281
+ except:
282
+ pretty_details = str(self.details)
283
+ with VerticalScroll():
284
+ yield Label(pretty_details, classes="log-detail-text")
285
+ with Horizontal(classes="log-detail-btn-row"):
286
+ yield Button("Close", variant="primary", id="btn-close-log")
287
+
288
+ @on(Button.Pressed, "#btn-close-log")
289
+ def on_close(self):
290
+ self.dismiss()