|
26 | 26 | import pytest |
27 | 27 | from fsspec.implementations.local import LocalFileSystem |
28 | 28 | from fsspec.implementations.memory import MemoryFileSystem |
| 29 | +from upath import UPath |
29 | 30 |
|
30 | 31 | from airflow.sdk import Asset, ObjectStoragePath |
31 | 32 | from airflow.sdk._shared.module_loading import qualname |
@@ -228,6 +229,114 @@ def test_standard_extended_api(self, fake_files, fn, args, fn2, path, expected_a |
228 | 229 | method.assert_called_once_with(expected_args, **expected_kwargs) |
229 | 230 |
|
230 | 231 |
|
| 232 | +class TestConnIdCredentialResolution: |
| 233 | + """ |
| 234 | + Regression tests for https://github.com/apache/airflow/issues/64632 |
| 235 | +
|
| 236 | + When ObjectStoragePath was migrated from CloudPath to ProxyUPath (3.2.0), |
| 237 | + methods like exists(), mkdir(), is_dir(), is_file() were delegated to |
| 238 | + self.__wrapped__ which carries empty storage_options (conn_id is stored |
| 239 | + separately). This caused NoCredentialsError / 401 errors for remote stores |
| 240 | + even when a valid conn_id was provided. |
| 241 | + """ |
| 242 | + |
| 243 | + @pytest.fixture(autouse=True) |
| 244 | + def restore_cache(self): |
| 245 | + cache = _STORE_CACHE.copy() |
| 246 | + yield |
| 247 | + _STORE_CACHE.clear() |
| 248 | + _STORE_CACHE.update(cache) |
| 249 | + |
| 250 | + @pytest.fixture |
| 251 | + def fake_fs_with_conn(self): |
| 252 | + fs = _FakeRemoteFileSystem(conn_id="my_conn") |
| 253 | + attach(protocol="ffs2", conn_id="my_conn", fs=fs) |
| 254 | + try: |
| 255 | + yield fs |
| 256 | + finally: |
| 257 | + _FakeRemoteFileSystem.store.clear() |
| 258 | + _FakeRemoteFileSystem.pseudo_dirs[:] = [""] |
| 259 | + |
| 260 | + def test_exists_uses_authenticated_fs(self, fake_fs_with_conn): |
| 261 | + """exists() must use self.fs (Airflow-attached) not __wrapped__.fs (unauthenticated).""" |
| 262 | + p = ObjectStoragePath("ffs2://my_conn@bucket/some_file.txt", conn_id="my_conn") |
| 263 | + # Verify the correct fs instance was injected, not merely any _FakeRemoteFileSystem |
| 264 | + assert p.__wrapped__._fs_cached is fake_fs_with_conn |
| 265 | + fake_fs_with_conn.touch("bucket/some_file.txt") |
| 266 | + |
| 267 | + assert p.exists() is True |
| 268 | + assert ( |
| 269 | + ObjectStoragePath("ffs2://my_conn@bucket/no_such_file.txt", conn_id="my_conn").exists() is False |
| 270 | + ) |
| 271 | + |
| 272 | + def test_mkdir_uses_authenticated_fs(self, fake_fs_with_conn): |
| 273 | + """mkdir() must use self.fs (Airflow-attached) not __wrapped__.fs (unauthenticated).""" |
| 274 | + p = ObjectStoragePath("ffs2://my_conn@bucket/new_dir/", conn_id="my_conn") |
| 275 | + p.mkdir(parents=True, exist_ok=True) |
| 276 | + assert fake_fs_with_conn.isdir("bucket/new_dir") |
| 277 | + |
| 278 | + def test_is_dir_uses_authenticated_fs(self, fake_fs_with_conn): |
| 279 | + """is_dir() must use self.fs (Airflow-attached) not __wrapped__.fs (unauthenticated).""" |
| 280 | + fake_fs_with_conn.mkdir("bucket/a_dir") |
| 281 | + p = ObjectStoragePath("ffs2://my_conn@bucket/a_dir", conn_id="my_conn") |
| 282 | + assert p.is_dir() is True |
| 283 | + |
| 284 | + def test_is_file_uses_authenticated_fs(self, fake_fs_with_conn): |
| 285 | + """is_file() must use self.fs (Airflow-attached) not __wrapped__.fs (unauthenticated).""" |
| 286 | + fake_fs_with_conn.touch("bucket/a_file.txt") |
| 287 | + p = ObjectStoragePath("ffs2://my_conn@bucket/a_file.txt", conn_id="my_conn") |
| 288 | + assert p.is_file() is True |
| 289 | + |
| 290 | + def test_touch_uses_authenticated_fs(self, fake_fs_with_conn): |
| 291 | + """touch() must use self.fs (Airflow-attached) not __wrapped__.fs (unauthenticated).""" |
| 292 | + p = ObjectStoragePath("ffs2://my_conn@bucket/touched_file.txt", conn_id="my_conn") |
| 293 | + p.touch() |
| 294 | + assert fake_fs_with_conn.exists("bucket/touched_file.txt") |
| 295 | + |
| 296 | + def test_unlink_uses_authenticated_fs(self, fake_fs_with_conn): |
| 297 | + """unlink() must use self.fs (Airflow-attached) not __wrapped__.fs (unauthenticated).""" |
| 298 | + fake_fs_with_conn.touch("bucket/to_delete.txt") |
| 299 | + p = ObjectStoragePath("ffs2://my_conn@bucket/to_delete.txt", conn_id="my_conn") |
| 300 | + p.unlink() |
| 301 | + assert not fake_fs_with_conn.exists("bucket/to_delete.txt") |
| 302 | + |
| 303 | + def test_rmdir_uses_authenticated_fs(self, fake_fs_with_conn): |
| 304 | + """rmdir() must use self.fs (Airflow-attached) not __wrapped__.fs (unauthenticated).""" |
| 305 | + fake_fs_with_conn.mkdir("bucket/empty_dir") |
| 306 | + p = ObjectStoragePath("ffs2://my_conn@bucket/empty_dir", conn_id="my_conn") |
| 307 | + # upath's rmdir(recursive=False) calls next(self.iterdir()) without a default, |
| 308 | + # which raises StopIteration on empty dirs — a upath bug. Use the default (recursive=True). |
| 309 | + p.rmdir() |
| 310 | + assert not fake_fs_with_conn.exists("bucket/empty_dir") |
| 311 | + |
| 312 | + def test_conn_id_in_uri_works_for_exists(self, fake_fs_with_conn): |
| 313 | + """conn_id embedded in URI (user@host) should also work for exists().""" |
| 314 | + fake_fs_with_conn.touch("bucket/target.txt") |
| 315 | + p = ObjectStoragePath("ffs2://my_conn@bucket/target.txt") |
| 316 | + assert p.conn_id == "my_conn" |
| 317 | + assert p.exists() is True |
| 318 | + |
| 319 | + def test_from_upath_injects_fs_when_no_cache(self, fake_fs_with_conn): |
| 320 | + """_from_upath must inject authenticated fs into a fresh UPath with no _fs_cached.""" |
| 321 | + # Simulate _from_upath called as an instance method with a fresh UPath that has |
| 322 | + # no _fs_cached set (e.g. cwd() / home() or a cross-protocol _from_upath call). |
| 323 | + p_instance = ObjectStoragePath("ffs2://my_conn@bucket/root", conn_id="my_conn") |
| 324 | + fresh_upath = UPath("ffs2://bucket/other") |
| 325 | + assert not hasattr(fresh_upath, "_fs_cached") |
| 326 | + child = p_instance._from_upath(fresh_upath) |
| 327 | + assert child.__wrapped__._fs_cached is fake_fs_with_conn |
| 328 | + |
| 329 | + def test_iterdir_children_use_authenticated_fs(self, fake_fs_with_conn): |
| 330 | + """Children yielded by iterdir() must also carry the authenticated filesystem.""" |
| 331 | + fake_fs_with_conn.touch("bucket/dir/file1.txt") |
| 332 | + fake_fs_with_conn.touch("bucket/dir/file2.txt") |
| 333 | + p = ObjectStoragePath("ffs2://my_conn@bucket/dir", conn_id="my_conn") |
| 334 | + children = list(p.iterdir()) |
| 335 | + assert len(children) == 2 |
| 336 | + # Each child path must use the same authenticated fs, not a fresh unauthenticated one |
| 337 | + assert all(c.__wrapped__._fs_cached is fake_fs_with_conn for c in children) |
| 338 | + |
| 339 | + |
231 | 340 | class TestRemotePath: |
232 | 341 | def test_bucket_key_protocol(self): |
233 | 342 | bucket = "bkt" |
|
0 commit comments