fujin-cli 0.2.0__py3-none-any.whl → 0.4.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/__main__.py CHANGED
@@ -1,3 +1,4 @@
1
+ import shlex
1
2
  import sys
2
3
  from pathlib import Path
3
4
 
@@ -6,8 +7,13 @@ import cappa
6
7
  from fujin.commands.app import App
7
8
  from fujin.commands.config import ConfigCMD
8
9
  from fujin.commands.deploy import Deploy
10
+ from fujin.commands.docs import Docs
9
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
10
15
  from fujin.commands.redeploy import Redeploy
16
+ from fujin.commands.rollback import Rollback
11
17
  from fujin.commands.server import Server
12
18
  from fujin.commands.up import Up
13
19
 
@@ -20,12 +26,22 @@ else:
20
26
  @cappa.command(help="Deployment of python web apps in a breeze :)")
21
27
  class Fujin:
22
28
  subcommands: cappa.Subcommands[
23
- Up | Deploy | Redeploy | App | Server | ConfigCMD | Down
29
+ Init
30
+ | Up
31
+ | Deploy
32
+ | Redeploy
33
+ | App
34
+ | Server
35
+ | Proxy
36
+ | ConfigCMD
37
+ | Docs
38
+ | Down
39
+ | Rollback
40
+ | Prune
24
41
  ]
25
42
 
26
43
 
27
44
  def main():
28
- # install(show_locals=True)
29
45
  alias_cmd = _parse_aliases()
30
46
  if alias_cmd:
31
47
  cappa.invoke(Fujin, argv=alias_cmd)
@@ -38,7 +54,7 @@ def _parse_aliases() -> list[str] | None:
38
54
  if not fujin_toml.exists():
39
55
  return
40
56
  data = tomllib.loads(fujin_toml.read_text())
41
- aliases = data.get("aliases")
57
+ aliases: dict[str, str] = data.get("aliases")
42
58
  if not aliases:
43
59
  return
44
60
  if len(sys.argv) == 1:
@@ -46,7 +62,9 @@ def _parse_aliases() -> list[str] | None:
46
62
  if sys.argv[1] not in aliases:
47
63
  return
48
64
  extra_args = sys.argv[2:] if len(sys.argv) > 2 else []
49
- return [*aliases.get(sys.argv[1]).split(), *extra_args]
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)]
50
68
 
51
69
 
52
70
  if __name__ == "__main__":
@@ -1,3 +1,2 @@
1
1
  from ._base import BaseCommand # noqa
2
-
3
- from ._base import AppCommand # noqa
2
+ from ._base import BaseCommand # noqa
fujin/commands/_base.py CHANGED
@@ -1,20 +1,25 @@
1
1
  import importlib
2
+ from contextlib import contextmanager
2
3
  from dataclasses import dataclass
3
4
  from functools import cached_property
4
- from typing import Annotated
5
5
 
6
6
  import cappa
7
7
 
8
8
  from fujin.config import Config
9
+ from fujin.connection import host_connection, Connection
9
10
  from fujin.errors import ImproperlyConfiguredError
10
11
  from fujin.hooks import HookManager
11
- from fujin.host import Host
12
12
  from fujin.process_managers import ProcessManager
13
13
  from fujin.proxies import WebProxy
14
14
 
15
15
 
16
16
  @dataclass
17
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
+
18
23
  @cached_property
19
24
  def config(self) -> Config:
20
25
  return Config.read()
@@ -23,61 +28,49 @@ class BaseCommand:
23
28
  def stdout(self) -> cappa.Output:
24
29
  return cappa.Output()
25
30
 
31
+ @cached_property
32
+ def app_dir(self) -> str:
33
+ return self.config.host.get_app_dir(app_name=self.config.app_name)
26
34
 
27
- @dataclass
28
- class AppCommand(BaseCommand):
29
- """
30
- A command that provides access to the current host and allows interaction with it,
31
- including configuring the web proxy and managing systemd services.
32
- """
33
-
34
- _host: Annotated[str | None, cappa.Arg(long="--host", value_name="HOST")]
35
-
36
- # TODO: add info / details command that will list all services with their current status, if they are installed or running or stopped
35
+ @contextmanager
36
+ def connection(self):
37
+ with host_connection(host=self.config.host) as conn:
38
+ yield conn
37
39
 
38
- @cached_property
39
- def host(self) -> Host:
40
- if not self._host:
41
- host_config = next(
42
- (hc for hc in self.config.hosts.values() if hc.default), None
43
- )
44
- if not host_config:
45
- raise ImproperlyConfiguredError(
46
- "No default host has been configured, either pass --host or set the default in your fujin.toml file"
47
- )
48
- else:
49
- host_config = next(
50
- (
51
- hc
52
- for name, hc in self.config.hosts.items()
53
- if self._host in [name, hc.ip]
54
- ),
55
- None,
56
- )
57
- if not host_config:
58
- raise cappa.Exit(f"Host {self._host} does not exist", code=1)
59
- return Host(config=host_config)
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
60
46
 
61
47
  @cached_property
62
- def web_proxy(self) -> WebProxy:
48
+ def web_proxy_class(self) -> type[WebProxy]:
63
49
  module = importlib.import_module(self.config.webserver.type)
64
50
  try:
65
- return getattr(module, "WebProxy")(host=self.host, config=self.config)
51
+ return getattr(module, "WebProxy")
66
52
  except KeyError as e:
67
53
  raise ImproperlyConfiguredError(
68
54
  f"Missing WebProxy class in {self.config.webserver.type}"
69
55
  ) from e
70
56
 
57
+ def create_web_proxy(self, conn: Connection) -> WebProxy:
58
+ return self.web_proxy_class.create(conn=conn, config=self.config)
59
+
71
60
  @cached_property
72
- def process_manager(self) -> ProcessManager:
61
+ def process_manager_class(self) -> type[ProcessManager]:
73
62
  module = importlib.import_module(self.config.process_manager)
74
63
  try:
75
- return getattr(module, "ProcessManager")(host=self.host, config=self.config)
64
+ return getattr(module, "ProcessManager")
76
65
  except KeyError as e:
77
66
  raise ImproperlyConfiguredError(
78
67
  f"Missing ProcessManager class in {self.config.process_manager}"
79
68
  ) from e
80
69
 
81
- @cached_property
82
- def hook_manager(self) -> HookManager:
83
- return HookManager(host=self.host, config=self.config)
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 CHANGED
@@ -4,11 +4,51 @@ from typing import Annotated
4
4
 
5
5
  import cappa
6
6
 
7
- from fujin.commands import AppCommand
7
+ from fujin.commands import BaseCommand
8
8
 
9
9
 
10
10
  @cappa.command(help="Run application-related tasks")
11
- class App(AppCommand):
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)
12
52
 
13
53
  @cappa.command(help="Run an arbitrary command via the application binary")
14
54
  def exec(
@@ -16,12 +56,13 @@ class App(AppCommand):
16
56
  command: str,
17
57
  interactive: Annotated[bool, cappa.Arg(default=False, short="-i")],
18
58
  ):
19
- with self.host.cd_project_dir(self.config.app):
59
+ with self.app_environment() as conn:
20
60
  if interactive:
21
- self.host.run(f"{self.config.app_bin} {command}", pty=interactive)
61
+ conn.run(f"{self.config.app_bin} {command}", pty=interactive, warn=True)
22
62
  else:
23
- result = self.host.run(f"{self.config.app_bin} {command}", hide=True)
24
- self.stdout.output(result)
63
+ self.stdout.output(
64
+ conn.run(f"{self.config.app_bin} {command}", hide=True).stdout
65
+ )
25
66
 
26
67
  @cappa.command(
27
68
  help="Start the specified service or all services if no name is provided"
@@ -32,7 +73,8 @@ class App(AppCommand):
32
73
  str | None, cappa.Arg(help="Service name, no value means all")
33
74
  ] = None,
34
75
  ):
35
- self.process_manager.start_services(name)
76
+ with self.app_environment() as conn:
77
+ self.create_process_manager(conn).start_services(name)
36
78
  msg = f"{name} Service" if name else "All Services"
37
79
  self.stdout.output(f"[green]{msg} started successfully![/green]")
38
80
 
@@ -45,7 +87,8 @@ class App(AppCommand):
45
87
  str | None, cappa.Arg(help="Service name, no value means all")
46
88
  ] = None,
47
89
  ):
48
- self.process_manager.restart_services(name)
90
+ with self.app_environment() as conn:
91
+ self.create_process_manager(conn).restart_services(name)
49
92
  msg = f"{name} Service" if name else "All Services"
50
93
  self.stdout.output(f"[green]{msg} restarted successfully![/green]")
51
94
 
@@ -58,7 +101,8 @@ class App(AppCommand):
58
101
  str | None, cappa.Arg(help="Service name, no value means all")
59
102
  ] = None,
60
103
  ):
61
- self.process_manager.stop_services(name)
104
+ with self.app_environment() as conn:
105
+ self.create_process_manager(conn).stop_services(name)
62
106
  msg = f"{name} Service" if name else "All Services"
63
107
  self.stdout.output(f"[green]{msg} stopped successfully![/green]")
64
108
 
@@ -67,4 +111,28 @@ class App(AppCommand):
67
111
  self, name: Annotated[str, cappa.Arg(help="Service name")], follow: bool = False
68
112
  ):
69
113
  # TODO: flash out this more
70
- self.process_manager.service_logs(name=name, follow=follow)
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]")
fujin/commands/config.py CHANGED
@@ -1,78 +1,52 @@
1
1
  from __future__ import annotations
2
2
 
3
- from pathlib import Path
4
- from typing import Annotated
5
-
6
3
  import cappa
7
- import tomli_w
8
4
  from rich.console import Console
9
- from rich.markdown import Markdown
10
5
  from rich.panel import Panel
11
6
  from rich.table import Table
12
7
 
13
8
  from fujin.commands import BaseCommand
14
- from fujin.config import tomllib
15
9
 
16
10
 
17
- @cappa.command(name="config", help="Manage application configuration")
11
+ @cappa.command(name="config", help="Display your current configuration")
18
12
  class ConfigCMD(BaseCommand):
19
-
20
- @cappa.command(help="Display the parsed configuration")
21
- def show(self):
13
+ def __call__(self):
22
14
  console = Console()
23
-
24
15
  general_config = {
25
- "app": self.config.app,
16
+ "app": self.config.app_name,
26
17
  "app_bin": self.config.app_bin,
27
18
  "version": self.config.version,
28
19
  "python_version": self.config.python_version,
29
20
  "build_command": self.config.build_command,
21
+ "release_command": self.config.release_command,
30
22
  "distfile": self.config.distfile,
31
23
  "requirements": self.config.requirements,
32
24
  "webserver": f"{{ upstream = '{self.config.webserver.upstream}', type = '{self.config.webserver.type}' }}",
33
25
  }
34
- formatted_text = "\n".join(
26
+ general_config_text = "\n".join(
35
27
  f"[bold green]{key}:[/bold green] {value}"
36
28
  for key, value in general_config.items()
37
29
  )
38
30
  console.print(
39
31
  Panel(
40
- formatted_text,
32
+ general_config_text,
41
33
  title="General Configuration",
42
34
  border_style="green",
43
35
  width=100,
44
36
  )
45
37
  )
46
38
 
47
- # Hosts Table with headers and each dictionary on its own line
48
- hosts_table = Table(title="Hosts", header_style="bold cyan")
49
- hosts_table.add_column("Host", style="dim")
50
- hosts_table.add_column("ip")
51
- hosts_table.add_column("domain_name")
52
- hosts_table.add_column("user")
53
- hosts_table.add_column("password_env")
54
- hosts_table.add_column("projects_dr")
55
- hosts_table.add_column("ssh_port")
56
- hosts_table.add_column("key_filename")
57
- hosts_table.add_column("envfile")
58
- hosts_table.add_column("primary", justify="center")
59
-
60
- for host_name, host in self.config.hosts.items():
61
- host_dict = host.to_dict()
62
- hosts_table.add_row(
63
- host_name,
64
- host_dict["ip"],
65
- host_dict["domain_name"],
66
- host_dict["user"],
67
- str(host_dict["password_env"] or "N/A"),
68
- host_dict["projects_dir"],
69
- str(host_dict["ssh_port"]),
70
- str(host_dict["_key_filename"] or "N/A"),
71
- host_dict["_envfile"],
72
- "[green]Yes[/green]" if host_dict["default"] else "[red]No[/red]",
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,
73
48
  )
74
-
75
- console.print(hosts_table)
49
+ )
76
50
 
77
51
  # Processes Table with headers and each dictionary on its own line
78
52
  processes_table = Table(title="Processes", header_style="bold cyan")
@@ -89,86 +63,3 @@ class ConfigCMD(BaseCommand):
89
63
  aliases_table.add_row(alias, command)
90
64
 
91
65
  console.print(aliases_table)
92
-
93
- @cappa.command(help="Generate a sample configuration file")
94
- def init(
95
- self,
96
- profile: Annotated[
97
- str, cappa.Arg(choices=["simple", "falco"], short="-p", long="--profile")
98
- ] = "simple",
99
- ):
100
- fujin_toml = Path("fujin.toml")
101
- if fujin_toml.exists():
102
- raise cappa.Exit("fujin.toml file already exists", code=1)
103
- profile_to_func = {"simple": simple_config, "falco": falco_config}
104
- config = profile_to_func[profile]()
105
- fujin_toml.write_text(tomli_w.dumps(config))
106
- self.stdout.output(
107
- "[green]Sample configuration file generated successfully![/green]"
108
- )
109
-
110
- @cappa.command(help="Config documentation")
111
- def docs(self):
112
- self.stdout.output(Markdown(docs))
113
-
114
-
115
- def simple_config() -> dict:
116
- app = Path().resolve().stem.replace("-", "_").replace(" ", "_").lower()
117
-
118
- config = {
119
- "app": app,
120
- "version": "0.1.0",
121
- "build_command": "uv build",
122
- "distfile": f"dist/{app}-{{version}}-py3-none-any.whl",
123
- "webserver": {
124
- "upstream": "localhost:8000",
125
- "type": "fujin.proxies.caddy",
126
- },
127
- "hooks": {"pre_deploy": f".venv/bin/{app} migrate"},
128
- "processes": {"web": f".venv/bin/gunicorn {app}.wsgi:app --bind 0.0.0.0:8000"},
129
- "aliases": {"shell": "server exec -i bash"},
130
- "hosts": {
131
- "primary": {
132
- "ip": "127.0.0.1",
133
- "user": "root",
134
- "domain_name": f"{app}.com",
135
- "envfile": ".env.prod",
136
- "default": True,
137
- }
138
- },
139
- }
140
- if not Path(".python-version").exists():
141
- config["python_version"] = "3.12"
142
- pyproject_toml = Path("pyproject.toml")
143
- if pyproject_toml.exists():
144
- pyproject = tomllib.loads(pyproject_toml.read_text())
145
- config["app"] = pyproject.get("project", {}).get("name", app)
146
- if pyproject.get("project", {}).get("version"):
147
- # fujin will read the version itself from the pyproject
148
- config.pop("version")
149
- return config
150
-
151
-
152
- def falco_config() -> dict:
153
- config = simple_config()
154
- config.update(
155
- {
156
- "hooks": {"pre_deploy": f".venv/bin/{config['app']} setup"},
157
- "processes": {
158
- "web": f".venv/bin/{config['app']} prodserver",
159
- "worker": f".venv/bin/{config['app']} qcluster",
160
- },
161
- "aliases": {
162
- "console": "app exec -i shell_plus",
163
- "dbconsole": "app exec -i dbshell",
164
- "print_settings": "app exec print_settings --format=pprint",
165
- "shell": "server exec -i bash",
166
- },
167
- }
168
- )
169
- return config
170
-
171
-
172
- docs = """
173
- # Fujin Configuration
174
- """
fujin/commands/deploy.py CHANGED
@@ -4,51 +4,113 @@ import subprocess
4
4
 
5
5
  import cappa
6
6
 
7
- from fujin.commands import AppCommand
7
+ from fujin.commands import BaseCommand
8
+ from fujin.connection import Connection
8
9
 
9
10
 
10
11
  @cappa.command(
11
12
  help="Deploy the project by building, transferring files, installing, and configuring services"
12
13
  )
13
- class Deploy(AppCommand):
14
-
14
+ class Deploy(BaseCommand):
15
15
  def __call__(self):
16
- try:
17
- subprocess.run(self.config.build_command.split(), check=True)
18
- except subprocess.CalledProcessError as e:
19
- raise cappa.Exit(f"build command failed: {e}", code=1) from e
16
+ self.build_app()
20
17
 
21
- self.host.make_project_dir(project_name=self.config.app)
22
- self.transfer_files()
23
- self.install_project()
24
- self.hook_manager.pre_deploy()
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)
25
23
 
26
- self.process_manager.install_services()
27
- self.process_manager.reload_configuration()
28
- self.process_manager.restart_services()
29
-
30
- self.web_proxy.setup()
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()
31
35
  self.stdout.output("[green]Project deployment completed successfully![/green]")
32
36
  self.stdout.output(
33
- f"[blue]Access the deployed project at: https://{self.host.config.domain_name}[/blue]"
37
+ f"[blue]Access the deployed project at: https://{self.config.host.domain_name}[/blue]"
34
38
  )
35
39
 
36
- def transfer_files(self):
37
- if not self.host.config.envfile.exists():
38
- raise cappa.Exit(f"{self.host.config.envfile} not found", code=1)
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)
39
53
 
40
54
  if not self.config.requirements.exists():
41
55
  raise cappa.Exit(f"{self.config.requirements} not found", code=1)
42
- project_dir = self.host.project_dir(self.config.app)
43
- self.host.put(str(self.config.requirements), f"{project_dir}/requirements.txt")
44
- self.host.put(str(self.host.config.envfile), f"{project_dir}/.env")
45
- self.host.put(
46
- str(self.config.distfile), f"{project_dir}/{self.config.distfile.name}"
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}",
47
67
  )
48
- self.host.run(f"echo {self.config.python_version} > .python-version")
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")
49
104
 
50
- def install_project(self):
51
- with self.host.cd_project_dir(self.config.app):
52
- self.host.run_uv("venv")
53
- self.host.run_uv("pip install -r requirements.txt")
54
- self.host.run_uv(f"pip install {self.config.distfile.name}")
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)