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.
Files changed (50) hide show
  1. dlt_filesystem/error.py +14 -0
  2. dlt_filesystem/source/adapter.py +180 -0
  3. dlt_filesystem/source/base.py +89 -0
  4. dlt_filesystem/source/core.py +132 -0
  5. dlt_filesystem/source/error.py +76 -0
  6. dlt_filesystem/source/format/bson_codec.py +99 -0
  7. dlt_filesystem/source/format/helpers.py +74 -0
  8. dlt_filesystem/source/format/iterable_codec.py +505 -0
  9. dlt_filesystem/source/format/readers.py +1090 -0
  10. dlt_filesystem/source/format/registry.py +156 -0
  11. dlt_filesystem/source/format/settings.py +1 -0
  12. dlt_filesystem/source/fsspec/databricks.py +81 -0
  13. dlt_filesystem/source/fsspec/dropbox.py +50 -0
  14. dlt_filesystem/source/fsspec/ftp.py +55 -0
  15. dlt_filesystem/source/fsspec/gdrive.py +56 -0
  16. dlt_filesystem/source/fsspec/hdfs.py +71 -0
  17. dlt_filesystem/source/fsspec/http.py +394 -0
  18. dlt_filesystem/source/fsspec/local.py +139 -0
  19. dlt_filesystem/source/fsspec/oci.py +61 -0
  20. dlt_filesystem/source/fsspec/onedrive.py +13 -0
  21. dlt_filesystem/source/fsspec/oss.py +60 -0
  22. dlt_filesystem/source/fsspec/r2.py +18 -0
  23. dlt_filesystem/source/fsspec/sharepoint.py +57 -0
  24. dlt_filesystem/source/fsspec/smb.py +67 -0
  25. dlt_filesystem/source/fsspec/webdav.py +79 -0
  26. dlt_filesystem/source/fsspec/webhdfs.py +67 -0
  27. dlt_filesystem/source/impl/remote.py +363 -0
  28. dlt_filesystem/source/impl/util.py +80 -0
  29. dlt_filesystem/source/lister.py +301 -0
  30. dlt_filesystem/source/model.py +324 -0
  31. dlt_filesystem/source/router.py +272 -0
  32. dlt_filesystem/staging.py +193 -0
  33. dlt_filesystem/target/api.py +13 -0
  34. dlt_filesystem/target/local.py +166 -0
  35. dlt_filesystem/target/model.py +1 -0
  36. dlt_filesystem/target/registry.py +122 -0
  37. dlt_filesystem/target/remote.py +266 -0
  38. dlt_filesystem/target/util.py +88 -0
  39. dlt_filesystem/target/writer.py +533 -0
  40. dlt_filesystem/testing/stub.py +77 -0
  41. dlt_filesystem/testing/writer.py +201 -0
  42. dlt_filesystem/util/auth.py +511 -0
  43. dlt_filesystem/util/fsspec.py +154 -0
  44. dlt_filesystem/util/loader.py +123 -0
  45. dlt_filesystem/util/python.py +198 -0
  46. dlt_filesystem/util/web.py +72 -0
  47. dlt_filesystem-0.18.0.dist-info/METADATA +114 -0
  48. dlt_filesystem-0.18.0.dist-info/RECORD +50 -0
  49. dlt_filesystem-0.18.0.dist-info/WHEEL +5 -0
  50. dlt_filesystem-0.18.0.dist-info/top_level.txt +1 -0
@@ -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)