cloudcat 0.6.1__tar.gz → 0.6.2__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 (68) hide show
  1. {cloudcat-0.6.1 → cloudcat-0.6.2}/PKG-INFO +5 -1
  2. {cloudcat-0.6.1 → cloudcat-0.6.2}/README.md +4 -0
  3. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/__init__.py +1 -1
  4. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/cli.py +104 -1
  5. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/completion.py +51 -2
  6. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat.egg-info/PKG-INFO +5 -1
  7. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat.egg-info/SOURCES.txt +1 -0
  8. cloudcat-0.6.2/tests/test_v062_fixes.py +155 -0
  9. {cloudcat-0.6.1 → cloudcat-0.6.2}/LICENSE +0 -0
  10. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/compression.py +0 -0
  11. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/config.py +0 -0
  12. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/data/SKILL.md +0 -0
  13. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/filtering.py +0 -0
  14. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/formatters.py +0 -0
  15. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/progress.py +0 -0
  16. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/readers/__init__.py +0 -0
  17. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/readers/avro.py +0 -0
  18. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/readers/csv.py +0 -0
  19. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/readers/json.py +0 -0
  20. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/readers/orc.py +0 -0
  21. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/readers/parquet.py +0 -0
  22. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/readers/text.py +0 -0
  23. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/storage/__init__.py +0 -0
  24. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/storage/azure.py +0 -0
  25. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/storage/base.py +0 -0
  26. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/storage/gcs.py +0 -0
  27. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/storage/local.py +0 -0
  28. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/storage/s3.py +0 -0
  29. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/streaming/__init__.py +0 -0
  30. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/streaming/filesystems.py +0 -0
  31. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/streaming/stats.py +0 -0
  32. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/streaming/tracking.py +0 -0
  33. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/tables/__init__.py +0 -0
  34. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/tables/delta.py +0 -0
  35. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/tables/iceberg.py +0 -0
  36. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/tables/pushdown.py +0 -0
  37. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat/user_config.py +0 -0
  38. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat.egg-info/dependency_links.txt +0 -0
  39. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat.egg-info/entry_points.txt +0 -0
  40. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat.egg-info/requires.txt +0 -0
  41. {cloudcat-0.6.1 → cloudcat-0.6.2}/cloudcat.egg-info/top_level.txt +0 -0
  42. {cloudcat-0.6.1 → cloudcat-0.6.2}/pyproject.toml +0 -0
  43. {cloudcat-0.6.1 → cloudcat-0.6.2}/setup.cfg +0 -0
  44. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_bugfix_round2.py +0 -0
  45. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_cli.py +0 -0
  46. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_cli_features.py +0 -0
  47. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_completion.py +0 -0
  48. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_compression.py +0 -0
  49. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_config.py +0 -0
  50. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_config_stats_completion.py +0 -0
  51. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_data_reading.py +0 -0
  52. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_file_filtering.py +0 -0
  53. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_filtering.py +0 -0
  54. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_format_detection.py +0 -0
  55. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_import_lightness.py +0 -0
  56. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_integration.py +0 -0
  57. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_output_formatting.py +0 -0
  58. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_path_parsing.py +0 -0
  59. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_read_data_streaming.py +0 -0
  60. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_readers_roundtrip.py +0 -0
  61. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_regressions.py +0 -0
  62. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_skill_install.py +0 -0
  63. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_storage_listing.py +0 -0
  64. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_streaming_stats.py +0 -0
  65. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_tables.py +0 -0
  66. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_tracking.py +0 -0
  67. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_v05_features.py +0 -0
  68. {cloudcat-0.6.1 → cloudcat-0.6.2}/tests/test_visual_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudcat
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Data Lake Storage Gen2 from your terminal
5
5
  Author: Jonathan Sudhakar
6
6
  License: MIT
@@ -554,6 +554,10 @@ Options:
554
554
 
555
555
  --config-profile TEXT Named profile from ~/.config/cloudcat/config.toml
556
556
 
557
+ --install-completion [bash|zsh|fish]
558
+ Enable completion with one command (adds one
559
+ line to your shell rc file; idempotent)
560
+
557
561
  --completion [bash|zsh|fish] Print the shell completion script and exit.
558
562
  Once enabled, TAB completes PATH too: local
559
563
  files natively, s3://<TAB> lists buckets,
@@ -472,6 +472,10 @@ Options:
472
472
 
473
473
  --config-profile TEXT Named profile from ~/.config/cloudcat/config.toml
474
474
 
475
+ --install-completion [bash|zsh|fish]
476
+ Enable completion with one command (adds one
477
+ line to your shell rc file; idempotent)
478
+
475
479
  --completion [bash|zsh|fish] Print the shell completion script and exit.
476
480
  Once enabled, TAB completes PATH too: local
477
481
  files natively, s3://<TAB> lists buckets,
@@ -2,4 +2,4 @@
2
2
  #!/usr/bin/env python
3
3
  """cloudcat - A CLI utility to read and display files from cloud storage."""
4
4
 
5
- __version__ = "0.6.1"
5
+ __version__ = "0.6.2"
@@ -202,6 +202,35 @@ def get_files_for_multiread(
202
202
  return selected_files
203
203
 
204
204
 
205
+ _ICEBERG_NESTED_RE = re.compile(r'(.*)/metadata/(?:v\d+|\d+-[^/]*)\.metadata\.json$')
206
+
207
+
208
+ def _find_nested_tables(files: List[Tuple[str, int]]) -> dict:
209
+ """Find lakehouse table roots nested inside a directory listing.
210
+
211
+ Returns {root_path: format}. Used to guide users who point cloudcat at
212
+ a folder OF tables rather than at a table — merging data files across
213
+ different tables would silently produce wrong results.
214
+ """
215
+ tables = {}
216
+ for name, _size in files:
217
+ if '/_delta_log/' in f'/{name}':
218
+ root = name.split('/_delta_log/')[0]
219
+ if root: # empty means the listing prefix IS the table root
220
+ tables.setdefault(root, 'delta')
221
+ continue
222
+ match = _ICEBERG_NESTED_RE.match(name)
223
+ if match and match.group(1):
224
+ tables.setdefault(match.group(1), 'iceberg')
225
+ return tables
226
+
227
+
228
+ def _has_known_extension(name: str) -> bool:
229
+ """True if the filename matches any recognized data-format extension."""
230
+ return any(re.search(regex, name, re.IGNORECASE)
231
+ for regex in FORMAT_EXTENSION_MAP.values())
232
+
233
+
205
234
  def find_first_non_empty_file(
206
235
  service: str,
207
236
  bucket: str,
@@ -239,7 +268,40 @@ def find_first_non_empty_file(
239
268
  info(Fore.YELLOW + f"No files matching format '{input_format}' found in "
240
269
  f"{service}://{bucket}/{prefix}. Using first available file." + Style.RESET_ALL)
241
270
 
242
- # Use the first non-empty, non-metadata file
271
+ # No explicit format: the selection drives format INFERENCE, so only
272
+ # consider files whose extension we can actually infer from.
273
+ if not input_format:
274
+ nested_tables = _find_nested_tables(non_empty_files)
275
+ if nested_tables:
276
+ listing = '\n'.join(
277
+ f" {fmt}: {root}/" for root, fmt in sorted(nested_tables.items())
278
+ )
279
+ first_root = sorted(nested_tables)[0]
280
+ raise ValueError(
281
+ "this directory contains lakehouse table(s), not a single dataset:\n"
282
+ f"{listing}\n"
283
+ f"Point at a table root, e.g.: cloudcat {first_root}/ "
284
+ "(or pass --input-format to force reading loose files)"
285
+ )
286
+
287
+ for file_name, file_size in non_empty_files:
288
+ if any(re.search(pattern, file_name) for pattern in SKIP_PATTERNS):
289
+ continue
290
+ if not _has_known_extension(file_name):
291
+ continue # e.g. catalog.db, .lock — can't infer a format from it
292
+ if not quiet:
293
+ info(Fore.BLUE + f"Selected file: {file_name} ({file_size} bytes)" + Style.RESET_ALL)
294
+ return file_name, file_size
295
+
296
+ sample = ', '.join(name.rsplit('/', 1)[-1] for name, _ in non_empty_files[:5])
297
+ raise ValueError(
298
+ f"no files with a recognized data format in {service}://{bucket}/{prefix} "
299
+ f"(found: {sample}). Formats are inferred from extensions "
300
+ "(.csv/.json/.parquet/.avro/.orc/.txt, optionally compressed); "
301
+ "pass --input-format to force one."
302
+ )
303
+
304
+ # Explicit format requested but unmatched: first non-metadata file.
243
305
  for file_name, file_size in non_empty_files:
244
306
  if not any(re.search(pattern, file_name) for pattern in SKIP_PATTERNS):
245
307
  if not quiet:
@@ -850,6 +912,44 @@ def _print_completion(ctx, param, value):
850
912
  ctx.exit()
851
913
 
852
914
 
915
+ _COMPLETION_SNIPPETS = {
916
+ 'zsh': ('~/.zshrc', 'eval "$(cloudcat --completion zsh)"'),
917
+ 'bash': ('~/.bashrc',
918
+ 'COMP_WORDBREAKS=${COMP_WORDBREAKS//:/} # let URLs like s3:// complete\n'
919
+ 'eval "$(cloudcat --completion bash)"'),
920
+ 'fish': ('~/.config/fish/config.fish', 'cloudcat --completion fish | source'),
921
+ }
922
+
923
+ _COMPLETION_MARKER = '# added by cloudcat --install-completion'
924
+
925
+
926
+ def _install_completion(ctx, param, value):
927
+ """Eager callback: enable shell completion in the user's rc file.
928
+
929
+ Idempotent — a marker comment prevents duplicate blocks on re-runs.
930
+ """
931
+ if not value or ctx.resilient_parsing:
932
+ return
933
+ rc_path, snippet = _COMPLETION_SNIPPETS[value]
934
+ rc_file = os.path.expanduser(rc_path)
935
+
936
+ existing = ''
937
+ if os.path.exists(rc_file):
938
+ with open(rc_file, 'r', encoding='utf-8', errors='replace') as f:
939
+ existing = f.read()
940
+ if _COMPLETION_MARKER in existing:
941
+ click.echo(f"Completion is already set up in {rc_file}")
942
+ ctx.exit()
943
+
944
+ os.makedirs(os.path.dirname(rc_file), exist_ok=True)
945
+ block = f"\n{_COMPLETION_MARKER}\n{snippet}\n"
946
+ with open(rc_file, 'a', encoding='utf-8') as f:
947
+ f.write(block)
948
+ click.echo(f"Added cloudcat completion to {rc_file} — restart your shell "
949
+ f"(or run: source {rc_path}) and try: cloudcat s3://<TAB>")
950
+ ctx.exit()
951
+
952
+
853
953
  # Where each agent expects personal skills (name -> path builder).
854
954
  _SKILL_DESTINATIONS = {
855
955
  'claude': lambda: os.path.join(os.path.expanduser('~'), '.claude', 'skills', 'cloudcat', 'SKILL.md'),
@@ -962,6 +1062,9 @@ def _render_data(df, output_format: str) -> str:
962
1062
  @click.option('--completion', type=click.Choice(['bash', 'zsh', 'fish']), is_eager=True,
963
1063
  expose_value=False, callback=_print_completion,
964
1064
  help='Print the shell completion script and exit (e.g. eval "$(cloudcat --completion zsh)")')
1065
+ @click.option('--install-completion', type=click.Choice(['bash', 'zsh', 'fish']), is_eager=True,
1066
+ expose_value=False, callback=_install_completion,
1067
+ help='Enable shell completion by adding one line to your shell rc file (idempotent)')
965
1068
  @click.option('--install-skill', type=click.Choice(['claude', 'claude-project', 'codex', 'print']),
966
1069
  is_eager=True, expose_value=False, callback=_install_skill,
967
1070
  help='Install the bundled AI-agent skill: claude (~/.claude/skills), '
@@ -15,7 +15,51 @@ Behavior mirrors `ls` completion, scheme-aware:
15
15
 
16
16
  from typing import List, Optional, Tuple
17
17
 
18
- from click.shell_completion import CompletionItem
18
+ import os
19
+
20
+ from click.shell_completion import CompletionItem, ZshComplete, add_completion_class
21
+
22
+
23
+ def _debug(message: str) -> None:
24
+ """Optionally record why a completion produced nothing.
25
+
26
+ Completion swallows every error so it can never break the shell — which
27
+ makes "TAB does nothing" undiagnosable. Set CLOUDCAT_COMPLETE_DEBUG=1 and
28
+ failures are appended to ~/.cache/cloudcat/completion.log instead.
29
+ """
30
+ if not os.environ.get('CLOUDCAT_COMPLETE_DEBUG'):
31
+ return
32
+ try:
33
+ import datetime
34
+ cache_dir = os.path.join(
35
+ os.environ.get('XDG_CACHE_HOME', os.path.expanduser('~/.cache')), 'cloudcat')
36
+ os.makedirs(cache_dir, exist_ok=True)
37
+ with open(os.path.join(cache_dir, 'completion.log'), 'a', encoding='utf-8') as f:
38
+ f.write(f"{datetime.datetime.now().isoformat()} {message}\n")
39
+ except Exception:
40
+ pass
41
+
42
+
43
+ class CloudcatZshComplete(ZshComplete):
44
+ """Click's zsh completer, hardened for real-world .zshrc files.
45
+
46
+ The stock script calls compdef unconditionally; if the user evals it
47
+ before compinit has run, registration fails silently and TAB "does
48
+ nothing". This variant bootstraps compinit when needed.
49
+ """
50
+
51
+ name = 'zsh'
52
+ source_template = ZshComplete.source_template.replace(
53
+ " compdef %(complete_func)s %(prog_name)s",
54
+ """ if ! typeset -f compdef >/dev/null 2>&1; then
55
+ autoload -Uz compinit && compinit -u
56
+ fi
57
+ compdef %(complete_func)s %(prog_name)s""",
58
+ )
59
+
60
+
61
+ # Replace the registered zsh completer with the hardened variant.
62
+ add_completion_class(CloudcatZshComplete)
19
63
 
20
64
  # Never return more candidates than a shell menu can usefully show.
21
65
  LIMIT = 100
@@ -36,7 +80,10 @@ def _s3_client(profile: Optional[str]):
36
80
  config = Config(connect_timeout=CONNECT_TIMEOUT, read_timeout=READ_TIMEOUT,
37
81
  retries={'max_attempts': 0})
38
82
  session = boto3.Session(profile_name=profile) if profile else boto3.Session()
39
- return session.client('s3', config=config)
83
+ # Without a configured region some boto3 versions raise NoRegionError at
84
+ # client creation; bucket listing is region-agnostic, so default it.
85
+ region = session.region_name or 'us-east-1'
86
+ return session.client('s3', region_name=region, config=config)
40
87
 
41
88
 
42
89
  def _list_s3_buckets(profile: Optional[str]) -> List[str]:
@@ -196,4 +243,6 @@ def complete_path(ctx, param, incomplete: str) -> List[CompletionItem]:
196
243
  return items
197
244
  except Exception:
198
245
  # Completion must never break the shell: no candidates beats a stack.
246
+ import traceback
247
+ _debug(f"complete_path({incomplete!r}) failed:\n{traceback.format_exc()}")
199
248
  return []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudcat
3
- Version: 0.6.1
3
+ Version: 0.6.2
4
4
  Summary: Preview and analyze data files in Google Cloud Storage, AWS S3, and Azure Data Lake Storage Gen2 from your terminal
5
5
  Author: Jonathan Sudhakar
6
6
  License: MIT
@@ -554,6 +554,10 @@ Options:
554
554
 
555
555
  --config-profile TEXT Named profile from ~/.config/cloudcat/config.toml
556
556
 
557
+ --install-completion [bash|zsh|fish]
558
+ Enable completion with one command (adds one
559
+ line to your shell rc file; idempotent)
560
+
557
561
  --completion [bash|zsh|fish] Print the shell completion script and exit.
558
562
  Once enabled, TAB completes PATH too: local
559
563
  files natively, s3://<TAB> lists buckets,
@@ -62,4 +62,5 @@ tests/test_streaming_stats.py
62
62
  tests/test_tables.py
63
63
  tests/test_tracking.py
64
64
  tests/test_v05_features.py
65
+ tests/test_v062_fixes.py
65
66
  tests/test_visual_output.py
@@ -0,0 +1,155 @@
1
+ """Tests for the 0.6.2 fixes: mixed-directory inference and completion setup."""
2
+
3
+ import os
4
+ from unittest.mock import patch
5
+
6
+ import pytest
7
+ from click.testing import CliRunner
8
+
9
+ from cloudcat.cli import main, _find_nested_tables
10
+ import cloudcat.completion as completion
11
+
12
+ runner = CliRunner()
13
+
14
+
15
+ # --------------------------------------------------------------------------
16
+ # directory inference (bug 1)
17
+ # --------------------------------------------------------------------------
18
+
19
+ class TestNestedTableDetection:
20
+ def test_finds_delta_and_iceberg_roots(self):
21
+ files = [
22
+ ("/lake/orders_delta/_delta_log/000.json", 10),
23
+ ("/lake/orders_delta/part-0.parquet", 10),
24
+ ("/lake/shop/orders/metadata/00001-abc.metadata.json", 10),
25
+ ("/lake/shop/orders/data/f.parquet", 10),
26
+ ("/lake/catalog.db", 10),
27
+ ]
28
+ tables = _find_nested_tables(files)
29
+ assert tables == {
30
+ "/lake/orders_delta": "delta",
31
+ "/lake/shop/orders": "iceberg",
32
+ }
33
+
34
+ def test_hadoop_style_iceberg_metadata(self):
35
+ files = [("/w/t/metadata/v3.metadata.json", 5)]
36
+ assert _find_nested_tables(files) == {"/w/t": "iceberg"}
37
+
38
+ def test_plain_files_are_not_tables(self):
39
+ files = [("/data/a.parquet", 5), ("/data/metadata/notes.txt", 5)]
40
+ assert _find_nested_tables(files) == {}
41
+
42
+
43
+ class TestMixedDirectoryCli:
44
+ def test_directory_of_tables_gets_guidance(self, tmp_path):
45
+ deltalake = pytest.importorskip("deltalake")
46
+ import pandas as pd
47
+ from deltalake import write_deltalake
48
+ write_deltalake(str(tmp_path / "orders_delta"),
49
+ pd.DataFrame({"a": [1, 2]}))
50
+ # a second, fake iceberg table (detection only needs the marker file)
51
+ meta = tmp_path / "shop" / "orders" / "metadata"
52
+ meta.mkdir(parents=True)
53
+ (meta / "00001-abc.metadata.json").write_text("{}")
54
+ (tmp_path / "catalog.db").write_text("sqlite")
55
+
56
+ res = runner.invoke(main, [str(tmp_path) + "/", "--no-color"])
57
+ assert res.exit_code == 1
58
+ assert "contains lakehouse table(s)" in res.output
59
+ assert "orders_delta" in res.output
60
+ assert "shop/orders" in res.output
61
+ assert "Point at a table root" in res.output
62
+
63
+ def test_unknown_extensions_are_skipped_for_inference(self, tmp_path):
64
+ (tmp_path / "catalog.db").write_text("junk")
65
+ (tmp_path / "data.csv").write_text("a\n1\n2\n")
66
+ res = runner.invoke(main, [str(tmp_path) + "/", "--no-color",
67
+ "-s", "dont_show", "-o", "csv"])
68
+ assert res.exit_code == 0
69
+ assert "1" in res.stdout and "2" in res.stdout
70
+
71
+ def test_only_unknown_extensions_gives_clear_error(self, tmp_path):
72
+ (tmp_path / "catalog.db").write_text("junk")
73
+ (tmp_path / "state.lock").write_text("x")
74
+ res = runner.invoke(main, [str(tmp_path) + "/", "--no-color"])
75
+ assert res.exit_code == 1
76
+ assert "no files with a recognized data format" in res.output
77
+ assert "catalog.db" in res.output
78
+ assert "--input-format" in res.output
79
+
80
+ def test_explicit_format_still_forces_reading(self, tmp_path):
81
+ (tmp_path / "catalog.db").write_text("junk")
82
+ (tmp_path / "data.csv").write_text("a\n1\n")
83
+ res = runner.invoke(main, [str(tmp_path) + "/", "--no-color", "-i", "csv",
84
+ "-s", "dont_show", "-o", "csv"])
85
+ assert res.exit_code == 0
86
+
87
+
88
+ # --------------------------------------------------------------------------
89
+ # completion hardening (bug 2)
90
+ # --------------------------------------------------------------------------
91
+
92
+ class TestS3RegionFallback:
93
+ def test_client_gets_a_region_when_none_configured(self, monkeypatch, tmp_path):
94
+ for var in ("AWS_DEFAULT_REGION", "AWS_REGION", "AWS_PROFILE"):
95
+ monkeypatch.delenv(var, raising=False)
96
+ monkeypatch.setenv("AWS_CONFIG_FILE", str(tmp_path / "none"))
97
+ monkeypatch.setenv("AWS_SHARED_CREDENTIALS_FILE", str(tmp_path / "none"))
98
+ client = completion._s3_client(None)
99
+ assert client.meta.region_name == "us-east-1"
100
+
101
+
102
+ class TestZshHardening:
103
+ def test_emitted_script_bootstraps_compinit(self):
104
+ res = runner.invoke(main, ["--completion", "zsh"])
105
+ assert res.exit_code == 0
106
+ assert "typeset -f compdef" in res.stdout
107
+ assert "compinit" in res.stdout
108
+
109
+
110
+ class TestInstallCompletion:
111
+ def test_zsh_install_is_idempotent(self, tmp_path, monkeypatch):
112
+ monkeypatch.setenv("HOME", str(tmp_path))
113
+ first = runner.invoke(main, ["--install-completion", "zsh"])
114
+ second = runner.invoke(main, ["--install-completion", "zsh"])
115
+ assert first.exit_code == 0 and second.exit_code == 0
116
+ content = (tmp_path / ".zshrc").read_text()
117
+ assert content.count('eval "$(cloudcat --completion zsh)"') == 1
118
+ assert "already set up" in second.output
119
+
120
+ def test_bash_install_includes_wordbreaks_fix(self, tmp_path, monkeypatch):
121
+ monkeypatch.setenv("HOME", str(tmp_path))
122
+ res = runner.invoke(main, ["--install-completion", "bash"])
123
+ assert res.exit_code == 0
124
+ content = (tmp_path / ".bashrc").read_text()
125
+ assert "COMP_WORDBREAKS" in content
126
+ assert 'eval "$(cloudcat --completion bash)"' in content
127
+
128
+ def test_fish_install_creates_config_dir(self, tmp_path, monkeypatch):
129
+ monkeypatch.setenv("HOME", str(tmp_path))
130
+ res = runner.invoke(main, ["--install-completion", "fish"])
131
+ assert res.exit_code == 0
132
+ assert "cloudcat --completion fish | source" in \
133
+ (tmp_path / ".config" / "fish" / "config.fish").read_text()
134
+
135
+
136
+ class TestCompletionDebugLog:
137
+ def test_failures_are_logged_when_enabled(self, tmp_path, monkeypatch):
138
+ monkeypatch.setenv("CLOUDCAT_COMPLETE_DEBUG", "1")
139
+ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
140
+ from click.shell_completion import ShellComplete
141
+ with patch.object(completion, "_list_s3_buckets", side_effect=RuntimeError("boom")):
142
+ sc = ShellComplete(main, {}, "cloudcat", "_CLOUDCAT_COMPLETE")
143
+ assert sc.get_completions([], "s3://") == []
144
+ log = tmp_path / "cloudcat" / "completion.log"
145
+ assert log.exists()
146
+ assert "boom" in log.read_text()
147
+
148
+ def test_silent_by_default(self, tmp_path, monkeypatch):
149
+ monkeypatch.delenv("CLOUDCAT_COMPLETE_DEBUG", raising=False)
150
+ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
151
+ from click.shell_completion import ShellComplete
152
+ with patch.object(completion, "_list_s3_buckets", side_effect=RuntimeError("boom")):
153
+ sc = ShellComplete(main, {}, "cloudcat", "_CLOUDCAT_COMPLETE")
154
+ sc.get_completions([], "s3://")
155
+ assert not (tmp_path / "cloudcat" / "completion.log").exists()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes