tab-cli 0.1.1__tar.gz → 0.1.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 (89) hide show
  1. {tab_cli-0.1.1 → tab_cli-0.1.2}/CHANGELOG.md +2 -0
  2. {tab_cli-0.1.1 → tab_cli-0.1.2}/PKG-INFO +3 -1
  3. {tab_cli-0.1.1 → tab_cli-0.1.2}/pyproject.toml +3 -3
  4. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/parquet.py +2 -16
  5. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/handlers/__init__.py +11 -2
  6. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/handlers/base.py +3 -3
  7. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/aws.py +4 -37
  8. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/az.py +7 -44
  9. tab_cli-0.1.2/tab_cli/storage/fsspec.py +104 -0
  10. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/gcloud.py +4 -46
  11. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/url_parser.py +2 -0
  12. tab_cli-0.1.1/tab_cli/storage/fsspec.py +0 -60
  13. {tab_cli-0.1.1 → tab_cli-0.1.2}/.gitignore +0 -0
  14. {tab_cli-0.1.1 → tab_cli-0.1.2}/LICENSE +0 -0
  15. {tab_cli-0.1.1 → tab_cli-0.1.2}/Makefile +0 -0
  16. {tab_cli-0.1.1 → tab_cli-0.1.2}/README.md +0 -0
  17. {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/cli-ref.md +0 -0
  18. {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/cloud.md +0 -0
  19. {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/gen_assets.sh +0 -0
  20. {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/index.md +0 -0
  21. {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/test.csv +0 -0
  22. {tab_cli-0.1.1 → tab_cli-0.1.2}/mkdocs.yml +0 -0
  23. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/404.html +0 -0
  24. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/images/favicon.png +0 -0
  25. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/bundle.d7c377c4.min.js +0 -0
  26. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/bundle.d7c377c4.min.js.map +0 -0
  27. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -0
  28. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -0
  29. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -0
  30. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -0
  31. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -0
  32. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -0
  33. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -0
  34. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -0
  35. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -0
  36. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -0
  37. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -0
  38. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -0
  39. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -0
  40. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -0
  41. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -0
  42. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -0
  43. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -0
  44. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -0
  45. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -0
  46. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -0
  47. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -0
  48. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -0
  49. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -0
  50. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -0
  51. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -0
  52. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -0
  53. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -0
  54. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -0
  55. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -0
  56. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -0
  57. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -0
  58. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -0
  59. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/tinyseg.js +0 -0
  60. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/wordcut.js +0 -0
  61. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/workers/search.f886a092.min.js +0 -0
  62. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/workers/search.f886a092.min.js.map +0 -0
  63. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/main.50c56a3b.min.css +0 -0
  64. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/main.50c56a3b.min.css.map +0 -0
  65. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/palette.06af60db.min.css +0 -0
  66. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/palette.06af60db.min.css.map +0 -0
  67. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/test-where.svg +0 -0
  68. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/test.svg +0 -0
  69. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/cli-ref/index.html +0 -0
  70. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/cloud/index.html +0 -0
  71. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/gen_assets.sh +0 -0
  72. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/index.html +0 -0
  73. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/search/search_index.json +0 -0
  74. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/sitemap.xml +0 -0
  75. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/sitemap.xml.gz +0 -0
  76. {tab_cli-0.1.1 → tab_cli-0.1.2}/site/test.csv +0 -0
  77. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/__init__.py +0 -0
  78. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/cli.py +0 -0
  79. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/config.py +0 -0
  80. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/__init__.py +0 -0
  81. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/avro.py +0 -0
  82. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/base.py +0 -0
  83. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/csv.py +0 -0
  84. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/jsonl.py +0 -0
  85. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/handlers/cli_table.py +0 -0
  86. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/__init__.py +0 -0
  87. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/base.py +0 -0
  88. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/local.py +0 -0
  89. {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/style.py +0 -0
@@ -1,3 +1,5 @@
1
+ - 0.1.2:
2
+ - Bugfix on reading directories.
1
3
  - 0.1.1:
2
4
  - Better credential handling for Azure Blob Storage and Google Cloud Storage.
3
5
  - 0.1.0: Initial release
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tab-cli
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: A CLI tool for tabular data
5
5
  Author-email: Tongfei Chen <tongfei@pm.me>
6
6
  License-File: LICENSE
@@ -18,7 +18,9 @@ Requires-Dist: adlfs>=2025.1.0; extra == 'azure'
18
18
  Requires-Dist: azure-identity>=1.10.0; extra == 'azure'
19
19
  Provides-Extra: gcs
20
20
  Requires-Dist: gcsfs>=2025.1.0; extra == 'gcs'
21
+ Requires-Dist: google-auth>=2.27.0; extra == 'gcs'
21
22
  Provides-Extra: s3
23
+ Requires-Dist: boto3>=1.28.0; extra == 's3'
22
24
  Requires-Dist: s3fs>=2025.1.0; extra == 's3'
23
25
  Description-Content-Type: text/markdown
24
26
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tab-cli"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "A CLI tool for tabular data"
5
5
  authors = [{name = "Tongfei Chen", email = "tongfei@pm.me"}]
6
6
  readme = "README.md"
@@ -18,8 +18,8 @@ dependencies = [
18
18
  ]
19
19
 
20
20
  [project.optional-dependencies]
21
- s3 = ["s3fs>=2025.1.0"]
22
- gcs = ["gcsfs>=2025.1.0"]
21
+ s3 = ["s3fs>=2025.1.0", "boto3>=1.28.0"]
22
+ gcs = ["gcsfs>=2025.1.0", "google-auth>=2.27.0"]
23
23
  azure = ["adlfs>=2025.1.0", "azure-identity>=1.10.0"]
24
24
 
25
25
  [dependency-groups]
@@ -31,22 +31,8 @@ class ParquetFormat(FormatHandler):
31
31
  return pl.scan_parquet(url, storage_options=storage_options).select(pl.len()).collect().item()
32
32
 
33
33
  def extra_summary(self, url: str) -> dict[str, str | int | float] | None:
34
- import pyarrow.parquet as pq
35
-
36
- pf = pq.ParquetFile(url)
37
- metadata = pf.metadata
38
-
39
- extra: dict[str, str | int | float] = {}
40
- codecs: set[str] = set()
41
- for rg_idx in range(metadata.num_row_groups):
42
- rg = metadata.row_group(rg_idx)
43
- for col_idx in range(rg.num_columns):
44
- col = rg.column(col_idx)
45
- codecs.add(col.compression)
46
- extra["Row groups"] = metadata.num_row_groups
47
- if codecs:
48
- extra["Compression"] = ", ".join(sorted(codecs))
49
- return extra
34
+ # TODO: Parquet metadata
35
+ pass
50
36
 
51
37
  def write(self, lf: pl.LazyFrame) -> Iterable[bytes]:
52
38
  output = BytesIO()
@@ -28,6 +28,13 @@ def _get_extension(path: str) -> str:
28
28
  return os.path.splitext(basename)[1][1:].lower()
29
29
 
30
30
 
31
+ def _is_data_file(path: str) -> bool:
32
+ """Check if a file is a data file (not metadata/marker file)."""
33
+ basename = os.path.basename(path.rstrip("/"))
34
+ # Skip hidden files (e.g., .crc files) or Spark/Hadoop marker and metadata files
35
+ return not (basename.startswith(".") or basename.startswith("_"))
36
+
37
+
31
38
  def infer_reader(path: str, format: str | None = None) -> TableReader:
32
39
  """Infer the reader for a file.
33
40
 
@@ -48,13 +55,15 @@ def infer_reader(path: str, format: str | None = None) -> TableReader:
48
55
 
49
56
  # Infer format from path
50
57
  if backend.is_directory(path):
51
- # Get extension from first file in directory
58
+ # Get extension from first data file in directory (skip metadata files)
52
59
  for file_info in backend.list_files(path, ""):
60
+ if not _is_data_file(file_info.url):
61
+ continue
53
62
  extension = _get_extension(file_info.url)
54
63
  if extension:
55
64
  break
56
65
  else:
57
- raise ValueError(f"No files found in directory: {path}")
66
+ raise ValueError(f"No data files found in directory: {path}")
58
67
  else:
59
68
  extension = _get_extension(path)
60
69
 
@@ -7,7 +7,7 @@ from dataclasses import dataclass
7
7
 
8
8
  import polars as pl
9
9
  from rich import box
10
- from rich.progress import Progress
10
+ from rich.progress import Progress, track
11
11
  from rich.table import Table
12
12
 
13
13
  from tab_cli.formats.base import FormatHandler
@@ -55,7 +55,7 @@ class TableSummary:
55
55
  table = Table(
56
56
  show_header=False,
57
57
  box=box.SIMPLE_HEAD,
58
- row_styles=["", _ALT_ROW_STYLE],
58
+ row_styles=[_ALT_ROW_STYLE_0, _ALT_ROW_STYLE_1],
59
59
  )
60
60
  table.add_column(style=_KEY_STYLE)
61
61
  table.add_column(style=_VAL_STYLE)
@@ -162,7 +162,7 @@ class TableReader:
162
162
  extra_numeric: dict[str, float] = {}
163
163
  extra_strings: dict[str, set[str]] = {}
164
164
 
165
- for file_info in files:
165
+ for file_info in track(files):
166
166
  file_size += file_info.size
167
167
  polars_uri = self.backend.normalize_for_polars(file_info.url)
168
168
  storage_options = self.backend.storage_options(file_info.url)
@@ -1,10 +1,9 @@
1
1
  import os
2
2
  from enum import Enum
3
3
 
4
- from typing import BinaryIO, Iterator
5
4
  from loguru import logger
6
5
 
7
- from tab_cli.storage.base import StorageBackend, FileInfo
6
+ from tab_cli.storage.fsspec import CloudFsspecBackend
8
7
  from tab_cli.url_parser import parse_url
9
8
 
10
9
 
@@ -14,7 +13,7 @@ class AwsAuthMethod(Enum):
14
13
  ANONYMOUS = 3 # Public buckets
15
14
 
16
15
 
17
- class AwsBackend(StorageBackend):
16
+ class AwsBackend(CloudFsspecBackend):
18
17
  """Storage backend for AWS S3.
19
18
 
20
19
  URL format: s3://bucket/path
@@ -31,6 +30,8 @@ class AwsBackend(StorageBackend):
31
30
  3. Anonymous access (for public buckets, if requested)
32
31
  """
33
32
 
33
+ protocol = "s3"
34
+
34
35
  def __init__(self, anon: bool = False) -> None:
35
36
  """Initialize the AWS S3 storage backend.
36
37
 
@@ -187,37 +188,3 @@ class AwsBackend(StorageBackend):
187
188
  except Exception:
188
189
  pass
189
190
  return None
190
-
191
- def _to_internal(self, url: str) -> str:
192
- """Convert URL to internal path for s3fs operations."""
193
- parsed = parse_url(url)
194
- return f"{parsed.bucket}/{parsed.path}"
195
-
196
- def _to_uri(self, internal_path: str) -> str:
197
- """Convert internal path back to s3:// URL."""
198
- return f"s3://{internal_path}"
199
-
200
- def open(self, url: str) -> BinaryIO:
201
- return self.fs.open(self._to_internal(url), "rb")
202
-
203
- def list_files(self, url: str, extension: str) -> Iterator[FileInfo]:
204
- internal_path = self._to_internal(url)
205
- pattern = f"{internal_path}/**/*{extension}"
206
- for path in sorted(self.fs.glob(pattern)):
207
- info = self.fs.info(path)
208
- yield FileInfo(url=self._to_uri(path), size=info["size"])
209
-
210
- def size(self, url: str) -> int:
211
- return self.fs.size(self._to_internal(url))
212
-
213
- def is_directory(self, url: str) -> bool:
214
- path = self._to_internal(url)
215
- try:
216
- info = self.fs.info(path)
217
- return info.get("type") == "directory"
218
- except FileNotFoundError:
219
- try:
220
- contents = self.fs.ls(path, detail=False)
221
- return len(contents) > 0
222
- except Exception:
223
- return False
@@ -1,10 +1,9 @@
1
1
  import os
2
2
  from enum import Enum
3
3
 
4
- from typing import Any, BinaryIO, Iterator
5
4
  from loguru import logger
6
5
 
7
- from tab_cli.storage import StorageBackend, FileInfo
6
+ from tab_cli.storage.fsspec import CloudFsspecBackend
8
7
  from tab_cli.url_parser import parse_url
9
8
 
10
9
 
@@ -16,7 +15,7 @@ class AzAuthMethod(Enum):
16
15
  AZURE_CLI = 5
17
16
 
18
17
 
19
- class AzBackend(StorageBackend):
18
+ class AzBackend(CloudFsspecBackend):
20
19
  """Storage backend for Azure Blob Storage with configurable URL interpretation.
21
20
 
22
21
  When az_url_authority_is_account=True:
@@ -27,6 +26,8 @@ class AzBackend(StorageBackend):
27
26
  - az://container/path - authority is the container name
28
27
  """
29
28
 
29
+ protocol = "az"
30
+
30
31
  def __init__(self, account: str | None, container: str | None, az_url_authority_is_account: bool = False) -> None:
31
32
  """Initialize the Azure Blob Storage backend.
32
33
 
@@ -97,6 +98,7 @@ class AzBackend(StorageBackend):
97
98
  logger.debug("azure-identity not installed, skipping Azure AD / RBAC authentication")
98
99
  except Exception as e:
99
100
  logger.debug(f"Azure AD / RBAC authentication failed: {e}")
101
+ self.fs = None # Reset fs so we can try CLI fallback
100
102
 
101
103
  if self.fs is None:
102
104
  # 5. Fallback to fetching account key via Azure CLI
@@ -202,48 +204,9 @@ class AzBackend(StorageBackend):
202
204
  "azure_use_azure_cli": "true",
203
205
  }
204
206
 
205
- def _to_internal(self, url: str) -> str:
206
- """Convert URL to (filesystem, internal_path) for adlfs operations."""
207
- parsed = parse_url(url)
208
- return f"{parsed.bucket}/{parsed.path}"
209
-
210
- def _to_uri(self, account: str, internal_path: str) -> str:
207
+ def _to_uri(self, internal_path: str) -> str:
211
208
  """Convert internal path back to az:// URL."""
212
209
  if self.url_authority_is_account:
213
- return f"az://{account}/{internal_path}"
210
+ return f"az://{self.account}/{internal_path}"
214
211
  else:
215
212
  return f"az://{internal_path}"
216
-
217
- def open(self, url: str) -> BinaryIO:
218
- return self.fs.open(self._to_internal(url), "rb")
219
-
220
- def list_files(self, url: str, extension: str) -> Iterator[FileInfo]:
221
- internal_path = self._to_internal(url)
222
- pattern = f"{internal_path}/**/*{extension}"
223
- for path in sorted(self.fs.glob(pattern)):
224
- info = self.fs.info(path)
225
- yield FileInfo(url=self._to_uri(self.account, path), size=info["size"])
226
-
227
- def size(self, url: str) -> int:
228
- return self.fs.size(self._to_internal(url))
229
-
230
- def is_directory(self, url: str) -> bool:
231
- path = self._to_internal(url)
232
- try:
233
- info = self.fs.info(path)
234
- return info.get("type") == "directory"
235
- except FileNotFoundError:
236
- try:
237
- contents = self.fs.ls(path, detail=False)
238
- return len(contents) > 0
239
- except Exception:
240
- return False
241
-
242
- def __del__(self):
243
- try:
244
- # Check if fs exists and has a close method
245
- if hasattr(self, 'fs') and self.fs is not None:
246
- self.fs.close()
247
- except Exception:
248
- # Silently fail as the interpreter is likely in mid-teardown
249
- pass
@@ -0,0 +1,104 @@
1
+ """Cloud storage backend using fsspec.
2
+
3
+ Fallback for any fsspec-supported protocol not handled by dedicated backends.
4
+
5
+ Note: The following have dedicated backends:
6
+ - az://, abfs://, abfss:// -> AzBackend
7
+ - gs:// -> GcsBackend
8
+ - s3:// -> S3Backend
9
+
10
+ The appropriate protocol handler package must be installed separately.
11
+ """
12
+
13
+ from typing import BinaryIO, Iterator, Any
14
+ import fsspec
15
+ from loguru import logger
16
+
17
+ from tab_cli.storage.base import FileInfo, StorageBackend
18
+ from tab_cli.url_parser import parse_url
19
+
20
+
21
+ class FsspecBackend(StorageBackend):
22
+ """Base class for fsspec-based storage backends.
23
+
24
+ This class provides common implementations for fsspec-compatible filesystems.
25
+ Subclasses can override _to_internal and _to_uri to customize path handling.
26
+ """
27
+
28
+ fs: fsspec.AbstractFileSystem
29
+ protocol: str
30
+
31
+ def __init__(self, protocol: str) -> None:
32
+ self.protocol = protocol
33
+
34
+ try:
35
+ self.fs = fsspec.filesystem(protocol)
36
+ except (ImportError, ValueError) as e:
37
+ raise ImportError(f"No handler found for {protocol}:// URLs") from e
38
+
39
+ def _to_internal(self, url: str) -> str:
40
+ """Convert URL to internal path for fsspec operations.
41
+
42
+ Default implementation returns the URL as-is.
43
+ Cloud backends override this to return bucket/path format.
44
+ """
45
+ return url
46
+
47
+ def _to_uri(self, internal_path: str) -> str:
48
+ """Convert internal path back to a URL.
49
+
50
+ Default implementation prefixes with protocol if needed.
51
+ """
52
+ if internal_path.startswith(f"{self.protocol}://"):
53
+ return internal_path
54
+ return f"{self.protocol}://{internal_path}"
55
+
56
+ def open(self, url: str) -> BinaryIO:
57
+ return self.fs.open(self._to_internal(url), "rb")
58
+
59
+ def list_files(self, url: str, extension: str) -> Iterator[FileInfo]:
60
+ internal_path = self._to_internal(url)
61
+ pattern = f"{internal_path}/**/*{extension}"
62
+ files = self.fs.glob(pattern)
63
+ logger.debug(f"{len(files)} files found.")
64
+ for path in sorted(files):
65
+ info = self.fs.info(path)
66
+ yield FileInfo(url=self._to_uri(path), size=info["size"])
67
+
68
+ def size(self, url: str) -> int:
69
+ return self.fs.size(self._to_internal(url))
70
+
71
+ def is_directory(self, url: str) -> bool:
72
+ path = self._to_internal(url)
73
+ try:
74
+ info = self.fs.info(path)
75
+ return info.get("type") == "directory"
76
+ except FileNotFoundError:
77
+ try:
78
+ contents = self.fs.ls(path, detail=False)
79
+ return len(contents) > 0
80
+ except Exception:
81
+ return False
82
+
83
+ def storage_options(self, url: str) -> dict[str, Any] | None:
84
+ return None
85
+
86
+
87
+ class CloudFsspecBackend(FsspecBackend):
88
+ """Base class for cloud storage backends (S3, Azure, GCS).
89
+
90
+ Cloud backends use bucket/path format internally instead of full URLs.
91
+ """
92
+
93
+ def __init__(self) -> None:
94
+ # Subclasses must set self.fs and self.protocol before calling methods
95
+ pass
96
+
97
+ def _to_internal(self, url: str) -> str:
98
+ """Convert URL to internal path (bucket/path) for cloud fsspec operations."""
99
+ parsed = parse_url(url.rstrip("/")) # strip trailing slash to avoid empty path segments
100
+ return f"{parsed.bucket}/{parsed.path}"
101
+
102
+ def _to_uri(self, internal_path: str) -> str:
103
+ """Convert internal path back to URL. Subclasses must implement."""
104
+ return f"{self.protocol}://{internal_path}"
@@ -2,10 +2,9 @@ import os
2
2
  from enum import Enum
3
3
  from pathlib import Path
4
4
 
5
- from typing import BinaryIO, Iterator
6
5
  from loguru import logger
7
6
 
8
- from tab_cli.storage.base import StorageBackend, FileInfo
7
+ from tab_cli.storage.fsspec import CloudFsspecBackend
9
8
  from tab_cli.url_parser import parse_url
10
9
 
11
10
 
@@ -16,7 +15,7 @@ class GcloudAuthMethod(Enum):
16
15
  GOOGLE_DEFAULT = 4 # google.auth.default()
17
16
 
18
17
 
19
- class GcloudBackend(StorageBackend):
18
+ class GcloudBackend(CloudFsspecBackend):
20
19
  """Storage backend for Google Cloud Storage.
21
20
 
22
21
  URL format: gs://bucket/path
@@ -28,6 +27,8 @@ class GcloudBackend(StorageBackend):
28
27
  4. gcloud CLI login credentials (gcloud auth print-access-token)
29
28
  """
30
29
 
30
+ protocol = "gs"
31
+
31
32
  def __init__(self) -> None:
32
33
  """Initialize the Google Cloud Storage backend."""
33
34
  try:
@@ -170,46 +171,3 @@ class GcloudBackend(StorageBackend):
170
171
  return {
171
172
  "token": "google_default",
172
173
  }
173
-
174
- def _to_internal(self, url: str) -> str:
175
- """Convert URL to internal path for gcsfs operations."""
176
- parsed = parse_url(url)
177
- return f"{parsed.bucket}/{parsed.path}"
178
-
179
- def _to_uri(self, internal_path: str) -> str:
180
- """Convert internal path back to gs:// URL."""
181
- return f"gs://{internal_path}"
182
-
183
- def open(self, url: str) -> BinaryIO:
184
- return self.fs.open(self._to_internal(url), "rb")
185
-
186
- def list_files(self, url: str, extension: str) -> Iterator[FileInfo]:
187
- internal_path = self._to_internal(url)
188
- pattern = f"{internal_path}/**/*{extension}"
189
- for path in sorted(self.fs.glob(pattern)):
190
- info = self.fs.info(path)
191
- yield FileInfo(url=self._to_uri(path), size=info["size"])
192
-
193
- def size(self, url: str) -> int:
194
- return self.fs.size(self._to_internal(url))
195
-
196
- def is_directory(self, url: str) -> bool:
197
- path = self._to_internal(url)
198
- try:
199
- info = self.fs.info(path)
200
- return info.get("type") == "directory"
201
- except FileNotFoundError:
202
- try:
203
- contents = self.fs.ls(path, detail=False)
204
- return len(contents) > 0
205
- except Exception:
206
- return False
207
-
208
- def __del__(self):
209
- try:
210
- if hasattr(self, 'fs') and self.fs is not None:
211
- # gcsfs doesn't have a close method, but clear session if possible
212
- if hasattr(self.fs, 'session') and self.fs.session:
213
- pass # aiohttp session cleanup is handled by gcsfs
214
- except Exception:
215
- pass
@@ -40,6 +40,8 @@ def parse_url(url: str) -> ParsedUrl:
40
40
  Returns:
41
41
  ParsedUrl with scheme, bucket, account, and path components.
42
42
  """
43
+ # Strips trailing slashes to avoid empty path segments
44
+ url = url.rstrip("/")
43
45
  parsed = urlparse(url)
44
46
  scheme = parsed.scheme.lower() if parsed.scheme else ""
45
47
 
@@ -1,60 +0,0 @@
1
- """Cloud storage backend using fsspec.
2
-
3
- Fallback for any fsspec-supported protocol not handled by dedicated backends.
4
-
5
- Note: The following have dedicated backends:
6
- - az://, abfs://, abfss:// -> AzBackend
7
- - gs:// -> GcsBackend
8
- - s3:// -> S3Backend
9
-
10
- The appropriate protocol handler package must be installed separately.
11
- """
12
-
13
- from typing import BinaryIO, Iterator, Any
14
- import fsspec
15
-
16
- from tab_cli.storage.base import FileInfo, StorageBackend
17
-
18
- # Package hints for common protocols
19
- _PACKAGE_HINTS: dict[str, str] = {}
20
-
21
-
22
- class FsspecBackend(StorageBackend):
23
-
24
- def __init__(self, protocol: str) -> None:
25
- self._protocol = protocol
26
-
27
- try:
28
- self._fs: fsspec.AbstractFileSystem = fsspec.filesystem(protocol)
29
- except (ImportError, ValueError) as e:
30
- pkg = _PACKAGE_HINTS.get(protocol)
31
- if pkg is not None:
32
- raise ImportError(f"Package '{pkg}' is required for {protocol}:// URLs. Install with: pip install {pkg}") from e
33
- raise ImportError(f"No handler found for {protocol}:// URLs") from e
34
-
35
- def open(self, url: str) -> BinaryIO:
36
- return self._fs.open(url, "rb")
37
-
38
- def list_files(self, url: str, extension: str) -> Iterator[FileInfo]:
39
- pattern = f"{url}/**/*{extension}"
40
- for path in sorted(self._fs.glob(pattern)):
41
- info = self._fs.info(path)
42
- full_uri = f"{self._protocol}://{path}" if not path.startswith(f"{self._protocol}://") else path
43
- yield FileInfo(url=full_uri, size=info["size"])
44
-
45
- def size(self, url: str) -> int:
46
- return self._fs.size(url)
47
-
48
- def is_directory(self, url: str) -> bool:
49
- try:
50
- info = self._fs.info(url)
51
- return info.get("type") == "directory"
52
- except FileNotFoundError:
53
- try:
54
- contents = self._fs.ls(url, detail=False)
55
- return len(contents) > 0
56
- except Exception:
57
- return False
58
-
59
- def storage_options(self, url: str) -> dict[str, Any] | None:
60
- return None
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
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
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