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.
Files changed (54) hide show
  1. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/PKG-INFO +4 -4
  2. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/README.md +3 -3
  3. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/pyproject.toml +1 -1
  4. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/__init__.py +2 -0
  5. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/arguments.py +213 -125
  6. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/destructive.py +67 -29
  7. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/flags.py +20 -2
  8. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/help.py +125 -22
  9. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/__init__.py +4 -0
  10. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/aliases_cmd.py +2 -14
  11. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/auth_cmd.py +0 -1
  12. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/completion_cmd.py +2 -46
  13. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/context_cmd.py +12 -5
  14. hop3_cli-0.6.0/src/hop3_cli/commands/local/help_text.py +314 -0
  15. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/server_cmd.py +3 -16
  16. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/settings_cmd.py +16 -0
  17. hop3_cli-0.6.0/src/hop3_cli/commands/local/tunnel_cmd.py +187 -0
  18. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/use_cmd.py +2 -8
  19. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/alias_registry.py +1 -0
  20. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/aliases.py +6 -2
  21. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/app_scope.py +46 -12
  22. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/deploy_preview.py +1 -24
  23. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/local_overlay.py +0 -4
  24. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/exit_codes.py +2 -9
  25. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/main.py +44 -27
  26. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/__init__.py +1 -2
  27. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/client.py +2 -3
  28. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/responses.py +66 -4
  29. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/rpc/streaming.py +4 -1
  30. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/__init__.py +1 -2
  31. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/console.py +0 -5
  32. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/rich_printer.py +35 -46
  33. hop3_cli-0.5.0.dev3/src/hop3_cli/commands/local/help_text.py +0 -166
  34. hop3_cli-0.5.0.dev3/src/hop3_cli/rpc/tunnel.py +0 -177
  35. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/__init__.py +0 -0
  36. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/init_cmd.py +0 -0
  37. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/login_cmd.py +0 -0
  38. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/project_context_cmd.py +0 -0
  39. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/ssh_ops.py +0 -0
  40. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/commands/local/version_cmd.py +0 -0
  41. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/config.py +0 -0
  42. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/__init__.py +0 -0
  43. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/cli_state.py +0 -0
  44. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/context_names.py +0 -0
  45. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/hop3_toml.py +0 -0
  46. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/project_guard.py +0 -0
  47. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/resolution.py +0 -0
  48. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/server_registry.py +0 -0
  49. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/core/suggest.py +0 -0
  50. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/exceptions.py +0 -0
  51. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/tokens.py +0 -0
  52. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/types.py +0 -0
  53. {hop3_cli-0.5.0.dev3 → hop3_cli-0.6.0}/src/hop3_cli/ui/messages.py +0 -0
  54. {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.5.0.dev3
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "hop3-cli"
3
- version = "0.5.0.dev3"
3
+ version = "0.6.0"
4
4
  authors = [
5
5
  {name = "Stefane Fermigier", email = "sf@abilian.com"},
6
6
  ]
@@ -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 collections import Counter
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
- # Ignore files in priority order (first found is used)
37
- IGNORE_FILES = [".hop3ignore", ".dockerignore", ".gitignore"]
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
- # Parse deploy-specific flags
71
- # args[0]="deploy", args[1]=app_name, remaining args may include --env and directory
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
- # Skip expensive archive generation if no app name provided
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 files and directories specified in ignore files.
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 <app_name> /path/to/app"
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 (.hop3ignore, .dockerignore, or .gitignore) ---
331
- spec, ignore_file = get_ignored_spec(source_dir)
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
- if ignore_file:
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
- top_dirs = _get_top_directories_by_file_count(files, source_dir)
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"Directories with most files:\n"
451
+ f"Largest entries:\n"
400
452
  f"{dir_summary}\n"
401
453
  f"\n"
402
- f"Add directories to .hop3ignore to exclude them from deployment.\n"
403
- f"The server may also have configurable size limits."
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 > SOFT_SIZE_LIMIT:
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 | None, str | None]:
417
- """Load ignore rules from a directory.
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
- Checks sources in priority order:
420
- 1. [build].ignore patterns in hop3.toml
421
- 2. [build].ignore-file reference in hop3.toml
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
- The first source found with patterns is used.
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 or None, source description or None)
496
+ Tuple of (PathSpec, human-readable description of the pattern sources).
430
497
  """
431
- # 1. Check hop3.toml for inline ignore patterns or ignore-file reference
432
- hop3_toml_spec, hop3_toml_source = _get_hop3_toml_ignore_spec(source_dir)
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
- # 2. Fall back to ignore files in priority order
437
- for ignore_file in IGNORE_FILES:
438
- ignore_path = source_dir / ignore_file
439
- if ignore_path.is_file():
440
- lines = ignore_path.read_text(encoding="utf-8").splitlines()
441
- spec = pathspec.PathSpec.from_lines("gitignore", lines) # pyrefly: ignore
442
- return spec, ignore_file
443
-
444
- return None, None
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
- Checks for:
453
- 1. [build].ignore - inline list of patterns
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
- Returns:
457
- Tuple of (PathSpec or None, source description or None)
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
- # Check for hop3.toml in standard locations
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
- for hop3_toml_path in hop3_toml_paths:
466
- if not hop3_toml_path.is_file():
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
- build_section = data.get("build", {})
477
- if not isinstance(build_section, dict):
478
- continue
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
- # Check for ignore-file reference
487
- ignore_file_ref = build_section.get("ignore-file")
488
- if ignore_file_ref and isinstance(ignore_file_ref, str):
489
- ignore_file_path = source_dir / ignore_file_ref
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
- return None, None
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 _get_top_directories_by_file_count(
499
- files: list[Path], source_dir: Path
500
- ) -> list[tuple[str, int]]:
501
- """Get the top-level directories sorted by file count.
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
- Returns:
508
- List of (directory_name, file_count) tuples, sorted by count descending
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
- dir_counts: Counter[str] = Counter()
511
- for f in files:
512
- rel = f.relative_to(source_dir)
513
- # Get top-level directory, or "(root)" for files in root
514
- top_dir = rel.parts[0] if len(rel.parts) > 1 else "(root)"
515
- dir_counts[top_dir] += 1
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
- return dir_counts.most_common()
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 | None) -> list[Path]:
572
- """Get list of files to add to archive, excluding gitignored files."""
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
- # Always exclude .git directory (not deployment material)
579
- if relative_str.startswith(".git") and (
580
- relative_str == ".git" or relative_str.startswith(".git/")
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