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 +3 -0
- phoxtail/__main__.py +80 -0
- phoxtail/commands/__init__.py +1 -0
- phoxtail/commands/db.py +323 -0
- phoxtail/commands/docker.py +333 -0
- phoxtail/commands/env.py +235 -0
- phoxtail/commands/lint.py +53 -0
- phoxtail/commands/manage.py +45 -0
- phoxtail/commands/media.py +152 -0
- phoxtail/commands/nginx.py +124 -0
- phoxtail/commands/requirements.py +67 -0
- phoxtail/commands/ssl.py +96 -0
- phoxtail/commands/test.py +52 -0
- phoxtail/templates/docker/Dockerfile +47 -0
- phoxtail/templates/docker/docker-compose.yaml +121 -0
- phoxtail/templates/env/development.env +23 -0
- phoxtail/templates/env/production.env +40 -0
- phoxtail/templates/nginx/initial.conf +39 -0
- phoxtail/templates/nginx/production.conf +153 -0
- phoxtail/utils/__init__.py +1 -0
- phoxtail/utils/config.py +130 -0
- phoxtail/utils/docker.py +35 -0
- phoxtail/utils/env.py +29 -0
- phoxtail/utils/templates.py +24 -0
- phoxtail/utils/venv.py +109 -0
- phoxtail-0.1.0.dist-info/METADATA +123 -0
- phoxtail-0.1.0.dist-info/RECORD +31 -0
- phoxtail-0.1.0.dist-info/WHEEL +5 -0
- phoxtail-0.1.0.dist-info/entry_points.txt +2 -0
- phoxtail-0.1.0.dist-info/licenses/LICENSE +28 -0
- phoxtail-0.1.0.dist-info/top_level.txt +1 -0
phoxtail/__init__.py
ADDED
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."""
|
phoxtail/commands/db.py
ADDED
|
@@ -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)
|