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.
- {tab_cli-0.1.1 → tab_cli-0.1.2}/CHANGELOG.md +2 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/PKG-INFO +3 -1
- {tab_cli-0.1.1 → tab_cli-0.1.2}/pyproject.toml +3 -3
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/parquet.py +2 -16
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/handlers/__init__.py +11 -2
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/handlers/base.py +3 -3
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/aws.py +4 -37
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/az.py +7 -44
- tab_cli-0.1.2/tab_cli/storage/fsspec.py +104 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/gcloud.py +4 -46
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/url_parser.py +2 -0
- tab_cli-0.1.1/tab_cli/storage/fsspec.py +0 -60
- {tab_cli-0.1.1 → tab_cli-0.1.2}/.gitignore +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/LICENSE +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/Makefile +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/README.md +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/cli-ref.md +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/cloud.md +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/gen_assets.sh +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/index.md +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/docs/test.csv +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/mkdocs.yml +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/404.html +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/images/favicon.png +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/bundle.d7c377c4.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/bundle.d7c377c4.min.js.map +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ar.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.da.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.de.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.du.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.el.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.es.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.fi.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.fr.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.he.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.hi.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.hu.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.hy.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.it.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ja.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.jp.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.kn.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ko.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.multi.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.nl.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.no.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.pt.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ro.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ru.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.sa.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.sv.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.ta.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.te.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.th.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.tr.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.vi.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.zh.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/tinyseg.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/wordcut.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/workers/search.f886a092.min.js +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/workers/search.f886a092.min.js.map +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/main.50c56a3b.min.css +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/main.50c56a3b.min.css.map +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/palette.06af60db.min.css +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/stylesheets/palette.06af60db.min.css.map +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/test-where.svg +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/test.svg +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/cli-ref/index.html +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/cloud/index.html +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/gen_assets.sh +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/index.html +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/search/search_index.json +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/sitemap.xml +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/sitemap.xml.gz +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/site/test.csv +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/__init__.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/cli.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/config.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/__init__.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/avro.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/base.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/csv.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/formats/jsonl.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/handlers/cli_table.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/__init__.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/base.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/storage/local.py +0 -0
- {tab_cli-0.1.1 → tab_cli-0.1.2}/tab_cli/style.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tab-cli
|
|
3
|
-
Version: 0.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.
|
|
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
|
-
|
|
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=[
|
|
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.
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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(
|
|
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
|
|
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
|
{tab_cli-0.1.1 → tab_cli-0.1.2}/site/assets/javascripts/lunr/min/lunr.stemmer.support.min.js
RENAMED
|
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
|
|
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
|