odooflow-cli 0.1.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.
odooflow/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
odooflow/cli.py ADDED
@@ -0,0 +1,105 @@
1
+ import typer
2
+ from typing import List, Optional
3
+
4
+ from odooflow.commands.init_module_env import init_module_env
5
+ from odooflow.commands.sync_env import sync_env as sync_env_command
6
+ from odooflow.commands.config import config as config_command
7
+ from odooflow.commands.clone_module import clone_module_command
8
+ from odooflow.commands.remote import remote as remote_command
9
+ from odooflow.commands.keygen import generate_ssh_key as keygen_command
10
+ from odooflow.commands.push import push_command
11
+
12
+ app = typer.Typer(help="OdooFlow CLI — streamline your Odoo development workflow.")
13
+
14
+ @app.command(name="init")
15
+ def init_manifest(
16
+ author: Optional[str] = typer.Option(None, help="Author name"),
17
+ website : Optional[str] = typer.Option(None, help="Website"),
18
+ odoo_version: Optional[str] = typer.Option(None, help="Odoo version"),
19
+ license_name: Optional[str] = typer.Option(None, help="License"),
20
+ ):
21
+ """
22
+ Initialize the Odoo module environment file and sync metadata with manifest.
23
+ """
24
+ init_module_env(author=author, odoo_version=odoo_version, license_name=license_name, website=website)
25
+
26
+ @app.command(name="sync-env")
27
+ def sync_env(
28
+ keys: Optional[str] = typer.Option(None, "--keys", help="Comma-separated keys to sync from manifest to env file.")
29
+ ):
30
+ """
31
+ Sync the environment file (.odooflow.env.json) from manifest.
32
+ """
33
+ sync_env_command(keys)
34
+
35
+ @app.command("config", help="Update configuration")
36
+ def config(
37
+ env_file: Optional[str] = typer.Option(None, help="Set custom env file name"),
38
+ manifest_file: Optional[str] = typer.Option(None, help="Set custom manifest file name"),
39
+ access_token : Optional[str] = typer.Option(None, help="Set Git access token"),
40
+ add_core_module: Optional[str] = typer.Option(None, "--add-core-module", help="Comma-separated list of core modules to add"),
41
+ sync_keys: Optional[str] = typer.Option(None, help="Set default keys to sync from manifest to .env (comma-separated)"),
42
+ show: Optional[bool] = typer.Option(False, "--show", help="Show current config")
43
+ ):
44
+ """
45
+ Update or show OdooFlow CLI configuration (.odooflowrc)
46
+ """
47
+ config_command(env_file, manifest_file, access_token, add_core_module, sync_keys,show)
48
+
49
+
50
+ @app.command("clone")
51
+ def clone_command(
52
+ repo_url: str = typer.Argument(..., help="HTTP URL of the module repository."),
53
+ branch: Optional[str] = typer.Option(None, "--branch", '-b', help="Branch to clone"),
54
+ depth: int = typer.Option(1, "--depth", "-d", help="Max dependency depth to clone. 1 clones only the target module, 2 clones target + immediate dependencies, etc.")
55
+ ):
56
+ """
57
+ Clone a module and its dependencies from a git repository.
58
+ """
59
+ clone_module_command(repo_url, branch, depth)
60
+
61
+
62
+ @app.command()
63
+ def remote(
64
+ add_repo: Optional[str] = typer.Option(None, help="Add Git remote URL"),
65
+ branch: Optional[str] = typer.Option(None, help="Target Git branch (defaults to current)"),
66
+ server_json: Optional[str] = typer.Option(None, help="Server config as JSON: '{\"host\": \"127.0.0.1\", \"port\": 22}'")
67
+ ):
68
+ """
69
+ Manage remote connections for Git and deployment server.
70
+ """
71
+ remote_command(
72
+ add_repo=add_repo,
73
+ server_json=server_json,
74
+ branch=branch
75
+ )
76
+
77
+ @app.command("ssh-keygen")
78
+ def generate_ssh_key(
79
+ key_name: str = typer.Option("odooflow_rsa", help="Name of the SSH key file to generate (without extension)."),
80
+ output_dir: Optional[str] = typer.Option(None, help="Directory to save the SSH key. Defaults to ~/.ssh"),
81
+ overwrite: bool = typer.Option(False, help="Overwrite existing key files if they exist."),
82
+ ):
83
+ """
84
+ Generate a secure SSH key pair.
85
+ """
86
+ keygen_command(key_name=key_name, output_dir=output_dir, overwrite=overwrite)
87
+
88
+
89
+ @app.command()
90
+ def push(
91
+ remote_only: bool = typer.Option(False, "--remote-only", help="Skip Git push and only upload to server"),
92
+ exec_cmd: Optional[str] = typer.Option(None, "--exec", help="Custom shell command to execute on the server after pushing"),
93
+ ):
94
+ """
95
+ Push the current Git branch and upload the project to the test server.
96
+ """
97
+ push_command(remote_only=remote_only, exec_cmd=exec_cmd)
98
+
99
+
100
+
101
+ def main():
102
+ app()
103
+
104
+ if __name__ == "__main__":
105
+ main()
File without changes
@@ -0,0 +1,160 @@
1
+ import ast
2
+ import typer
3
+ import requests
4
+ import threading
5
+
6
+ from typing import Optional
7
+ from concurrent.futures import ThreadPoolExecutor
8
+
9
+ from urllib.parse import urlparse, urlunparse
10
+ from git import Repo, GitCommandError
11
+ from pathlib import Path
12
+
13
+ from odooflow.config_manager import get_access_token, get_core_modules_from_config, load_config
14
+
15
+
16
+ def get_project_url_from_gitlab(module_name: str, base_url: Optional[str] = None) -> Optional[str]:
17
+ """
18
+ Search GitLab for a project by name and return its HTTPS URL.
19
+ """
20
+ if base_url is None:
21
+ config = load_config()
22
+ base_url = config.get("gitlab_url", "https://gitlab.ebtech-solution.com")
23
+
24
+ api_url = f"{base_url}/api/v4/projects"
25
+ params = {"search": module_name, "simple": "true", "per_page": 100, "access_token": get_access_token()}
26
+ headers = {"Accept": "application/json"}
27
+
28
+ try:
29
+ typer.secho(f"🔍 Searching GitLab for module: {module_name}", fg="cyan")
30
+ response = requests.get(api_url, headers=headers, params=params)
31
+ response.raise_for_status()
32
+
33
+ for project in response.json():
34
+ if project["name"] == module_name or project["path"] == module_name:
35
+ typer.secho(f"✅ Found module '{module_name}'", fg="green")
36
+ return project["http_url_to_repo"]
37
+
38
+ typer.secho(f"❌ Module '{module_name}' not found in GitLab.", fg="yellow")
39
+ return None
40
+
41
+ except requests.RequestException as e:
42
+ typer.secho(f"❌ GitLab API error: {e}", fg="red")
43
+ return None
44
+
45
+
46
+ def inject_token_into_url(url: str, token: str) -> str:
47
+ parsed = urlparse(url)
48
+ if not parsed.netloc:
49
+ raise ValueError(f"Invalid URL: {url}")
50
+ netloc = f"oauth2:{token}@{parsed.netloc}"
51
+ return urlunparse(parsed._replace(netloc=netloc, scheme="https"))
52
+
53
+
54
+ def extract_module_name_from_url(url: str) -> str:
55
+ return url.rstrip("/").split("/")[-1].removesuffix(".git")
56
+
57
+
58
+ def safe_eval_manifest(content: str) -> dict:
59
+ try:
60
+ return ast.literal_eval(content)
61
+ except Exception as e:
62
+ typer.secho(f"❌ Failed to evaluate manifest: {e}", fg="red")
63
+ return {}
64
+
65
+
66
+ def clone_repo(url: str, target_dir: Path, branch: str = "main") -> bool:
67
+ if target_dir.exists():
68
+ typer.secho(f"⚠️ Skipping '{target_dir.name}': already exists.", fg="yellow")
69
+ return True
70
+ try:
71
+ access_token = get_access_token()
72
+ url_with_token = inject_token_into_url(url, access_token)
73
+ typer.secho(f"📥 Cloning into '{target_dir}'...", fg="cyan")
74
+ Repo.clone_from(url_with_token, target_dir, branch=branch)
75
+ typer.secho(f"✅ Successfully cloned '{url}'", fg="green")
76
+ return True
77
+ except GitCommandError as e:
78
+ typer.secho(f"❌ Git error: Failed to clone '{url}'\n{e}", fg="red")
79
+ return False
80
+ except Exception as e:
81
+ typer.secho(f"❌ Unexpected error: {e}", fg="red")
82
+ return False
83
+
84
+
85
+ def clone_module_command(
86
+ url: str = typer.Option(..., "--url", help="Full HTTP URL of the module repo."),
87
+ branch: Optional[str] = None,
88
+ depth: int = typer.Option(1, "--depth", "-d", help="Max dependency depth to clone. 1 clones only the target module, 2 clones target + immediate dependencies, etc."),
89
+ ):
90
+ """
91
+ Clone a module and its dependencies into the current directory.
92
+ """
93
+ typer.secho("🚀 Starting module cloning process...", fg="cyan", bold=True)
94
+ core_modules = get_core_modules_from_config()
95
+ visited = set()
96
+ fail_count = 0
97
+ lock = threading.Lock()
98
+
99
+ def clone_recursive(module_url: str, current_branch: Optional[str], current_depth: int):
100
+ nonlocal fail_count
101
+ module_name = extract_module_name_from_url(module_url)
102
+
103
+ with lock:
104
+ if module_name in visited:
105
+ typer.secho(f"🔁 Already processed '{module_name}', skipping.", fg="yellow")
106
+ return False
107
+ visited.add(module_name)
108
+
109
+ target_path = Path.cwd() / module_name
110
+
111
+ if not clone_repo(module_url, target_path, current_branch or "main"):
112
+ typer.secho(f"❌ Failed to clone '{module_name}'. Skipping its dependencies.", fg="red")
113
+ with lock:
114
+ fail_count += 1
115
+ return False
116
+
117
+ if current_depth <= 0:
118
+ return False
119
+
120
+ manifest_path = target_path / "__manifest__.py"
121
+ if not manifest_path.exists():
122
+ typer.secho(f"📦 No manifest found in '{module_name}', skipping dependencies.", fg="yellow")
123
+ return True
124
+
125
+ manifest_data = safe_eval_manifest(manifest_path.read_text())
126
+ dependencies = manifest_data.get("depends", [])
127
+
128
+ if not dependencies:
129
+ typer.secho(f"ℹ️ No dependencies for '{module_name}'.", fg="blue")
130
+ return True
131
+
132
+ candidate_deps = [dep for dep in dependencies if dep not in core_modules]
133
+ if not candidate_deps:
134
+ return True
135
+
136
+ next_depth = current_depth - 1
137
+
138
+ def _resolve_and_run(dep_name: str):
139
+ dep_url = get_project_url_from_gitlab(module_name=dep_name)
140
+ if not dep_url:
141
+ typer.secho(f"❗ Could not resolve dependency: '{dep_name}'", fg="red")
142
+ nonlocal_assign = True
143
+ with lock:
144
+ fail_count += 1
145
+ return
146
+ clone_recursive(dep_url, current_branch, next_depth)
147
+
148
+ with ThreadPoolExecutor(max_workers=4) as executor:
149
+ futures = [executor.submit(_resolve_and_run, dep) for dep in candidate_deps]
150
+ for f in futures:
151
+ f.result()
152
+
153
+ return True
154
+
155
+ clone_recursive(url, branch, depth)
156
+
157
+ if fail_count > 0:
158
+ typer.secho(f"⚠️ Finished with {fail_count} failed clones.", fg="yellow", bold=True)
159
+ else:
160
+ typer.secho("✅ All done without errors!", fg="green", bold=True)
@@ -0,0 +1,63 @@
1
+ import typer
2
+ from rich import print
3
+ from typing import Optional
4
+
5
+ from odooflow import config_manager
6
+
7
+ def config(
8
+ env_file: str = typer.Option(None),
9
+ manifest_file: str = typer.Option(None),
10
+ access_token: str = typer.Option(None),
11
+ add_core_module: Optional[str] = typer.Option(None),
12
+ sync_keys: Optional[str] = typer.Option(None),
13
+ show: bool = typer.Option(False, "--show", help="Display current configuration")
14
+ ):
15
+ current_config = config_manager.load_config()
16
+ updated = False
17
+
18
+ if show:
19
+ typer.secho("📦 Current Configuration:", fg="cyan", bold=True)
20
+ print(current_config)
21
+ raise typer.Exit()
22
+
23
+ if env_file:
24
+ current_config["env_file"] = env_file
25
+ updated = True
26
+
27
+ if manifest_file:
28
+ current_config["manifest_file"] = manifest_file
29
+ updated = True
30
+
31
+ if access_token:
32
+ current_config["access_token"] = access_token
33
+ updated = True
34
+
35
+ if add_core_module:
36
+ modules = [m.strip() for m in add_core_module.split(",") if m.strip()]
37
+ if modules:
38
+ existing_modules = set(current_config.get(
39
+ "core_modules",
40
+ config_manager.DEFAULT_CONFIG.get("core_modules", [])
41
+ ))
42
+ new_modules = set(modules)
43
+ combined_modules = sorted(existing_modules.union(new_modules))
44
+ current_config["core_modules"] = combined_modules
45
+ updated = True
46
+ typer.secho(f"✅ Added core module(s): {', '.join(new_modules)}", fg="green")
47
+
48
+ if sync_keys:
49
+ new_keys = [k.strip() for k in sync_keys.split(",") if k.strip()]
50
+ existing_keys = set(current_config.get(
51
+ "sync_keys",
52
+ config_manager.DEFAULT_CONFIG['sync_keys']
53
+ ))
54
+ combined_keys = sorted(existing_keys.union(new_keys))
55
+ current_config["sync_keys"] = combined_keys
56
+ updated = True
57
+ typer.secho(f"🔑 Updated sync keys: {', '.join(new_keys)}", fg="cyan")
58
+
59
+ if updated:
60
+ config_manager.save_config(current_config)
61
+ typer.secho("💾 Configuration updated successfully.", fg="green")
62
+ else:
63
+ typer.secho("⚠️ No changes provided. Use --help to see available options.", fg="yellow")
@@ -0,0 +1,37 @@
1
+ from pathlib import Path
2
+ from rich import print
3
+ import typer
4
+
5
+ from odooflow.config_manager import load_config
6
+ from odooflow.utils.env import read_manifest, write_env_file, update_manifest
7
+
8
+ config = load_config()
9
+ ENV_FILENAME = config["env_file"]
10
+ MANIFEST_FILENAME = config["manifest_file"]
11
+
12
+
13
+
14
+ def init_module_env(author: str, odoo_version: str, license_name: str, website : str):
15
+ cwd = Path.cwd()
16
+ env_path = cwd / ENV_FILENAME
17
+ manifest_path = cwd / MANIFEST_FILENAME
18
+
19
+ if not manifest_path.exists():
20
+ print(f"[red]No {MANIFEST_FILENAME} found in this directory[/red]")
21
+ raise SystemExit(1)
22
+
23
+ manifest = read_manifest(manifest_path)
24
+
25
+ values = {
26
+ "name" : manifest.get("name", ""),
27
+ "author": author or typer.prompt("Author", default=manifest.get("author", "Unknown")),
28
+ "version": odoo_version or typer.prompt("Odoo version", default=manifest.get("version", "16.0")),
29
+ "license": license_name or typer.prompt("License", default=manifest.get("license", "LGPL-3")),
30
+ "website" : website or typer.prompt("Website", default=manifest.get("website", 'https://www.yourcompany.com')),
31
+ "depends" : manifest.get("depends" , []),
32
+ }
33
+
34
+ write_env_file(env_path, values)
35
+
36
+ if author or odoo_version or license_name or website:
37
+ update_manifest(manifest_path, values)
@@ -0,0 +1,56 @@
1
+ import platform
2
+ import typer
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import subprocess
6
+
7
+
8
+ DEFAULT_COMMENT = "odooflow"
9
+
10
+ def generate_ssh_key(
11
+ key_name: str = typer.Option(),
12
+ output_dir: Optional[str] = typer.Option(),
13
+ overwrite: bool = typer.Option(),
14
+ passphrase: Optional[str] = typer.Option(None, help="Passphrase for the SSH key"),
15
+ ):
16
+ typer.secho("🔐 Generating SSH key pair...", fg="cyan")
17
+
18
+ home = Path.home()
19
+ ssh_dir = Path(output_dir).expanduser() if output_dir else home / ".ssh"
20
+ ssh_dir.mkdir(parents=True, exist_ok=True)
21
+
22
+ private_key_path = ssh_dir / key_name
23
+ public_key_path = ssh_dir / f"{key_name}.pub"
24
+
25
+ if private_key_path.exists() or public_key_path.exists():
26
+ if not overwrite:
27
+ typer.secho(f"❌ Key files already exist at {private_key_path}.", fg="red")
28
+ typer.secho("Use --overwrite to replace them.", fg="yellow")
29
+ raise typer.Exit()
30
+
31
+ os_name = platform.system()
32
+
33
+ try:
34
+ if os_name in ["Linux", "Darwin", "Windows"]:
35
+ # Use ssh-keygen if available
36
+ command = [
37
+ "ssh-keygen",
38
+ "-t", "rsa",
39
+ "-b", "4096",
40
+ "-f", str(private_key_path),
41
+ "-C", DEFAULT_COMMENT,
42
+ "-N", passphrase if passphrase else "",
43
+ ]
44
+ subprocess.run(command, check=True)
45
+ else:
46
+ typer.secho(f"❌ Unsupported OS: {os_name}", fg="red")
47
+ raise typer.Exit()
48
+
49
+ typer.secho(f"✅ SSH key pair generated:", fg="green")
50
+ typer.echo(f"🔑 Private key: {private_key_path}")
51
+ typer.echo(f"🔓 Public key: {public_key_path}")
52
+
53
+ except FileNotFoundError:
54
+ typer.secho("❌ ssh-keygen not found on your system. Please install OpenSSH.", fg="red")
55
+ except subprocess.CalledProcessError as e:
56
+ typer.secho(f"❌ Failed to generate SSH key: {e}", fg="red")
@@ -0,0 +1,132 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from git import Repo, GitCommandError
5
+ import getpass
6
+
7
+ from odooflow import config_manager
8
+ from odooflow.utils.env import read_env_file
9
+ from odooflow.utils.ssh import upload_directory_via_ssh
10
+
11
+
12
+ EXCLUDED_DIRS = {".git", "__pycache__", ".venv", "node_modules", ".mypy_cache", ".pytest_cache", ".env", ".odooflowrc", ".odooflow.env.json"}
13
+
14
+ def get_gitignore_exclusions(base_path: Path):
15
+ gitignore_path = base_path / ".gitignore"
16
+ exclusions = set()
17
+
18
+ if not gitignore_path.exists():
19
+ return exclusions
20
+
21
+ with gitignore_path.open("r") as f:
22
+ for line in f:
23
+ line = line.strip()
24
+ if not line or line.startswith("#"):
25
+ continue
26
+
27
+ if "/" in line or "*" in line:
28
+ continue # Skip wildcards and nested ignores for now
29
+
30
+ exclusions.add(line.strip("/"))
31
+
32
+ return exclusions
33
+
34
+
35
+
36
+ def push_command(remote_only: bool = False, exec_cmd: Optional[str] = None):
37
+ cwd = Path.cwd()
38
+
39
+ # Load config and env
40
+ config = config_manager.load_config()
41
+ env_path = cwd / config.get("env_file", ".env")
42
+ env = read_env_file(env_path)
43
+
44
+ excluded_dirs = EXCLUDED_DIRS.union(get_gitignore_exclusions(cwd))
45
+
46
+ module_name = env.get("name")
47
+ if not module_name:
48
+ typer.secho("❌ Module name not found in environment file.", fg="red")
49
+ raise typer.Exit(1)
50
+
51
+ remote_config = env.get("remotes", {})
52
+ if not remote_config:
53
+ typer.secho(f"❌ No remote config found for module: {module_name}", fg="red")
54
+ raise typer.Exit(1)
55
+
56
+ # Git Push (if not remote only)
57
+ if not remote_only:
58
+ repo_config = remote_config.get("repo", {})
59
+ if not repo_config:
60
+ typer.secho(f"❌ No repo config provided", fg="red")
61
+ raise typer.Exit(1)
62
+ repo_url = remote_config.get("repo")
63
+ branch = remote_config.get("branch")
64
+
65
+ try:
66
+ repo = Repo(cwd)
67
+ if repo.head.is_detached:
68
+ typer.secho("❌ Git repository is in a detached HEAD state. Cannot push.", fg="red")
69
+ raise typer.Exit(1)
70
+ active_branch = repo.active_branch.name
71
+ branch_to_push = branch or active_branch
72
+
73
+ typer.secho(f"🚀 Pushing branch '{branch_to_push}' to remote...", fg="cyan")
74
+ origin = repo.remote(name="origin")
75
+ origin.push(refspec=f"{branch_to_push}:{branch_to_push}")
76
+ typer.secho("✅ Git push successful.", fg="green")
77
+ except GitCommandError as e:
78
+ typer.secho(f"❌ Git error: {e}", fg="red")
79
+ raise typer.Exit(1)
80
+ except Exception as e:
81
+ typer.secho(f"❌ Unexpected Git error: {e}", fg="red")
82
+ raise typer.Exit(1)
83
+ else:
84
+ typer.secho("📦 Skipping Git push (remote only mode).", fg="yellow")
85
+
86
+ # Upload to server
87
+ server = remote_config.get("server")
88
+ if not server:
89
+ typer.secho("⚠️ No server config found for this module. Skipping upload.", fg="yellow")
90
+ raise typer.Exit(0)
91
+
92
+ required_keys = ["host", "user", "directory"]
93
+ if not all(k in server for k in required_keys):
94
+ typer.secho(f"❌ Incomplete server config. Required keys: {', '.join(required_keys)}", fg="red")
95
+ raise typer.Exit(1)
96
+
97
+ key_path = server.get("key") or server.get("key_path")
98
+ password = server.get("password")
99
+ if not key_path and not password:
100
+ password = getpass.getpass("🔑 Enter SSH password: ")
101
+
102
+ final_exec_cmd = exec_cmd or server.get("post_push_cmd")
103
+
104
+ def _report_post_exec(stdout_text: str, stderr_text: str, exit_status: int):
105
+ if stdout_text:
106
+ typer.secho(stdout_text, fg="cyan")
107
+ if exit_status != 0:
108
+ typer.secho(
109
+ f"❌ Post-upload command failed (exit {exit_status}): {stderr_text.strip() or '(no stderr)'}",
110
+ fg="red",
111
+ bold=True,
112
+ )
113
+ raise typer.Exit(1)
114
+
115
+ try:
116
+ typer.secho("📤 Uploading project to the test server...", fg="cyan")
117
+ upload_directory_via_ssh(
118
+ local_path=cwd,
119
+ remote_user=server["user"],
120
+ remote_host=server["host"],
121
+ remote_path=server["directory"],
122
+ port=int(server.get("port", 22)),
123
+ key_path=key_path,
124
+ password=password,
125
+ exclude_dirs=excluded_dirs,
126
+ post_exec_cmd=final_exec_cmd,
127
+ on_post_exec=_report_post_exec if final_exec_cmd else None,
128
+ )
129
+ typer.secho("✅ Project uploaded successfully.", fg="green")
130
+ except Exception as e:
131
+ typer.secho(f"❌ Upload failed: {e}", fg="red")
132
+ raise typer.Exit(1)