phoxtail 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.
phoxtail/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """The engine behind every Phoxtail project."""
2
+
3
+ __version__ = "0.1.0"
phoxtail/__main__.py ADDED
@@ -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)