Skip to content

Commit f61921b

Browse files
committed
fix(deployment): exclude self-name from tier detection
`detect_tier` used a substring match on Cargo.toml / pyproject.toml to decide whether the repo depends on hyperi-rustlib / hyperi-pylib. That also matched the library's own `name = "hyperi-rustlib"` declaration, so the library's own repo got dispatched as a Tier 1 / Tier 2 consumer and the Generate stage failed with "no Rust binary found". `_depends_on` now extracts the manifest's own package name from [package] / [project] / [tool.poetry] and returns False on self-match, so library repos correctly land in Tier.NONE. Consumer projects whose own name shares a prefix (e.g. "hyperi-rustlib-extras") are unaffected because the check is exact-equals, not substring. Generic fix — applies to any *lib repo (hyperi-rustlib, hyperi-pylib, future hyperi-golib) and to consumer projects with similar prefixes (dfe-* etc.). 9 new tests in TestSelfMatchExclusion cover the cases: own-name-only, dev-deps loop, poetry section, single-quoted, real consumer, prefix-collision, inline comment, and out-of-section name. Publish: true
1 parent 895a4a6 commit f61921b

2 files changed

Lines changed: 172 additions & 7 deletions

File tree

src/hyperi_ci/deployment/detect.py

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,25 +94,91 @@ def _depends_on(manifest: Path, package_name: str) -> bool:
9494
dependencies = ["hyperi-pylib>=2.24"]
9595
dependencies = ["hyperi-pylib[metrics]>=2.24"]
9696
97-
Doesn't try to parse TOML because:
97+
**Self-match exclusion.** If the manifest declares its own package
98+
name as ``package_name`` (i.e. this manifest IS the library, not a
99+
consumer of it), returns False. Without this, the library's own
100+
repo gets misdispatched as a Tier 1/2 consumer and the
101+
deployment-artefact producer fails with "no Rust binary found" /
102+
equivalent. The check is generic — applies to any rustlib /
103+
pylib / future *lib and to consumer projects whose own name
104+
happens to share a prefix.
105+
106+
Doesn't try to parse TOML beyond pulling the ``name`` field out of
107+
a recognised top-level section because:
108+
98109
1. Avoids hauling `tomllib` in just for tier detection.
99110
2. Catches every form (workspace inheritance, extras, comments)
100111
that a stricter parse would have to handle case by case.
101-
3. False positives only if the package name appears in another
102-
context (e.g., a comment mentioning it) — acceptable, since
103-
the consequence is "we try to invoke generate-artefacts and
104-
the binary fails clearly" rather than silent miscategorisation.
112+
3. False positives in the dep-match are bounded: the consequence
113+
is "we try to invoke generate-artefacts and the binary fails
114+
clearly" rather than silent miscategorisation.
105115
106116
Args:
107117
manifest: Path to Cargo.toml or pyproject.toml.
108118
package_name: Dependency name to look for.
109119
110120
Returns:
111-
True if the substring appears in the manifest's text.
121+
True if the substring appears AND the manifest isn't itself
122+
the named package; False otherwise.
112123
113124
"""
114125
try:
115126
text = manifest.read_text(encoding="utf-8", errors="replace")
116127
except OSError:
117128
return False
118-
return package_name in text
129+
if package_name not in text:
130+
return False
131+
return _manifest_self_name(text) != package_name
132+
133+
134+
# Top-level tables whose ``name`` field is the manifest's own package
135+
# name. Listed in scan precedence — the first match wins, so a Cargo
136+
# manifest's ``[package] name`` beats any later table (unlikely in
137+
# Cargo, but pyproject.toml can legitimately have both ``[project]``
138+
# and ``[tool.poetry]`` and we treat them equivalently).
139+
_SELF_NAME_SECTIONS: frozenset[str] = frozenset(
140+
{"[package]", "[project]", "[tool.poetry]"}
141+
)
142+
143+
144+
def _manifest_self_name(text: str) -> str | None:
145+
"""Extract the manifest's own package name, if declared.
146+
147+
Scans for a ``name = "..."`` (or single-quoted) line inside one of
148+
the recognised self-name sections (:data:`_SELF_NAME_SECTIONS`).
149+
Returns the first match. No TOML parser — line-scoped, tolerant of
150+
extra whitespace around ``=``.
151+
152+
Args:
153+
text: Full manifest text.
154+
155+
Returns:
156+
The declared package name, or ``None`` if no recognised
157+
declaration is found.
158+
159+
"""
160+
current_section: str | None = None
161+
for raw in text.splitlines():
162+
stripped = raw.strip()
163+
if stripped.startswith("[") and stripped.endswith("]"):
164+
current_section = stripped
165+
continue
166+
if current_section not in _SELF_NAME_SECTIONS:
167+
continue
168+
# Cheap pre-filter: skip lines that don't even start with "name".
169+
if not stripped.startswith("name"):
170+
continue
171+
eq = stripped.find("=")
172+
if eq < 0:
173+
continue
174+
# Confirm the LHS is exactly "name" (avoids matching "name-foo").
175+
lhs = stripped[:eq].strip()
176+
if lhs != "name":
177+
continue
178+
rhs = stripped[eq + 1 :].strip()
179+
# Strip a trailing inline comment if present.
180+
if "#" in rhs:
181+
rhs = rhs[: rhs.index("#")].strip()
182+
if len(rhs) >= 2 and rhs[0] in {'"', "'"} and rhs[-1] == rhs[0]:
183+
return rhs[1:-1]
184+
return None

tests/unit/deployment/test_detect_tier.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,105 @@ def test_python_wins_over_other(self, tmp_path: Path) -> None:
120120
assert detect_tier(tmp_path) == Tier.PYTHON
121121

122122

123+
class TestSelfMatchExclusion:
124+
"""A library's own repo is NOT a Tier 1/2 consumer of itself.
125+
126+
These tests cover the case where a library's Cargo.toml /
127+
pyproject.toml has its own package name (e.g. `hyperi-rustlib`)
128+
in the `[package]` / `[project]` table. The substring match would
129+
otherwise misdetect the library as a consumer and dispatch the
130+
Tier 1/2 producer, which then fails with "no binary found".
131+
"""
132+
133+
def test_hyperi_rustlib_own_repo_is_not_rust(self, tmp_path: Path) -> None:
134+
# The library's own Cargo.toml has `name = "hyperi-rustlib"` in
135+
# [package] but no `hyperi-rustlib = ...` dep line.
136+
(tmp_path / "Cargo.toml").write_text(
137+
'[package]\nname = "hyperi-rustlib"\nversion = "2.7.0"\n',
138+
encoding="utf-8",
139+
)
140+
assert detect_tier(tmp_path) == Tier.NONE
141+
142+
def test_hyperi_rustlib_with_self_in_dev_deps_still_excluded(
143+
self, tmp_path: Path
144+
) -> None:
145+
# Hypothetical: library lists its own name in [dev-dependencies]
146+
# for an example-binary workspace pattern. Still not a consumer.
147+
(tmp_path / "Cargo.toml").write_text(
148+
'[package]\nname = "hyperi-rustlib"\nversion = "2.7.0"\n'
149+
'[dev-dependencies]\nhyperi-rustlib = { path = "." }\n',
150+
encoding="utf-8",
151+
)
152+
assert detect_tier(tmp_path) == Tier.NONE
153+
154+
def test_hyperi_pylib_own_repo_is_not_python(self, tmp_path: Path) -> None:
155+
(tmp_path / "pyproject.toml").write_text(
156+
'[project]\nname = "hyperi-pylib"\nversion = "2.24.0"\n',
157+
encoding="utf-8",
158+
)
159+
assert detect_tier(tmp_path) == Tier.NONE
160+
161+
def test_hyperi_pylib_poetry_section_also_excluded(self, tmp_path: Path) -> None:
162+
# Poetry-managed projects use [tool.poetry] instead of [project].
163+
(tmp_path / "pyproject.toml").write_text(
164+
'[tool.poetry]\nname = "hyperi-pylib"\nversion = "2.24.0"\n',
165+
encoding="utf-8",
166+
)
167+
assert detect_tier(tmp_path) == Tier.NONE
168+
169+
def test_single_quoted_name_excluded(self, tmp_path: Path) -> None:
170+
# TOML allows single-quoted strings — must still match.
171+
(tmp_path / "Cargo.toml").write_text(
172+
"[package]\nname = 'hyperi-rustlib'\nversion = '2.7.0'\n",
173+
encoding="utf-8",
174+
)
175+
assert detect_tier(tmp_path) == Tier.NONE
176+
177+
def test_consumer_with_real_dep_still_detected(self, tmp_path: Path) -> None:
178+
# A real consumer has its own name AND lists the library as a dep.
179+
# Self-match exclusion must not break this case.
180+
(tmp_path / "Cargo.toml").write_text(
181+
'[package]\nname = "dfe-loader"\n[dependencies]\nhyperi-rustlib = "2.5"\n',
182+
encoding="utf-8",
183+
)
184+
assert detect_tier(tmp_path) == Tier.RUST
185+
186+
def test_consumer_with_similar_prefix_not_misdetected(self, tmp_path: Path) -> None:
187+
# `name = "hyperi-rustlib-extras"` should NOT count as self-match
188+
# for `hyperi-rustlib` because the full string differs. This
189+
# consumer DOES depend on hyperi-rustlib.
190+
(tmp_path / "Cargo.toml").write_text(
191+
'[package]\nname = "hyperi-rustlib-extras"\n'
192+
'[dependencies]\nhyperi-rustlib = "2.5"\n',
193+
encoding="utf-8",
194+
)
195+
assert detect_tier(tmp_path) == Tier.RUST
196+
197+
def test_name_with_trailing_comment_handled(self, tmp_path: Path) -> None:
198+
# Inline comments after the name field shouldn't break parsing.
199+
(tmp_path / "Cargo.toml").write_text(
200+
'[package]\nname = "hyperi-rustlib" # the library itself\n',
201+
encoding="utf-8",
202+
)
203+
assert detect_tier(tmp_path) == Tier.NONE
204+
205+
def test_name_outside_recognised_section_not_self_match(
206+
self, tmp_path: Path
207+
) -> None:
208+
# A `name = "hyperi-rustlib"` in some other table (e.g.
209+
# [features.something]) shouldn't count as the manifest's own
210+
# name. We only treat [package] / [project] / [tool.poetry] as
211+
# self-name sections.
212+
(tmp_path / "Cargo.toml").write_text(
213+
'[package]\nname = "consumer-app"\n'
214+
"[features.weird]\n"
215+
'name = "hyperi-rustlib"\n'
216+
'[dependencies]\nhyperi-rustlib = "2.5"\n',
217+
encoding="utf-8",
218+
)
219+
assert detect_tier(tmp_path) == Tier.RUST
220+
221+
123222
class TestTierEnum:
124223
"""`Tier` enum has the four values expected by callers."""
125224

0 commit comments

Comments
 (0)