fujin-cli 0.1.0__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of fujin-cli might be problematic. Click here for more details.

fujin/__init__.py CHANGED
@@ -1,2 +0,0 @@
1
- def main() -> None:
2
- print("Hello from fujin!")
fujin/__main__.py ADDED
@@ -0,0 +1,71 @@
1
+ import shlex
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import cappa
6
+
7
+ from fujin.commands.app import App
8
+ from fujin.commands.config import ConfigCMD
9
+ from fujin.commands.deploy import Deploy
10
+ from fujin.commands.docs import Docs
11
+ from fujin.commands.down import Down
12
+ from fujin.commands.init import Init
13
+ from fujin.commands.proxy import Proxy
14
+ from fujin.commands.prune import Prune
15
+ from fujin.commands.redeploy import Redeploy
16
+ from fujin.commands.rollback import Rollback
17
+ from fujin.commands.server import Server
18
+ from fujin.commands.up import Up
19
+
20
+ if sys.version_info >= (3, 11):
21
+ import tomllib
22
+ else:
23
+ import tomli as tomllib
24
+
25
+
26
+ @cappa.command(help="Deployment of python web apps in a breeze :)")
27
+ class Fujin:
28
+ subcommands: cappa.Subcommands[
29
+ Init
30
+ | Up
31
+ | Deploy
32
+ | Redeploy
33
+ | App
34
+ | Server
35
+ | Proxy
36
+ | ConfigCMD
37
+ | Docs
38
+ | Down
39
+ | Rollback
40
+ | Prune
41
+ ]
42
+
43
+
44
+ def main():
45
+ alias_cmd = _parse_aliases()
46
+ if alias_cmd:
47
+ cappa.invoke(Fujin, argv=alias_cmd)
48
+ else:
49
+ cappa.invoke(Fujin)
50
+
51
+
52
+ def _parse_aliases() -> list[str] | None:
53
+ fujin_toml = Path("fujin.toml")
54
+ if not fujin_toml.exists():
55
+ return
56
+ data = tomllib.loads(fujin_toml.read_text())
57
+ aliases: dict[str, str] = data.get("aliases")
58
+ if not aliases:
59
+ return
60
+ if len(sys.argv) == 1:
61
+ return
62
+ if sys.argv[1] not in aliases:
63
+ return
64
+ extra_args = sys.argv[2:] if len(sys.argv) > 2 else []
65
+ aliased_cmd = aliases.get(sys.argv[1])
66
+ subcommand, args = aliased_cmd.split(" ", 1)
67
+ return [subcommand, *extra_args, *shlex.split(args, posix=True)]
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,2 @@
1
+ from ._base import BaseCommand # noqa
2
+ from ._base import BaseCommand # noqa
@@ -0,0 +1,76 @@
1
+ import importlib
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass
4
+ from functools import cached_property
5
+
6
+ import cappa
7
+
8
+ from fujin.config import Config
9
+ from fujin.connection import host_connection, Connection
10
+ from fujin.errors import ImproperlyConfiguredError
11
+ from fujin.hooks import HookManager
12
+ from fujin.process_managers import ProcessManager
13
+ from fujin.proxies import WebProxy
14
+
15
+
16
+ @dataclass
17
+ class BaseCommand:
18
+ """
19
+ A command that provides access to the host config and provide a connection to interact with it,
20
+ including configuring the web proxy and managing systemd services.
21
+ """
22
+
23
+ @cached_property
24
+ def config(self) -> Config:
25
+ return Config.read()
26
+
27
+ @cached_property
28
+ def stdout(self) -> cappa.Output:
29
+ return cappa.Output()
30
+
31
+ @cached_property
32
+ def app_dir(self) -> str:
33
+ return self.config.host.get_app_dir(app_name=self.config.app_name)
34
+
35
+ @contextmanager
36
+ def connection(self):
37
+ with host_connection(host=self.config.host) as conn:
38
+ yield conn
39
+
40
+ @contextmanager
41
+ def app_environment(self) -> Connection:
42
+ with self.connection() as conn:
43
+ with conn.cd(self.app_dir):
44
+ with conn.prefix("source .appenv"):
45
+ yield conn
46
+
47
+ @cached_property
48
+ def web_proxy_class(self) -> type[WebProxy]:
49
+ module = importlib.import_module(self.config.webserver.type)
50
+ try:
51
+ return getattr(module, "WebProxy")
52
+ except KeyError as e:
53
+ raise ImproperlyConfiguredError(
54
+ f"Missing WebProxy class in {self.config.webserver.type}"
55
+ ) from e
56
+
57
+ def create_web_proxy(self, conn: Connection) -> WebProxy:
58
+ return self.web_proxy_class.create(conn=conn, config=self.config)
59
+
60
+ @cached_property
61
+ def process_manager_class(self) -> type[ProcessManager]:
62
+ module = importlib.import_module(self.config.process_manager)
63
+ try:
64
+ return getattr(module, "ProcessManager")
65
+ except KeyError as e:
66
+ raise ImproperlyConfiguredError(
67
+ f"Missing ProcessManager class in {self.config.process_manager}"
68
+ ) from e
69
+
70
+ def create_process_manager(self, conn: Connection) -> ProcessManager:
71
+ return self.process_manager_class.create(conn=conn, config=self.config)
72
+
73
+ def create_hook_manager(self, conn: Connection) -> HookManager:
74
+ return HookManager(
75
+ conn=conn, hooks=self.config.hooks, app_name=self.config.app_name
76
+ )
fujin/commands/app.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import cappa
6
+
7
+ from fujin.commands import BaseCommand
8
+
9
+
10
+ @cappa.command(help="Run application-related tasks")
11
+ class App(BaseCommand):
12
+ @cappa.command(help="Display information about the application")
13
+ def info(self):
14
+ with self.app_environment() as conn:
15
+ remote_version = (
16
+ conn.run("head -n 1 .versions", warn=True, hide=True).stdout.strip()
17
+ or "N/A"
18
+ )
19
+ rollback_targets = conn.run(
20
+ "sed -n '2,$p' .versions", warn=True, hide=True
21
+ ).stdout.strip()
22
+ infos = {
23
+ "app_name": self.config.app_name,
24
+ "app_dir": self.app_dir,
25
+ "app_bin": self.config.app_bin,
26
+ "local_version": self.config.version,
27
+ "remote_version": remote_version,
28
+ "python_version": self.config.python_version,
29
+ "rollback_targets": ", ".join(rollback_targets.split("\n"))
30
+ if rollback_targets
31
+ else "N/A",
32
+ }
33
+ pm = self.create_process_manager(conn)
34
+ services: dict[str, bool] = pm.is_active()
35
+
36
+ infos_text = "\n".join(f"{key}: {value}" for key, value in infos.items())
37
+ from rich.table import Table
38
+
39
+ table = Table(title="", header_style="bold cyan")
40
+ table.add_column("Service", style="")
41
+ table.add_column("Running?")
42
+ for service, is_active in services.items():
43
+ table.add_row(
44
+ service,
45
+ "[bold green]Yes[/bold green]"
46
+ if is_active
47
+ else "[bold red]No[/bold red]",
48
+ )
49
+
50
+ self.stdout.output(infos_text)
51
+ self.stdout.output(table)
52
+
53
+ @cappa.command(help="Run an arbitrary command via the application binary")
54
+ def exec(
55
+ self,
56
+ command: str,
57
+ interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
58
+ ):
59
+ with self.app_environment() as conn:
60
+ if interactive:
61
+ conn.run(f"{self.config.app_bin} {command}", pty=interactive, warn=True)
62
+ else:
63
+ self.stdout.output(
64
+ conn.run(f"{self.config.app_bin} {command}", hide=True).stdout
65
+ )
66
+
67
+ @cappa.command(
68
+ help="Start the specified service or all services if no name is provided"
69
+ )
70
+ def start(
71
+ self,
72
+ name: Annotated[
73
+ str | None, cappa.Arg(help="Service name, no value means all")
74
+ ] = None,
75
+ ):
76
+ with self.app_environment() as conn:
77
+ self.create_process_manager(conn).start_services(name)
78
+ msg = f"{name} Service" if name else "All Services"
79
+ self.stdout.output(f"[green]{msg} started successfully![/green]")
80
+
81
+ @cappa.command(
82
+ help="Restart the specified service or all services if no name is provided"
83
+ )
84
+ def restart(
85
+ self,
86
+ name: Annotated[
87
+ str | None, cappa.Arg(help="Service name, no value means all")
88
+ ] = None,
89
+ ):
90
+ with self.app_environment() as conn:
91
+ self.create_process_manager(conn).restart_services(name)
92
+ msg = f"{name} Service" if name else "All Services"
93
+ self.stdout.output(f"[green]{msg} restarted successfully![/green]")
94
+
95
+ @cappa.command(
96
+ help="Stop the specified service or all services if no name is provided"
97
+ )
98
+ def stop(
99
+ self,
100
+ name: Annotated[
101
+ str | None, cappa.Arg(help="Service name, no value means all")
102
+ ] = None,
103
+ ):
104
+ with self.app_environment() as conn:
105
+ self.create_process_manager(conn).stop_services(name)
106
+ msg = f"{name} Service" if name else "All Services"
107
+ self.stdout.output(f"[green]{msg} stopped successfully![/green]")
108
+
109
+ @cappa.command(help="Show logs for the specified service")
110
+ def logs(
111
+ self, name: Annotated[str, cappa.Arg(help="Service name")], follow: bool = False
112
+ ):
113
+ # TODO: flash out this more
114
+ with self.app_environment() as conn:
115
+ self.create_process_manager(conn).service_logs(name=name, follow=follow)
116
+
117
+ @cappa.command(
118
+ name="export-config",
119
+ help="Export the service configuration files locally to the .fujin directory",
120
+ )
121
+ def export_config(
122
+ self,
123
+ overwrite: Annotated[
124
+ bool, cappa.Arg(help="overwrite any existing config file")
125
+ ] = False,
126
+ ):
127
+ with self.connection() as conn:
128
+ for filename, content in self.create_process_manager(
129
+ conn
130
+ ).get_configuration_files(ignore_local=True):
131
+ local_config = self.config.local_config_dir / filename
132
+ if local_config.exists() and not overwrite:
133
+ self.stdout.output(
134
+ f"[blue]Skipping {filename}, file already exists. Use --overwrite to replace it.[/blue]"
135
+ )
136
+ continue
137
+ local_config.write_text(content)
138
+ self.stdout.output(f"[green]{filename} exported successfully![/green]")
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import cappa
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+ from rich.table import Table
7
+
8
+ from fujin.commands import BaseCommand
9
+
10
+
11
+ @cappa.command(name="config", help="Display your current configuration")
12
+ class ConfigCMD(BaseCommand):
13
+ def __call__(self):
14
+ console = Console()
15
+ general_config = {
16
+ "app": self.config.app_name,
17
+ "app_bin": self.config.app_bin,
18
+ "version": self.config.version,
19
+ "python_version": self.config.python_version,
20
+ "build_command": self.config.build_command,
21
+ "release_command": self.config.release_command,
22
+ "distfile": self.config.distfile,
23
+ "requirements": self.config.requirements,
24
+ "webserver": f"{{ upstream = '{self.config.webserver.upstream}', type = '{self.config.webserver.type}' }}",
25
+ }
26
+ general_config_text = "\n".join(
27
+ f"[bold green]{key}:[/bold green] {value}"
28
+ for key, value in general_config.items()
29
+ )
30
+ console.print(
31
+ Panel(
32
+ general_config_text,
33
+ title="General Configuration",
34
+ border_style="green",
35
+ width=100,
36
+ )
37
+ )
38
+
39
+ host_config_text = "\n".join(
40
+ f"[dim]{key}:[/dim] {value}"
41
+ for key, value in self.config.host.to_dict().items()
42
+ )
43
+ console.print(
44
+ Panel(
45
+ host_config_text,
46
+ title="Host Configuration",
47
+ width=100,
48
+ )
49
+ )
50
+
51
+ # Processes Table with headers and each dictionary on its own line
52
+ processes_table = Table(title="Processes", header_style="bold cyan")
53
+ processes_table.add_column("Name", style="dim")
54
+ processes_table.add_column("Command")
55
+ for name, command in self.config.processes.items():
56
+ processes_table.add_row(name, command)
57
+ console.print(processes_table)
58
+
59
+ aliases_table = Table(title="Aliases", header_style="bold cyan")
60
+ aliases_table.add_column("Alias", style="dim")
61
+ aliases_table.add_column("Command")
62
+ for alias, command in self.config.aliases.items():
63
+ aliases_table.add_row(alias, command)
64
+
65
+ console.print(aliases_table)
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+
5
+ import cappa
6
+
7
+ from fujin.commands import BaseCommand
8
+ from fujin.connection import Connection
9
+
10
+
11
+ @cappa.command(
12
+ help="Deploy the project by building, transferring files, installing, and configuring services"
13
+ )
14
+ class Deploy(BaseCommand):
15
+ def __call__(self):
16
+ self.build_app()
17
+
18
+ with self.connection() as conn:
19
+ conn.run(f"mkdir -p {self.app_dir}")
20
+ with conn.cd(self.app_dir):
21
+ self.create_hook_manager(conn).pre_deploy()
22
+ self.transfer_files(conn)
23
+
24
+ with self.app_environment() as conn:
25
+ process_manager = self.create_process_manager(conn)
26
+ self.install_project(conn)
27
+ self.release(conn)
28
+ process_manager.install_services()
29
+ process_manager.reload_configuration()
30
+ process_manager.restart_services()
31
+ self.create_web_proxy(conn).setup()
32
+ self.update_version_history(conn)
33
+ self.prune_assets(conn)
34
+ self.create_hook_manager(conn).post_deploy()
35
+ self.stdout.output("[green]Project deployment completed successfully![/green]")
36
+ self.stdout.output(
37
+ f"[blue]Access the deployed project at: https://{self.config.host.domain_name}[/blue]"
38
+ )
39
+
40
+ def build_app(self) -> None:
41
+ try:
42
+ subprocess.run(self.config.build_command, check=True, shell=True)
43
+ except subprocess.CalledProcessError as e:
44
+ raise cappa.Exit(f"build command failed: {e}", code=1) from e
45
+
46
+ @property
47
+ def versioned_assets_dir(self) -> str:
48
+ return f"{self.app_dir}/v{self.config.version}"
49
+
50
+ def transfer_files(self, conn: Connection, skip_requirements: bool = False):
51
+ if not self.config.host.envfile.exists():
52
+ raise cappa.Exit(f"{self.config.host.envfile} not found", code=1)
53
+
54
+ if not self.config.requirements.exists():
55
+ raise cappa.Exit(f"{self.config.requirements} not found", code=1)
56
+ conn.put(str(self.config.host.envfile), f"{self.app_dir}/.env")
57
+ conn.run(f"mkdir -p {self.versioned_assets_dir}")
58
+ if not skip_requirements:
59
+ conn.put(
60
+ str(self.config.requirements),
61
+ f"{self.versioned_assets_dir}/requirements.txt",
62
+ )
63
+ distfile_path = self.config.get_distfile_path()
64
+ conn.put(
65
+ str(distfile_path),
66
+ f"{self.versioned_assets_dir}/{distfile_path.name}",
67
+ )
68
+ appenv = f"""
69
+ set -a # Automatically export all variables
70
+ source .env
71
+ set +a # Stop automatic export
72
+ export UV_COMPILE_BYTECODE=1
73
+ export UV_PYTHON=python{self.config.python_version}
74
+ export PATH=".venv/bin:$PATH"
75
+ """
76
+ conn.run(f"echo '{appenv.strip()}' > .appenv")
77
+
78
+ def install_project(
79
+ self, conn: Connection, version: str | None = None, *, skip_setup: bool = False
80
+ ):
81
+ if self.config.skip_project_install:
82
+ return
83
+ version = version or self.config.version
84
+ versioned_assets_dir = f"{self.app_dir}/v{version}"
85
+ if not skip_setup:
86
+ conn.run("uv venv")
87
+ conn.run(f"uv pip install -r {versioned_assets_dir}/requirements.txt")
88
+ conn.run(
89
+ f"uv pip install {versioned_assets_dir}/{self.config.get_distfile_path(version).name}"
90
+ )
91
+
92
+ def release(self, conn: Connection):
93
+ if self.config.release_command:
94
+ conn.run(f"source .env && {self.config.release_command}")
95
+
96
+ def update_version_history(self, conn: Connection):
97
+ result = conn.run("head -n 1 .versions", warn=True, hide=True).stdout.strip()
98
+ if result == self.config.version:
99
+ return
100
+ if result == "":
101
+ conn.run(f"echo '{self.config.version}' > .versions")
102
+ else:
103
+ conn.run(f"sed -i '1i {self.config.version}' .versions")
104
+
105
+ def prune_assets(self, conn: Connection):
106
+ if not self.config.versions_to_keep:
107
+ return
108
+ result = conn.run(
109
+ f"sed -n '{self.config.versions_to_keep + 1},$p' .versions", hide=True
110
+ ).stdout.strip()
111
+ result_list = result.split("\n")
112
+ if result == "":
113
+ return
114
+ to_prune = [f"{self.app_dir}/v{v}" for v in result_list]
115
+ conn.run(f"rm -r {' '.join(to_prune)}", warn=True)
116
+ conn.run(f"sed -i '{self.config.versions_to_keep + 1},$d' .versions", warn=True)
fujin/commands/docs.py ADDED
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import cappa
4
+
5
+ import fujin.config
6
+ from fujin.commands import BaseCommand
7
+
8
+
9
+ @cappa.command(help="Configuration documentation")
10
+ class Docs(BaseCommand):
11
+ def __call__(self):
12
+ docs = f"""
13
+ # Fujin Configuration
14
+ {fujin.config.__doc__}
15
+ """
16
+ self.stdout.output(docs)
fujin/commands/down.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Annotated
5
+
6
+ import cappa
7
+ from rich.prompt import Confirm
8
+
9
+ from fujin.commands import BaseCommand
10
+
11
+
12
+ @cappa.command(
13
+ help="Tear down the project by stopping services and cleaning up resources"
14
+ )
15
+ @dataclass
16
+ class Down(BaseCommand):
17
+ full: Annotated[
18
+ bool,
19
+ cappa.Arg(
20
+ short="-f",
21
+ long="--full",
22
+ help="Stop and uninstall proxy as part of teardown",
23
+ ),
24
+ ] = False
25
+
26
+ def __call__(self):
27
+ try:
28
+ confirm = Confirm.ask(
29
+ f"""[red]You are about to delete all project files, stop all services, and remove all configurations on the host {self.config.host.ip} for the project {self.config.app_name}. Any assets in your project folder will be lost (sqlite not in there ?). Are you sure you want to proceed? This action is irreversible.[/red]""",
30
+ )
31
+ except KeyboardInterrupt as e:
32
+ raise cappa.Exit("Teardown aborted", code=0)
33
+ if not confirm:
34
+ return
35
+ with self.connection() as conn:
36
+ hook_manager = self.create_hook_manager(conn)
37
+ hook_manager.pre_teardown()
38
+ process_manager = self.create_process_manager(conn)
39
+ conn.run(f"rm -rf {self.app_dir}")
40
+ self.create_web_proxy(conn).teardown()
41
+ process_manager.uninstall_services()
42
+ process_manager.reload_configuration()
43
+ if self.full:
44
+ self.create_web_proxy(conn).uninstall()
45
+ hook_manager.post_teardown()
46
+ self.stdout.output(
47
+ "[green]Project teardown completed successfully![/green]"
48
+ )
fujin/commands/init.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import cappa
7
+ import tomli_w
8
+
9
+ from fujin.commands import BaseCommand
10
+ from fujin.config import tomllib
11
+
12
+
13
+ @cappa.command(help="Generate a sample configuration file")
14
+ class Init(BaseCommand):
15
+ profile: Annotated[
16
+ str, cappa.Arg(choices=["simple", "falco"], short="-p", long="--profile")
17
+ ] = "simple"
18
+
19
+ def __call__(self):
20
+ fujin_toml = Path("fujin.toml")
21
+ if fujin_toml.exists():
22
+ raise cappa.Exit("fujin.toml file already exists", code=1)
23
+ profile_to_func = {"simple": simple_config, "falco": falco_config}
24
+ app_name = Path().resolve().stem.replace("-", "_").replace(" ", "_").lower()
25
+ config = profile_to_func[self.profile](app_name)
26
+ if not Path(".python-version").exists():
27
+ config["python_version"] = "3.12"
28
+ pyproject_toml = Path("pyproject.toml")
29
+ if pyproject_toml.exists():
30
+ pyproject = tomllib.loads(pyproject_toml.read_text())
31
+ config["app"] = pyproject.get("project", {}).get("name", app_name)
32
+ if pyproject.get("project", {}).get("version"):
33
+ # fujin will read the version itself from the pyproject
34
+ config.pop("version")
35
+ fujin_toml.write_text(tomli_w.dumps(config))
36
+ self.stdout.output(
37
+ "[green]Sample configuration file generated successfully![/green]"
38
+ )
39
+
40
+
41
+ def simple_config(app_name) -> dict:
42
+ return {
43
+ "app": app_name,
44
+ "version": "0.1.0",
45
+ "build_command": "uv build && uv pip compile pyproject.toml -o requirements.txt",
46
+ "distfile": f"dist/{app_name}-{{version}}-py3-none-any.whl",
47
+ "webserver": {
48
+ "upstream": "localhost:8000",
49
+ "type": "fujin.proxies.caddy",
50
+ },
51
+ "release_command": f"{app_name} migrate",
52
+ "processes": {
53
+ "web": f".venv/bin/gunicorn {app_name}.wsgi:app --bind 0.0.0.0:8000"
54
+ },
55
+ "aliases": {"shell": "server exec --appenv -i bash"},
56
+ "host": {
57
+ "ip": "127.0.0.1",
58
+ "user": "root",
59
+ "domain_name": f"{app_name}.com",
60
+ "envfile": ".env.prod",
61
+ },
62
+ }
63
+
64
+
65
+ def falco_config(app_name: str) -> dict:
66
+ config = simple_config(app_name)
67
+ config.update(
68
+ {
69
+ "release_command": f"{config['app']} setup",
70
+ "processes": {
71
+ "web": f".venv/bin/{config['app']} prodserver",
72
+ "worker": f".venv/bin/{config['app']} qcluster",
73
+ },
74
+ "aliases": {
75
+ "console": "app exec -i shell_plus",
76
+ "dbconsole": "app exec -i dbshell",
77
+ "print_settings": "app exec print_settings --format=pprint",
78
+ "shell": "server exec --appenv -i bash",
79
+ },
80
+ }
81
+ )
82
+ return config