phoxtail 0.1.0__tar.gz

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.
Files changed (37) hide show
  1. phoxtail-0.1.0/LICENSE +28 -0
  2. phoxtail-0.1.0/MANIFEST.in +9 -0
  3. phoxtail-0.1.0/PKG-INFO +123 -0
  4. phoxtail-0.1.0/README.md +92 -0
  5. phoxtail-0.1.0/phoxtail/__init__.py +3 -0
  6. phoxtail-0.1.0/phoxtail/__main__.py +80 -0
  7. phoxtail-0.1.0/phoxtail/commands/__init__.py +1 -0
  8. phoxtail-0.1.0/phoxtail/commands/db.py +323 -0
  9. phoxtail-0.1.0/phoxtail/commands/docker.py +333 -0
  10. phoxtail-0.1.0/phoxtail/commands/env.py +235 -0
  11. phoxtail-0.1.0/phoxtail/commands/lint.py +53 -0
  12. phoxtail-0.1.0/phoxtail/commands/manage.py +45 -0
  13. phoxtail-0.1.0/phoxtail/commands/media.py +152 -0
  14. phoxtail-0.1.0/phoxtail/commands/nginx.py +124 -0
  15. phoxtail-0.1.0/phoxtail/commands/requirements.py +67 -0
  16. phoxtail-0.1.0/phoxtail/commands/ssl.py +96 -0
  17. phoxtail-0.1.0/phoxtail/commands/test.py +52 -0
  18. phoxtail-0.1.0/phoxtail/templates/docker/Dockerfile +47 -0
  19. phoxtail-0.1.0/phoxtail/templates/docker/docker-compose.yaml +121 -0
  20. phoxtail-0.1.0/phoxtail/templates/env/development.env +23 -0
  21. phoxtail-0.1.0/phoxtail/templates/env/production.env +40 -0
  22. phoxtail-0.1.0/phoxtail/templates/nginx/initial.conf +39 -0
  23. phoxtail-0.1.0/phoxtail/templates/nginx/production.conf +153 -0
  24. phoxtail-0.1.0/phoxtail/utils/__init__.py +1 -0
  25. phoxtail-0.1.0/phoxtail/utils/config.py +130 -0
  26. phoxtail-0.1.0/phoxtail/utils/docker.py +35 -0
  27. phoxtail-0.1.0/phoxtail/utils/env.py +29 -0
  28. phoxtail-0.1.0/phoxtail/utils/templates.py +24 -0
  29. phoxtail-0.1.0/phoxtail/utils/venv.py +109 -0
  30. phoxtail-0.1.0/phoxtail.egg-info/PKG-INFO +123 -0
  31. phoxtail-0.1.0/phoxtail.egg-info/SOURCES.txt +35 -0
  32. phoxtail-0.1.0/phoxtail.egg-info/dependency_links.txt +1 -0
  33. phoxtail-0.1.0/phoxtail.egg-info/entry_points.txt +2 -0
  34. phoxtail-0.1.0/phoxtail.egg-info/requires.txt +9 -0
  35. phoxtail-0.1.0/phoxtail.egg-info/top_level.txt +1 -0
  36. phoxtail-0.1.0/pyproject.toml +65 -0
  37. phoxtail-0.1.0/setup.cfg +4 -0
phoxtail-0.1.0/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Phoxmor
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,9 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include phoxtail/templates *
4
+ prune phoxtail/tests
5
+ prune phoxtail/docs
6
+ prune docs-temp
7
+ prune build
8
+ exclude CLAUDE.md
9
+ exclude uv.lock
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: phoxtail
3
+ Version: 0.1.0
4
+ Summary: The engine behind every Phoxtail project.
5
+ Author-email: Phoxmor <info@phoxtail.com>
6
+ Maintainer-email: Evangelos Pisinas <evangelos.pisinas@phoxtail.com>
7
+ License-Expression: BSD-3-Clause
8
+ Project-URL: Homepage, https://phoxtail.com
9
+ Project-URL: Repository, https://github.com/phoxmor/phoxtail
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Wagtail
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Build Tools
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: typer>=0.9.0
23
+ Requires-Dist: rich>=13.0.0
24
+ Requires-Dist: questionary>=2.0.0
25
+ Requires-Dist: jinja2>=3.1.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pip-tools>=7.0.0; extra == "dev"
28
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
29
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # Phoxtail
33
+
34
+ The engine behind every Phoxtail project.
35
+
36
+ Phoxtail is a CLI toolkit that automates the development and deployment workflow for Phoxtail projects — Docker configuration, environment setup, database operations, nginx, SSL, media sync, and more.
37
+
38
+ ## Requirements
39
+
40
+ - Python 3.11+
41
+ - [uv](https://docs.astral.sh/uv/) (recommended) or pip
42
+
43
+ ## Installation
44
+
45
+ ### As a CLI tool
46
+
47
+ ```bash
48
+ # Recommended: installs in an isolated environment
49
+ uv tool install phoxtail
50
+
51
+ # Or with pip/pipx
52
+ pip install phoxtail
53
+ ```
54
+
55
+ ### For development
56
+
57
+ ```bash
58
+ git clone git@github.com:phoxmor/phoxtail.git
59
+ cd phoxtail
60
+ uv venv
61
+ source .venv/bin/activate
62
+ uv pip install -e ".[dev]"
63
+ ```
64
+
65
+ ## Usage
66
+
67
+ Run `phoxtail` from the root of any Phoxtail project (where `phoxtail.toml` lives):
68
+
69
+ ```bash
70
+ phoxtail --help
71
+ ```
72
+
73
+ ### Commands
74
+
75
+ | Command | Description |
76
+ |---|---|
77
+ | `phoxtail docker up/down/restart` | Docker lifecycle management |
78
+ | `phoxtail docker create compose` | Generate docker-compose.yaml |
79
+ | `phoxtail docker create dockerfile` | Generate Dockerfile |
80
+ | `phoxtail env create` | Generate .env files (dev/production) |
81
+ | `phoxtail nginx create initial` | Nginx config for SSL certificate retrieval |
82
+ | `phoxtail nginx create production` | Nginx config with SSL/TLS |
83
+ | `phoxtail ssl obtain/renew` | SSL certificate management |
84
+ | `phoxtail db backup/restore` | Database backup and restore |
85
+ | `phoxtail db pull` | Pull database from remote server |
86
+ | `phoxtail media pull` | Sync media files from remote server |
87
+ | `phoxtail requirements compile` | Compile Python dependencies |
88
+ | `phoxtail manage <command>` | Run Django management commands |
89
+ | `phoxtail test` | Run the test suite |
90
+ | `phoxtail lint` | Run linting and formatting |
91
+ | `phoxtail version` | Show CLI version |
92
+
93
+ ## Project detection
94
+
95
+ Phoxtail detects projects by looking for `phoxtail.toml` in the current directory. This file declares the project name, Docker image prefix, database clusters, and their dependencies.
96
+
97
+ ## Development
98
+
99
+ ### Running tests
100
+
101
+ ```bash
102
+ pytest
103
+ ```
104
+
105
+ ### Project structure
106
+
107
+ ```
108
+ phoxtail/
109
+ phoxtail/
110
+ __init__.py # Package version
111
+ __main__.py # CLI entry point (Typer app)
112
+ commands/ # Command modules
113
+ utils/ # Shared utilities (config, docker, env, templates)
114
+ templates/ # Jinja2 templates (Dockerfile, compose, nginx, env)
115
+ tests/ # Test suite
116
+ pyproject.toml
117
+ LICENSE
118
+ README.md
119
+ ```
120
+
121
+ ## License
122
+
123
+ BSD 3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,92 @@
1
+ # Phoxtail
2
+
3
+ The engine behind every Phoxtail project.
4
+
5
+ Phoxtail is a CLI toolkit that automates the development and deployment workflow for Phoxtail projects — Docker configuration, environment setup, database operations, nginx, SSL, media sync, and more.
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.11+
10
+ - [uv](https://docs.astral.sh/uv/) (recommended) or pip
11
+
12
+ ## Installation
13
+
14
+ ### As a CLI tool
15
+
16
+ ```bash
17
+ # Recommended: installs in an isolated environment
18
+ uv tool install phoxtail
19
+
20
+ # Or with pip/pipx
21
+ pip install phoxtail
22
+ ```
23
+
24
+ ### For development
25
+
26
+ ```bash
27
+ git clone git@github.com:phoxmor/phoxtail.git
28
+ cd phoxtail
29
+ uv venv
30
+ source .venv/bin/activate
31
+ uv pip install -e ".[dev]"
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ Run `phoxtail` from the root of any Phoxtail project (where `phoxtail.toml` lives):
37
+
38
+ ```bash
39
+ phoxtail --help
40
+ ```
41
+
42
+ ### Commands
43
+
44
+ | Command | Description |
45
+ |---|---|
46
+ | `phoxtail docker up/down/restart` | Docker lifecycle management |
47
+ | `phoxtail docker create compose` | Generate docker-compose.yaml |
48
+ | `phoxtail docker create dockerfile` | Generate Dockerfile |
49
+ | `phoxtail env create` | Generate .env files (dev/production) |
50
+ | `phoxtail nginx create initial` | Nginx config for SSL certificate retrieval |
51
+ | `phoxtail nginx create production` | Nginx config with SSL/TLS |
52
+ | `phoxtail ssl obtain/renew` | SSL certificate management |
53
+ | `phoxtail db backup/restore` | Database backup and restore |
54
+ | `phoxtail db pull` | Pull database from remote server |
55
+ | `phoxtail media pull` | Sync media files from remote server |
56
+ | `phoxtail requirements compile` | Compile Python dependencies |
57
+ | `phoxtail manage <command>` | Run Django management commands |
58
+ | `phoxtail test` | Run the test suite |
59
+ | `phoxtail lint` | Run linting and formatting |
60
+ | `phoxtail version` | Show CLI version |
61
+
62
+ ## Project detection
63
+
64
+ Phoxtail detects projects by looking for `phoxtail.toml` in the current directory. This file declares the project name, Docker image prefix, database clusters, and their dependencies.
65
+
66
+ ## Development
67
+
68
+ ### Running tests
69
+
70
+ ```bash
71
+ pytest
72
+ ```
73
+
74
+ ### Project structure
75
+
76
+ ```
77
+ phoxtail/
78
+ phoxtail/
79
+ __init__.py # Package version
80
+ __main__.py # CLI entry point (Typer app)
81
+ commands/ # Command modules
82
+ utils/ # Shared utilities (config, docker, env, templates)
83
+ templates/ # Jinja2 templates (Dockerfile, compose, nginx, env)
84
+ tests/ # Test suite
85
+ pyproject.toml
86
+ LICENSE
87
+ README.md
88
+ ```
89
+
90
+ ## License
91
+
92
+ BSD 3-Clause. See [LICENSE](LICENSE).
@@ -0,0 +1,3 @@
1
+ """The engine behind every Phoxtail project."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,80 @@
1
+ """Main CLI entry point for Phoxtail."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+ from rich.panel import Panel
6
+
7
+ from phoxtail.commands import (
8
+ db,
9
+ docker,
10
+ env,
11
+ lint,
12
+ manage,
13
+ media,
14
+ nginx,
15
+ requirements,
16
+ ssl,
17
+ test,
18
+ )
19
+ from phoxtail.utils.config import _find_config_file
20
+
21
+ app = typer.Typer(
22
+ name="phoxtail",
23
+ help="The engine behind every Phoxtail project.",
24
+ add_completion=True,
25
+ invoke_without_command=True,
26
+ )
27
+ console = Console()
28
+
29
+ # Commands that don't require a phoxtail project.
30
+ NO_PROJECT_COMMANDS = {"version"}
31
+
32
+
33
+ @app.callback(invoke_without_command=True)
34
+ def main(ctx: typer.Context):
35
+ """The engine behind every Phoxtail project."""
36
+ if ctx.invoked_subcommand is None:
37
+ console.print(ctx.get_help())
38
+ raise typer.Exit()
39
+
40
+ if ctx.invoked_subcommand in NO_PROJECT_COMMANDS or ctx.resilient_parsing:
41
+ return
42
+
43
+ if _find_config_file() is None:
44
+ console.print(
45
+ Panel(
46
+ "No [bold]phoxtail.toml[/bold] found in this directory or any parent.\n"
47
+ "Run this command from the root of a Phoxtail project.",
48
+ title="[red]Not a Phoxtail project[/red]",
49
+ border_style="red",
50
+ expand=False,
51
+ )
52
+ )
53
+ raise typer.Exit(code=1)
54
+
55
+
56
+ # Command groups
57
+ app.add_typer(docker.app, name="docker", help="Docker lifecycle and configuration")
58
+ app.add_typer(db.app, name="db", help="Database operations")
59
+ app.add_typer(media.app, name="media", help="Media file management")
60
+ app.add_typer(nginx.app, name="nginx", help="Nginx configuration")
61
+ app.add_typer(env.app, name="env", help="Environment configuration")
62
+ app.add_typer(requirements.app, name="requirements", help="Python requirements")
63
+ app.add_typer(ssl.app, name="ssl", help="SSL certificate management")
64
+
65
+ # Top-level commands
66
+ app.add_typer(manage.app, name="manage", help="Run Django management commands")
67
+ app.add_typer(test.app, name="test", help="Run the test suite")
68
+ app.add_typer(lint.app, name="lint", help="Run linting and formatting")
69
+
70
+
71
+ @app.command()
72
+ def version():
73
+ """Show the CLI version."""
74
+ from phoxtail import __version__
75
+
76
+ console.print(f"Phoxtail CLI v{__version__}", style="bold green")
77
+
78
+
79
+ if __name__ == "__main__":
80
+ app()
@@ -0,0 +1 @@
1
+ """Command modules for Phoxtail CLI."""
@@ -0,0 +1,323 @@
1
+ """Database management commands for Phoxtail."""
2
+
3
+ import ipaddress
4
+ import json
5
+ import subprocess
6
+ from enum import Enum
7
+ from pathlib import Path
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.progress import Progress, SpinnerColumn, TextColumn
12
+
13
+ from phoxtail.utils.config import get_cluster_names, get_clusters, resolve_cluster_order
14
+ from phoxtail.utils.docker import docker_db, docker_manage
15
+ from phoxtail.utils.env import read_env_value
16
+
17
+ app = typer.Typer()
18
+ console = Console()
19
+
20
+
21
+ def _build_data_cluster_enum():
22
+ """Build DataCluster enum from phoxtail.toml cluster names + 'all'."""
23
+ members = {name.upper(): name for name in get_cluster_names()}
24
+ members["ALL"] = "all"
25
+ return Enum("DataCluster", members, type=str)
26
+
27
+
28
+ DataCluster = _build_data_cluster_enum()
29
+
30
+
31
+ def _deep_localize(val, hostname_map: dict[str, str]):
32
+ """Recursively replace hostnames in a data structure.
33
+
34
+ Handles plain dicts/lists and JSON-encoded strings that may
35
+ contain hostname references (e.g. StreamField data).
36
+ """
37
+ if isinstance(val, dict):
38
+ return {k: _deep_localize(v, hostname_map) for k, v in val.items()}
39
+ elif isinstance(val, list):
40
+ # Natural key format: ["hostname", port]
41
+ if len(val) == 2 and isinstance(val[0], str) and isinstance(val[1], int):
42
+ h = val[0].lower()
43
+ if h in hostname_map:
44
+ return [hostname_map[h], val[1]]
45
+ return [_deep_localize(i, hostname_map) for i in val]
46
+ elif isinstance(val, str):
47
+ if (val.startswith("{") and val.endswith("}")) or (
48
+ val.startswith("[") and val.endswith("]")
49
+ ):
50
+ try:
51
+ parsed = json.loads(val)
52
+ translated = _deep_localize(parsed, hostname_map)
53
+ return json.dumps(translated)
54
+ except (json.JSONDecodeError, TypeError):
55
+ pass
56
+ return val
57
+
58
+
59
+ def _localize_hostnames(data: list, target_domain: str) -> list:
60
+ """Translate remote hostnames to the target domain.
61
+
62
+ Builds a hostname map from wagtailcore.site entries, then applies it
63
+ across the entire fixture data including JSON-encoded string fields.
64
+ """
65
+ hostname_map = {}
66
+ for entry in data:
67
+ if entry.get("model") == "wagtailcore.site":
68
+ fields = entry.get("fields", {})
69
+ old_h = fields.get("hostname", "").lower()
70
+ if (
71
+ not old_h
72
+ or old_h == target_domain
73
+ or old_h.endswith(f".{target_domain}")
74
+ ):
75
+ continue
76
+ try:
77
+ ipaddress.ip_address(old_h)
78
+ continue
79
+ except ValueError:
80
+ pass
81
+
82
+ parts = old_h.split(".")
83
+ if len(parts) > 2:
84
+ subdomain = ".".join(parts[:-2])
85
+ new_h = f"{subdomain}.{target_domain}"
86
+ else:
87
+ new_h = target_domain
88
+ hostname_map[old_h] = new_h
89
+ fields["hostname"] = new_h
90
+
91
+ if not hostname_map:
92
+ return data
93
+
94
+ for entry in data:
95
+ entry["fields"] = _deep_localize(entry.get("fields", {}), hostname_map)
96
+
97
+ return data
98
+
99
+
100
+ def _pull_single_cluster(
101
+ cluster_name: str,
102
+ remote_host: str,
103
+ remote_dir: str,
104
+ localize: bool,
105
+ progress,
106
+ task,
107
+ ) -> None:
108
+ """Pull and load a single cluster from the remote server."""
109
+ clusters = get_clusters()
110
+ selected_apps = clusters[cluster_name]["apps"]
111
+ dump_filename = f"remote_{cluster_name}_pull.json"
112
+ local_dump_path = Path.cwd() / dump_filename
113
+
114
+ try:
115
+ remote_cmd = (
116
+ f"cd {remote_dir} && "
117
+ f"docker compose exec web python manage.py dumpdata "
118
+ f"{' '.join(selected_apps)} "
119
+ f"--natural-foreign --indent 2 > {dump_filename}"
120
+ )
121
+ progress.update(
122
+ task, description=f"Dumping {cluster_name.upper()} on remote..."
123
+ )
124
+ subprocess.run(
125
+ ["ssh", remote_host, remote_cmd],
126
+ check=True,
127
+ capture_output=True,
128
+ text=True,
129
+ )
130
+ progress.update(
131
+ task, description=f"Downloading {cluster_name.upper()} dump..."
132
+ )
133
+ subprocess.run(
134
+ [
135
+ "scp",
136
+ f"{remote_host}:{remote_dir}/{dump_filename}",
137
+ str(local_dump_path),
138
+ ],
139
+ check=True,
140
+ capture_output=True,
141
+ text=True,
142
+ )
143
+ # Clean up remote dump file (failure is non-critical)
144
+ subprocess.run(
145
+ ["ssh", remote_host, f"rm -f {remote_dir}/{dump_filename}"],
146
+ capture_output=True,
147
+ )
148
+
149
+ if localize:
150
+ target_domain = read_env_value("DOMAIN") or "localhost"
151
+ progress.update(
152
+ task,
153
+ description=(
154
+ f"Localizing {cluster_name.upper()} "
155
+ f"hostnames to {target_domain}..."
156
+ ),
157
+ )
158
+ with open(local_dump_path) as f:
159
+ data = json.load(f)
160
+ data = _localize_hostnames(data, target_domain)
161
+ with open(local_dump_path, "w") as f:
162
+ json.dump(data, f, indent=2)
163
+
164
+ progress.update(
165
+ task, description=f"Loading {cluster_name.upper()} into database..."
166
+ )
167
+ docker_manage("loaddata", local_dump_path.name)
168
+ finally:
169
+ if local_dump_path.exists():
170
+ local_dump_path.unlink()
171
+
172
+
173
+ @app.command(name="pull")
174
+ def pull(
175
+ cluster: DataCluster = typer.Argument(
176
+ ...,
177
+ help="The data cluster to pull from the remote server",
178
+ ),
179
+ remote_host: str = typer.Argument(
180
+ ...,
181
+ help="SSH connection string (e.g., user@example.com)",
182
+ ),
183
+ remote_dir: str = typer.Argument(
184
+ ...,
185
+ help="Path to the project on the remote server",
186
+ ),
187
+ localize: bool = typer.Option(
188
+ True,
189
+ "--localize/--no-localize",
190
+ help="Whether to translate remote hostnames to localhost/subdomains.localhost",
191
+ ),
192
+ ) -> None:
193
+ """Pull data clusters from a remote server to the local environment.
194
+
195
+ DANGER: This will FLUSH your local database before loading.
196
+ A safety backup is created automatically before flushing.
197
+
198
+ Dependencies are resolved automatically. For example, pulling 'booking'
199
+ will pull 'cms' first since booking data depends on it.
200
+ """
201
+ try:
202
+ cluster_order = resolve_cluster_order(cluster.value)
203
+ except ValueError as e:
204
+ console.print(f"[red]Error:[/red] {e}")
205
+ raise typer.Exit(1)
206
+
207
+ if len(cluster_order) > 1:
208
+ names = " → ".join(c.upper() for c in cluster_order)
209
+ console.print(f"[dim]Resolved pull order: {names}[/dim]")
210
+
211
+ with Progress(
212
+ SpinnerColumn(),
213
+ TextColumn("[progress.description]{task.description}"),
214
+ transient=True,
215
+ ) as progress:
216
+ task = progress.add_task(description="Starting...", total=None)
217
+ try:
218
+ progress.update(task, description="Creating safety backup...")
219
+ docker_db(
220
+ "PGPASSWORD=$POSTGRES_PASSWORD pg_dump -h localhost"
221
+ " -U $POSTGRES_USER $POSTGRES_DB"
222
+ " > /db-backups/pre_pull_safety.sql"
223
+ )
224
+
225
+ progress.update(task, description="Flushing local database...")
226
+ docker_manage("flush", "--no-input")
227
+
228
+ for cluster_name in cluster_order:
229
+ _pull_single_cluster(
230
+ cluster_name, remote_host, remote_dir, localize, progress, task
231
+ )
232
+
233
+ pulled = ", ".join(c.upper() for c in cluster_order)
234
+ console.print(
235
+ f"[green bold]✓ {pulled} data pulled"
236
+ " and loaded[/green bold]"
237
+ )
238
+ except subprocess.CalledProcessError as e:
239
+ stderr = e.stderr if e.stderr else str(e)
240
+ console.print(f"[red]Error:[/red] {stderr}")
241
+ raise typer.Exit(1)
242
+
243
+
244
+ @app.command(name="backup")
245
+ def backup() -> None:
246
+ """Create a database backup.
247
+
248
+ Saves a timestamped SQL dump to the db-backups volume.
249
+ """
250
+ with Progress(
251
+ SpinnerColumn(),
252
+ TextColumn("[progress.description]{task.description}"),
253
+ transient=True,
254
+ ) as progress:
255
+ progress.add_task(description="Creating database backup...", total=None)
256
+ try:
257
+ docker_db(
258
+ "PGPASSWORD=$POSTGRES_PASSWORD pg_dump -h localhost"
259
+ " -U $POSTGRES_USER $POSTGRES_DB"
260
+ " > /db-backups/$(date +%F_%H-%M-%S).sql"
261
+ )
262
+ console.print("[green bold]✓ Database backup created[/green bold]")
263
+ except subprocess.CalledProcessError as e:
264
+ stderr = e.stderr if e.stderr else str(e)
265
+ console.print(f"[red]Error:[/red] {stderr}")
266
+ raise typer.Exit(1)
267
+
268
+
269
+ @app.command(name="restore")
270
+ def restore(
271
+ backup_file: str = typer.Argument(
272
+ ...,
273
+ help="Backup filename to restore (e.g., 2026-03-15_12-00-00.sql)",
274
+ ),
275
+ ) -> None:
276
+ """Restore the database from a backup file.
277
+
278
+ A safety backup of the current state is created automatically before restoring.
279
+ The restore runs in a single transaction and will roll back on error.
280
+ """
281
+ from rich.prompt import Confirm
282
+
283
+ if not Confirm.ask(
284
+ "This will replace the current database with "
285
+ f"[bold]{backup_file}[/bold]. Continue?",
286
+ default=False,
287
+ ):
288
+ raise typer.Exit(0)
289
+
290
+ with Progress(
291
+ SpinnerColumn(),
292
+ TextColumn("[progress.description]{task.description}"),
293
+ transient=True,
294
+ ) as progress:
295
+ try:
296
+ progress.add_task(
297
+ description="Creating safety backup of current state...", total=None
298
+ )
299
+ docker_db(
300
+ "PGPASSWORD=$POSTGRES_PASSWORD pg_dump -h localhost"
301
+ " -U $POSTGRES_USER $POSTGRES_DB"
302
+ " > /db-backups/pre_restore_safety.sql"
303
+ )
304
+
305
+ progress.add_task(
306
+ description=f"Restoring from {backup_file}...", total=None
307
+ )
308
+ docker_db(
309
+ "PGPASSWORD=$POSTGRES_PASSWORD psql -h localhost"
310
+ " -U $POSTGRES_USER -d $POSTGRES_DB"
311
+ " -v ON_ERROR_STOP=1 --single-transaction"
312
+ ' -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
313
+ ' GRANT ALL ON SCHEMA public TO \\"$POSTGRES_USER\\";'
314
+ ' GRANT ALL ON SCHEMA public TO public;"'
315
+ f" -f /db-backups/{backup_file}"
316
+ )
317
+ console.print(
318
+ f"[green bold]✓ Database restored from {backup_file}[/green bold]"
319
+ )
320
+ except subprocess.CalledProcessError as e:
321
+ stderr = e.stderr if e.stderr else str(e)
322
+ console.print(f"[red]Error:[/red] {stderr}")
323
+ raise typer.Exit(1)