diff --git a/benchmate/api/sync.py b/benchmate/api/sync.py index f34b070..0e9adb5 100644 --- a/benchmate/api/sync.py +++ b/benchmate/api/sync.py @@ -1,3 +1,6 @@ +import ast +import configparser +import json import subprocess from pathlib import Path @@ -8,14 +11,23 @@ @frappe.whitelist() def sync(): - """Sync and return list of valid benches under default_path""" + """ + Sync and return list of all valid benches under default_path + as configured in BenchMate settings. + """ settings = get_benchmate_settings() default_path = settings.get("default_path", "/home/karan/benches/") return get_all_benches(default_path) +# ------------------------------------------------------------- +# ? Utility functions +# ------------------------------------------------------------- def run_cmd(cmd: str, cwd: Path | None = None) -> str | None: - """Run a shell command and return stdout text (or None on failure).""" + """ + Execute a shell command and return stdout as string. + Returns None if the command fails. + """ try: return subprocess.check_output(cmd, cwd=cwd, shell=True, text=True, stderr=subprocess.STDOUT).strip() except subprocess.CalledProcessError as e: @@ -23,10 +35,183 @@ def run_cmd(cmd: str, cwd: Path | None = None) -> str | None: return None +def get_git_remote(app_path: Path) -> str | None: + """ + Extract repo URL from .git/config (prefer upstream → origin). + """ + git_config = app_path / ".git" / "config" + if not git_config.exists(): + return None + + config = configparser.ConfigParser() + try: + config.read(git_config) + if config.has_section('remote "upstream"'): + return config.get('remote "upstream"', "url", fallback=None) + if config.has_section('remote "origin"'): + return config.get('remote "origin"', "url", fallback=None) + except Exception as e: + frappe.log_error(f"Failed to parse git remote for {app_path}: {e}", "BenchMate Sync") + return None + + +# ------------------------------------------------------------- +# ? App title extraction +# ------------------------------------------------------------- +def _parse_hooks_title(hooks_path: Path) -> str | None: + """ + Parse hooks.py safely using AST to extract app_title. + """ + if not hooks_path.exists(): + return None + try: + tree = ast.parse(hooks_path.read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Name) and target.id == "app_title": + if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str): + return node.value.value.strip() + return None + except Exception as e: + frappe.log_error(f"Failed reading hooks.py for title: {hooks_path}\n{e}", "BenchMate Title") + return None + + +def _parse_pyproject_name(pyproject_path: Path) -> str | None: + """ + Read project/app name from pyproject.toml ([project] or [tool.poetry]). + """ + if not pyproject_path.exists(): + return None + try: + # ? Python 3.11+ has tomllib in stdlib + try: + import tomllib # type: ignore + + data = tomllib.loads(pyproject_path.read_text(encoding="utf-8")) + except Exception: + # ? Fallback: very naive parsing for "name =" + raw = pyproject_path.read_text(encoding="utf-8") + for line in raw.splitlines(): + if line.strip().startswith("name"): + val = line.split("=", 1)[1].strip().strip("\"'") + if val: + return val + return None + + # ? PEP 621 & poetry fallback + if "project" in data and isinstance(data["project"], dict): + return data["project"].get("name") + if "tool" in data and isinstance(data["tool"], dict): + poetry = data["tool"].get("poetry") + if isinstance(poetry, dict): + return poetry.get("name") + return None + except Exception as e: + frappe.log_error(f"Failed reading pyproject.toml: {pyproject_path}\n{e}", "BenchMate Title") + return None + + +def get_app_title(app_path: Path, app_name: str) -> str: + """ + Best-effort app title resolution: + 1. hooks.py (app_title) + 2. pyproject.toml (project.name / poetry.name) + 3. Fallback: prettified app_name + """ + # ? hooks.py + hooks_title = _parse_hooks_title(app_path / app_name / "hooks.py") + if hooks_title: + return hooks_title + + # ? pyproject.toml + pyproject_name = _parse_pyproject_name(app_path / "pyproject.toml") + if pyproject_name: + return pyproject_name.replace("-", " ").replace("_", " ").title() + + # ? fallback: format folder name + return app_name.replace("-", " ").replace("_", " ").title() + + +# ------------------------------------------------------------- +# ? Bench & site parsing +# ------------------------------------------------------------- +def parse_installed_apps(entry: Path) -> tuple[dict, str | None, str | None]: + """ + Parse bench-wide installed apps via `bench version --format json`. + Returns: + - installed_apps dict + - frappe_version + - frappe_branch + """ + installed_apps = {} + frappe_version, frappe_branch = None, None + + version_json = run_cmd("bench version --format json", cwd=entry) + if not version_json: + return installed_apps, None, None + + try: + apps_data = json.loads(version_json) + if isinstance(apps_data, list): + for app in apps_data: + app_name = app.get("app") + app_branch = app.get("branch") + app_version = app.get("version") + app_commit = app.get("commit") + + app_path = entry / "apps" / app_name + app_repo = get_git_remote(app_path) + app_title = get_app_title(app_path, app_name) + + installed_apps[app_name] = { + "title": app_title, + "branch": app_branch, + "version": app_version, + "commit": app_commit, + "repo": app_repo, + } + + # ? store frappe version/branch separately + if app_name == "frappe": + frappe_version, frappe_branch = app_version, app_branch + except Exception as e: + frappe.log_error(f"Failed to parse bench version json: {e}", "BenchMate Sync") + + return installed_apps, frappe_version, frappe_branch + + +def get_site_apps(bench_path: Path, site_name: str, bench_apps: dict) -> dict: + """ + Get installed apps for a specific site using: + `bench --site list-apps --format json` + Filter against bench-wide apps metadata. + """ + cmd = f"bench --site {site_name} list-apps --format json" + result = run_cmd(cmd, cwd=bench_path) + if not result: + return {} + + site_apps = {} + try: + data = json.loads(result) + for app_name in data.get(site_name, []): + if app_name in bench_apps: + site_apps[app_name] = bench_apps[app_name] + except Exception as e: + frappe.log_error(f"Failed to parse site apps for {site_name}: {e}", "BenchMate Sync") + + return site_apps + + def get_all_benches(default_path: str): """ - Return list of all valid bench directories under default_path. - A valid bench must contain both `sites/` dir and `Procfile`. + Return list of all valid benches under default_path. + Each bench includes: + - frappe version & branch + - installed apps metadata + - detailed sites with site-specific installed apps """ benches: list[dict] = [] root = Path(default_path).expanduser().resolve() @@ -39,24 +224,39 @@ def get_all_benches(default_path: str): if not entry.is_dir(): continue - # Precompute paths sites_path = entry / "sites" procfile_path = entry / "Procfile" - # Validate bench structure + # ? validate bench structure if not (sites_path.is_dir() and procfile_path.is_file()): continue - # Collect site names (skip special folders like assets) - site_names = [s.name for s in sites_path.iterdir() if s.is_dir() and s.name != "assets"] + # ? Bench apps & frappe version/branch + bench_apps, frappe_version, frappe_branch = parse_installed_apps(entry) + + # ? Sites metadata + sites = [] + for s in sites_path.iterdir(): + if s.is_dir() and s.name != "assets": + site_apps = get_site_apps(entry, s.name, bench_apps) + sites.append( + { + "site_name": s.name, + "bench_name": entry.name, + "path": str(s), + "installed_apps": site_apps, + } + ) + # ? final bench info benches.append( { "name": entry.name, "path": str(entry), - "frappe_version": None, # placeholder for future extension - "sites": site_names, - "installed_apps": {}, # placeholder for future extension + "version": frappe_version, + "branch": frappe_branch, + "sites": sites, + "installed_apps": bench_apps, } ) diff --git a/benchmate/benchmate/doctype/bm_bench/bm_bench.json b/benchmate/benchmate/doctype/bm_bench/bm_bench.json index 9c2f106..385def9 100644 --- a/benchmate/benchmate/doctype/bm_bench/bm_bench.json +++ b/benchmate/benchmate/doctype/bm_bench/bm_bench.json @@ -11,6 +11,7 @@ "last_synced_on", "column_break_nigq", "status", + "branch", "version", "section_break_gudv", "installed_apps", @@ -74,6 +75,13 @@ "fieldname": "last_synced_on", "fieldtype": "Datetime", "label": "Last Synced On" + }, + { + "fieldname": "branch", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Branch" } ], "grid_page_length": 50, @@ -85,7 +93,7 @@ "link_fieldname": "bench_name" } ], - "modified": "2025-08-17 20:10:43.279628", + "modified": "2025-08-17 21:32:33.248615", "modified_by": "Administrator", "module": "BenchMate", "name": "BM Bench", diff --git a/benchmate/benchmate/doctype/bm_installed_apps/bm_installed_apps.json b/benchmate/benchmate/doctype/bm_installed_apps/bm_installed_apps.json index 227bfa2..e29baa7 100644 --- a/benchmate/benchmate/doctype/bm_installed_apps/bm_installed_apps.json +++ b/benchmate/benchmate/doctype/bm_installed_apps/bm_installed_apps.json @@ -12,8 +12,7 @@ "column_break_jghv", "version", "link", - "section_break_ggtn", - "git_log" + "commit" ], "fields": [ { @@ -49,26 +48,22 @@ "in_list_view": 1, "label": "Link" }, - { - "fieldname": "git_log", - "fieldtype": "Small Text", - "in_list_view": 1, - "label": "Git Log" - }, { "fieldname": "column_break_jghv", "fieldtype": "Column Break" }, { - "fieldname": "section_break_ggtn", - "fieldtype": "Section Break" + "fieldname": "commit", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Commit" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-08-17 19:35:43.002644", + "modified": "2025-08-17 21:44:59.925833", "modified_by": "Administrator", "module": "BenchMate", "name": "BM Installed Apps",