litdata 0.2.61__tar.gz → 0.2.65__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {litdata-0.2.61/src/litdata.egg-info → litdata-0.2.65}/PKG-INFO +3 -3
  2. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/__about__.py +1 -1
  3. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/cli/commands.py +1 -1
  4. litdata-0.2.65/src/litdata/cli/handler/__init__.py +21 -0
  5. litdata-0.2.65/src/litdata/cli/handler/cache.py +32 -0
  6. litdata-0.2.65/src/litdata/cli/handler/optimize.py +19 -0
  7. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/constants.py +3 -0
  8. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/processing/data_processor.py +1 -1
  9. litdata-0.2.65/src/litdata/streaming/async_prefetch.py +256 -0
  10. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/client.py +90 -49
  11. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/config.py +144 -30
  12. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/dataloader.py +58 -1
  13. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/dataset.py +46 -40
  14. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/downloader.py +150 -28
  15. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/fs_provider.py +9 -7
  16. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/item_loader.py +320 -63
  17. litdata-0.2.65/src/litdata/streaming/reader.py +1090 -0
  18. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/resolver.py +13 -1
  19. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/serializers.py +49 -6
  20. litdata-0.2.65/src/litdata/streaming/timing.py +84 -0
  21. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/writer.py +5 -1
  22. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/parquet.py +11 -3
  23. {litdata-0.2.61 → litdata-0.2.65/src/litdata.egg-info}/PKG-INFO +3 -3
  24. {litdata-0.2.61 → litdata-0.2.65}/src/litdata.egg-info/SOURCES.txt +5 -0
  25. {litdata-0.2.61 → litdata-0.2.65}/src/litdata.egg-info/requires.txt +2 -2
  26. litdata-0.2.61/src/litdata/streaming/reader.py +0 -629
  27. {litdata-0.2.61 → litdata-0.2.65}/CONTRIBUTING.md +0 -0
  28. {litdata-0.2.61 → litdata-0.2.65}/LICENSE +0 -0
  29. {litdata-0.2.61 → litdata-0.2.65}/MANIFEST.in +0 -0
  30. {litdata-0.2.61 → litdata-0.2.65}/README.md +0 -0
  31. {litdata-0.2.61 → litdata-0.2.65}/requirements.txt +0 -0
  32. {litdata-0.2.61 → litdata-0.2.65}/setup.cfg +0 -0
  33. {litdata-0.2.61 → litdata-0.2.65}/setup.py +0 -0
  34. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/__init__.py +0 -0
  35. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/__main__.py +0 -0
  36. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/cli/__init__.py +0 -0
  37. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/cli/parser.py +0 -0
  38. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/debugger.py +0 -0
  39. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/helpers.py +0 -0
  40. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/imports.py +0 -0
  41. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/processing/__init__.py +0 -0
  42. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/processing/functions.py +0 -0
  43. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/processing/readers.py +0 -0
  44. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/processing/utilities.py +0 -0
  45. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/raw/__init__.py +0 -0
  46. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/raw/dataset.py +0 -0
  47. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/raw/indexer.py +0 -0
  48. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/raw/types.py +0 -0
  49. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/requirements.py +0 -0
  50. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/__init__.py +0 -0
  51. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/cache.py +0 -0
  52. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/combined.py +0 -0
  53. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/compression.py +0 -0
  54. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/parallel.py +0 -0
  55. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/sampler.py +0 -0
  56. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/streaming/shuffle.py +0 -0
  57. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/__init__.py +0 -0
  58. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/_pytree.py +0 -0
  59. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/base.py +0 -0
  60. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/breakpoint.py +0 -0
  61. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/broadcast.py +0 -0
  62. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/dataset_utilities.py +0 -0
  63. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/encryption.py +0 -0
  64. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/env.py +0 -0
  65. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/format.py +0 -0
  66. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/hf_dataset.py +0 -0
  67. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/packing.py +0 -0
  68. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/shuffle.py +0 -0
  69. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/subsample.py +0 -0
  70. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/torch_utils.py +0 -0
  71. {litdata-0.2.61 → litdata-0.2.65}/src/litdata/utilities/train_test_split.py +0 -0
  72. {litdata-0.2.61 → litdata-0.2.65}/src/litdata.egg-info/dependency_links.txt +0 -0
  73. {litdata-0.2.61 → litdata-0.2.65}/src/litdata.egg-info/entry_points.txt +0 -0
  74. {litdata-0.2.61 → litdata-0.2.65}/src/litdata.egg-info/not-zip-safe +0 -0
  75. {litdata-0.2.61 → litdata-0.2.65}/src/litdata.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: litdata
3
- Version: 0.2.61
3
+ Version: 0.2.65
4
4
  Summary: The Deep Learning framework to train, deploy, and ship AI products Lightning fast.
5
5
  Home-page: https://github.com/Lightning-AI/litdata
6
6
  Download-URL: https://github.com/Lightning-AI/litdata
@@ -39,10 +39,10 @@ Requires-Dist: obstore
39
39
  Provides-Extra: extras
40
40
  Requires-Dist: fsspec; extra == "extras"
41
41
  Requires-Dist: google-cloud-storage; extra == "extras"
42
- Requires-Dist: lightning-sdk==2025.12.17; extra == "extras"
42
+ Requires-Dist: lightning-sdk==2026.2.6; extra == "extras"
43
43
  Requires-Dist: pillow; extra == "extras"
44
44
  Requires-Dist: polars; extra == "extras"
45
- Requires-Dist: pyarrow; extra == "extras"
45
+ Requires-Dist: pyarrow<25.0.0; extra == "extras"
46
46
  Requires-Dist: tqdm; extra == "extras"
47
47
  Requires-Dist: viztracer; extra == "extras"
48
48
  Dynamic: author
@@ -14,7 +14,7 @@
14
14
 
15
15
  import time
16
16
 
17
- __version__ = "0.2.61"
17
+ __version__ = "0.2.65"
18
18
  __author__ = "Lightning AI et al."
19
19
  __author_email__ = "pytorch@lightning.ai"
20
20
  __license__ = "Apache-2.0"
@@ -13,9 +13,9 @@
13
13
 
14
14
  from argparse import _SubParsersAction
15
15
 
16
+ from litdata.cli import LitFormatter
16
17
  from litdata.cli.handler.cache import clear_cache, show_cache_path
17
18
  from litdata.cli.handler.optimize import optimize_dataset
18
- from litdata.cli.parser import LitFormatter
19
19
 
20
20
 
21
21
  def register_cache_subcommand(subparser: _SubParsersAction) -> None:
@@ -0,0 +1,21 @@
1
+ # Copyright The Lightning AI team.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ from litdata.cli.handler.cache import clear_cache, show_cache_path
15
+ from litdata.cli.handler.optimize import optimize_dataset
16
+
17
+ __all__ = [
18
+ "clear_cache",
19
+ "show_cache_path",
20
+ "optimize_dataset",
21
+ ]
@@ -0,0 +1,32 @@
1
+ # Copyright The Lightning AI team.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ import shutil
15
+ from argparse import Namespace
16
+
17
+ from litdata.utilities.dataset_utilities import get_default_cache_dir
18
+
19
+
20
+ def clear_cache(args: Namespace) -> None:
21
+ """Clear default cache used for StreamingDataset and other utilities."""
22
+ streaming_default_cache_dir = get_default_cache_dir()
23
+
24
+ shutil.rmtree(streaming_default_cache_dir, ignore_errors=True)
25
+
26
+ print(f"Cache directory '{streaming_default_cache_dir}' cleared.")
27
+
28
+
29
+ def show_cache_path(args: Namespace) -> None:
30
+ """Show the path to the cache directory."""
31
+ streaming_default_cache_dir = get_default_cache_dir()
32
+ print(f"Default cache directory: {streaming_default_cache_dir}")
@@ -0,0 +1,19 @@
1
+ # Copyright The Lightning AI team.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ from argparse import Namespace
15
+
16
+
17
+ def optimize_dataset(args: Namespace) -> None:
18
+ """Handle the optimize command."""
19
+ print(f"Optimizing dataset at {args.dataset}...")
@@ -49,6 +49,7 @@ _PYARROW_AVAILABLE = RequirementCache("pyarrow")
49
49
  _POLARS_AVAILABLE = RequirementCache("polars>1.0.0")
50
50
  _PIL_AVAILABLE = RequirementCache("PIL")
51
51
  _TORCH_VISION_AVAILABLE = RequirementCache("torchvision")
52
+ _TORCH_VISION_LESS_THAN_0_26 = RequirementCache("torchvision<0.26.0")
52
53
  _AV_AVAILABLE = RequirementCache("av")
53
54
  _OBSTORE_AVAILABLE = RequirementCache("obstore")
54
55
 
@@ -58,6 +59,8 @@ _PRINT_DEBUG_LOGS = bool(int(os.getenv("PRINT_DEBUG_LOGS", "0")))
58
59
  _MAX_WAIT_TIME = int(os.getenv("MAX_WAIT_TIME", "120"))
59
60
  _FORCE_DOWNLOAD_TIME = int(os.getenv("FORCE_DOWNLOAD_TIME", "30"))
60
61
  _LITDATA_DISABLE_VERSION_CHECK = int(os.getenv("LITDATA_DISABLE_VERSION_CHECK", "0"))
62
+ # Experimental async chunk prefetch: set env ``LITDATA_ASYNC_CHUNK_PREFETCH=1``
63
+ # (see ``litdata.streaming.async_prefetch``). Off by default.
61
64
 
62
65
  # DON'T CHANGE ORDER
63
66
  _TORCH_DTYPES_MAPPING = {
@@ -160,7 +160,7 @@ def _download_data_target(
160
160
  if input_dir.path:
161
161
  local_path = path.replace(input_dir.path, cache_dir)
162
162
 
163
- if input_dir.url and input_dir.path:
163
+ if input_dir.url and input_dir.path and not os.path.isfile(path):
164
164
  path = path.replace(input_dir.path, input_dir.url)
165
165
 
166
166
  obj = parse.urlparse(path)
@@ -0,0 +1,256 @@
1
+ # Copyright The Lightning AI team.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ """Experimental asyncio helpers for overlapping **remote chunk downloads**.
15
+
16
+ This is intentionally **not** an async ``StreamingDataLoader``. Training stays on
17
+ the sync ``for batch in loader`` API; decode stays on process workers. Asyncio is
18
+ only useful where we wait on network IO.
19
+
20
+ On by default for remote datasets; force off with ``LITDATA_ASYNC_CHUNK_PREFETCH=0``,
21
+ or force on locally with ``LITDATA_ASYNC_CHUNK_PREFETCH=1``.
22
+
23
+ Strategy:
24
+ * Prefer ``Downloader.adownload_file`` (streaming to disk) when overridden.
25
+ * Else ``Downloader.adownload_fileobj`` + atomic write.
26
+ * Otherwise run sync ``download_chunk_from_index`` in ``asyncio.to_thread`` and
27
+ ``gather`` several chunk indexes — still overlaps latency for blocking cloud SDKs.
28
+
29
+ Real-S3 note (Studio benches on ~67MB ImageNet chunks): gather concurrency needs
30
+ ``max_pre_download >= 4`` to matter; the prepare thread raises the floor when
31
+ async prefetch is enabled (see :func:`async_prefetch_min_pre_download`).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import contextlib
38
+ import logging
39
+ import os
40
+ import threading
41
+ from typing import TYPE_CHECKING
42
+
43
+ from litdata.streaming.downloader import Downloader
44
+
45
+ if TYPE_CHECKING:
46
+ from litdata.streaming.config import ChunksConfig
47
+
48
+ logger = logging.getLogger("litdata.streaming.async_prefetch")
49
+
50
+ # Thread-local event loop so PrepareChunksThread does not pay asyncio.run()
51
+ # startup on every drain batch.
52
+ _THREAD_LOOPS = threading.local()
53
+
54
+ # Empirically, async gather on real S3 is bottlenecked when max_pre_download==2
55
+ # (only 1–2 in-flight). Floor to 4 when the feature is enabled unless overridden.
56
+ _DEFAULT_ASYNC_MIN_PRE_DOWNLOAD = 4
57
+
58
+
59
+ def async_chunk_prefetch_enabled(remote_dir: str | None = None) -> bool:
60
+ """Return True when async chunk prefetch should run.
61
+
62
+ Env ``LITDATA_ASYNC_CHUNK_PREFETCH`` wins when set (``1``/``0``). When unset,
63
+ default **on** for remote datasets (real-S3: process+async is the fast path)
64
+ and **off** for local-only caches.
65
+ """
66
+ raw = os.getenv("LITDATA_ASYNC_CHUNK_PREFETCH")
67
+ if raw is not None:
68
+ return bool(int(raw))
69
+ return bool(remote_dir)
70
+
71
+
72
+ def async_prefetch_min_pre_download() -> int:
73
+ """Minimum ``max_pre_download`` applied when async chunk prefetch is on.
74
+
75
+ Override with ``LITDATA_ASYNC_MIN_PRE_DOWNLOAD`` (default 4). Set to ``0`` to
76
+ disable the floor and keep the caller's ``max_pre_download`` unchanged.
77
+ """
78
+ raw = os.getenv("LITDATA_ASYNC_MIN_PRE_DOWNLOAD")
79
+ if raw is None:
80
+ return _DEFAULT_ASYNC_MIN_PRE_DOWNLOAD
81
+ return max(0, int(raw))
82
+
83
+
84
+ def apply_async_pre_download_floor(max_pre_download: int, remote_dir: str | None = None) -> int:
85
+ """Raise ``max_pre_download`` when async prefetch needs gather width."""
86
+ if not async_chunk_prefetch_enabled(remote_dir):
87
+ return max_pre_download
88
+ floor = async_prefetch_min_pre_download()
89
+ if floor <= 0 or max_pre_download >= floor:
90
+ return max_pre_download
91
+ logger.info(
92
+ "Async chunk prefetch: raising max_pre_download %s → %s so "
93
+ "asyncio.gather can overlap remote chunk downloads. "
94
+ "Set LITDATA_ASYNC_MIN_PRE_DOWNLOAD=0 to keep the original value.",
95
+ max_pre_download,
96
+ floor,
97
+ )
98
+ return floor
99
+
100
+
101
+ def downloader_supports_adownload(downloader: Downloader | None) -> bool:
102
+ """True when an async download path is available on ``downloader``."""
103
+ if downloader is None:
104
+ return False
105
+ cls = type(downloader)
106
+ return (
107
+ cls.adownload_file is not Downloader.adownload_file or cls.adownload_fileobj is not Downloader.adownload_fileobj
108
+ )
109
+
110
+
111
+ def _remote_join(remote_dir: str, filename: str) -> str:
112
+ """Join cloud URLs without ``os.path.join`` quirks on schemes."""
113
+ return remote_dir.rstrip("/") + "/" + filename.lstrip("/")
114
+
115
+
116
+ async def _adownload_file_to_path(downloader: Downloader, remote_filepath: str, local_filepath: str) -> None:
117
+ """Fetch ``remote_filepath`` asynchronously and publish atomically."""
118
+ if os.path.exists(local_filepath):
119
+ return
120
+ # Prefer streaming-to-disk when the backend overrides adownload_file.
121
+ if type(downloader).adownload_file is not Downloader.adownload_file:
122
+ await downloader.adownload_file(remote_filepath, local_filepath)
123
+ return
124
+ data = await downloader.adownload_fileobj(remote_filepath)
125
+ if data is None:
126
+ raise NotImplementedError(
127
+ f"{type(downloader).__name__}.adownload_fileobj returned None; "
128
+ "cannot use async chunk prefetch for this backend."
129
+ )
130
+ tmp_path = downloader._temp_download_path(local_filepath)
131
+ try:
132
+ os.makedirs(os.path.dirname(local_filepath) or ".", exist_ok=True)
133
+ with open(tmp_path, "wb") as f:
134
+ f.write(data)
135
+ downloader._atomic_replace(tmp_path, local_filepath)
136
+ except Exception:
137
+ with contextlib.suppress(FileNotFoundError, PermissionError):
138
+ os.remove(tmp_path)
139
+ raise
140
+
141
+
142
+ async def _adownload_chunk_index(config: ChunksConfig, chunk_index: int) -> None:
143
+ """Async download + decompress for one chunk index (mirrors sync config path)."""
144
+ assert config._chunks is not None
145
+ downloader = config._downloader
146
+ if downloader is None:
147
+ return
148
+
149
+ chunk_filename = config._chunks[chunk_index]["filename"]
150
+ local_chunkpath = os.path.join(config._cache_dir, chunk_filename)
151
+ remote_chunkpath = _remote_join(downloader._remote_dir, chunk_filename)
152
+ lazily_ref_counted = chunk_index not in config._shared_chunk_indexes
153
+ lock_path = (
154
+ local_chunkpath.replace(f".{config._compressor_name}", "") if config._compressor_name else local_chunkpath
155
+ )
156
+
157
+ if os.path.exists(local_chunkpath):
158
+ config.try_decompress(local_chunkpath)
159
+ if lazily_ref_counted:
160
+ downloader._increment_local_lock(lock_path, chunk_index)
161
+ return
162
+
163
+ if lazily_ref_counted:
164
+ downloader._increment_local_lock(lock_path, chunk_index)
165
+
166
+ if downloader_supports_adownload(downloader):
167
+ await _adownload_file_to_path(downloader, remote_chunkpath, local_chunkpath)
168
+ else:
169
+ # Overlap blocking SDK calls across threads when native async is unavailable.
170
+ await asyncio.to_thread(downloader.download_chunk_from_index, chunk_index)
171
+
172
+ config.try_decompress(local_chunkpath)
173
+
174
+
175
+ async def adownload_chunk_indexes(config: ChunksConfig, chunk_indexes: list[int]) -> None:
176
+ """Download several chunk indexes concurrently (gather)."""
177
+ if not chunk_indexes:
178
+ return
179
+ if len(chunk_indexes) == 1:
180
+ await _adownload_chunk_index(config, chunk_indexes[0])
181
+ return
182
+ await asyncio.gather(*[_adownload_chunk_index(config, idx) for idx in chunk_indexes])
183
+
184
+
185
+ def _thread_event_loop() -> asyncio.AbstractEventLoop:
186
+ """Return a reusable event loop for the current thread."""
187
+ loop = getattr(_THREAD_LOOPS, "loop", None)
188
+ if loop is None or loop.is_closed():
189
+ loop = asyncio.new_event_loop()
190
+ _THREAD_LOOPS.loop = loop
191
+ return loop
192
+
193
+
194
+ def close_thread_event_loop() -> None:
195
+ """Shut down the thread-local loop and its default executor.
196
+
197
+ ``asyncio.to_thread`` (used when a downloader has no native async path) parks
198
+ workers named ``asyncio_N`` on the loop's default executor. Leaving the loop
199
+ open leaks those threads and trips the session thread-police on Windows.
200
+
201
+ Executor shutdown is non-blocking (``wait=False``) so prepare-thread ``finally``
202
+ never joins forever if a download worker is stuck. Threads are marked daemon and
203
+ futures cancelled so orphans cannot keep the process alive or poison later
204
+ DataLoader forks under pytest-xdist.
205
+ """
206
+ loop = getattr(_THREAD_LOOPS, "loop", None)
207
+ _THREAD_LOOPS.loop = None
208
+ if loop is None or loop.is_closed():
209
+ return
210
+ try:
211
+ if not loop.is_running():
212
+ with contextlib.suppress(Exception):
213
+ loop.run_until_complete(loop.shutdown_asyncgens())
214
+ # Prefer non-blocking teardown over ``shutdown_default_executor()``,
215
+ # which joins executor threads and can hang prepare-thread exit.
216
+ executor = getattr(loop, "_default_executor", None)
217
+ if executor is not None:
218
+ with contextlib.suppress(Exception):
219
+ loop._default_executor = None
220
+ for thread in getattr(executor, "_threads", set()):
221
+ thread.daemon = True
222
+ # cancel_futures is 3.9+; litdata already requires newer Python.
223
+ executor.shutdown(wait=False, cancel_futures=True)
224
+ finally:
225
+ with contextlib.suppress(Exception):
226
+ loop.close()
227
+
228
+
229
+ def download_chunk_indexes_concurrently(config: ChunksConfig, chunk_indexes: list[int]) -> None:
230
+ """Sync entry point for ``PrepareChunksThread``: run ``adownload_chunk_indexes``."""
231
+ if not chunk_indexes:
232
+ return
233
+ if len(chunk_indexes) == 1:
234
+ config.download_chunk_from_index(chunk_indexes[0])
235
+ return
236
+ # Reuse a per-thread loop instead of asyncio.run() (which creates+closes a loop
237
+ # on every prefetch batch — measurable overhead under high chunk churn).
238
+ try:
239
+ running = asyncio.get_running_loop()
240
+ except RuntimeError:
241
+ running = None
242
+ if running is not None:
243
+ # Already inside an event loop (tests / nested callers): block via a bridge.
244
+ import concurrent.futures
245
+
246
+ def _run() -> None:
247
+ try:
248
+ _thread_event_loop().run_until_complete(adownload_chunk_indexes(config, chunk_indexes))
249
+ finally:
250
+ close_thread_event_loop()
251
+
252
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
253
+ pool.submit(_run).result()
254
+ return
255
+ loop = _thread_event_loop()
256
+ loop.run_until_complete(adownload_chunk_indexes(config, chunk_indexes))
@@ -45,6 +45,63 @@ class _CustomRetryAdapter(HTTPAdapter):
45
45
  return super().send(request, **kwargs)
46
46
 
47
47
 
48
+ def _login_and_get_temp_bucket_credentials(data_connection_id: str) -> dict[str, Any]:
49
+ """Mint temporary bucket credentials for a data connection via the Lightning Cloud API.
50
+
51
+ Shared by R2 (lightning storage) connections and by S3 connections marked
52
+ ``available_in_non_aws_providers``: both bypass the FUSE mount and need short-lived creds
53
+ from the same control-plane ``temp-bucket-credentials`` endpoint. Returns the raw response
54
+ (accessKeyId/secretAccessKey/sessionToken, plus accountId for R2).
55
+ """
56
+ retry_strategy = Retry(
57
+ total=_CONNECTION_RETRY_TOTAL,
58
+ backoff_factor=_CONNECTION_RETRY_BACKOFF_FACTOR,
59
+ status_forcelist=[
60
+ 408, # Request Timeout
61
+ 429, # Too Many Requests
62
+ 500, # Internal Server Error
63
+ 502, # Bad Gateway
64
+ 503, # Service Unavailable
65
+ 504, # Gateway Timeout
66
+ ],
67
+ )
68
+ adapter = _CustomRetryAdapter(max_retries=retry_strategy, timeout=_DEFAULT_REQUEST_TIMEOUT)
69
+ session = requests.Session()
70
+ session.mount("http://", adapter)
71
+ session.mount("https://", adapter)
72
+
73
+ cloud_url = os.getenv("LIGHTNING_CLOUD_URL", "https://lightning.ai")
74
+ api_key = os.getenv("LIGHTNING_API_KEY")
75
+ username = os.getenv("LIGHTNING_USERNAME")
76
+ project_id = os.getenv("LIGHTNING_CLOUD_PROJECT_ID")
77
+
78
+ if not all([api_key, username, project_id]):
79
+ raise RuntimeError("Missing required environment variables")
80
+
81
+ # Login to get token
82
+ payload = {"apiKey": api_key, "username": username}
83
+ login_url = f"{cloud_url}/v1/auth/login"
84
+ response = session.post(login_url, data=json.dumps(payload))
85
+
86
+ if "token" not in response.json():
87
+ raise RuntimeError("Failed to get authentication token")
88
+
89
+ token = response.json()["token"]
90
+
91
+ # Get temporary bucket credentials
92
+ headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
93
+ credentials_url = (
94
+ f"{cloud_url}/v1/projects/{project_id}/data-connections/{data_connection_id}/temp-bucket-credentials"
95
+ )
96
+
97
+ credentials_response = session.get(credentials_url, headers=headers, timeout=10)
98
+
99
+ if credentials_response.status_code != 200:
100
+ raise RuntimeError(f"Failed to get credentials: {credentials_response.status_code}")
101
+
102
+ return credentials_response.json()
103
+
104
+
48
105
  class S3Client:
49
106
  # TODO: Generalize to support more cloud providers.
50
107
 
@@ -61,6 +118,15 @@ class S3Client:
61
118
  self._session_options: dict = session_options or {}
62
119
 
63
120
  def _create_client(self) -> None:
121
+ # S3 data connections marked available on non-AWS providers can't reach the bucket via the
122
+ # FUSE mount or an instance profile off AWS, so mint temporary project-role credentials from
123
+ # the control plane instead (mirrors R2Client). Gated on data_connection_id being threaded
124
+ # through storage_options, so plain S3 access is unaffected.
125
+ data_connection_id = self._storage_options.get("data_connection_id")
126
+ if data_connection_id:
127
+ self._create_client_from_temp_credentials(data_connection_id)
128
+ return
129
+
64
130
  has_shared_credentials_file = (
65
131
  os.getenv("AWS_SHARED_CREDENTIALS_FILE") == os.getenv("AWS_CONFIG_FILE") == "/.credentials/.aws_credentials"
66
132
  )
@@ -86,6 +152,29 @@ class S3Client:
86
152
  config=botocore.config.Config(retries={"max_attempts": 1000, "mode": "adaptive"}),
87
153
  )
88
154
 
155
+ def _create_client_from_temp_credentials(self, data_connection_id: str) -> None:
156
+ """Create an S3 client backed by temporary project-role credentials for a data connection.
157
+
158
+ Used for S3 connections available on non-AWS providers. Unlike R2 there is no custom
159
+ endpoint — this is real AWS S3, so botocore resolves the bucket region on first use.
160
+ """
161
+ temp_credentials = _login_and_get_temp_bucket_credentials(data_connection_id)
162
+
163
+ # data_connection_id is our own metadata; drop it before handing options to boto3.
164
+ storage_options = {k: v for k, v in self._storage_options.items() if k != "data_connection_id"}
165
+
166
+ session = boto3.Session(**self._session_options)
167
+ self._client = session.client(
168
+ "s3",
169
+ **{
170
+ "aws_access_key_id": temp_credentials["accessKeyId"],
171
+ "aws_secret_access_key": temp_credentials["secretAccessKey"],
172
+ "aws_session_token": temp_credentials["sessionToken"],
173
+ "config": botocore.config.Config(retries={"max_attempts": 1000, "mode": "adaptive"}),
174
+ **storage_options,
175
+ },
176
+ )
177
+
89
178
  @property
90
179
  def client(self) -> Any:
91
180
  if self._client is None:
@@ -121,56 +210,8 @@ class R2Client(S3Client):
121
210
 
122
211
  def get_r2_bucket_credentials(self, data_connection_id: str) -> dict[str, str]:
123
212
  """Fetch temporary R2 credentials for the current lightning storage connection."""
124
- # Create session with retry logic
125
- retry_strategy = Retry(
126
- total=_CONNECTION_RETRY_TOTAL,
127
- backoff_factor=_CONNECTION_RETRY_BACKOFF_FACTOR,
128
- status_forcelist=[
129
- 408, # Request Timeout
130
- 429, # Too Many Requests
131
- 500, # Internal Server Error
132
- 502, # Bad Gateway
133
- 503, # Service Unavailable
134
- 504, # Gateway Timeout
135
- ],
136
- )
137
- adapter = _CustomRetryAdapter(max_retries=retry_strategy, timeout=_DEFAULT_REQUEST_TIMEOUT)
138
- session = requests.Session()
139
- session.mount("http://", adapter)
140
- session.mount("https://", adapter)
141
-
142
213
  try:
143
- # Get Lightning Cloud API token
144
- cloud_url = os.getenv("LIGHTNING_CLOUD_URL", "https://lightning.ai")
145
- api_key = os.getenv("LIGHTNING_API_KEY")
146
- username = os.getenv("LIGHTNING_USERNAME")
147
- project_id = os.getenv("LIGHTNING_CLOUD_PROJECT_ID")
148
-
149
- if not all([api_key, username, project_id]):
150
- raise RuntimeError("Missing required environment variables")
151
-
152
- # Login to get token
153
- payload = {"apiKey": api_key, "username": username}
154
- login_url = f"{cloud_url}/v1/auth/login"
155
- response = session.post(login_url, data=json.dumps(payload))
156
-
157
- if "token" not in response.json():
158
- raise RuntimeError("Failed to get authentication token")
159
-
160
- token = response.json()["token"]
161
-
162
- # Get temporary bucket credentials
163
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
164
- credentials_url = (
165
- f"{cloud_url}/v1/projects/{project_id}/data-connections/{data_connection_id}/temp-bucket-credentials"
166
- )
167
-
168
- credentials_response = session.get(credentials_url, headers=headers, timeout=10)
169
-
170
- if credentials_response.status_code != 200:
171
- raise RuntimeError(f"Failed to get credentials: {credentials_response.status_code}")
172
-
173
- temp_credentials = credentials_response.json()
214
+ temp_credentials = _login_and_get_temp_bucket_credentials(data_connection_id)
174
215
 
175
216
  endpoint_url = f"https://{temp_credentials['accountId']}.r2.cloudflarestorage.com"
176
217