benchops 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.
benchops/__init__.py ADDED
File without changes
benchops/auth.py ADDED
@@ -0,0 +1,24 @@
1
+ """Authentication and credential management for benchops."""
2
+
3
+ import keyring
4
+
5
+ SERVICE_NAME = "benchops"
6
+
7
+
8
+ class AuthManager:
9
+ """Securely stores and retrieves server passwords via the system keyring."""
10
+
11
+ def set_password(self, server_alias: str, password: str) -> None:
12
+ """Store the password for a server in the system credential store."""
13
+ keyring.set_password(SERVICE_NAME, server_alias, password)
14
+
15
+ def get_password(self, server_alias: str) -> str | None:
16
+ """Return the stored password for a server, or None if not set."""
17
+ return keyring.get_password(SERVICE_NAME, server_alias)
18
+
19
+ def delete_password(self, server_alias: str) -> None:
20
+ """Delete the stored password for a server if one exists."""
21
+ try:
22
+ keyring.delete_password(SERVICE_NAME, server_alias)
23
+ except keyring.errors.PasswordDeleteError:
24
+ pass
benchops/cli.py ADDED
@@ -0,0 +1,177 @@
1
+ """CLI entry point for benchops."""
2
+
3
+ import os
4
+ import subprocess
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from benchops.auth import AuthManager
13
+ from benchops.config import ConfigManager
14
+ from benchops.runner import LocalRunner, RemoteConnectionError, RemoteRunner
15
+ from benchops.sync import create_tarball, extract_and_cleanup, transfer_tarball
16
+
17
+ app = typer.Typer(
18
+ name="benchops",
19
+ help="A CLI tool to synchronize local Frappe development environments with remote servers.",
20
+ no_args_is_help=True,
21
+ )
22
+ server_app = typer.Typer(help="Manage configured remote servers.")
23
+ app.add_typer(server_app, name="server")
24
+
25
+ console = Console()
26
+
27
+
28
+ def _resolve_app_dir(app_name: str) -> Path:
29
+ cwd = Path.cwd()
30
+ for candidate in (cwd / "apps" / app_name, Path(app_name)):
31
+ if candidate.is_dir():
32
+ return candidate
33
+ raise FileNotFoundError(
34
+ f"Local app directory '{app_name}' not found (looked in '{cwd / 'apps'}' and '{cwd}')."
35
+ )
36
+
37
+
38
+ @app.command()
39
+ def init() -> None:
40
+ """Initialize the benchops configuration."""
41
+ ConfigManager().init_config()
42
+ console.print("[green]benchops initialized successfully.[/green]")
43
+
44
+
45
+ @server_app.command("add")
46
+ def add_server(
47
+ alias: str = typer.Option(..., prompt="Server alias", help="Alias for the server."),
48
+ host: str = typer.Option(..., prompt="Server host", help="Hostname or IP address."),
49
+ port: int = typer.Option(22, prompt="SSH port", help="SSH port (default: 22)."),
50
+ user: str = typer.Option(..., prompt="SSH user", help="SSH username."),
51
+ bench_path: str = typer.Option(..., prompt="Remote bench path", help="Path to the bench directory on the server."),
52
+ ) -> None:
53
+ """Add or update a configured server."""
54
+ ConfigManager().add_server(alias, host, port, user, bench_path)
55
+ console.print(f"[green]Server '{alias}' saved to configuration.[/green]")
56
+
57
+
58
+ @server_app.command("list")
59
+ def list_servers() -> None:
60
+ """List all configured servers."""
61
+ servers = ConfigManager().list_servers()
62
+ if not servers:
63
+ console.print("[yellow]No servers configured yet. Run 'benchops server add' to add one.[/yellow]")
64
+ return
65
+ table = Table(title="Configured Servers")
66
+ table.add_column("Alias", style="bold cyan", no_wrap=True)
67
+ table.add_column("Host")
68
+ table.add_column("Port")
69
+ table.add_column("User")
70
+ table.add_column("Bench Path")
71
+ for alias, config in sorted(servers.items()):
72
+ table.add_row(
73
+ alias,
74
+ config["host"],
75
+ str(config["port"]),
76
+ config["user"],
77
+ config["bench_path"],
78
+ )
79
+ console.print(table)
80
+
81
+
82
+ @server_app.command("set-auth")
83
+ def set_auth(
84
+ alias: str = typer.Argument(..., help="Alias of the configured server."),
85
+ ) -> None:
86
+ """Set authentication credentials (password or SSH key) for a server."""
87
+ config = ConfigManager()
88
+ if config.get_server(alias) is None:
89
+ console.print(f"[red]Error: No server found with alias '{alias}'.[/red]")
90
+ raise typer.Exit(1)
91
+
92
+ while True:
93
+ auth_type = typer.prompt("Authentication type [password/key]").strip().lower()
94
+ if auth_type in ("password", "key"):
95
+ break
96
+ console.print("[red]Invalid choice. Enter 'password' or 'key'.[/red]")
97
+
98
+ if auth_type == "password":
99
+ password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
100
+ try:
101
+ AuthManager().set_password(alias, password)
102
+ except Exception as exc:
103
+ console.print(f"[red]Error: Failed to save password: {exc}[/red]")
104
+ raise typer.Exit(1)
105
+ console.print(f"[green]Password saved for server '{alias}'.[/green]")
106
+ else:
107
+ key_path = typer.prompt("Absolute path to the SSH private key", default="~/.ssh/id_rsa")
108
+ try:
109
+ config.update_server_key(alias, key_path)
110
+ except ValueError as exc:
111
+ console.print(f"[red]Error: {exc}[/red]")
112
+ raise typer.Exit(1)
113
+ console.print(f"[green]Private key path saved for server '{alias}'.[/green]")
114
+
115
+
116
+ @app.command("deploy")
117
+ def deploy(
118
+ app_name: str = typer.Argument(..., help="Name of the local Frappe app directory to sync."),
119
+ server_alias: str = typer.Argument(..., help="Alias of the target server."),
120
+ ) -> None:
121
+ """Deploy a local Frappe app to a remote server."""
122
+ config = ConfigManager().get_server(server_alias)
123
+ if config is None:
124
+ console.print(f"[red]Error: Server '{server_alias}' not found in configuration.[/red]")
125
+ raise typer.Exit(1)
126
+
127
+ try:
128
+ password = AuthManager().get_password(server_alias)
129
+ except Exception:
130
+ password = None
131
+ key_path = config.get("private_key_path")
132
+
133
+ if not password and not key_path:
134
+ console.print(
135
+ f"[red]Error: No authentication configured for server '{server_alias}'. "
136
+ "Run 'benchops server set-auth' first.[/red]"
137
+ )
138
+ raise typer.Exit(1)
139
+
140
+ remote_runner = RemoteRunner(
141
+ host=config["host"],
142
+ port=int(config["port"]),
143
+ user=config["user"],
144
+ password=password,
145
+ key_path=key_path,
146
+ )
147
+ local_runner = LocalRunner()
148
+
149
+ try:
150
+ app_dir = _resolve_app_dir(app_name)
151
+ console.print("[yellow]Starting local builds...[/yellow]")
152
+ local_runner.run(["bench", "build", "--app", app_name], cwd=str(app_dir.parent))
153
+
154
+ with tempfile.TemporaryDirectory() as tmp_dir:
155
+ tarball = create_tarball(str(app_dir), os.path.join(tmp_dir, f"{app_name}.tar.gz"))
156
+ console.print(f"[green]Created tarball: {tarball}[/green]")
157
+
158
+ console.print(
159
+ f"[yellow]Connecting to {config['user']}@{config['host']}:{config['port']}...[/yellow]"
160
+ )
161
+ remote_dest_dir = f"{config['bench_path']}/apps"
162
+ remote_tar_path = transfer_tarball(remote_runner, tarball, remote_dest_dir)
163
+ console.print(f"[green]Transferred tarball to {remote_tar_path}[/green]")
164
+
165
+ extract_and_cleanup(remote_runner, remote_tar_path, remote_dest_dir)
166
+ console.print("[green]Extracted on remote server.[/green]")
167
+
168
+ console.print("[yellow]Starting remote operations...[/yellow]")
169
+ remote_runner.run("bench --site all migrate", cwd=config["bench_path"])
170
+ remote_runner.run("bench clear-cache", cwd=config["bench_path"])
171
+
172
+ console.print(f"[green]Successfully deployed '{app_name}' to '{server_alias}'.[/green]")
173
+ except (subprocess.CalledProcessError, RemoteConnectionError, FileNotFoundError) as exc:
174
+ console.print(f"[red]Deployment failed: {exc}[/red]")
175
+ raise typer.Exit(1)
176
+ finally:
177
+ remote_runner.close()
benchops/config.py ADDED
@@ -0,0 +1,77 @@
1
+ """Configuration management for benchops."""
2
+
3
+ from pathlib import Path
4
+
5
+ import tomlkit
6
+ from tomlkit.toml_file import TOMLFile
7
+
8
+ CONFIG_PATH = Path.home() / ".benchops" / "config.toml"
9
+
10
+
11
+ class ConfigManager:
12
+ """Reads and writes the local benchops TOML configuration file."""
13
+
14
+ def __init__(self, config_path: Path = CONFIG_PATH) -> None:
15
+ self.config_path = config_path
16
+ self.config_dir = self.config_path.parent
17
+
18
+ def init_config(self) -> None:
19
+ """Create the config directory and file with a [servers] table."""
20
+ self.config_dir.mkdir(parents=True, exist_ok=True)
21
+ if self.config_path.exists():
22
+ return
23
+ doc = tomlkit.document()
24
+ doc["servers"] = tomlkit.table()
25
+ TOMLFile(self.config_path).write(doc)
26
+
27
+ def _read(self) -> tomlkit.TOMLDocument:
28
+ return TOMLFile(self.config_path).read()
29
+
30
+ def add_server(
31
+ self,
32
+ alias: str,
33
+ host: str,
34
+ port: int,
35
+ user: str,
36
+ bench_path: str,
37
+ private_key_path: str | None = None,
38
+ ) -> None:
39
+ """Add or update a server, preserving existing comments and formatting."""
40
+ self.init_config()
41
+ doc = self._read()
42
+ if "servers" not in doc:
43
+ doc["servers"] = tomlkit.table()
44
+ entry = tomlkit.inline_table()
45
+ entry["host"] = host
46
+ entry["port"] = port
47
+ entry["user"] = user
48
+ entry["bench_path"] = bench_path
49
+ if private_key_path is not None:
50
+ entry["private_key_path"] = private_key_path
51
+ doc["servers"][alias] = entry
52
+ TOMLFile(self.config_path).write(doc)
53
+
54
+ def update_server_key(self, alias: str, private_key_path: str) -> None:
55
+ """Add or update the private key path for an existing server."""
56
+ self.init_config()
57
+ doc = self._read()
58
+ servers = doc.get("servers")
59
+ if servers is None or alias not in servers:
60
+ raise ValueError(f"Server '{alias}' not found in configuration.")
61
+ servers[alias]["private_key_path"] = private_key_path
62
+ TOMLFile(self.config_path).write(doc)
63
+
64
+ def get_server(self, alias: str) -> dict | None:
65
+ """Return the configuration for a specific server alias."""
66
+ return self.list_servers().get(alias)
67
+
68
+ def list_servers(self) -> dict:
69
+ """Return all configured servers keyed by alias."""
70
+ try:
71
+ doc = self._read()
72
+ except FileNotFoundError:
73
+ return {}
74
+ servers = doc.get("servers")
75
+ if servers is None:
76
+ return {}
77
+ return {str(alias): dict(config) for alias, config in servers.items()}
benchops/runner.py ADDED
@@ -0,0 +1,74 @@
1
+ """Execution engine: local subprocess and remote SSH command runners."""
2
+
3
+ import subprocess
4
+
5
+ import paramiko
6
+ from fabric import Connection
7
+
8
+
9
+ class RemoteConnectionError(Exception):
10
+ """Raised when an SSH connection to a remote host cannot be established."""
11
+
12
+
13
+ class LocalRunner:
14
+ """Runs commands locally, streaming output to the terminal in real-time."""
15
+
16
+ def run(self, command: list[str], cwd: str | None = None) -> None:
17
+ """Run a command locally, streaming stdout and stderr to the console."""
18
+ proc = subprocess.Popen(
19
+ command,
20
+ stdout=subprocess.PIPE,
21
+ stderr=subprocess.STDOUT,
22
+ text=True,
23
+ cwd=cwd,
24
+ )
25
+ assert proc.stdout is not None
26
+ for line in iter(proc.stdout.readline, ""):
27
+ print(line, end="")
28
+ returncode = proc.wait()
29
+ if returncode != 0:
30
+ raise subprocess.CalledProcessError(returncode, command)
31
+
32
+
33
+ class RemoteRunner:
34
+ """Runs commands on a remote host over SSH, streaming output in real-time."""
35
+
36
+ def __init__(
37
+ self,
38
+ host: str,
39
+ port: int,
40
+ user: str,
41
+ password: str | None = None,
42
+ key_path: str | None = None,
43
+ ) -> None:
44
+ self.host = host
45
+ self.port = port
46
+ self.user = user
47
+ connect_kwargs: dict = {}
48
+ if password:
49
+ connect_kwargs["password"] = password
50
+ if key_path:
51
+ connect_kwargs["key_filename"] = key_path
52
+ self.connection = Connection(
53
+ host=host,
54
+ port=port,
55
+ user=user,
56
+ connect_kwargs=connect_kwargs,
57
+ )
58
+
59
+ def run(self, command: str, cwd: str | None = None) -> None:
60
+ """Run a command on the remote host, streaming output to the terminal."""
61
+ try:
62
+ if cwd:
63
+ with self.connection.cd(cwd):
64
+ self.connection.run(command, hide=False)
65
+ else:
66
+ self.connection.run(command, hide=False)
67
+ except (paramiko.ssh_exception.SSHException, OSError) as exc:
68
+ raise RemoteConnectionError(
69
+ f"Failed to connect to {self.user}@{self.host}:{self.port}: {exc}"
70
+ ) from exc
71
+
72
+ def close(self) -> None:
73
+ """Close the SSH connection."""
74
+ self.connection.close()
benchops/sync.py ADDED
@@ -0,0 +1,39 @@
1
+ """Sync engine: local compression, SFTP transfer, and remote extraction."""
2
+
3
+ import tarfile
4
+ from pathlib import Path
5
+
6
+ from benchops.runner import RemoteRunner
7
+
8
+
9
+ def _tar_filter(tarinfo: tarfile.TarInfo) -> tarfile.TarInfo | None:
10
+ parts = tarinfo.name.split("/")
11
+ if any(part in (".git", "__pycache__", "node_modules") for part in parts):
12
+ return None
13
+ if tarinfo.name.endswith(".pyc"):
14
+ return None
15
+ return tarinfo
16
+
17
+
18
+ def create_tarball(app_path: str, output_path: str) -> str:
19
+ """Create a .tar.gz archive of a local directory, excluding common junk."""
20
+ src = Path(app_path)
21
+ if not src.is_dir():
22
+ raise FileNotFoundError(f"Application directory not found: {app_path}")
23
+ out = Path(output_path)
24
+ out.parent.mkdir(parents=True, exist_ok=True)
25
+ with tarfile.open(out, mode="w:gz") as tar:
26
+ tar.add(src, arcname=src.name, filter=_tar_filter)
27
+ return str(out.resolve())
28
+
29
+
30
+ def transfer_tarball(runner: RemoteRunner, local_path: str, remote_dest_dir: str) -> str:
31
+ """Transfer a local tarball to a remote directory over SFTP."""
32
+ runner.connection.put(local_path, remote_dest_dir)
33
+ return f"{remote_dest_dir.rstrip('/')}/{Path(local_path).name}"
34
+
35
+
36
+ def extract_and_cleanup(runner: RemoteRunner, remote_tar_path: str, remote_extract_dir: str) -> None:
37
+ """Extract a remote tarball and remove it afterwards."""
38
+ runner.run(f"tar -xzf {remote_tar_path} -C {remote_extract_dir}")
39
+ runner.run(f"rm {remote_tar_path}")
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.5
2
+ Name: benchops
3
+ Version: 0.1.0
4
+ Summary: A CLI tool to synchronize local Frappe development environments with remote servers.
5
+ Author-email: Mohammad <your.email@example.com>
6
+ Keywords: cli,deployment,erpnext,frappe,sync
7
+ Classifier: Environment :: Console
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.14.6
12
+ Requires-Dist: fabric>=3.2.3
13
+ Requires-Dist: keyring>=25.7.0
14
+ Requires-Dist: paramiko>=5.0.0
15
+ Requires-Dist: rich>=15.0.0
16
+ Requires-Dist: tomlkit>=0.15.1
17
+ Requires-Dist: typer>=0.27.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # benchops
21
+
22
+ A cross-platform CLI tool that synchronizes local Frappe development
23
+ environments with remote servers.
24
+
25
+ `benchops` automates the repetitive "ship my app to the server" workflow: it
26
+ builds your app locally, packages it into a compressed archive, uploads it to
27
+ the target server over SSH, extracts it into the remote bench, and runs the
28
+ Frappe housekeeping commands to apply the changes — all with output streamed to
29
+ your terminal in real time.
30
+
31
+ ## Features
32
+
33
+ - **Server management** — define remote servers in a local TOML config
34
+ (`~/.benchops/config.toml`) with a friendly interactive CLI.
35
+ - **Secure authentication** — store server passwords in your OS credential
36
+ store (`keyring`) or use an SSH private key.
37
+ - **Real-time streaming** — every local and remote command streams its stdout
38
+ and stderr to your terminal as it runs.
39
+ - **Lean transfers** — source tarballs exclude `.git`, `__pycache__`,
40
+ `node_modules`, and `*.pyc` to keep uploads small.
41
+ - **One-command deploy** — build, archive, upload, extract, migrate, and clear
42
+ cache with a single invocation.
43
+
44
+ ## Requirements
45
+
46
+ - Python `>= 3.14.6`
47
+ - [uv](https://docs.astral.sh/uv/) (recommended) or `pip`
48
+ - SSH access to the target server (password or key)
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ git clone <your-repo-url> benchops
54
+ cd benchops
55
+ uv sync
56
+ ```
57
+
58
+ Install the CLI into your environment so the `benchops` command is available:
59
+
60
+ ```bash
61
+ uv run pip install -e .
62
+ # or, if you built a distribution:
63
+ uv build && uv pip install --python .venv/bin/python dist/benchops-0.1.0-py3-none-any.whl
64
+ ```
65
+
66
+ Verify it works:
67
+
68
+ ```bash
69
+ benchops --help
70
+ ```
71
+
72
+ ## Quick start
73
+
74
+ ### 1. Initialize the configuration
75
+
76
+ ```bash
77
+ benchops init
78
+ ```
79
+
80
+ This creates `~/.benchops/config.toml` with an empty `[servers]` table.
81
+
82
+ ### 2. Register a server
83
+
84
+ ```bash
85
+ benchops server add
86
+ ```
87
+
88
+ You will be prompted for:
89
+
90
+ | Field | Description |
91
+ | ----------- | ---------------------------------------- |
92
+ | `alias` | Short name used to reference the server |
93
+ | `host` | Hostname or IP address |
94
+ | `port` | SSH port (default: `22`) |
95
+ | `user` | SSH username |
96
+ | `bench_path`| Remote path to the bench directory |
97
+
98
+ You can also pass everything non-interactively:
99
+
100
+ ```bash
101
+ benchops server add --alias dev1 --host 192.168.1.10 --port 22 --user frappe --bench-path /home/frappe/bench
102
+ ```
103
+
104
+ List your configured servers:
105
+
106
+ ```bash
107
+ benchops server list
108
+ ```
109
+
110
+ ### 3. Configure authentication
111
+
112
+ ```bash
113
+ benchops server set-auth dev1
114
+ ```
115
+
116
+ You will be asked to choose between:
117
+
118
+ - **`password`** — prompted securely (hidden input, double confirmation) and
119
+ stored in your system keyring.
120
+ - **`key`** — path to your SSH private key (default `~/.ssh/id_rsa`), stored in
121
+ the server's configuration.
122
+
123
+ ### 4. Deploy
124
+
125
+ Run `benchops deploy` from the directory that contains your app (either inside
126
+ the local bench's `apps/` folder or directly):
127
+
128
+ ```bash
129
+ benchops deploy <app_name> <server_alias>
130
+ ```
131
+
132
+ For example:
133
+
134
+ ```bash
135
+ benchops deploy myapp dev1
136
+ ```
137
+
138
+ The deploy pipeline:
139
+
140
+ 1. **Config & auth** — loads the server settings and its credentials.
141
+ 2. **Local build** — runs `bench build --app <app_name>` in the app's parent
142
+ directory.
143
+ 3. **Archive** — compresses `<app_name>` into a `.tar.gz` (excluding `.git`,
144
+ `__pycache__`, `node_modules`, and `*.pyc`) in a temporary directory.
145
+ 4. **Connect** — opens an SSH connection to the server.
146
+ 5. **Transfer & extract** — uploads the tarball and extracts it into
147
+ `{bench_path}/apps`.
148
+ 6. **Remote operations** — runs `bench --site all migrate` and
149
+ `bench clear-cache` inside `{bench_path}`.
150
+ 7. **Cleanup** — closes the connection and reports success.
151
+
152
+ ## Configuration
153
+
154
+ All server definitions live in `~/.benchops/config.toml`:
155
+
156
+ ```toml
157
+ [servers]
158
+ dev1 = {host = "192.168.1.10", port = 22, user = "frappe", bench_path = "/home/frappe/bench", private_key_path = "/home/mohammad/.ssh/id_rsa"}
159
+ staging = {host = "staging.example.com", port = 22, user = "deploy", bench_path = "/srv/bench"}
160
+ ```
161
+
162
+ Passwords are **not** stored in this file — they are kept in the operating
163
+ system's credential store via `keyring`.
164
+
165
+ ## Development
166
+
167
+ ```bash
168
+ uv sync # install dependencies
169
+ uv build # build wheel + sdist
170
+ uv publish # upload to PyPI (set UV_PUBLISH_TOKEN first)
171
+ ```
172
+
173
+ ## License
174
+
175
+ MIT
@@ -0,0 +1,10 @@
1
+ benchops/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ benchops/auth.py,sha256=uJ73ws12ZY5QmcyBqEW_JJgNhQ9I22CK_aAsAOYfsCU,896
3
+ benchops/cli.py,sha256=gLmY3E8aXZNmA6GloV3aEmorK_u7dHcEt_kVTzAA5a4,6760
4
+ benchops/config.py,sha256=TCEEZ3KA8b6IqyeXLhudta3wT_8fVp8YcQ3qRlxlgAc,2645
5
+ benchops/runner.py,sha256=IwWeN03S5yDj09NQ0tNNKPw1EO1O6-iM8G-pjDDtnx4,2330
6
+ benchops/sync.py,sha256=id-nFwin70xI5aeJNlC4sEOB_gI5xn_0i9BlzQSMzl0,1487
7
+ benchops-0.1.0.dist-info/METADATA,sha256=BXdq84bJ8-3CobVR_Mci08cDcSuCRISBr7hwjjBcYt8,5078
8
+ benchops-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
9
+ benchops-0.1.0.dist-info/entry_points.txt,sha256=G9fj6wj9SOj_kyvaJSd2XX_107-QAakTuk-7b9LdxdM,46
10
+ benchops-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ benchops = benchops.cli:app