Skip to content

Commit a0344d0

Browse files
jawwad-aliclaude
andcommitted
refactor(presets): extract shared _manifest_declared_template for resolve()/collect_all_layers()
Both methods reimplemented the manifest-entry lookup + authoritative-fallback rules independently — the exact duplication that let them diverge and caused the bug this PR fixes. Extract a single _manifest_declared_template(pack_dir, name, type) -> (entry, candidate) helper (candidate is the declared file only when it is_file(); a declared-but-unusable file returns (entry, None) so callers skip the convention fallback). resolve() and collect_all_layers() now both call it, so their manifest-first resolution cannot silently diverge again. Pure refactor, behavior-preserving: full test_presets.py (331) still passes, including the directory-at-file:, missing-file, and manifest-file-wins cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 74a8b07 commit a0344d0

1 file changed

Lines changed: 58 additions & 52 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 58 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2574,6 +2574,39 @@ def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]:
25742574
self._manifest_cache[key] = None
25752575
return self._manifest_cache[key]
25762576

2577+
def _manifest_declared_template(
2578+
self, pack_dir: Path, template_name: str, template_type: str
2579+
) -> tuple[dict | None, Path | None]:
2580+
"""Resolve a preset's manifest-declared template entry and usable file.
2581+
2582+
Returns ``(entry, candidate)``:
2583+
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
2584+
the manifest is absent or does not list this ``(name, type)``.
2585+
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
2586+
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
2587+
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
2588+
2589+
The manifest is authoritative: when it declares a template (``entry`` is
2590+
not ``None``) but the file is unusable (``candidate`` is ``None``),
2591+
callers must NOT fall back to the convention lookup — that would mask a
2592+
typo or pick up an undeclared file. Shared by ``resolve()`` and
2593+
``collect_all_layers()`` so their manifest-first resolution cannot
2594+
silently diverge again (the divergence this fix addressed).
2595+
"""
2596+
manifest = self._get_manifest(pack_dir)
2597+
if not manifest:
2598+
return None, None
2599+
for tmpl in manifest.templates:
2600+
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
2601+
file_path = tmpl.get("file")
2602+
if file_path:
2603+
manifest_candidate = pack_dir / file_path
2604+
return tmpl, (
2605+
manifest_candidate if manifest_candidate.is_file() else None
2606+
)
2607+
return tmpl, None
2608+
return None, None
2609+
25772610
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
25782611
"""Build unified list of registered and unregistered extensions sorted by priority.
25792612
@@ -2685,32 +2718,17 @@ def resolve(
26852718
# collect_all_layers()/resolve_content() so resolve() and
26862719
# resolve_with_source() agree with them instead of returning
26872720
# the core template (or a stray convention file).
2688-
manifest_file_path = None
2689-
manifest_found_entry = False
2690-
manifest = self._get_manifest(pack_dir)
2691-
if manifest:
2692-
for tmpl in manifest.templates:
2693-
if (tmpl.get("name") == template_name
2694-
and tmpl.get("type") == template_type):
2695-
manifest_file_path = tmpl.get("file")
2696-
manifest_found_entry = True
2697-
break
2698-
if manifest_file_path:
2699-
manifest_candidate = pack_dir / manifest_file_path
2700-
# is_file() (not exists()) so a manifest ``file:`` that points
2701-
# at a directory is treated as missing rather than returned to
2702-
# callers that will read_text() it and crash.
2703-
if manifest_candidate.is_file():
2704-
return manifest_candidate
2705-
# Declared file missing/non-file: skip this pack's convention
2706-
# fallback.
2707-
continue
2708-
if manifest_found_entry:
2709-
# Manifest lists this template but with an empty/falsey
2710-
# ``file`` value (``file`` is a required key per
2711-
# PresetManifest._validate(), so this is a non-usable path,
2712-
# not a truly absent one). Don't fall through to convention
2713-
# — mirrors collect_all_layers().
2721+
entry, manifest_candidate = self._manifest_declared_template(
2722+
pack_dir, template_name, template_type
2723+
)
2724+
if manifest_candidate is not None:
2725+
return manifest_candidate
2726+
if entry is not None:
2727+
# Manifest declares this template but the file is missing,
2728+
# non-file (e.g. a directory), or an empty/falsey ``file``
2729+
# value. The manifest is authoritative, so skip this pack's
2730+
# convention fallback rather than mask a typo — mirrors
2731+
# collect_all_layers().
27142732
continue
27152733
for subdir in subdirs:
27162734
if subdir:
@@ -2979,34 +2997,22 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
29792997
pack_dir = self.presets_dir / pack_id
29802998
# Read strategy and manifest file path from preset manifest
29812999
strategy = "replace"
2982-
manifest_file_path = None
29833000
manifest_has_strategy = False
2984-
manifest_found_entry = False
2985-
manifest = self._get_manifest(pack_dir)
2986-
if manifest:
2987-
for tmpl in manifest.templates:
2988-
if (tmpl.get("name") == template_name
2989-
and tmpl.get("type") == template_type):
2990-
strategy = tmpl.get("strategy", "replace")
2991-
manifest_has_strategy = "strategy" in tmpl
2992-
manifest_file_path = tmpl.get("file")
2993-
manifest_found_entry = True
2994-
break
2995-
# Use manifest file path if specified, otherwise convention-based
2996-
# lookup — but only when the manifest doesn't exist or doesn't
2997-
# list this template, so preset.yml stays authoritative.
3001+
entry, manifest_candidate = self._manifest_declared_template(
3002+
pack_dir, template_name, template_type
3003+
)
3004+
if entry is not None:
3005+
strategy = entry.get("strategy", "replace")
3006+
manifest_has_strategy = "strategy" in entry
3007+
# Use the manifest's declared file when it's a usable regular file;
3008+
# only fall back to convention-based lookup when the manifest
3009+
# doesn't list this template at all, so preset.yml stays
3010+
# authoritative (a declared-but-unusable file skips convention —
3011+
# parity with resolve()).
29983012
candidate = None
2999-
if manifest_file_path:
3000-
manifest_candidate = pack_dir / manifest_file_path
3001-
# is_file() (not exists()): a directory at the declared path is
3002-
# not a usable layer, so treat it as missing (parity with
3003-
# resolve()).
3004-
if manifest_candidate.is_file():
3005-
candidate = manifest_candidate
3006-
# Explicit file path that isn't a regular file: skip convention
3007-
# fallback to avoid masking typos or picking up unintended files.
3008-
elif not manifest_found_entry:
3009-
# Manifest doesn't list this template — check convention paths
3013+
if manifest_candidate is not None:
3014+
candidate = manifest_candidate
3015+
elif entry is None:
30103016
candidate = _find_in_subdirs(pack_dir)
30113017
if candidate:
30123018
# Legacy fallback: if manifest doesn't explicitly declare a

0 commit comments

Comments
 (0)