hop3-cli 0.5.0.dev3__tar.gz → 0.6.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.
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/PKG-INFO +4 -4
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/README.md +3 -3
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/pyproject.toml +1 -1
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/__init__.py +2 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/arguments.py +213 -125
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/destructive.py +67 -29
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/flags.py +20 -2
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/help.py +125 -22
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/__init__.py +4 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/aliases_cmd.py +2 -14
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/auth_cmd.py +0 -1
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/completion_cmd.py +2 -46
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/context_cmd.py +12 -5
- hop3_cli-0.6.0/src/hop3_cli/commands/local/help_text.py +314 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/server_cmd.py +3 -16
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/settings_cmd.py +16 -0
- hop3_cli-0.6.0/src/hop3_cli/commands/local/tunnel_cmd.py +187 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/use_cmd.py +2 -8
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/alias_registry.py +1 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/aliases.py +6 -2
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/app_scope.py +46 -12
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/deploy_preview.py +1 -24
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/local_overlay.py +0 -4
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/exit_codes.py +2 -9
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/main.py +44 -27
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/__init__.py +1 -2
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/client.py +2 -3
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/responses.py +66 -4
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/streaming.py +4 -1
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/__init__.py +1 -2
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/console.py +0 -5
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/rich_printer.py +35 -46
- hop3_cli-0.5.0.dev3/src/hop3_cli/commands/local/help_text.py +0 -166
- hop3_cli-0.5.0.dev3/src/hop3_cli/rpc/tunnel.py +0 -177
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/__init__.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/init_cmd.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/login_cmd.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/project_context_cmd.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/ssh_ops.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/version_cmd.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/config.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/__init__.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/cli_state.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/context_names.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/hop3_toml.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/project_guard.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/resolution.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/server_registry.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/suggest.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/exceptions.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/tokens.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/types.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/messages.py +0 -0
- {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/prompts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hop3-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary:
|
|
5
5
|
Author: Stefane Fermigier
|
|
6
6
|
Author-email: Stefane Fermigier <sf@abilian.com>
|
|
@@ -57,9 +57,9 @@ hop3 apps
|
|
|
57
57
|
hop3 use myapp
|
|
58
58
|
|
|
59
59
|
# From here on, app-scoped commands resolve myapp automatically:
|
|
60
|
-
hop3 logs
|
|
60
|
+
hop3 app logs
|
|
61
61
|
hop3 config set KEY=value
|
|
62
|
-
hop3 restart
|
|
62
|
+
hop3 app restart
|
|
63
63
|
```
|
|
64
64
|
|
|
65
65
|
## App Resolution
|
|
@@ -79,7 +79,7 @@ Use `hop3 --why <command>` to print the full trace and see which source won. `--
|
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
81
|
# Explicit (always works):
|
|
82
|
-
hop3 logs --app myapp
|
|
82
|
+
hop3 app logs --app myapp
|
|
83
83
|
hop3 config set --app myapp KEY=value
|
|
84
84
|
|
|
85
85
|
# Per-shell:
|
|
@@ -37,9 +37,9 @@ hop3 apps
|
|
|
37
37
|
hop3 use myapp
|
|
38
38
|
|
|
39
39
|
# From here on, app-scoped commands resolve myapp automatically:
|
|
40
|
-
hop3 logs
|
|
40
|
+
hop3 app logs
|
|
41
41
|
hop3 config set KEY=value
|
|
42
|
-
hop3 restart
|
|
42
|
+
hop3 app restart
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
## App Resolution
|
|
@@ -59,7 +59,7 @@ Use `hop3 --why <command>` to print the full trace and see which source won. `--
|
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
61
|
# Explicit (always works):
|
|
62
|
-
hop3 logs --app myapp
|
|
62
|
+
hop3 app logs --app myapp
|
|
63
63
|
hop3 config set --app myapp KEY=value
|
|
64
64
|
|
|
65
65
|
# Per-shell:
|
|
@@ -19,6 +19,7 @@ from .destructive import confirm_destructive_action, is_destructive_command
|
|
|
19
19
|
from .flags import CliFlags, parse_flags
|
|
20
20
|
from .help import (
|
|
21
21
|
append_feedback_footer,
|
|
22
|
+
append_local_commands_full_help,
|
|
22
23
|
emit_status_line,
|
|
23
24
|
handle_help_flags,
|
|
24
25
|
inject_local_commands_into_help,
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"LOCAL_COMMANDS_INFO",
|
|
37
38
|
"CliFlags",
|
|
38
39
|
"append_feedback_footer",
|
|
40
|
+
"append_local_commands_full_help",
|
|
39
41
|
"confirm_destructive_action",
|
|
40
42
|
"emit_status_line",
|
|
41
43
|
"generate_archive",
|
|
@@ -10,31 +10,67 @@ import base64
|
|
|
10
10
|
import io
|
|
11
11
|
import sys
|
|
12
12
|
import tarfile
|
|
13
|
-
from
|
|
13
|
+
from operator import itemgetter
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import TYPE_CHECKING
|
|
16
16
|
|
|
17
17
|
import pathspec
|
|
18
18
|
|
|
19
|
+
from hop3_cli.core.hop3_toml import read_hop3_toml
|
|
20
|
+
|
|
19
21
|
if TYPE_CHECKING:
|
|
20
22
|
from hop3_cli.types import JsonDict
|
|
21
23
|
|
|
22
24
|
__all__ = ["generate_archive", "get_extra_args", "pack_repository"]
|
|
23
25
|
|
|
24
|
-
# tomllib is stdlib in Python 3.11+, use toml package for 3.10
|
|
25
|
-
if sys.version_info >= (3, 11):
|
|
26
|
-
import tomllib
|
|
27
|
-
else:
|
|
28
|
-
import toml as tomllib
|
|
29
|
-
|
|
30
26
|
# Archive size limits (in bytes)
|
|
31
27
|
# Soft limit: warn the user but proceed
|
|
32
28
|
# Hard limit: refuse to upload (can be overridden on server)
|
|
33
29
|
SOFT_SIZE_LIMIT = 100 * 1024 * 1024 # 100 MB
|
|
34
30
|
HARD_SIZE_LIMIT = 1024 * 1024 * 1024 # 1 GB
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
31
|
+
# The server's documented default upload limit (Litestar request_max_body_size
|
|
32
|
+
# and nginx client_max_body_size). The real limit is server-configured, but at
|
|
33
|
+
# this size a deploy is likely to be rejected with HTTP 413 — warn loudly and
|
|
34
|
+
# show what's big, before wasting the upload. ponytail: hardcoded default; a
|
|
35
|
+
# future /rpc capability handshake could report the server's actual limit.
|
|
36
|
+
DEFAULT_SERVER_UPLOAD_LIMIT = 200 * 1024 * 1024 # 200 MB
|
|
37
|
+
|
|
38
|
+
# What the `hop3 deploy` upload always excludes, regardless of deployment
|
|
39
|
+
# method: VCS metadata, OS/IDE cruft, and dependency/build caches the server
|
|
40
|
+
# regenerates (the toolchain runs npm/pip install; the venv is built
|
|
41
|
+
# server-side). Per-app additions go in hop3.toml [build].ignore (ADR 046 §5).
|
|
42
|
+
#
|
|
43
|
+
# Deliberately NOT consulted for this upload:
|
|
44
|
+
# - .gitignore governs the git-push deploy path only, not this upload.
|
|
45
|
+
# - .dockerignore scopes the server-side `docker build` context (Docker
|
|
46
|
+
# applies it there); e.g. Quarkus ships `*` + a target/
|
|
47
|
+
# allowlist, which would gut the upload if honored here.
|
|
48
|
+
_DEFAULT_IGNORE_PATTERNS = [
|
|
49
|
+
".git/",
|
|
50
|
+
".hg/",
|
|
51
|
+
".svn/",
|
|
52
|
+
".DS_Store",
|
|
53
|
+
".idea/",
|
|
54
|
+
"__pycache__/",
|
|
55
|
+
"*.py[cod]",
|
|
56
|
+
"*.egg-info/",
|
|
57
|
+
".venv/",
|
|
58
|
+
"venv/",
|
|
59
|
+
"node_modules/",
|
|
60
|
+
# Compiled-language build output, rebuilt server-side and never deployed.
|
|
61
|
+
# `target/` is both Rust's (cargo) and Java/Maven's output dir; cargo
|
|
62
|
+
# hardlinks the release binary into target/release/deps/, which the
|
|
63
|
+
# server's safe-extract refuses — and it is hundreds of MB. Same rationale
|
|
64
|
+
# as node_modules/.venv above.
|
|
65
|
+
"target/",
|
|
66
|
+
".mypy_cache/",
|
|
67
|
+
".pytest_cache/",
|
|
68
|
+
".ruff_cache/",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
# Deprecated Hop3-specific sidecar, superseded by hop3.toml [build].ignore.
|
|
72
|
+
# Honored for one transition release with a loud warning, then removed (ADR 046).
|
|
73
|
+
_DEPRECATED_IGNORE_FILE = ".hop3ignore"
|
|
38
74
|
|
|
39
75
|
|
|
40
76
|
def get_extra_args(args: list[str], verbosity: int = 1) -> JsonDict:
|
|
@@ -67,17 +103,12 @@ def get_extra_args(args: list[str], verbosity: int = 1) -> JsonDict:
|
|
|
67
103
|
|
|
68
104
|
match command:
|
|
69
105
|
case "deploy":
|
|
70
|
-
#
|
|
71
|
-
#
|
|
106
|
+
# The app is the `--app` flag (ADR 036 D5), stripped by
|
|
107
|
+
# _parse_deploy_args — so `remaining_args` is just an optional source
|
|
108
|
+
# directory (default: the current directory).
|
|
72
109
|
env_vars, remaining_args, streaming = _parse_deploy_args(args[1:])
|
|
73
110
|
|
|
74
|
-
|
|
75
|
-
# Let the server return a proper usage error instead
|
|
76
|
-
if not remaining_args:
|
|
77
|
-
return extra_args
|
|
78
|
-
|
|
79
|
-
# Directory is the last non-flag argument (if any)
|
|
80
|
-
directory = Path(remaining_args[-1]) if len(remaining_args) > 1 else Path()
|
|
111
|
+
directory = Path(remaining_args[0]) if remaining_args else Path()
|
|
81
112
|
extra_args["repository"] = pack_repository(directory, verbosity=verbosity)
|
|
82
113
|
|
|
83
114
|
# Include env vars if any were specified
|
|
@@ -87,9 +118,32 @@ def get_extra_args(args: list[str], verbosity: int = 1) -> JsonDict:
|
|
|
87
118
|
# Enable streaming by default for real-time log output
|
|
88
119
|
extra_args["streaming"] = streaming
|
|
89
120
|
|
|
121
|
+
case "addon":
|
|
122
|
+
# `addon <type> import <name> < dump.sql`: ship the piped dump to
|
|
123
|
+
# the server as a base64 blob (same approach as deploy's upload).
|
|
124
|
+
if len(args) >= 3 and args[2] == "import":
|
|
125
|
+
import_data = _read_import_data()
|
|
126
|
+
if import_data is not None:
|
|
127
|
+
extra_args["import_data"] = import_data
|
|
128
|
+
|
|
90
129
|
return extra_args
|
|
91
130
|
|
|
92
131
|
|
|
132
|
+
def _read_import_data() -> str | None:
|
|
133
|
+
"""Read a dump piped on stdin and base64-encode it for transport.
|
|
134
|
+
|
|
135
|
+
Returns None when stdin is a terminal (no dump piped) or empty, so the
|
|
136
|
+
server can emit a clear "pipe a dump" error instead of the CLI hanging on
|
|
137
|
+
a read from an interactive terminal.
|
|
138
|
+
"""
|
|
139
|
+
if sys.stdin.isatty():
|
|
140
|
+
return None
|
|
141
|
+
raw = sys.stdin.buffer.read()
|
|
142
|
+
if not raw:
|
|
143
|
+
return None
|
|
144
|
+
return base64.b64encode(raw).decode("ascii")
|
|
145
|
+
|
|
146
|
+
|
|
93
147
|
def _resolve_run_input(args: list[str]) -> None:
|
|
94
148
|
"""Resolve --input -/@path on `hop run` so the server gets literal bytes.
|
|
95
149
|
|
|
@@ -267,6 +321,12 @@ def _parse_deploy_args(args: list[str]) -> tuple[dict[str, str], list[str], bool
|
|
|
267
321
|
# Explicitly enable streaming (default, but allow explicit)
|
|
268
322
|
streaming = True
|
|
269
323
|
i += 1
|
|
324
|
+
elif arg in {"--app", "-a"}:
|
|
325
|
+
# The app is a flag, not a positional (ADR 036 D5). Skip it (and its
|
|
326
|
+
# value) so it never gets mistaken for the source directory below.
|
|
327
|
+
i += 2 if i + 1 < len(args) else 1
|
|
328
|
+
elif arg.startswith(("--app=", "-a=")):
|
|
329
|
+
i += 1
|
|
270
330
|
else:
|
|
271
331
|
remaining.append(arg)
|
|
272
332
|
i += 1
|
|
@@ -291,9 +351,8 @@ def pack_repository(directory: Path = Path(), verbosity: int = 1) -> str:
|
|
|
291
351
|
def generate_archive(source_dir: Path, verbosity: int = 1) -> bytes:
|
|
292
352
|
"""
|
|
293
353
|
Creates an in-memory tar.gz archive of a source directory as a bytes object,
|
|
294
|
-
excluding
|
|
295
|
-
|
|
296
|
-
Ignore files are checked in priority order: .hop3ignore, .dockerignore, .gitignore
|
|
354
|
+
excluding built-in defaults plus the app's hop3.toml [build].ignore patterns
|
|
355
|
+
(ADR 046 §5). .gitignore and .dockerignore are not consulted for this upload.
|
|
297
356
|
|
|
298
357
|
Args:
|
|
299
358
|
source_dir: The path to the directory to archive.
|
|
@@ -314,7 +373,7 @@ def generate_archive(source_dir: Path, verbosity: int = 1) -> bytes:
|
|
|
314
373
|
f"Directory not found: {source_dir}\n\n"
|
|
315
374
|
f"Make sure you are in the directory containing your application code,\n"
|
|
316
375
|
f"or specify the path as the last argument:\n"
|
|
317
|
-
f" hop3 deploy <
|
|
376
|
+
f" hop3 deploy --app <app> /path/to/app"
|
|
318
377
|
)
|
|
319
378
|
raise FileNotFoundError(msg)
|
|
320
379
|
if not source_dir.is_dir():
|
|
@@ -327,16 +386,10 @@ def generate_archive(source_dir: Path, verbosity: int = 1) -> bytes:
|
|
|
327
386
|
if verbose:
|
|
328
387
|
print(f"Creating archive from: {source_dir}", file=sys.stderr)
|
|
329
388
|
|
|
330
|
-
# --- 1. Load ignore rules (
|
|
331
|
-
spec,
|
|
389
|
+
# --- 1. Load ignore rules (built-in defaults + hop3.toml [build].ignore) ---
|
|
390
|
+
spec, ignore_source = get_ignored_spec(source_dir)
|
|
332
391
|
if verbose:
|
|
333
|
-
|
|
334
|
-
print(f"Using ignore patterns from: {ignore_file}", file=sys.stderr)
|
|
335
|
-
else:
|
|
336
|
-
print(
|
|
337
|
-
"No ignore file found (.hop3ignore, .dockerignore, .gitignore)",
|
|
338
|
-
file=sys.stderr,
|
|
339
|
-
)
|
|
392
|
+
print(f"Using ignore patterns from: {ignore_source}", file=sys.stderr)
|
|
340
393
|
|
|
341
394
|
# --- 2. Walk the directory and gather files to include ---
|
|
342
395
|
if verbose:
|
|
@@ -389,22 +442,35 @@ def _check_archive_size(
|
|
|
389
442
|
print(f"Archive created: {size_mb:.2f} MB", file=sys.stderr)
|
|
390
443
|
|
|
391
444
|
if archive_size > HARD_SIZE_LIMIT:
|
|
392
|
-
|
|
393
|
-
dir_summary = "\n".join(f" {d}: {c} files" for d, c in top_dirs[:5])
|
|
445
|
+
dir_summary = _largest_dirs_summary(files, source_dir)
|
|
394
446
|
hard_limit_mb = HARD_SIZE_LIMIT / (1024 * 1024)
|
|
395
447
|
|
|
396
448
|
msg = (
|
|
397
449
|
f"Archive too large: {size_mb:.1f} MB exceeds the {hard_limit_mb:.0f} MB limit.\n"
|
|
398
450
|
f"\n"
|
|
399
|
-
f"
|
|
451
|
+
f"Largest entries:\n"
|
|
400
452
|
f"{dir_summary}\n"
|
|
401
453
|
f"\n"
|
|
402
|
-
f"
|
|
403
|
-
f"
|
|
454
|
+
f"Run 'hop3 deploy --dry-run' to see the full archive manifest.\n"
|
|
455
|
+
f"Add patterns to the [build].ignore list in hop3.toml to exclude\n"
|
|
456
|
+
f"them from deployment. The server may also have configurable size limits."
|
|
404
457
|
)
|
|
405
458
|
raise ValueError(msg)
|
|
406
459
|
|
|
407
|
-
if archive_size >
|
|
460
|
+
if archive_size > DEFAULT_SERVER_UPLOAD_LIMIT:
|
|
461
|
+
dir_summary = _largest_dirs_summary(files, source_dir)
|
|
462
|
+
limit_mb = DEFAULT_SERVER_UPLOAD_LIMIT / (1024 * 1024)
|
|
463
|
+
print(
|
|
464
|
+
f"Warning: archive ({size_mb:.1f} MB) exceeds the default server "
|
|
465
|
+
f"upload limit ({limit_mb:.0f} MB); the deploy will likely be "
|
|
466
|
+
f"rejected (HTTP 413).\n"
|
|
467
|
+
f"Largest entries:\n{dir_summary}\n"
|
|
468
|
+
f"Run 'hop3 deploy --dry-run' to see the full manifest. Exclude large "
|
|
469
|
+
f"entries via [build].ignore in hop3.toml, or ask the server admin to "
|
|
470
|
+
f"raise the limit.",
|
|
471
|
+
file=sys.stderr,
|
|
472
|
+
)
|
|
473
|
+
elif archive_size > SOFT_SIZE_LIMIT:
|
|
408
474
|
soft_limit_mb = SOFT_SIZE_LIMIT / (1024 * 1024)
|
|
409
475
|
print(
|
|
410
476
|
f"Warning: Large archive ({size_mb:.1f} MB). "
|
|
@@ -413,108 +479,135 @@ def _check_archive_size(
|
|
|
413
479
|
)
|
|
414
480
|
|
|
415
481
|
|
|
416
|
-
def get_ignored_spec(source_dir: Path) -> tuple[pathspec.PathSpec
|
|
417
|
-
"""
|
|
482
|
+
def get_ignored_spec(source_dir: Path) -> tuple[pathspec.PathSpec, str]:
|
|
483
|
+
"""Build the ignore spec for the `hop3 deploy` upload.
|
|
484
|
+
|
|
485
|
+
The upload always excludes a built-in set of never-deploy paths
|
|
486
|
+
(`_DEFAULT_IGNORE_PATTERNS`), extended by the canonical per-app source:
|
|
418
487
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
3. .hop3ignore file
|
|
423
|
-
4. .dockerignore file
|
|
424
|
-
5. .gitignore file
|
|
488
|
+
1. hop3.toml ``[build].ignore`` — the declarative ignore list (ADR 046 §5).
|
|
489
|
+
2. ``.hop3ignore`` — DEPRECATED sidecar, still honored for one release with
|
|
490
|
+
a loud warning; move its patterns into ``[build].ignore``.
|
|
425
491
|
|
|
426
|
-
|
|
492
|
+
``.gitignore`` (git-push path) and ``.dockerignore`` (server-side
|
|
493
|
+
``docker build``) are intentionally NOT consulted here.
|
|
427
494
|
|
|
428
495
|
Returns:
|
|
429
|
-
Tuple of (PathSpec
|
|
496
|
+
Tuple of (PathSpec, human-readable description of the pattern sources).
|
|
430
497
|
"""
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if hop3_toml_spec is not None:
|
|
434
|
-
return hop3_toml_spec, hop3_toml_source
|
|
498
|
+
patterns = list(_DEFAULT_IGNORE_PATTERNS)
|
|
499
|
+
sources = ["built-in defaults"]
|
|
435
500
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
501
|
+
build_ignore = _get_build_ignore_patterns(source_dir)
|
|
502
|
+
if build_ignore is not None:
|
|
503
|
+
patterns.extend(build_ignore)
|
|
504
|
+
sources.append(f"hop3.toml [build].ignore ({len(build_ignore)} patterns)")
|
|
505
|
+
else:
|
|
506
|
+
deprecated = source_dir / _DEPRECATED_IGNORE_FILE
|
|
507
|
+
if deprecated.is_file():
|
|
508
|
+
patterns.extend(deprecated.read_text(encoding="utf-8").splitlines())
|
|
509
|
+
sources.append(f"{_DEPRECATED_IGNORE_FILE} (deprecated)")
|
|
510
|
+
print(
|
|
511
|
+
f"Warning: {_DEPRECATED_IGNORE_FILE} is deprecated and will stop "
|
|
512
|
+
f"being read in a future release. Move its patterns into the "
|
|
513
|
+
f"[build].ignore list in hop3.toml.",
|
|
514
|
+
file=sys.stderr,
|
|
515
|
+
)
|
|
445
516
|
|
|
517
|
+
spec = pathspec.PathSpec.from_lines("gitignore", patterns) # pyrefly: ignore
|
|
518
|
+
return spec, ", ".join(sources)
|
|
446
519
|
|
|
447
|
-
def _get_hop3_toml_ignore_spec(
|
|
448
|
-
source_dir: Path,
|
|
449
|
-
) -> tuple[pathspec.PathSpec | None, str | None]:
|
|
450
|
-
"""Extract ignore patterns from hop3.toml if present.
|
|
451
520
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
2. [build].ignore-file - reference to an ignore file
|
|
521
|
+
def _get_build_ignore_patterns(source_dir: Path) -> list[str] | None:
|
|
522
|
+
"""Return hop3.toml ``[build].ignore`` patterns, or None if not declared.
|
|
455
523
|
|
|
456
|
-
|
|
457
|
-
|
|
524
|
+
Reads the app's hop3.toml from the standard locations. ``[build].ignore`` is
|
|
525
|
+
the canonical, declarative ignore list for the deploy upload (ADR 046 §5);
|
|
526
|
+
the legacy ``[build].ignore-file`` pointer is no longer supported.
|
|
458
527
|
"""
|
|
459
|
-
|
|
460
|
-
hop3_toml_paths = [
|
|
528
|
+
for hop3_toml_path in (
|
|
461
529
|
source_dir / "hop3" / "hop3.toml",
|
|
462
530
|
source_dir / "hop3.toml",
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if not
|
|
531
|
+
):
|
|
532
|
+
# read_hop3_toml returns {} for a missing or unparseable file.
|
|
533
|
+
build_section = read_hop3_toml(hop3_toml_path).get("build", {})
|
|
534
|
+
if not isinstance(build_section, dict):
|
|
467
535
|
continue
|
|
536
|
+
patterns = build_section.get("ignore")
|
|
537
|
+
if isinstance(patterns, list) and patterns:
|
|
538
|
+
return [str(p) for p in patterns]
|
|
539
|
+
return None
|
|
468
540
|
|
|
469
|
-
try:
|
|
470
|
-
content = hop3_toml_path.read_text(encoding="utf-8")
|
|
471
|
-
data = tomllib.loads(content)
|
|
472
|
-
except Exception:
|
|
473
|
-
# If TOML parsing fails, skip and try next location
|
|
474
|
-
continue
|
|
475
541
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
542
|
+
def _human_size(num_bytes: int) -> str:
|
|
543
|
+
"""Format a byte count as a short human-readable size (e.g. '207.8 MB')."""
|
|
544
|
+
size = float(num_bytes)
|
|
545
|
+
for unit in ("B", "KB", "MB"):
|
|
546
|
+
if size < 1024:
|
|
547
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
548
|
+
size /= 1024
|
|
549
|
+
return f"{size:.1f} GB"
|
|
479
550
|
|
|
480
|
-
# Check for inline ignore patterns
|
|
481
|
-
ignore_patterns = build_section.get("ignore")
|
|
482
|
-
if ignore_patterns and isinstance(ignore_patterns, list):
|
|
483
|
-
spec = pathspec.PathSpec.from_lines("gitignore", ignore_patterns)
|
|
484
|
-
return spec, f"hop3.toml [build].ignore ({len(ignore_patterns)} patterns)"
|
|
485
551
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if ignore_file_path.is_file():
|
|
491
|
-
lines = ignore_file_path.read_text(encoding="utf-8").splitlines()
|
|
492
|
-
spec = pathspec.PathSpec.from_lines("gitignore", lines) # pyrefly: ignore
|
|
493
|
-
return spec, f"hop3.toml [build].ignore-file -> {ignore_file_ref}"
|
|
552
|
+
def _aggregate_by_top_dir(
|
|
553
|
+
sized_files: list[tuple[Path, int]], source_dir: Path
|
|
554
|
+
) -> list[tuple[str, int]]:
|
|
555
|
+
"""Total upload size per top-level entry, largest first.
|
|
494
556
|
|
|
495
|
-
|
|
557
|
+
Size (not file count) is what matters for the 413 limit, and a top-level
|
|
558
|
+
entry is what you'd actually add to [build].ignore.
|
|
559
|
+
"""
|
|
560
|
+
dir_sizes: dict[str, int] = {}
|
|
561
|
+
for f, size in sized_files:
|
|
562
|
+
rel = f.relative_to(source_dir)
|
|
563
|
+
top = rel.parts[0] if len(rel.parts) > 1 else "(root)"
|
|
564
|
+
dir_sizes[top] = dir_sizes.get(top, 0) + size
|
|
565
|
+
return sorted(dir_sizes.items(), key=itemgetter(1), reverse=True)
|
|
496
566
|
|
|
497
567
|
|
|
498
|
-
def
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
568
|
+
def _largest_dirs_summary(files: list[Path], source_dir: Path, n: int = 5) -> str:
|
|
569
|
+
"""A few-line summary of the largest top-level entries, by size."""
|
|
570
|
+
sized = [(f, f.stat().st_size) for f in files]
|
|
571
|
+
top = _aggregate_by_top_dir(sized, source_dir)[:n]
|
|
572
|
+
return "\n".join(f" {_human_size(sz):>10} {name}" for name, sz in top)
|
|
502
573
|
|
|
503
|
-
Args:
|
|
504
|
-
files: List of file paths
|
|
505
|
-
source_dir: The source directory (for computing relative paths)
|
|
506
574
|
|
|
507
|
-
|
|
508
|
-
|
|
575
|
+
def describe_archive(source_dir: Path) -> str:
|
|
576
|
+
"""Human-readable manifest of what `hop3 deploy` would upload.
|
|
577
|
+
|
|
578
|
+
Walks the source, applies the SAME ignore rules as the real upload, and
|
|
579
|
+
reports the total size, the ignore rules in effect, and the largest
|
|
580
|
+
directories and files — so the user can see exactly what's in the archive
|
|
581
|
+
(and what to add to [build].ignore) instead of guessing. Stats only; does
|
|
582
|
+
not build the tarball.
|
|
509
583
|
"""
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
584
|
+
spec, sources = get_ignored_spec(source_dir)
|
|
585
|
+
files = get_files_to_add(source_dir, spec)
|
|
586
|
+
sized = [(f, f.stat().st_size) for f in files]
|
|
587
|
+
total = sum(size for _, size in sized)
|
|
588
|
+
|
|
589
|
+
lines = [
|
|
590
|
+
(
|
|
591
|
+
f"Deploy archive: {_human_size(total)} across {len(files)} files "
|
|
592
|
+
f"(uncompressed; the upload itself is gzipped)."
|
|
593
|
+
),
|
|
594
|
+
f"Ignore rules: {sources}.",
|
|
595
|
+
]
|
|
596
|
+
|
|
597
|
+
top_dirs = _aggregate_by_top_dir(sized, source_dir)
|
|
598
|
+
if top_dirs:
|
|
599
|
+
lines.append("\nLargest entries:")
|
|
600
|
+
lines += [f" {_human_size(sz):>10} {name}" for name, sz in top_dirs[:10]]
|
|
516
601
|
|
|
517
|
-
|
|
602
|
+
largest = sorted(sized, key=itemgetter(1), reverse=True)[:15]
|
|
603
|
+
if largest:
|
|
604
|
+
lines.append("\nLargest files:")
|
|
605
|
+
lines += [
|
|
606
|
+
f" {_human_size(sz):>10} {f.relative_to(source_dir)}" for f, sz in largest
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
lines.append("\nTo shrink it, add large entries to [build].ignore in hop3.toml.")
|
|
610
|
+
return "\n".join(lines)
|
|
518
611
|
|
|
519
612
|
|
|
520
613
|
def _check_directory_is_app(source_dir: Path, verbose: bool) -> None:
|
|
@@ -568,21 +661,16 @@ def _check_directory_is_app(source_dir: Path, verbose: bool) -> None:
|
|
|
568
661
|
)
|
|
569
662
|
|
|
570
663
|
|
|
571
|
-
def get_files_to_add(source_dir: Path, spec: pathspec.PathSpec
|
|
572
|
-
"""Get list of files to add to archive, excluding
|
|
664
|
+
def get_files_to_add(source_dir: Path, spec: pathspec.PathSpec) -> list[Path]:
|
|
665
|
+
"""Get list of files to add to archive, excluding ignored files."""
|
|
573
666
|
files_to_add: list[Path] = []
|
|
574
667
|
for file_path in source_dir.rglob("*"):
|
|
575
668
|
relative_path = file_path.relative_to(source_dir)
|
|
576
669
|
relative_str = str(relative_path)
|
|
577
670
|
|
|
578
|
-
#
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
):
|
|
582
|
-
continue
|
|
583
|
-
|
|
584
|
-
# Let pathspec determine if the file should be ignored
|
|
585
|
-
if spec and spec.match_file(relative_str):
|
|
671
|
+
# Let pathspec determine if the file should be ignored (.git/ and other
|
|
672
|
+
# never-deploy paths are in _DEFAULT_IGNORE_PATTERNS).
|
|
673
|
+
if spec.match_file(relative_str):
|
|
586
674
|
continue
|
|
587
675
|
|
|
588
676
|
# We only add files to the tar, not directories
|