dlt-filesystem 0.18.0__py3-none-any.whl
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.
- dlt_filesystem/error.py +14 -0
- dlt_filesystem/source/adapter.py +180 -0
- dlt_filesystem/source/base.py +89 -0
- dlt_filesystem/source/core.py +132 -0
- dlt_filesystem/source/error.py +76 -0
- dlt_filesystem/source/format/bson_codec.py +99 -0
- dlt_filesystem/source/format/helpers.py +74 -0
- dlt_filesystem/source/format/iterable_codec.py +505 -0
- dlt_filesystem/source/format/readers.py +1090 -0
- dlt_filesystem/source/format/registry.py +156 -0
- dlt_filesystem/source/format/settings.py +1 -0
- dlt_filesystem/source/fsspec/databricks.py +81 -0
- dlt_filesystem/source/fsspec/dropbox.py +50 -0
- dlt_filesystem/source/fsspec/ftp.py +55 -0
- dlt_filesystem/source/fsspec/gdrive.py +56 -0
- dlt_filesystem/source/fsspec/hdfs.py +71 -0
- dlt_filesystem/source/fsspec/http.py +394 -0
- dlt_filesystem/source/fsspec/local.py +139 -0
- dlt_filesystem/source/fsspec/oci.py +61 -0
- dlt_filesystem/source/fsspec/onedrive.py +13 -0
- dlt_filesystem/source/fsspec/oss.py +60 -0
- dlt_filesystem/source/fsspec/r2.py +18 -0
- dlt_filesystem/source/fsspec/sharepoint.py +57 -0
- dlt_filesystem/source/fsspec/smb.py +67 -0
- dlt_filesystem/source/fsspec/webdav.py +79 -0
- dlt_filesystem/source/fsspec/webhdfs.py +67 -0
- dlt_filesystem/source/impl/remote.py +363 -0
- dlt_filesystem/source/impl/util.py +80 -0
- dlt_filesystem/source/lister.py +301 -0
- dlt_filesystem/source/model.py +324 -0
- dlt_filesystem/source/router.py +272 -0
- dlt_filesystem/staging.py +193 -0
- dlt_filesystem/target/api.py +13 -0
- dlt_filesystem/target/local.py +166 -0
- dlt_filesystem/target/model.py +1 -0
- dlt_filesystem/target/registry.py +122 -0
- dlt_filesystem/target/remote.py +266 -0
- dlt_filesystem/target/util.py +88 -0
- dlt_filesystem/target/writer.py +533 -0
- dlt_filesystem/testing/stub.py +77 -0
- dlt_filesystem/testing/writer.py +201 -0
- dlt_filesystem/util/auth.py +511 -0
- dlt_filesystem/util/fsspec.py +154 -0
- dlt_filesystem/util/loader.py +123 -0
- dlt_filesystem/util/python.py +198 -0
- dlt_filesystem/util/web.py +72 -0
- dlt_filesystem-0.18.0.dist-info/METADATA +114 -0
- dlt_filesystem-0.18.0.dist-info/RECORD +50 -0
- dlt_filesystem-0.18.0.dist-info/WHEEL +5 -0
- dlt_filesystem-0.18.0.dist-info/top_level.txt +1 -0
dlt_filesystem/error.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MissingConnectorOption(Exception):
|
|
5
|
+
def __init__(self, option, connector):
|
|
6
|
+
super().__init__(f"{option} is required to connect to {connector}")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class InvalidBlobTableError(Exception):
|
|
10
|
+
def __init__(self, source):
|
|
11
|
+
super().__init__(
|
|
12
|
+
f"Invalid source table for: {source}. "
|
|
13
|
+
"Ensure that the table is in the format {bucket-name}/{file glob}"
|
|
14
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Copyright 2022-2025 ScaleVector
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Reads files in s3, gs or azure buckets using fsspec and provides convenience resources for chunked reading of various file formats"""
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Tuple, Union
|
|
18
|
+
|
|
19
|
+
import dlt
|
|
20
|
+
from dlt.sources import DltResource
|
|
21
|
+
from dlt.sources.credentials import FileSystemCredentials
|
|
22
|
+
from dlt.sources.filesystem import FileItem, FileItemDict, fsspec_filesystem
|
|
23
|
+
from fsspec import AbstractFileSystem
|
|
24
|
+
|
|
25
|
+
from dlt_filesystem.source.error import NoFilesFoundError
|
|
26
|
+
from dlt_filesystem.source.format import readers as reader_functions
|
|
27
|
+
from dlt_filesystem.source.format.readers import ReadersSource
|
|
28
|
+
from dlt_filesystem.source.format.registry import (
|
|
29
|
+
READER_REGISTRATIONS,
|
|
30
|
+
ReaderRegistration,
|
|
31
|
+
)
|
|
32
|
+
from dlt_filesystem.source.lister import glob_files
|
|
33
|
+
|
|
34
|
+
from .model import FilesystemConfigurationResource
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_reader(registration: ReaderRegistration):
|
|
38
|
+
"""Resolve a registered reader name without making the registry import reader code."""
|
|
39
|
+
reader = getattr(reader_functions, registration.reader_name, None)
|
|
40
|
+
if not callable(reader):
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"Reader function {registration.reader_name!r} is not defined in "
|
|
43
|
+
"dlt_filesystem.source.format.readers"
|
|
44
|
+
)
|
|
45
|
+
return reader
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dlt.source(_impl_cls=ReadersSource, spec=FilesystemConfigurationResource)
|
|
49
|
+
def readers(
|
|
50
|
+
bucket_url: str = dlt.secrets.value,
|
|
51
|
+
credentials: Union[FileSystemCredentials, AbstractFileSystem] = dlt.secrets.value,
|
|
52
|
+
file_glob: Optional[str] = "*",
|
|
53
|
+
*,
|
|
54
|
+
kwargs: Optional[Dict[str, Any]] = None,
|
|
55
|
+
client_kwargs: Optional[Dict[str, Any]] = None,
|
|
56
|
+
incremental: Optional[dlt.sources.incremental[Any]] = None,
|
|
57
|
+
) -> Tuple[DltResource, ...]:
|
|
58
|
+
"""This source provides a few resources that are chunked file readers. Readers can be further parametrized before use
|
|
59
|
+
read_csv(chunksize, **pandas_kwargs)
|
|
60
|
+
read_json(chunksize)
|
|
61
|
+
read_jsonl(chunksize)
|
|
62
|
+
read_parquet(chunksize)
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
bucket_url (str): The url to the bucket.
|
|
66
|
+
credentials (FileSystemCredentials | AbstractFilesystem): The credentials to the filesystem of fsspec `AbstractFilesystem` instance.
|
|
67
|
+
file_glob (str, optional): The filter to apply to the files in glob format. by default lists all files in bucket_url non-recursively
|
|
68
|
+
kwargs (Optional[Dict[str, Any]]): Additional arguments passed to the fsspec constructor, ie. dict(use_ssl=True) for s3fs
|
|
69
|
+
client_kwargs (Optional[Dict[str, Any]]): Additional arguments passed to the underlying fsspec native client, ie. dict(verify="public.crt") for botocore
|
|
70
|
+
incremental (Optional[dlt.sources.incremental[Any]]): Defines an incremental cursor on the listed files, with `modification_date`
|
|
71
|
+
being the most common choice, which returns only files created since the previous run.
|
|
72
|
+
"""
|
|
73
|
+
filesystem_resource = filesystem(
|
|
74
|
+
bucket_url,
|
|
75
|
+
credentials,
|
|
76
|
+
file_glob=file_glob,
|
|
77
|
+
kwargs=kwargs,
|
|
78
|
+
client_kwargs=client_kwargs,
|
|
79
|
+
incremental=incremental,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return tuple(
|
|
83
|
+
filesystem_resource
|
|
84
|
+
| dlt.transformer(
|
|
85
|
+
name=registration.reader_name,
|
|
86
|
+
max_table_nesting=registration.max_table_nesting,
|
|
87
|
+
)(_resolve_reader(registration))
|
|
88
|
+
for registration in READER_REGISTRATIONS
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dlt.resource(
|
|
93
|
+
primary_key="file_url", spec=FilesystemConfigurationResource, standalone=True
|
|
94
|
+
)
|
|
95
|
+
def filesystem(
|
|
96
|
+
bucket_url: str = dlt.secrets.value,
|
|
97
|
+
credentials: Union[FileSystemCredentials, AbstractFileSystem] = dlt.secrets.value,
|
|
98
|
+
file_glob: Optional[str] = "*",
|
|
99
|
+
files_per_page: int = 100,
|
|
100
|
+
extract_content: bool = False,
|
|
101
|
+
require_file_match: bool = False,
|
|
102
|
+
filesystem_incremental: bool = False,
|
|
103
|
+
*,
|
|
104
|
+
kwargs: Optional[Dict[str, Any]] = None,
|
|
105
|
+
client_kwargs: Optional[Dict[str, Any]] = None,
|
|
106
|
+
incremental: Optional[dlt.sources.incremental[Any]] = None,
|
|
107
|
+
) -> Iterator[List[FileItem]]:
|
|
108
|
+
"""This resource lists files in `bucket_url` using `file_glob` pattern. The files are yielded as FileItem which also
|
|
109
|
+
provide methods to open and read file data. It should be combined with transformers that further process (ie. load files)
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
bucket_url (str): The url to the bucket.
|
|
113
|
+
credentials (FileSystemCredentials | AbstractFilesystem): The credentials to the filesystem of fsspec `AbstractFilesystem` instance.
|
|
114
|
+
file_glob (str, optional): The filter to apply to the files in glob format. by default lists all files in bucket_url non-recursively
|
|
115
|
+
files_per_page (int, optional): The number of files to process at once, defaults to 100.
|
|
116
|
+
extract_content (bool, optional): If true, the content of the file will be extracted if
|
|
117
|
+
false it will return a fsspec file, defaults to False.
|
|
118
|
+
require_file_match (bool, optional): Raise when the concrete source selection
|
|
119
|
+
matches no file. Defaults to False for direct uses of this resource.
|
|
120
|
+
filesystem_incremental (bool, optional): Resolve trustworthy modification
|
|
121
|
+
times when the listing itself does not carry one. Defaults to False.
|
|
122
|
+
kwargs (Optional[Dict[str, Any]]): Additional arguments passed to the fsspec constructor, ie. dict(use_ssl=True) for s3fs
|
|
123
|
+
client_kwargs (Optional[Dict[str, Any]]): Additional arguments passed to the underlying fsspec native client, ie. dict(verify="public.crt") for botocore
|
|
124
|
+
incremental (Optional[dlt.sources.incremental[Any]]): Defines an incremental cursor on the listed files, with `modification_date`
|
|
125
|
+
being the most common choice, which returns only files created since the previous run.
|
|
126
|
+
A cursor carrying `row_order` also orders the listing by its cursor field.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
Iterator[List[FileItem]]: The list of files.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
fs_client: AbstractFileSystem
|
|
133
|
+
if isinstance(credentials, AbstractFileSystem):
|
|
134
|
+
# A caller who hands over a constructed filesystem has already spent
|
|
135
|
+
# `kwargs` and `client_kwargs` on building it, so both are ignored here,
|
|
136
|
+
# exactly as they are in dlt's own resource.
|
|
137
|
+
fs_client = credentials
|
|
138
|
+
else:
|
|
139
|
+
fs_client = fsspec_filesystem(
|
|
140
|
+
bucket_url, credentials, kwargs=kwargs, client_kwargs=client_kwargs
|
|
141
|
+
)[0]
|
|
142
|
+
|
|
143
|
+
file_models: Iterable[FileItem] = glob_files(
|
|
144
|
+
fs_client,
|
|
145
|
+
bucket_url,
|
|
146
|
+
file_glob or "**",
|
|
147
|
+
filesystem_incremental=filesystem_incremental,
|
|
148
|
+
)
|
|
149
|
+
if incremental and incremental.row_order:
|
|
150
|
+
# `row_order` is ascending or descending *in the direction `last_value_func`
|
|
151
|
+
# advances*, so it maps onto a raw sort only through that function: `max`
|
|
152
|
+
# advances upwards and `min` advances downwards, which inverts the
|
|
153
|
+
# comparison for `min`. Mirrors dlt's own expression.
|
|
154
|
+
reverse = (
|
|
155
|
+
incremental.row_order == "asc" and incremental.last_value_func is min
|
|
156
|
+
) or (incremental.row_order == "desc" and incremental.last_value_func is max)
|
|
157
|
+
# The listing has to be materialised to be ordered. Only this branch pays
|
|
158
|
+
# for it; the default stays lazy.
|
|
159
|
+
file_models = sorted(
|
|
160
|
+
file_models,
|
|
161
|
+
key=lambda listed: listed[incremental.cursor_path], # ty: ignore[invalid-key]
|
|
162
|
+
reverse=reverse,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
matched_files = 0
|
|
166
|
+
files_chunk: List[FileItem] = []
|
|
167
|
+
for file_model in file_models:
|
|
168
|
+
matched_files += 1
|
|
169
|
+
file_dict = FileItemDict(file_model, fs_client)
|
|
170
|
+
if extract_content:
|
|
171
|
+
file_dict["file_content"] = file_dict.read_bytes()
|
|
172
|
+
files_chunk.append(file_dict) # ty: ignore[invalid-argument-type]
|
|
173
|
+
# wait for the chunk to be full
|
|
174
|
+
if len(files_chunk) >= files_per_page:
|
|
175
|
+
yield files_chunk
|
|
176
|
+
files_chunk = []
|
|
177
|
+
if require_file_match and matched_files == 0:
|
|
178
|
+
raise NoFilesFoundError(bucket_url, file_glob or "**")
|
|
179
|
+
if files_chunk:
|
|
180
|
+
yield files_chunk
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from typing import Union
|
|
2
|
+
from urllib.parse import urlparse
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class FilesystemSource:
|
|
6
|
+
"""Shared capabilities for the filesystem-family sources.
|
|
7
|
+
|
|
8
|
+
Covers the local ``file://`` source and every remote transport
|
|
9
|
+
(``s3://``, ``gs://``, ``az://`` / ``adls://`` / ``abfss://``, ``sftp://``),
|
|
10
|
+
which all converge on the same reader after URI parsing.
|
|
11
|
+
|
|
12
|
+
Filesystem sources manage their own incremental behaviour
|
|
13
|
+
(``handles_incrementality`` is ``True``) and support opt-in file selection by
|
|
14
|
+
modification time (``supports_filesystem_incremental`` is ``True``). They
|
|
15
|
+
carry no resource-level write disposition, so a run-level disposition is safe
|
|
16
|
+
to apply: ``run_ingest`` honours an explicit ``--incremental-strategy append``
|
|
17
|
+
/ ``replace`` for them (``honours_run_disposition`` is ``True``). Sources that
|
|
18
|
+
set their own resource-level disposition leave this ``False`` (the default)
|
|
19
|
+
so the run-level value never overrides theirs.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def handles_incrementality(self) -> bool:
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def honours_run_disposition(self) -> bool:
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
def consumed_run_options(self) -> frozenset:
|
|
29
|
+
"""Return the run options this source's ``dlt_source`` accepts by name.
|
|
30
|
+
|
|
31
|
+
Every other name in omniload's run vocabulary is filtered out before the
|
|
32
|
+
call, so it never reaches an fsspec or Arrow constructor as a stray
|
|
33
|
+
keyword. The two named here are resource options, not connector ones:
|
|
34
|
+
they configure how the reader resource is built (``FilesystemReference``)
|
|
35
|
+
and every ``dlt_source`` implementation in this family declares them
|
|
36
|
+
explicitly rather than reading them out of ``**kwargs``.
|
|
37
|
+
"""
|
|
38
|
+
return frozenset({"filesystem_incremental", "column_types"})
|
|
39
|
+
|
|
40
|
+
def supports_filesystem_incremental(self) -> bool:
|
|
41
|
+
"""Return whether the source supports file-level mtime selection."""
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
def produces_multiple_tables(self, uri: str, table: str) -> bool:
|
|
45
|
+
"""Return whether a workbook selection dispatches worksheet tables."""
|
|
46
|
+
from dlt_filesystem.source.error import UnsupportedEndpointError
|
|
47
|
+
from dlt_filesystem.source.format.readers import (
|
|
48
|
+
spreadsheet_selection_is_plural,
|
|
49
|
+
)
|
|
50
|
+
from dlt_filesystem.source.router import (
|
|
51
|
+
blob_hints,
|
|
52
|
+
determine_endpoint,
|
|
53
|
+
parse_uri,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
parsed_uri = urlparse(uri)
|
|
57
|
+
_, path = parse_uri(parsed_uri, table)
|
|
58
|
+
try:
|
|
59
|
+
endpoint = determine_endpoint(table, path)
|
|
60
|
+
except (UnsupportedEndpointError, ValueError):
|
|
61
|
+
return False
|
|
62
|
+
return endpoint in {
|
|
63
|
+
"read_excel",
|
|
64
|
+
"read_ods",
|
|
65
|
+
} and spreadsheet_selection_is_plural(blob_hints(parsed_uri, table))
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def endpoint_namespace(endpoint: Union[str, None], default: str) -> str:
|
|
69
|
+
"""
|
|
70
|
+
Return a normalized endpoint identity without credentials or query values.
|
|
71
|
+
It is used for incremental loading based on file modification times.
|
|
72
|
+
|
|
73
|
+
# TODO: Remove `default` argument again?
|
|
74
|
+
"""
|
|
75
|
+
if not endpoint:
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
parsed = urlparse(endpoint if "://" in endpoint else f"//{endpoint}")
|
|
79
|
+
host = parsed.hostname
|
|
80
|
+
if not host:
|
|
81
|
+
return default
|
|
82
|
+
|
|
83
|
+
host = host.lower()
|
|
84
|
+
if ":" in host:
|
|
85
|
+
host = f"[{host}]"
|
|
86
|
+
if parsed.port is not None:
|
|
87
|
+
host = f"{host}:{parsed.port}"
|
|
88
|
+
|
|
89
|
+
return f"{host}{parsed.path.rstrip('/')}"
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from typing import Any, Optional, Union
|
|
2
|
+
|
|
3
|
+
import dlt
|
|
4
|
+
from dlt.extract import DltResource, DltSource
|
|
5
|
+
from fsspec import AbstractFileSystem
|
|
6
|
+
|
|
7
|
+
from dlt_filesystem.source.adapter import filesystem, readers
|
|
8
|
+
from dlt_filesystem.source.error import UnsupportedEndpointError
|
|
9
|
+
from dlt_filesystem.source.format.registry import (
|
|
10
|
+
reader_for_format,
|
|
11
|
+
supported_file_format_message,
|
|
12
|
+
)
|
|
13
|
+
from dlt_filesystem.source.model import (
|
|
14
|
+
FilesystemLocator,
|
|
15
|
+
FilesystemReference,
|
|
16
|
+
ResourceOptions,
|
|
17
|
+
)
|
|
18
|
+
from dlt_filesystem.source.router import determine_endpoint
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resource_for_reader(ref: FilesystemReference) -> Union[DltSource, DltResource]:
|
|
22
|
+
"""Build the filesystem reader resource named by ``ref.reader_name``.
|
|
23
|
+
|
|
24
|
+
Threads ``column_types`` into ``read_csv_headless`` and per-URI reader hints (e.g. XML's
|
|
25
|
+
``#tagname``) into a hint-consuming reader; every other reader is selected as-is.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
# Enforce concrete selections on this outer lister only. Piping it into the
|
|
29
|
+
# selected reader replaces the reader source's inner parent, so discovery runs
|
|
30
|
+
# exactly once and the check happens before any downstream incremental filter.
|
|
31
|
+
filesystem_resource = filesystem(
|
|
32
|
+
ref.bucket_url,
|
|
33
|
+
ref.fs,
|
|
34
|
+
file_glob=ref.file_glob,
|
|
35
|
+
extract_content=False,
|
|
36
|
+
require_file_match=ref.require_file_match,
|
|
37
|
+
filesystem_incremental=ref.filesystem_incremental,
|
|
38
|
+
)
|
|
39
|
+
if ref.filesystem_incremental:
|
|
40
|
+
filesystem_resource = filesystem_resource.with_name(
|
|
41
|
+
ref.incremental_resource_name
|
|
42
|
+
)
|
|
43
|
+
filesystem_resource.apply_hints(
|
|
44
|
+
incremental=dlt.sources.incremental("modification_date")
|
|
45
|
+
)
|
|
46
|
+
all_readers = readers(
|
|
47
|
+
ref.bucket_url, ref.fs, file_glob=ref.file_glob
|
|
48
|
+
).with_resources(ref.reader_name)
|
|
49
|
+
reader = all_readers.selected_resources[ref.reader_name]
|
|
50
|
+
|
|
51
|
+
# Apply parameter bindings for certain readers.
|
|
52
|
+
# TODO: Can this be generalized? Why not always loop in column_names into reader hints?
|
|
53
|
+
reader_kwargs: dict[str, Any] = dict(ref.hints)
|
|
54
|
+
if ref.reader_name in {"read_excel", "read_ods"}:
|
|
55
|
+
# The filesystem lister yields pages of files. Keep one collision registry
|
|
56
|
+
# bound to the reader so distinct worksheet names cannot normalize to the
|
|
57
|
+
# same table even when the workbooks occur in different pages.
|
|
58
|
+
reader_kwargs["worksheet_names"] = {}
|
|
59
|
+
|
|
60
|
+
if ref.reader_name == "read_csv_headless":
|
|
61
|
+
column_names = list(ref.column_types.keys()) if ref.column_types else None
|
|
62
|
+
reader = reader.bind(column_names=column_names, **reader_kwargs)
|
|
63
|
+
else:
|
|
64
|
+
reader = reader.bind(**reader_kwargs)
|
|
65
|
+
|
|
66
|
+
# Connect and propagate elements.
|
|
67
|
+
return filesystem_resource | reader
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def infer_resource(
|
|
71
|
+
fs: AbstractFileSystem,
|
|
72
|
+
locator: FilesystemLocator,
|
|
73
|
+
options: Optional[ResourceOptions] = None,
|
|
74
|
+
) -> Union[DltSource, DltResource]:
|
|
75
|
+
"""
|
|
76
|
+
Infer dlt resource from fsspec filesystem, with reader.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
fs: The filesystem the connector built from its own connection arguments.
|
|
80
|
+
locator: The parsed source URI.
|
|
81
|
+
options: The resource options a `dlt_source` implementation declares by
|
|
82
|
+
name in its own signature (`filesystem_incremental`, `column_types`,
|
|
83
|
+
`reader_hints`). Omitted by callers that have none, which reads the
|
|
84
|
+
same as a run that enabled nothing.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
options = options or ResourceOptions()
|
|
88
|
+
|
|
89
|
+
# Decode into base url and url path / file glob, and apply sanity checks.
|
|
90
|
+
locator.validate()
|
|
91
|
+
|
|
92
|
+
# TODO: Naming things: Rename `determine_endpoint` to `infer_reader`.
|
|
93
|
+
try:
|
|
94
|
+
# A `#format` the selection names wins over the file extension, whichever
|
|
95
|
+
# carrier it rode in on. `determine_endpoint` reads it from the table form
|
|
96
|
+
# itself; this also honours it on the URI, which for an HTTP URL is the
|
|
97
|
+
# only place it can be written.
|
|
98
|
+
endpoint = (
|
|
99
|
+
reader_for_format(locator.format_hint)
|
|
100
|
+
if locator.format_hint
|
|
101
|
+
else determine_endpoint(locator.path, locator.file_glob)
|
|
102
|
+
)
|
|
103
|
+
except UnsupportedEndpointError:
|
|
104
|
+
raise ValueError(supported_file_format_message(locator.name)) from None
|
|
105
|
+
|
|
106
|
+
# TODO: FilesystemLocator and FilesystemReference are somewhat redundant now. Refactor!
|
|
107
|
+
# => Bundle fs, locator and reader into another data class , then feed that to
|
|
108
|
+
# `resource_for_reader`.
|
|
109
|
+
return resource_for_reader(
|
|
110
|
+
FilesystemReference(
|
|
111
|
+
fs=fs,
|
|
112
|
+
bucket_url=locator.bucket_url,
|
|
113
|
+
file_glob=locator.file_glob,
|
|
114
|
+
reader_name=endpoint,
|
|
115
|
+
# Require a match only when the locator's unparsed carrier names one
|
|
116
|
+
# concrete file. This keeps wildcard discovery empty-safe.
|
|
117
|
+
require_file_match=locator.require_file_match,
|
|
118
|
+
# A URI fragment addresses one file, so it wins over a hint the source
|
|
119
|
+
# derived for the whole run.
|
|
120
|
+
hints={**(options.reader_hints or {}), **locator.hints},
|
|
121
|
+
filesystem_incremental=options.filesystem_incremental,
|
|
122
|
+
# TODO: Can `column_types` be looped into reader|writer hints instead?
|
|
123
|
+
# We believe it represents a special case handling for `csv_headless`.
|
|
124
|
+
# The run value (`--columns`) wins; the URI query parameter remains the
|
|
125
|
+
# fallback for callers that address it there.
|
|
126
|
+
column_types=(
|
|
127
|
+
options.column_types
|
|
128
|
+
if options.column_types is not None
|
|
129
|
+
else locator.options.params.get("column_types")
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _safe_location(bucket_url: str) -> str:
|
|
5
|
+
"""Remove query and fragment data that may contain filesystem credentials."""
|
|
6
|
+
parsed = urlsplit(bucket_url)
|
|
7
|
+
netloc = parsed.netloc.rpartition("@")[2]
|
|
8
|
+
location = urlunsplit((parsed.scheme, netloc, parsed.path, "", ""))
|
|
9
|
+
if parsed.scheme and not netloc and not parsed.path and "://" in bucket_url:
|
|
10
|
+
return f"{parsed.scheme}://"
|
|
11
|
+
return location
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoFilesFoundError(FileNotFoundError):
|
|
15
|
+
"""A concrete filesystem source selection matched no file."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, bucket_url: str, file_glob: str):
|
|
18
|
+
super().__init__(
|
|
19
|
+
f"No files found at {_safe_location(bucket_url)!r} "
|
|
20
|
+
f"for concrete source selection {file_glob!r}"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnsupportedEndpointError(Exception):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class WorksheetNameCollisionError(ValueError):
|
|
29
|
+
"""Two distinct worksheet names resolve to the same destination table."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
table_name: str,
|
|
35
|
+
first_sheet: str,
|
|
36
|
+
first_file: str,
|
|
37
|
+
second_sheet: str,
|
|
38
|
+
second_file: str,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(
|
|
41
|
+
f"Worksheet names {first_sheet!r} in {first_file!r} and "
|
|
42
|
+
f"{second_sheet!r} in {second_file!r} both resolve to destination "
|
|
43
|
+
f"table {table_name!r} under the active schema naming convention"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MissingDecoderError(UnsupportedEndpointError):
|
|
48
|
+
"""A routable format resolved to a reader or writer whose package is not installed.
|
|
49
|
+
|
|
50
|
+
Raised (instead of a bare ``ImportError``) when an iterable-backed format such as
|
|
51
|
+
``msgpack`` is requested but the optional ``iterable`` extra / its per-format decoder is
|
|
52
|
+
absent. Carries the exact ``pip install`` target so the message is actionable.
|
|
53
|
+
|
|
54
|
+
The write side raises it too (``target.writer.write_yaml``): the missing package is the
|
|
55
|
+
format's, not the direction's, so both halves owe the same install hint rather than one
|
|
56
|
+
of them a bare ``ImportError``.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MissingReaderOptionError(ValueError):
|
|
63
|
+
"""A file reader needs a per-URI ``#key=value`` hint that was not supplied.
|
|
64
|
+
|
|
65
|
+
Raised (instead of the connection-shaped ``MissingValueError`` or a bare ``AttributeError``
|
|
66
|
+
from the decoder) when a format requires a reader option that must arrive via the
|
|
67
|
+
``#key=value`` fragment. XML, for instance, needs ``#tagname=<row-tag>`` to know which
|
|
68
|
+
repeated element is a row. Subclasses ``ValueError`` so it reads as a bad-argument error.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, option: str, file_format: str, example: str):
|
|
72
|
+
super().__init__(
|
|
73
|
+
f"The {file_format} reader requires a '{option}' hint naming the repeated row "
|
|
74
|
+
f"element. Append it to the source URI as a #{option}=<value> fragment, "
|
|
75
|
+
f"e.g. {example}"
|
|
76
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""BSON extended-type normalization for the filesystem BSON reader.
|
|
2
|
+
|
|
3
|
+
Self-contained mirror of omniload's ``convert_mongo_objs`` MongoDB helper that
|
|
4
|
+
deliberately does *not* import the Mongo source: ``mongodb/helpers.py`` imports the
|
|
5
|
+
``pymongo`` client classes (``MongoClient``/``Collection``/``Cursor``) at module top,
|
|
6
|
+
which would couple this filesystem reader to the Mongo driver. ``bson`` itself ships
|
|
7
|
+
with ``pymongo`` (a hard dependency), so decoding needs no extra package.
|
|
8
|
+
|
|
9
|
+
This module imports the ``bson`` submodules at its own top level rather than lazily,
|
|
10
|
+
because it is itself imported lazily (only from ``_read_bson``), so the cost is paid
|
|
11
|
+
only when a BSON file is actually read, never at CLI startup or for other formats.
|
|
12
|
+
|
|
13
|
+
It goes beyond the Mongo helper's coverage: besides the shared ObjectId / Decimal128 /
|
|
14
|
+
datetime / Regex / Timestamp cases, it converts ``Binary`` (base64 str) and the extended
|
|
15
|
+
types the Mongo helper leaves raw (``DBRef``, ``MinKey``, ``MaxKey``, ``Code``). Those
|
|
16
|
+
raw BSON objects are not JSON-serializable, so without conversion a dump containing one
|
|
17
|
+
crashes the load (``TypeError: Type is not JSON serializable``); here they become
|
|
18
|
+
portable Extended-JSON-shaped values.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import datetime as _datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from bson.code import Code
|
|
26
|
+
from bson.dbref import DBRef
|
|
27
|
+
from bson.decimal128 import Decimal128
|
|
28
|
+
from bson.max_key import MaxKey
|
|
29
|
+
from bson.min_key import MinKey
|
|
30
|
+
from bson.objectid import ObjectId
|
|
31
|
+
from bson.regex import Regex
|
|
32
|
+
from bson.timestamp import Timestamp
|
|
33
|
+
from dlt.common.time import ensure_pendulum_datetime_utc
|
|
34
|
+
from dlt.common.utils import map_nested_values_in_place
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def convert_bson_objs(value: Any) -> Any:
|
|
38
|
+
"""Convert a single BSON extended value to a dlt-serializable Python type.
|
|
39
|
+
|
|
40
|
+
Applied to leaf values only: ``map_nested_values_in_place`` recurses into nested
|
|
41
|
+
dicts and lists and calls this on the scalars, so this never sees a plain container
|
|
42
|
+
(BSON extended types like ``DBRef`` are objects, not dict/list, so they do reach
|
|
43
|
+
here). Conversions:
|
|
44
|
+
|
|
45
|
+
- ``Binary`` (and any raw ``bytes``) -> base64 ``str``
|
|
46
|
+
- ``ObjectId`` / ``Decimal128`` -> ``str``
|
|
47
|
+
- ``datetime`` -> pendulum UTC ``datetime``
|
|
48
|
+
- ``Regex`` -> pattern ``str``
|
|
49
|
+
- ``Timestamp`` -> pendulum UTC ``datetime``
|
|
50
|
+
- ``DBRef`` -> ``{"$ref", "$id" (normalized), "$db"?}``
|
|
51
|
+
- ``MinKey`` / ``MaxKey`` -> ``{"$minKey": 1}`` / ``{"$maxKey": 1}``
|
|
52
|
+
- ``Code`` -> code ``str`` (or ``{"$code", "$scope" (normalized)}`` when it has scope)
|
|
53
|
+
- anything else -> unchanged
|
|
54
|
+
"""
|
|
55
|
+
# Binary subclasses bytes, so the bytes branch must come first and covers both. Raw
|
|
56
|
+
# bytes are emitted as base64 str because dlt's CSV/text writer UTF-8-decodes binary
|
|
57
|
+
# values (raises on arbitrary bytes); base64 str is portable across jsonl, file,
|
|
58
|
+
# parquet and warehouse targets. See PLAN Decisions §1.
|
|
59
|
+
if isinstance(value, (bytes, bytearray)):
|
|
60
|
+
return base64.b64encode(bytes(value)).decode("ascii")
|
|
61
|
+
if isinstance(value, (ObjectId, Decimal128)):
|
|
62
|
+
return str(value)
|
|
63
|
+
if isinstance(value, _datetime.datetime):
|
|
64
|
+
return ensure_pendulum_datetime_utc(value)
|
|
65
|
+
if isinstance(value, Regex):
|
|
66
|
+
# value.pattern is the raw pattern string; do NOT try_compile() it (BSON regexes
|
|
67
|
+
# can carry PCRE-only syntax that Python's re rejects, which would crash the read
|
|
68
|
+
# of an otherwise valid dump).
|
|
69
|
+
return value.pattern
|
|
70
|
+
if isinstance(value, Timestamp):
|
|
71
|
+
return ensure_pendulum_datetime_utc(value.as_datetime())
|
|
72
|
+
if isinstance(value, DBRef):
|
|
73
|
+
# $id is normalized recursively (it is typically an ObjectId). The convert()
|
|
74
|
+
# return is used as-is by the caller (map_nested does not re-descend into it), so
|
|
75
|
+
# the id must be converted here rather than relying on outer recursion.
|
|
76
|
+
ref: dict[str, Any] = {
|
|
77
|
+
"$ref": value.collection,
|
|
78
|
+
"$id": convert_bson_objs(value.id),
|
|
79
|
+
}
|
|
80
|
+
if value.database is not None:
|
|
81
|
+
ref["$db"] = value.database
|
|
82
|
+
return ref
|
|
83
|
+
if isinstance(value, MinKey):
|
|
84
|
+
return {"$minKey": 1}
|
|
85
|
+
if isinstance(value, MaxKey):
|
|
86
|
+
return {"$maxKey": 1}
|
|
87
|
+
if isinstance(value, Code):
|
|
88
|
+
# Code subclasses str, so str(value) is the code text. Scope (when present) is a
|
|
89
|
+
# nested doc that needs its own normalization pass.
|
|
90
|
+
if value.scope:
|
|
91
|
+
return {
|
|
92
|
+
"$code": str(value),
|
|
93
|
+
"$scope": map_nested_values_in_place(
|
|
94
|
+
convert_bson_objs, dict(value.scope)
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
return str(value)
|
|
98
|
+
|
|
99
|
+
return value
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright 2022-2025 ScaleVector
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Helpers for the filesystem resource."""
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, Generator, Iterable, List, Optional
|
|
18
|
+
|
|
19
|
+
from dlt.common.typing import TDataItem
|
|
20
|
+
|
|
21
|
+
from dlt_filesystem.source.format.settings import DEFAULT_CHUNK_SIZE
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def add_columns(columns: List[str], rows: List[List[Any]]) -> List[Dict[str, Any]]:
|
|
25
|
+
"""Adds column names to the given rows.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
columns (List[str]): The column names.
|
|
29
|
+
rows (List[List[Any]]): The rows.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List[Dict[str, Any]]: The rows with column names.
|
|
33
|
+
"""
|
|
34
|
+
result = []
|
|
35
|
+
for row in rows:
|
|
36
|
+
result.append(dict(zip(columns, row)))
|
|
37
|
+
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fetch_arrow(file_data, chunk_size: Optional[int] = None) -> Iterable[TDataItem]:
|
|
42
|
+
"""Fetches data from the given CSV file.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
file_data (DuckDBPyRelation): The CSV file data.
|
|
46
|
+
chunk_size (int): The number of rows to read at once.
|
|
47
|
+
|
|
48
|
+
Yields:
|
|
49
|
+
Iterable[TDataItem]: Data items, read from the given CSV file.
|
|
50
|
+
"""
|
|
51
|
+
chunk_size = chunk_size or DEFAULT_CHUNK_SIZE
|
|
52
|
+
batcher = file_data.fetch_arrow_reader(batch_size=chunk_size)
|
|
53
|
+
yield from batcher
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fetch_json(
|
|
57
|
+
file_data, chunk_size: Optional[int] = None
|
|
58
|
+
) -> Generator[List[Dict[str, Any]], None, None]:
|
|
59
|
+
"""Fetches data from the given CSV file.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
file_data (DuckDBPyRelation): The CSV file data.
|
|
63
|
+
chunk_size (int): The number of rows to read at once.
|
|
64
|
+
|
|
65
|
+
Yields:
|
|
66
|
+
Iterable[TDataItem]: Data items, read from the given CSV file.
|
|
67
|
+
"""
|
|
68
|
+
chunk_size = chunk_size or DEFAULT_CHUNK_SIZE
|
|
69
|
+
while True:
|
|
70
|
+
batch = file_data.fetchmany(chunk_size)
|
|
71
|
+
if not batch:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
yield add_columns(file_data.columns, batch)
|