gwdatafind-server 2.1.0__tar.gz → 2.2.0__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 (84) hide show
  1. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/CHANGELOG.md +9 -0
  2. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/PKG-INFO +1 -1
  3. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/changelog.md +9 -0
  4. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/_version.py +3 -3
  5. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/source/pelican.py +125 -42
  6. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server.egg-info/PKG-INFO +1 -1
  7. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/pyproject.toml +5 -0
  8. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_source_pelican.py +145 -13
  9. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/.gitignore +0 -0
  10. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/.gitlab-ci.yml +0 -0
  11. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/.markdownlint.yml +0 -0
  12. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/Dockerfile +0 -0
  13. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/LICENSE +0 -0
  14. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/MANIFEST.in +0 -0
  15. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/README.md +0 -0
  16. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/config/docker-compose.yml +0 -0
  17. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/config/gunicorn.conf.py +0 -0
  18. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/config/gwdatafind-server.service +0 -0
  19. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/config/gwdatafind-server.toml +0 -0
  20. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/diskcache/Makefile +0 -0
  21. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/diskcache/create_testdata.py +0 -0
  22. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/diskcache/diskcache.rsc +0 -0
  23. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docker-compose.yml +0 -0
  24. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/api/index.md +0 -0
  25. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/api/ldr.md +0 -0
  26. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/api/v1.md +0 -0
  27. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/configuration.md +0 -0
  28. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/deploy/examples/auth.md +0 -0
  29. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/deploy/examples/diskcache.md +0 -0
  30. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/deploy/examples/gwosc.md +0 -0
  31. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/deploy/examples/index.md +0 -0
  32. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/deploy/index.md +0 -0
  33. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/index.md +0 -0
  34. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/overview.md +0 -0
  35. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/docs/requirements.txt +0 -0
  36. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/examples/auth/docker-compose.yml +0 -0
  37. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/examples/auth/gwdatafind-server.toml +0 -0
  38. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/examples/diskcache/docker-compose.yml +0 -0
  39. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/examples/diskcache/gwdatafind-server.toml +0 -0
  40. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/__init__.py +0 -0
  41. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/api/__init__.py +0 -0
  42. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/api/base.py +0 -0
  43. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/api/ldr.py +0 -0
  44. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/api/utils.py +0 -0
  45. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/api/v1.py +0 -0
  46. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/app.py +0 -0
  47. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/auth/__init__.py +0 -0
  48. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/auth/core.py +0 -0
  49. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/auth/scitoken.py +0 -0
  50. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/cache.py +0 -0
  51. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/config.py +0 -0
  52. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/credential/__init__.py +0 -0
  53. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/credential/credential.py +0 -0
  54. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/credential/scitoken.py +0 -0
  55. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/gwdatafind_server.wsgi +0 -0
  56. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/source/__init__.py +0 -0
  57. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/source/diskcache.py +0 -0
  58. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/source/source.py +0 -0
  59. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server/urls.py +0 -0
  60. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server.egg-info/SOURCES.txt +0 -0
  61. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server.egg-info/dependency_links.txt +0 -0
  62. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server.egg-info/requires.txt +0 -0
  63. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/gwdatafind_server.egg-info/top_level.txt +0 -0
  64. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/mkdocs.yml +0 -0
  65. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/setup.cfg +0 -0
  66. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/__init__.py +0 -0
  67. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/cache-0x00ff.dat +0 -0
  68. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/cache-0x0101.dat +0 -0
  69. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/conftest.py +0 -0
  70. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/grid-mapfile +0 -0
  71. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/gwdatafind-server.ini +0 -0
  72. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/gwdatafind-server.toml +0 -0
  73. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_api_v1.py +0 -0
  74. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_app.py +0 -0
  75. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_auth_override.py +0 -0
  76. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_auth_scitoken.py +0 -0
  77. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_cache.py +0 -0
  78. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_config.py +0 -0
  79. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_credential.py +0 -0
  80. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_credential_scitoken.py +0 -0
  81. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_source.py +0 -0
  82. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_source_diskcache.py +0 -0
  83. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/test_urls.py +0 -0
  84. {gwdatafind_server-2.1.0 → gwdatafind_server-2.2.0}/tests/utils.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 - 2025-04-09 { #2.2.0 }
4
+
5
+ - Fix directory recursion with Pelican memory cache
6
+ ([!129](https://git.ligo.org/computing/gwdatafind/server/-/merge_requests/129))
7
+ - Add Pelican cache versioning and compatibility checks
8
+ ([!130](https://git.ligo.org/computing/gwdatafind/server/-/merge_requests/130))
9
+
10
+ [Full details](https://git.ligo.org/computing/gwdatafind/server/-/releases/2.2.0)
11
+
3
12
  ## 2.1.0 - 2025-03-26 { #2.1.0 }
4
13
 
5
14
  - Add Pelican database cache file
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gwdatafind-server
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: The GWDataFind data discover server application
5
5
  Author-email: Duncan Macleod <duncan.macleod@ligo.org>, Duncan Meacher <duncan.meacher@ligo.org>
6
6
  License: GPL-3.0-or-later
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.2.0 - 2025-04-09 { #2.2.0 }
4
+
5
+ - Fix directory recursion with Pelican memory cache
6
+ ([!129](https://git.ligo.org/computing/gwdatafind/server/-/merge_requests/129))
7
+ - Add Pelican cache versioning and compatibility checks
8
+ ([!130](https://git.ligo.org/computing/gwdatafind/server/-/merge_requests/130))
9
+
10
+ [Full details](https://git.ligo.org/computing/gwdatafind/server/-/releases/2.2.0)
11
+
3
12
  ## 2.1.0 - 2025-03-26 { #2.1.0 }
4
13
 
5
14
  - Add Pelican database cache file
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.1.0'
22
- __version_tuple__ = version_tuple = (2, 1, 0)
21
+ __version__ = version = '2.2.0'
22
+ __version_tuple__ = version_tuple = (2, 2, 0)
23
23
 
24
- __commit_id__ = commit_id = 'gca848aa98'
24
+ __commit_id__ = commit_id = 'gc287e7f56'
@@ -22,6 +22,7 @@ from typing import (
22
22
  cast,
23
23
  )
24
24
 
25
+ from .. import __version__ as GWDATAFIND_SERVER_VERSION
25
26
  from ..cache import CacheEntry
26
27
  from .source import (
27
28
  Source,
@@ -67,8 +68,17 @@ class PelicanCache:
67
68
  back to SQLite at the end of each update cycle.
68
69
  """
69
70
 
71
+ #: Version of the Pelican cache DB schema
70
72
  SCHEMA_VERSION = 1
71
73
 
74
+ #: Token to identify compatible cache files
75
+ #:
76
+ #: This should be incremented if changes are made to the way the cache is
77
+ #: constructed or used that would make old cache files incompatible,
78
+ #: even if the schema version is unchanged.
79
+ #: This allows the server to force a refresh of the cache if needed.
80
+ COMPATIBILITY_TOKEN = "pelican-cache-v1" # noqa: S105
81
+
72
82
  def __init__(self, cache_file: str | Path):
73
83
  """Initialize the Pelican cache.
74
84
 
@@ -86,9 +96,19 @@ class PelicanCache:
86
96
  # Create directory if it doesn't exist
87
97
  self.cache_file.parent.mkdir(parents=True, exist_ok=True)
88
98
 
99
+ if self.cache_file.exists():
100
+ compatible, reason = self._is_existing_cache_compatible()
101
+ if not compatible:
102
+ self._invalidate_cache(reason)
103
+
89
104
  with self._get_connection() as conn:
90
- # Create tables
91
- conn.executescript("""
105
+ self._create_schema(conn)
106
+ self._write_metadata(conn)
107
+ conn.commit()
108
+
109
+ def _create_schema(self, conn: sqlite3.Connection) -> None:
110
+ """Create cache tables and indexes if they don't already exist."""
111
+ conn.executescript("""
92
112
  CREATE TABLE IF NOT EXISTS directories (
93
113
  path TEXT PRIMARY KEY,
94
114
  last_modified TEXT,
@@ -113,28 +133,72 @@ class PelicanCache:
113
133
  ON urls(directory_path);
114
134
  CREATE INDEX IF NOT EXISTS idx_directories_path
115
135
  ON directories(path);
116
- """)
136
+ """)
137
+
138
+ def _read_metadata(self, conn: sqlite3.Connection) -> dict[str, str]:
139
+ """Read all metadata keys from the database."""
140
+ cursor = conn.execute("SELECT key, value FROM cache_metadata")
141
+ return dict(cursor.fetchall())
142
+
143
+ def _write_metadata(self, conn: sqlite3.Connection) -> None:
144
+ """Write metadata required to validate cache compatibility."""
145
+ conn.executemany(
146
+ """
147
+ INSERT INTO cache_metadata (key, value)
148
+ VALUES (?, ?)
149
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value
150
+ """,
151
+ [
152
+ ("schema_version", str(self.SCHEMA_VERSION)),
153
+ ("compatibility_token", self.COMPATIBILITY_TOKEN),
154
+ ],
155
+ )
156
+ conn.execute(
157
+ "INSERT OR IGNORE INTO cache_metadata (key, value) VALUES (?, ?)",
158
+ ("created_by", GWDATAFIND_SERVER_VERSION),
159
+ )
160
+
161
+ def _is_existing_cache_compatible(self) -> tuple[bool, str]:
162
+ """Check whether an existing cache file can be used safely."""
163
+ try:
164
+ with self._get_connection() as conn:
165
+ metadata = self._read_metadata(conn)
166
+ except sqlite3.Error as exc:
167
+ return False, f"failed to read metadata: {exc}"
168
+
169
+ raw_schema_version = metadata.get("schema_version")
170
+ if raw_schema_version is None:
171
+ return False, "missing schema_version metadata"
117
172
 
118
- # Set/check schema version
119
- cursor = conn.execute(
120
- "SELECT value FROM cache_metadata WHERE key = ?",
121
- ("schema_version",),
173
+ try:
174
+ schema_version = int(raw_schema_version)
175
+ except ValueError:
176
+ return False, f"invalid schema_version value: {raw_schema_version}"
177
+
178
+ if schema_version != self.SCHEMA_VERSION:
179
+ return False, (
180
+ "schema_version mismatch "
181
+ f"(expected={self.SCHEMA_VERSION}, found={schema_version})"
122
182
  )
123
- result = cursor.fetchone()
124
183
 
125
- if result is None:
126
- # First time setup
127
- conn.execute(
128
- "INSERT INTO cache_metadata (key, value) VALUES (?, ?)",
129
- ("schema_version", str(self.SCHEMA_VERSION)),
130
- )
131
- elif int(result[0]) != self.SCHEMA_VERSION:
132
- # Schema migration would go here in future versions
133
- self.logger.warning(
134
- "Cache schema version mismatch. Expected %d, got %s",
135
- self.SCHEMA_VERSION,
136
- result[0],
137
- )
184
+ compatibility_token = metadata.get("compatibility_token")
185
+ if compatibility_token != self.COMPATIBILITY_TOKEN:
186
+ return False, (
187
+ "compatibility_token mismatch "
188
+ f"(expected={self.COMPATIBILITY_TOKEN}, "
189
+ f"found={compatibility_token})"
190
+ )
191
+
192
+ return True, ""
193
+
194
+ def _invalidate_cache(self, reason: str) -> None:
195
+ """Invalidate an incompatible cache database by deleting it."""
196
+ self.cache_file.unlink()
197
+ self.logger.warning(
198
+ "Invalidated incompatible Pelican cache %s (%s)",
199
+ self.cache_file,
200
+ reason,
201
+ )
138
202
 
139
203
  @contextmanager
140
204
  def _get_connection(self) -> Generator[sqlite3.Connection, None, None]:
@@ -400,6 +464,7 @@ class PelicanSource(Source):
400
464
  )
401
465
 
402
466
  cache: dict[str, list[str]] = defaultdict(list)
467
+ unchanged_dirs: set[str] = set()
403
468
 
404
469
  # Walk the tree for URLs
405
470
  try:
@@ -416,27 +481,36 @@ class PelicanSource(Source):
416
481
  subdirs = cast("dict[str, PelicanInfo]", subdirs)
417
482
  files = cast("dict[str, PelicanInfo]", files)
418
483
 
419
- # parse files
420
- for info in files.values():
421
- filename = info["name"]
422
- url = f"{pelican_url}{filename}"
423
- cache[name].append(url)
424
- yield url
484
+ if name in unchanged_dirs:
485
+ cached_urls = self._cache[name]
486
+ cache[name] = cached_urls
487
+ yield from cached_urls
488
+ else:
489
+ # Parse files for this directory when not marked unchanged
490
+ for info in files.values():
491
+ filename = info["name"]
492
+ url = f"{pelican_url}{filename}"
493
+ cache[name].append(url)
494
+ yield url
425
495
 
426
- yield from self._walk_dirnames(subdirs, exclude, cache)
496
+ self._check_subdirs(subdirs, exclude, unchanged_dirs)
427
497
 
428
498
  # Update memory cache
429
499
  self._cache = cache
430
500
 
431
- def _walk_dirnames(
501
+ def _check_subdirs(
432
502
  self,
433
503
  dirnames: dict[str, PelicanInfo],
434
504
  exclude: Iterable[re.Pattern],
435
- cache: dict[str, list[str]],
436
- ) -> Iterator[str]:
437
- """Walk through the dirnames in a Pelican directory walk.
505
+ unchanged_dirs: set[str],
506
+ ) -> None:
507
+ """Check subdirectories in a Pelican directory walk.
508
+
509
+ This method attemps to optimise the outer walk() by checking
510
+ each subdirectory for exclusion patterns, emptiness,
511
+ and whether it is unchanged based on modification time.
438
512
 
439
- Note that ``dirnames`` and ``cache`` are modified in place.
513
+ Note that ``dirnames`` and ``unchanged_dirs`` are modified in place.
440
514
  """
441
515
  # NOTE: need list() here to get a static copy of the subdirs, so we can
442
516
  # modify the original dict while iterating
@@ -456,25 +530,34 @@ class PelicanSource(Source):
456
530
  # if subdir is empty, remove it
457
531
  if info["size"] == 0:
458
532
  del dirnames[path]
533
+ self.logger.debug(
534
+ "[%s] Skipping empty subdirectory: %s",
535
+ self.name,
536
+ name,
537
+ )
459
538
  continue
460
539
 
461
540
  mod = info.get("modified")
462
541
  # Check if directory is current in memory cache
463
- if (
464
- name in self._cache
465
- and name in self._cache_modified
466
- and mod == self._cache_modified[name] # unchanged
467
- ):
468
- # Directory is current - yield from cache
469
- yield from self._cache[name]
470
- cache[name] = self._cache[name]
471
- del dirnames[path]
542
+ if self._is_unchanged(name, mod):
543
+ unchanged_dirs.add(name)
472
544
  continue
473
545
 
474
546
  # Store the current modification time for this directory
475
547
  if mod is not None:
476
548
  self._cache_modified[name] = mod
477
549
 
550
+ def _is_unchanged(self, name: str, mod: datetime | None) -> bool:
551
+ """Check if a directory is unchanged based on modification time."""
552
+ return (
553
+ # has been cached before
554
+ name in self._cache
555
+ # reports its own modification time
556
+ and mod is not None
557
+ # and the modification time matches the cached one
558
+ and self._cache_modified.get(name) == mod
559
+ )
560
+
478
561
  def _iter_cache(self) -> Iterator[str]:
479
562
  for dirurls in self._cache.values():
480
563
  yield from dirurls
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gwdatafind-server
3
- Version: 2.1.0
3
+ Version: 2.2.0
4
4
  Summary: The GWDataFind data discover server application
5
5
  Author-email: Duncan Macleod <duncan.macleod@ligo.org>, Duncan Meacher <duncan.meacher@ligo.org>
6
6
  License: GPL-3.0-or-later
@@ -166,6 +166,11 @@ force-wrap-aliases = true
166
166
  "INP001", # implicit namespace package
167
167
  ]
168
168
 
169
+ [tool.ruff.lint.pep8-naming]
170
+ extend-ignore-names = [
171
+ "__version__", # allow __version__ to be renamed on import
172
+ ]
173
+
169
174
  # -- setuptools
170
175
 
171
176
  [tool.setuptools]
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import re
9
9
  import sqlite3
10
+ from contextlib import closing
10
11
  from datetime import (
11
12
  UTC,
12
13
  datetime,
@@ -16,6 +17,7 @@ from unittest.mock import patch
16
17
 
17
18
  import pytest
18
19
 
20
+ from gwdatafind_server import __version__ as GWDATAFIND_SERVER_VERSION
19
21
  from gwdatafind_server.cache import (
20
22
  T010150SourceCache,
21
23
  )
@@ -72,6 +74,12 @@ def test_parse_federation_url(input_url: str, expected_output: str) -> None:
72
74
  class TestPelicanCache:
73
75
  """Test suite for PelicanCache class."""
74
76
 
77
+ @staticmethod
78
+ def _read_metadata(cache_file: Path) -> dict[str, str]:
79
+ with closing(sqlite3.connect(cache_file)) as conn:
80
+ cursor = conn.execute("SELECT key, value FROM cache_metadata")
81
+ return dict(cursor.fetchall())
82
+
75
83
  @pytest.fixture
76
84
  def cache_file(self, tmp_path: Path) -> Path:
77
85
  """Temporary cache file path."""
@@ -104,6 +112,17 @@ class TestPelicanCache:
104
112
  assert url_cache == {}
105
113
  assert modification_times == {}
106
114
 
115
+ def test_init_writes_metadata(self, cache_file: Path):
116
+ """Test cache initialization writes required metadata keys."""
117
+ PelicanCache(cache_file)
118
+ metadata = self._read_metadata(cache_file)
119
+
120
+ assert metadata["schema_version"] == str(PelicanCache.SCHEMA_VERSION)
121
+ assert (
122
+ metadata["compatibility_token"] == PelicanCache.COMPATIBILITY_TOKEN
123
+ )
124
+ assert metadata["created_by"] == GWDATAFIND_SERVER_VERSION
125
+
107
126
  def test_save_and_load_cache(self, pelican_cache: PelicanCache):
108
127
  """Test saving and loading cache data."""
109
128
  # Prepare test data
@@ -149,6 +168,51 @@ class TestPelicanCache:
149
168
  assert loaded_cache == url_cache
150
169
  assert loaded_times == {}
151
170
 
171
+ def test_init_invalidates_mismatched_schema_version(self, cache_file: Path):
172
+ """Test incompatible schema version causes automatic cache invalidation."""
173
+ cache = PelicanCache(cache_file)
174
+ cache.save(
175
+ {"/test/dir": ["pelican://osg-htc.org/gwdata/file.gwf"]},
176
+ {"/test/dir": datetime.now(UTC)},
177
+ )
178
+
179
+ with closing(sqlite3.connect(cache_file)) as conn:
180
+ conn.execute(
181
+ "UPDATE cache_metadata SET value = ? WHERE key = ?",
182
+ ("0", "schema_version"),
183
+ )
184
+ conn.commit()
185
+
186
+ reset_cache = PelicanCache(cache_file)
187
+ loaded_cache, loaded_times = reset_cache.load()
188
+ metadata = self._read_metadata(cache_file)
189
+
190
+ assert loaded_cache == {}
191
+ assert loaded_times == {}
192
+ assert metadata["schema_version"] == str(PelicanCache.SCHEMA_VERSION)
193
+ assert (
194
+ metadata["compatibility_token"] == PelicanCache.COMPATIBILITY_TOKEN
195
+ )
196
+
197
+ def test_init_invalidates_missing_compatibility_token(self, cache_file: Path):
198
+ """Test missing compatibility token causes automatic cache invalidation."""
199
+ PelicanCache(cache_file)
200
+
201
+ with closing(sqlite3.connect(cache_file)) as conn:
202
+ conn.execute(
203
+ "DELETE FROM cache_metadata WHERE key = ?",
204
+ ("compatibility_token",),
205
+ )
206
+ conn.commit()
207
+
208
+ PelicanCache(cache_file)
209
+ metadata = self._read_metadata(cache_file)
210
+
211
+ assert metadata["schema_version"] == str(PelicanCache.SCHEMA_VERSION)
212
+ assert (
213
+ metadata["compatibility_token"] == PelicanCache.COMPATIBILITY_TOKEN
214
+ )
215
+
152
216
  @patch(
153
217
  "sqlite3.connect",
154
218
  side_effect=sqlite3.OperationalError("mocked failure"),
@@ -272,8 +336,8 @@ class TestPelicanSource(BaseTestSource):
272
336
  """Test _exclude_dir with empty patterns list."""
273
337
  assert not PelicanSource._exclude_dir("any_file", []) # noqa: SLF001
274
338
 
275
- def test_walk_dirnames(self, basic_config: PelicanSourceConfig):
276
- """Test _walk_dirnames method."""
339
+ def test_check_subdirs(self, basic_config: PelicanSourceConfig):
340
+ """Test _check_subdirs method."""
277
341
  source = PelicanSource(basic_config)
278
342
 
279
343
  # Set up test data
@@ -299,21 +363,21 @@ class TestPelicanSource(BaseTestSource):
299
363
  }
300
364
 
301
365
  exclude_patterns = [re.compile(r".*debug.*")]
302
- cache = {}
366
+ unchanged_dirs: set[str] = set()
303
367
 
304
368
  # Test the method - don't need to keep return value
305
- list(source._walk_dirnames(dirnames, exclude_patterns, cache)) # noqa: SLF001
369
+ source._check_subdirs(dirnames, exclude_patterns, unchanged_dirs) # noqa: SLF001
306
370
 
307
371
  # Verify empty and excluded directories are removed
308
372
  assert "/gwdata/O4/tmp" not in dirnames # Empty directory removed
309
373
  assert "/gwdata/O4/debug" not in dirnames # Excluded directory removed
310
374
  assert "/gwdata/O4/strain" in dirnames # Valid directory remains
311
375
 
312
- def test_walk_dirnames_with_cache_hit(
376
+ def test_check_subdirs_with_cache_hit(
313
377
  self,
314
378
  basic_config: PelicanSourceConfig,
315
379
  ):
316
- """Test _walk_dirnames with cache hit."""
380
+ """Test _check_subdirs marks unchanged directories for cache use."""
317
381
  source = PelicanSource(basic_config)
318
382
 
319
383
  # Set up existing cache
@@ -335,16 +399,84 @@ class TestPelicanSource(BaseTestSource):
335
399
  },
336
400
  }
337
401
 
338
- cache = {}
402
+ unchanged_dirs: set[str] = set()
339
403
 
340
404
  # Test the method
341
- urls = list(source._walk_dirnames(dirnames, [], cache)) # noqa: SLF001
405
+ source._check_subdirs(dirnames, [], unchanged_dirs) # noqa: SLF001
406
+
407
+ # Verify directory is marked unchanged and retained for traversal
408
+ assert "/gwdata/O4/strain" in unchanged_dirs
409
+ assert "/gwdata/O4/strain" in dirnames
410
+
411
+ def test_query_pelican_uses_cache_without_skipping_descendants(
412
+ self,
413
+ basic_config: PelicanSourceConfig,
414
+ ):
415
+ """Test unchanged directories use cache while descendants are still walked."""
416
+ source = PelicanSource(basic_config)
417
+
418
+ mod_time = datetime(2024, 1, 1, tzinfo=UTC)
419
+ new_mod = datetime(2024, 1, 2, tzinfo=UTC)
420
+ parent_cached_url = "pelican://osg-htc.org/gwdata/O4/parent.gwf"
421
+ source._cache = { # noqa: SLF001
422
+ "/gwdata/O4": [parent_cached_url],
423
+ }
424
+ source._cache_modified = { # noqa: SLF001
425
+ "/gwdata/O4": mod_time,
426
+ }
427
+
428
+ parent_subdirs: dict[str, PelicanInfo] = {
429
+ "/gwdata/O4/new": {
430
+ "name": "/gwdata/O4/new/",
431
+ "size": 1024,
432
+ "type": "directory",
433
+ "modified": new_mod,
434
+ },
435
+ }
436
+ parent_files: dict[str, PelicanInfo] = {
437
+ "parent.gwf": {
438
+ "name": "/gwdata/O4/parent.gwf",
439
+ "size": 128,
440
+ "type": "file",
441
+ "modified": mod_time,
442
+ },
443
+ }
444
+ child_files: dict[str, PelicanInfo] = {
445
+ "child.gwf": {
446
+ "name": "/gwdata/O4/new/child.gwf",
447
+ "size": 256,
448
+ "type": "file",
449
+ "modified": new_mod,
450
+ },
451
+ }
452
+
453
+ walk_entries = [
454
+ ("/gwdata/O4", parent_subdirs, parent_files),
455
+ ("/gwdata/O4/new", {}, child_files),
456
+ ]
457
+
458
+ with patch("pelicanfs.core.PelicanFileSystem") as mock_pelfs_init:
459
+ mock_pelfs = mock_pelfs_init.return_value
460
+ mock_pelfs.discovery_url = "pelican://osg-htc.org"
461
+ mock_pelfs.dircache.use_listings_cache = True
462
+ mock_pelfs.http_file_system.dircache.use_listings_cache = True
463
+ mock_pelfs.fastwalk.return_value = iter(walk_entries)
464
+
465
+ urls = list(
466
+ source.query_pelican(
467
+ source.federation_url,
468
+ source.path,
469
+ ),
470
+ )
471
+
472
+ child_url = "pelican://osg-htc.org/gwdata/O4/new/child.gwf"
473
+
474
+ # Parent comes from cache exactly once; child is discovered by traversal
475
+ assert urls == [parent_cached_url, child_url]
342
476
 
343
- # Verify cache was used
344
- assert urls == ["cached_url1", "cached_url2"]
345
- # No trailing slash in cache key
346
- assert cache["/gwdata/O4/strain"] == ["cached_url1", "cached_url2"]
347
- assert "/gwdata/O4/strain" not in dirnames # Directory removed after cache hit
477
+ # Cache is refreshed with both parent and child directories
478
+ assert source._cache["/gwdata/O4"] == [parent_cached_url] # noqa: SLF001
479
+ assert source._cache["/gwdata/O4/new"] == [child_url] # noqa: SLF001
348
480
 
349
481
  def test_run_method_basic(
350
482
  self,