From d0c987131764423e6d0b388c16bf8b82c79d34c4 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Sat, 10 Jan 2026 03:55:41 +0100 Subject: [PATCH 001/337] spack find: group pre Spack v1.0 specs together by compiler (#51814) Since #50909 the `spack find` command has been displaying specfile formats < 5 under the "no compilers" group. This was likely due to the incomplete information we have on language / compiler associations for specs using these old formats. This PR refines how we display these specs and uses the annotated compiler spec _and_ the specfile format to group them. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/spec.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 26929412d2d30a..3ad0da150fadc9 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -4508,6 +4508,11 @@ def _short_spec(self, color: Optional[bool] = False) -> str: @property def compilers(self): + if self.original_spec_format() < 5: + # These specs don't have compilers as dependencies, return the + # specfile format and compiler + return f"[specfile v{self.original_spec_format()}] {self.compiler}" + # TODO: get rid of the space here and make formatting smarter return " " + self._format_dependencies( "{name}{@version}", From 505097ea34cef5a98d7df3ac2756e69d1fb5e189 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sun, 11 Jan 2026 06:11:37 +0100 Subject: [PATCH 002/337] version_types.py: cache `ClosedOpenRange` `__str__` and `__hash__` (#51832) * version_types.py: cache ClosedOpenRange str/hash Since ClosedOpenRange is immutable we can cache its string representation and hash value. That saves the expensive `_prev_version` call. Signed-off-by: Harmen Stoppels * add a test Signed-off-by: Harmen Stoppels --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/test/versions.py | 17 +++++++++ lib/spack/spack/version/version_types.py | 44 +++++++++++++++++------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/lib/spack/spack/test/versions.py b/lib/spack/spack/test/versions.py index af103aac7d4ef8..1f9d73fb75d4dc 100644 --- a/lib/spack/spack/test/versions.py +++ b/lib/spack/spack/test/versions.py @@ -17,6 +17,7 @@ import spack.version from spack.llnl.util.filesystem import working_dir from spack.version import ( + ClosedOpenRange, EmptyRangeError, GitVersion, StandardVersion, @@ -598,6 +599,22 @@ def check_repr_and_str(vrs): check_repr_and_str("R2016a.2-3_4") +def test_str_and_hash_version_range(): + """Test that precomputed string and hash values are consistent with computed ones.""" + x = ver("1.2:3.4") + assert isinstance(x, ClosedOpenRange) + # Test that precomputed str() and hash() are assigned + assert x._string is not None and x._hash is not None + _str = str(x) + _hash = hash(x) + assert x._string == _str and x._hash == _hash + # Ensure computed values match precomputed ones + x._string = None + x._hash = None + assert _str == str(x) + assert _hash == hash(x) + + @pytest.mark.parametrize( "version_str", ["1.2string3", "1.2-3xyz_4-alpha.5", "1.2beta", "1_x_rc-4"] ) diff --git a/lib/spack/spack/version/version_types.py b/lib/spack/spack/version/version_types.py index aea3aaadbdc48e..593eaa264013b8 100644 --- a/lib/spack/spack/version/version_types.py +++ b/lib/spack/spack/version/version_types.py @@ -773,39 +773,59 @@ def up_to(self, index) -> StandardVersion: return self.ref_version.up_to(index) +def _str_range(lo: StandardVersion, hi: StandardVersion) -> str: + """Create a string representation from lo:hi range.""" + if lo == _STANDARD_VERSION_TYPEMIN: + if hi == _STANDARD_VERSION_TYPEMAX: + return ":" + else: + return f":{hi}" + elif hi == _STANDARD_VERSION_TYPEMAX: + return f"{lo}:" + elif lo == hi: + return str(lo) + else: + return f"{lo}:{hi}" + + class ClosedOpenRange(VersionType): - __slots__ = ("lo", "hi") + __slots__ = ("lo", "hi", "_string", "_hash") def __init__(self, lo: StandardVersion, hi: StandardVersion): if hi < lo: raise EmptyRangeError(f"{lo}..{hi} is an empty range") self.lo: StandardVersion = lo self.hi: StandardVersion = hi + self._string: Optional[str] = None + self._hash: Optional[int] = None @classmethod def from_version_range(cls, lo: StandardVersion, hi: StandardVersion) -> "ClosedOpenRange": """Construct ClosedOpenRange from lo:hi range.""" try: - return ClosedOpenRange(lo, _next_version(hi)) + r = ClosedOpenRange(lo, _next_version(hi)) except EmptyRangeError as e: raise EmptyRangeError(f"{lo}:{hi} is an empty range") from e + # Cache hash and string representation + r._hash = hash((lo, hi)) + r._string = _str_range(lo, hi) + return r + def __str__(self) -> str: - # This simplifies 3.1:<3.2 to 3.1:3.1 to 3.1 - # 3:3 -> 3 - hi_prev = _prev_version(self.hi) - if self.lo != StandardVersion.typemin() and self.lo == hi_prev: - return str(self.lo) - lhs = "" if self.lo == StandardVersion.typemin() else str(self.lo) - rhs = "" if hi_prev == StandardVersion.typemax() else str(hi_prev) - return f"{lhs}:{rhs}" + if self._string: + return self._string + self._string = _str_range(self.lo, _prev_version(self.hi)) + return self._string def __repr__(self): return str(self) def __hash__(self): - # prev_version for backward compat. - return hash((self.lo, _prev_version(self.hi))) + if self._hash is not None: + return self._hash + self._hash = hash((self.lo, _prev_version(self.hi))) + return self._hash def __eq__(self, other): if isinstance(other, StandardVersion): From a31f840d651f5cb4ad1e1f48e542357e16f3b817 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 12 Jan 2026 09:24:41 +0100 Subject: [PATCH 003/337] asp.py: do not mutate spec.name (#51821) Mutating Spec.name during concretization invalidates hash values used in dictionary keys and causes non-local side effects for shared immutable specs. This commit removes the need for mutation by passing the package name as an explicit argument through the ASP generation logic. Signed-off-by: Harmen Stoppels --- lib/spack/spack/solver/asp.py | 251 ++++++++++++++--------------- lib/spack/spack/solver/runtimes.py | 3 +- 2 files changed, 123 insertions(+), 131 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index dc985c7dbebb7e..059a69fa548dcd 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -16,7 +16,6 @@ import sys import time import warnings -from contextlib import contextmanager from typing import ( Any, Callable, @@ -86,7 +85,7 @@ GitOrStandardVersion = Union[vn.GitVersion, vn.StandardVersion] -TransformFunction = Callable[[spack.spec.Spec, List[AspFunction]], List[AspFunction]] +TransformFunction = Callable[[str, spack.spec.Spec, List[AspFunction]], List[AspFunction]] EMPTY_SPEC = spack.spec.Spec() @@ -119,23 +118,6 @@ def default_clingo_control(): return control -@contextmanager -def named_spec( - spec: Optional[spack.spec.Spec], name: Optional[str] -) -> Iterator[Optional[spack.spec.Spec]]: - """Context manager to temporarily set the name of a spec""" - if spec is None or name is None: - yield spec - return - - old_name = spec.name - spec.name = name - try: - yield spec - finally: - spec.name = old_name - - # Below numbers are used to map names of criteria to the order # they appear in the solution. See concretize.lp @@ -224,13 +206,15 @@ def specify(spec): def remove_facts(*to_be_removed: str) -> TransformFunction: """Returns a transformation function that removes facts from the input list of facts.""" - def _remove(spec: spack.spec.Spec, facts: List[AspFunction]) -> List[AspFunction]: + def _remove(name: str, spec: spack.spec.Spec, facts: List[AspFunction]) -> List[AspFunction]: return list(filter(lambda x: x.args[0] not in to_be_removed, facts)) return _remove -def dag_closure_by_deptype(spec: spack.spec.Spec, facts: List[AspFunction]) -> List[AspFunction]: +def dag_closure_by_deptype( + name: str, spec: spack.spec.Spec, facts: List[AspFunction] +) -> List[AspFunction]: edges = spec.edges_to_dependencies() # Compute the "link" transitive closure with `when: root ^[deptypes=link] ` if len(edges) == 1: @@ -1327,9 +1311,9 @@ def impose_context(self) -> ConditionIdContext: def _track_dependencies( - input_spec: spack.spec.Spec, requirements: List[AspFunction] + name: str, input_spec: spack.spec.Spec, requirements: List[AspFunction] ) -> List[AspFunction]: - return requirements + [fn.attr("track_dependencies", input_spec.name)] + return requirements + [fn.attr("track_dependencies", name)] class SpackSolverSetup: @@ -1424,11 +1408,12 @@ def pkg_version_rules(self, pkg: Type[spack.package_base.PackageBase]) -> None: for v in sorted(deprecated): self.gen.fact(fn.pkg_fact(pkg.name, fn.deprecated_version(v))) - def spec_versions(self, spec: spack.spec.Spec) -> List[AspFunction]: + def spec_versions( + self, spec: spack.spec.Spec, *, name: Optional[str] = None + ) -> List[AspFunction]: """Return list of clauses expressing spec's version constraints.""" - name = spec.name - msg = "Internal Error: spec with no name occured. Please report to the spack maintainers." - assert name, msg + name = spec.name or name + assert name, "Internal Error: spec with no name occured. Please file an issue." if spec.concrete: return [fn.attr("version", name, spec.version)] @@ -1440,8 +1425,11 @@ def spec_versions(self, spec: spack.spec.Spec) -> List[AspFunction]: self.version_constraints.add((name, spec.versions)) return [fn.attr("node_version_satisfies", name, spec.versions)] - def target_ranges(self, spec: spack.spec.Spec, single_target_fn) -> List[AspFunction]: - name = spec.name + def target_ranges( + self, spec: spack.spec.Spec, single_target_fn, *, name: Optional[str] = None + ) -> List[AspFunction]: + name = spec.name or name + assert name, "Internal Error: spec with no name occured. Please file an issue." target = spec.architecture.target # Check if the target is a concrete target @@ -1683,7 +1671,8 @@ def variant_rules(self, pkg: Type[spack.package_base.PackageBase]): def _get_condition_id( self, - named_cond: spack.spec.Spec, + name: str, + cond: spack.spec.Spec, cache: ConditionSpecCache, body: bool, context: ConditionIdContext, @@ -1698,17 +1687,18 @@ def _get_condition_id( The id of the cached trigger or effect. """ - pkg_cache = cache[named_cond.name] + pkg_cache = cache[name] + cond_str = str(cond) if cond.name else f"{name} {cond}" + named_cond_key = (cond_str, context.transform) - named_cond_key = (str(named_cond), context.transform) result = pkg_cache.get(named_cond_key) if result: return result[0] cond_id = next(self._id_counter) - requirements = self.spec_clauses(named_cond, body=body, context=context) + requirements = self.spec_clauses(cond, name=name, body=body, context=context) if context.transform: - requirements = context.transform(named_cond, requirements) + requirements = context.transform(name, cond, requirements) pkg_cache[named_cond_key] = (cond_id, requirements) return cond_id @@ -1732,38 +1722,39 @@ def _condition_clauses( context = ConditionContext() context.transform_imposed = remove_facts("node", "virtual_node") - if imposed_spec: - imposed_name = imposed_spec.name or imposed_name - if not imposed_name: - raise ValueError(f"Must provide a name for imposed constraint: '{imposed_spec}'") - - with named_spec(required_spec, required_name), named_spec(imposed_spec, imposed_name): - # Check if we can emit the requirements before updating the condition ID counter. - # In this way, if a condition can't be emitted but the exception is handled in the - # caller, we won't emit partial facts. + # Check if we can emit the requirements before updating the condition ID counter. + # In this way, if a condition can't be emitted but the exception is handled in the + # caller, we won't emit partial facts. + condition_id = next(self._id_counter) + requirement_context = context.requirement_context() + trigger_id = self._get_condition_id( + required_name, + required_spec, + cache=self._trigger_cache, + body=True, + context=requirement_context, + ) + clauses.append(fn.pkg_fact(required_name, fn.condition(condition_id))) + clauses.append(fn.condition_reason(condition_id, msg)) + clauses.append(fn.pkg_fact(required_name, fn.condition_trigger(condition_id, trigger_id))) + if not imposed_spec: + return clauses, condition_id - condition_id = next(self._id_counter) - requirement_context = context.requirement_context() - trigger_id = self._get_condition_id( - required_spec, cache=self._trigger_cache, body=True, context=requirement_context - ) - clauses.append(fn.pkg_fact(required_spec.name, fn.condition(condition_id))) - clauses.append(fn.condition_reason(condition_id, msg)) - clauses.append( - fn.pkg_fact(required_spec.name, fn.condition_trigger(condition_id, trigger_id)) - ) - if not imposed_spec: - return clauses, condition_id + imposed_name = imposed_spec.name or imposed_name + if not imposed_name: + raise ValueError(f"Must provide a name for imposed constraint: '{imposed_spec}'") + + impose_context = context.impose_context() + effect_id = self._get_condition_id( + imposed_name, + imposed_spec, + cache=self._effect_cache, + body=False, + context=impose_context, + ) + clauses.append(fn.pkg_fact(required_name, fn.condition_effect(condition_id, effect_id))) - impose_context = context.impose_context() - effect_id = self._get_condition_id( - imposed_spec, cache=self._effect_cache, body=False, context=impose_context - ) - clauses.append( - fn.pkg_fact(required_spec.name, fn.condition_effect(condition_id, effect_id)) - ) - - return clauses, condition_id + return clauses, condition_id def condition( self, @@ -1856,23 +1847,23 @@ def package_dependencies_rules(self, pkg): msg = f"{pkg.name} depends on {dep.spec}{cond_str_suffix}" def dependency_holds( - input_spec: spack.spec.Spec, requirements: List[AspFunction] + name: str, input_spec: spack.spec.Spec, requirements: List[AspFunction] ) -> List[AspFunction]: # TODO: `dependency_holds` is used as a cache key, and is a unique object in # every iteration of the loop. This prevents deduplication of identical # "effects" when unique when specs impose the same dependency. We cannot move # this out of the loop, because the effect cache is keyed only by a spec, and # not by the dependency type. - result = remove_facts("node", "virtual_node")(input_spec, requirements) + [ - fn.attr( - "dependency_holds", pkg.name, input_spec.name, dt.flag_to_string(t) - ) + result = remove_facts("node", "virtual_node")( + name, input_spec, requirements + ) + [ + fn.attr("dependency_holds", pkg.name, name, dt.flag_to_string(t)) for t in dt.ALL_FLAGS if t & depflag ] - if input_spec.name not in pkg.extendees: + if name not in pkg.extendees: return result - return result + [fn.attr("extends", pkg.name, input_spec.name)] + return result + [fn.attr("extends", pkg.name, name)] context = ConditionContext() context.source = ConstraintOrigin.append_type_suffix( @@ -1917,59 +1908,56 @@ def package_splice_rules(self, pkg): for i, (cond, (spec_to_splice, match_variants)) in enumerate( sorted(pkg.splice_specs.items()) ): - with named_spec(cond, pkg.name): - self.version_constraints.add((cond.name, cond.versions)) - self.version_constraints.add((spec_to_splice.name, spec_to_splice.versions)) - hash_var = AspVar("Hash") - splice_node = fn.node(AspVar("NID"), cond.name) - when_spec_attrs = [ - fn.attr(c.args[0], splice_node, *(c.args[2:])) - for c in self.spec_clauses(cond, body=True, required_from=None) - if c.args[0] != "node" - ] - splice_spec_hash_attrs = [ - fn.hash_attr(hash_var, *(c.args)) - for c in self.spec_clauses(spec_to_splice, body=True, required_from=None) - if c.args[0] != "node" - ] - if match_variants is None: - variant_constraints = [] - elif match_variants == "*": - filt_match_variants = set() - for map in pkg.variants.values(): - for k in map: - filt_match_variants.add(k) - filt_match_variants = sorted(filt_match_variants) - variant_constraints = self._gen_match_variant_splice_constraints( - pkg, cond, spec_to_splice, hash_var, splice_node, filt_match_variants - ) - else: - if any( - v in cond.variants or v in spec_to_splice.variants for v in match_variants - ): - raise spack.error.PackageError( - "Overlap between match_variants and explicitly set variants" - ) - variant_constraints = self._gen_match_variant_splice_constraints( - pkg, cond, spec_to_splice, hash_var, splice_node, match_variants - ) - - rule_head = fn.abi_splice_conditions_hold( - i, splice_node, spec_to_splice.name, hash_var + self.version_constraints.add((pkg.name, cond.versions)) + self.version_constraints.add((spec_to_splice.name, spec_to_splice.versions)) + hash_var = AspVar("Hash") + splice_node = fn.node(AspVar("NID"), pkg.name) + when_spec_attrs = [ + fn.attr(c.args[0], splice_node, *(c.args[2:])) + for c in self.spec_clauses(cond, name=pkg.name, body=True, required_from=None) + if c.args[0] != "node" + ] + splice_spec_hash_attrs = [ + fn.hash_attr(hash_var, *(c.args)) + for c in self.spec_clauses(spec_to_splice, body=True, required_from=None) + if c.args[0] != "node" + ] + if match_variants is None: + variant_constraints = [] + elif match_variants == "*": + filt_match_variants = set() + for map in pkg.variants.values(): + for k in map: + filt_match_variants.add(k) + filt_match_variants = sorted(filt_match_variants) + variant_constraints = self._gen_match_variant_splice_constraints( + pkg, cond, spec_to_splice, hash_var, splice_node, filt_match_variants ) - rule_body_components = ( - [ - # splice_set_fact, - fn.attr("node", splice_node), - fn.installed_hash(spec_to_splice.name, hash_var), - ] - + when_spec_attrs - + splice_spec_hash_attrs - + variant_constraints + else: + if any(v in cond.variants or v in spec_to_splice.variants for v in match_variants): + raise spack.error.PackageError( + "Overlap between match_variants and explicitly set variants" + ) + variant_constraints = self._gen_match_variant_splice_constraints( + pkg, cond, spec_to_splice, hash_var, splice_node, match_variants ) - rule_body = ",\n ".join(str(r) for r in rule_body_components) - rule = f"{rule_head} :-\n {rule_body}." - self.gen.append(rule) + + rule_head = fn.abi_splice_conditions_hold( + i, splice_node, spec_to_splice.name, hash_var + ) + rule_body_components = ( + [ + # splice_set_fact, + fn.attr("node", splice_node), + fn.installed_hash(spec_to_splice.name, hash_var), + ] + + when_spec_attrs + + splice_spec_hash_attrs + + variant_constraints + ) + rule_body = ",\n ".join(str(r) for r in rule_body_components) + rule = f"{rule_head} :-\n {rule_body}." + self.gen.append(rule) self.gen.newline() @@ -2172,6 +2160,7 @@ def spec_clauses( self, spec: spack.spec.Spec, *, + name: Optional[str] = None, body: bool = False, transitive: bool = True, expand_hashes: bool = False, @@ -2190,6 +2179,7 @@ def spec_clauses( try: clauses = self._spec_clauses( spec, + name=spec.name or name, body=body, transitive=transitive, expand_hashes=expand_hashes, @@ -2208,6 +2198,7 @@ def _spec_clauses( self, spec: spack.spec.Spec, *, + name: Optional[str] = None, body: bool = False, transitive: bool = True, expand_hashes: bool = False, @@ -2220,6 +2211,7 @@ def _spec_clauses( Arguments: spec: the spec to analyze + name: optional fallback of spec.name (used for anonymous roots) body: if True, generate clauses to be used in rule bodies (final values) instead of rule heads (setters). transitive: if False, don't generate clauses from dependencies (default True) @@ -2240,7 +2232,7 @@ def _spec_clauses( """ clauses = [] seen = seen if seen is not None else set() - name = spec.name + name = spec.name or name or "" seen.add(id(spec)) f: Union[Type[_Head], Type[_Body]] = _Body if body else _Head @@ -2252,7 +2244,7 @@ def _spec_clauses( if spec.namespace: clauses.append(f.namespace(name, spec.namespace)) - clauses.extend(self.spec_versions(spec)) + clauses.extend(self.spec_versions(spec, name=name)) # seed architecture at the root (we'll propagate later) # TODO: use better semantics. @@ -2263,7 +2255,7 @@ def _spec_clauses( if arch.os: clauses.append(f.node_os(name, arch.os)) if arch.target: - clauses.extend(self.target_ranges(spec, f.node_target)) + clauses.extend(self.target_ranges(spec, f.node_target, name=name)) # variants for vname, variant in sorted(spec.variants.items()): @@ -3203,12 +3195,13 @@ def generate_conditional_dep_conditions(self, spec: spack.spec.Spec, condition_i # because reused specs do not track virtual nodes. # Instead, track whether the parent uses the virtual def virtual_handler( - input_spec: spack.spec.Spec, requirements: List[AspFunction] + name: str, input_spec: spack.spec.Spec, requirements: List[AspFunction] ) -> List[AspFunction]: - ret = remove_facts("virtual_node")(input_spec, requirements) + ret = remove_facts("virtual_node")(name, input_spec, requirements) for edge in input_spec.traverse_edges(root=False, cover="edges"): if spack.repo.PATH.is_virtual(edge.spec.name): - ret.append(fn.attr("uses_virtual", edge.parent.name, edge.spec.name)) + parent_name = name if edge.parent is input_spec else edge.parent.name + ret.append(fn.attr("uses_virtual", parent_name, edge.spec.name)) return ret context = ConditionContext() @@ -3217,7 +3210,7 @@ def virtual_handler( ) # Default is to remove node-like attrs, override here context.transform_required = virtual_handler - context.transform_imposed = lambda x, y: y + context.transform_imposed = lambda x, y, z: z try: subcondition_id = self.condition( diff --git a/lib/spack/spack/solver/runtimes.py b/lib/spack/spack/solver/runtimes.py index db54085c350faa..0755b15b427adf 100644 --- a/lib/spack/spack/solver/runtimes.py +++ b/lib/spack/spack/solver/runtimes.py @@ -134,8 +134,7 @@ def rule_body_from(self, when_spec: "spack.spec.Spec") -> Tuple[str, str]: when_substitutions = {} for s in when_spec.traverse(root=False): when_substitutions[f'"{s.name}"'] = self.node_for(s.name) - when_spec.name = node_placeholder - body_clauses = self._setup.spec_clauses(when_spec, body=True) + body_clauses = self._setup.spec_clauses(when_spec, name=node_placeholder, body=True) for clause in body_clauses: if clause.args[0] == "virtual_on_incoming_edges": # Substitute: attr("virtual_on_incoming_edges", ProviderNode, Virtual) From fccc2d4b99dab777e05850e915455975051befba Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 12 Jan 2026 12:13:30 +0100 Subject: [PATCH 004/337] version_types.py: optimize branch order (#51800) Signed-off-by: Harmen Stoppels --- lib/spack/spack/version/version_types.py | 78 ++++++++++++------------ 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/lib/spack/spack/version/version_types.py b/lib/spack/spack/version/version_types.py index 593eaa264013b8..015afa6715b8f4 100644 --- a/lib/spack/spack/version/version_types.py +++ b/lib/spack/spack/version/version_types.py @@ -365,18 +365,18 @@ def intersects(self, other: VersionType) -> bool: return other.intersects(self) def satisfies(self, other: VersionType) -> bool: + if isinstance(other, VersionList): + return other.intersects(self) + + if isinstance(other, ClosedOpenRange): + return other.intersects(self) + if isinstance(other, GitVersion): return False if isinstance(other, StandardVersion): return self == other - if isinstance(other, ClosedOpenRange): - return other.intersects(self) - - if isinstance(other, VersionList): - return other.intersects(self) - raise NotImplementedError def union(self, other: VersionType) -> VersionType: @@ -828,10 +828,10 @@ def __hash__(self): return self._hash def __eq__(self, other): - if isinstance(other, StandardVersion): - return False if isinstance(other, ClosedOpenRange): return (self.lo, self.hi) == (other.lo, other.hi) + if isinstance(other, StandardVersion): + return False return NotImplemented def __ne__(self, other): @@ -842,10 +842,10 @@ def __ne__(self, other): return NotImplemented def __lt__(self, other): - if isinstance(other, StandardVersion): - return other > self if isinstance(other, ClosedOpenRange): return (self.lo, self.hi) < (other.lo, other.hi) + if isinstance(other, StandardVersion): + return other > self return NotImplemented def __le__(self, other): @@ -877,10 +877,10 @@ def __contains__(rhs, lhs): def intersects(self, other: VersionType) -> bool: if isinstance(other, StandardVersion): return self.lo <= other < self.hi - if isinstance(other, GitVersion): - return self.lo <= other.ref_version < self.hi if isinstance(other, ClosedOpenRange): return (self.lo < other.hi) and (other.lo < self.hi) + if isinstance(other, GitVersion): + return self.lo <= other.ref_version < self.hi if isinstance(other, VersionList): return any(self.intersects(rhs) for rhs in other) raise TypeError(f"'intersects' not supported for instances of {type(other)}") @@ -897,12 +897,6 @@ def satisfies(self, other: VersionType) -> bool: def _union_if_not_disjoint(self, other: VersionType) -> Optional["ClosedOpenRange"]: """Same as union, but returns None when the union is not connected. This function is not implemented for version lists as right-hand side, as that makes little sense.""" - if isinstance(other, StandardVersion): - return self if self.lo <= other < self.hi else None - - if isinstance(other, GitVersion): - return self if self.lo <= other.ref_version < self.hi else None - if isinstance(other, ClosedOpenRange): # Notice <= cause we want union(1:2, 3:4) = 1:4. return ( @@ -911,6 +905,12 @@ def _union_if_not_disjoint(self, other: VersionType) -> Optional["ClosedOpenRang else None ) + if isinstance(other, StandardVersion): + return self if self.lo <= other < self.hi else None + + if isinstance(other, GitVersion): + return self if self.lo <= other.ref_version < self.hi else None + raise TypeError(f"'union()' not supported for instances of {type(other)}") def union(self, other: VersionType) -> VersionType: @@ -942,22 +942,22 @@ class VersionList(VersionType): versions: List[VersionType] def __init__(self, vlist: Optional[Union[str, VersionType, Iterable]] = None): - if vlist is None: - self.versions = [] - - elif isinstance(vlist, str): + if isinstance(vlist, str): vlist = from_string(vlist) if isinstance(vlist, VersionList): self.versions = vlist.versions else: self.versions = [vlist] - elif isinstance(vlist, (ConcreteVersion, ClosedOpenRange)): - self.versions = [vlist] + elif vlist is None: + self.versions = [] elif isinstance(vlist, VersionList): self.versions = vlist[:] + elif isinstance(vlist, (ConcreteVersion, ClosedOpenRange)): + self.versions = [vlist] + elif isinstance(vlist, Iterable): self.versions = [] for v in vlist: @@ -967,15 +967,7 @@ def __init__(self, vlist: Optional[Union[str, VersionType, Iterable]] = None): raise TypeError(f"Cannot construct VersionList from {type(vlist)}") def add(self, item: VersionType) -> None: - if isinstance(item, (StandardVersion, GitVersion)): - i = bisect_left(self, item) - # Only insert when prev and next are not intersected. - if (i == 0 or not item.intersects(self[i - 1])) and ( - i == len(self) or not item.intersects(self[i]) - ): - self.versions.insert(i, item) - - elif isinstance(item, ClosedOpenRange): + if isinstance(item, ClosedOpenRange): i = bisect_left(self, item) # Note: can span multiple concrete versions to the left (as well as to the right). @@ -1002,6 +994,14 @@ def add(self, item: VersionType) -> None: for v in item: self.add(v) + elif isinstance(item, (StandardVersion, GitVersion)): + i = bisect_left(self, item) + # Only insert when prev and next are not intersected. + if (i == 0 or not item.intersects(self[i - 1])) and ( + i == len(self) or not item.intersects(self[i]) + ): + self.versions.insert(i, item) + else: raise TypeError("Can't add %s to VersionList" % type(item)) @@ -1058,6 +1058,9 @@ def satisfies(self, other: VersionType) -> bool: raise TypeError(f"'satisfies()' not supported for instances of {type(other)}") def intersects(self, other: VersionType) -> bool: + if isinstance(other, (ClosedOpenRange, StandardVersion)): + return any(v.intersects(other) for v in self) + if isinstance(other, VersionList): s = o = 0 while s < len(self) and o < len(other): @@ -1069,9 +1072,6 @@ def intersects(self, other: VersionType) -> bool: o += 1 return False - if isinstance(other, (ClosedOpenRange, StandardVersion)): - return any(v.intersects(other) for v in self) - raise TypeError(f"'intersects()' not supported for instances of {type(other)}") def to_dict(self) -> Dict: @@ -1317,13 +1317,13 @@ def from_string(string: str) -> VersionType: # VersionList if "," in string: - return VersionList(list(map(from_string, string.split(",")))) + return VersionList([from_string(x) for x in string.split(",")]) # ClosedOpenRange elif ":" in string: s, e = string.split(":") - lo = StandardVersion.typemin() if s == "" else StandardVersion.from_string(s) - hi = StandardVersion.typemax() if e == "" else StandardVersion.from_string(e) + lo = _STANDARD_VERSION_TYPEMIN if s == "" else StandardVersion.from_string(s) + hi = _STANDARD_VERSION_TYPEMAX if e == "" else StandardVersion.from_string(e) return VersionRange(lo, hi) # StandardVersion From 6284f0dcb2e8b19d1637414ea13c048515e0497a Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 12 Jan 2026 20:24:28 +0100 Subject: [PATCH 005/337] spec.py: add EMPTY_SPEC (#51819) * spec.py: use EMPTY_SPEC for unconditional deps Conditional dependencies are the exception, but all unconditional dependencies allocate a `when=Spec()` object after the introduction of toolchains. Trivial specs are also common in package metadata, since almost all directives are now dictionaries keyed by `when` condition, where the unconditional `Spec()` is popular as well. tracemalloc analysis shows that during `spack spec zlib`, 61% of all `Spec` objects that are *garbage collected* are trivial/empty. (I'm not counting at the time of allocation, where some specs are temporarily empty, but then mutated by the parser). This commit introduced a singleton `spack.spec.EMPTY_SPEC = Spec()`, which is "immutable by convention", used in the hot spots listed below. Before this commit: ``` Total Spec objects: 85803 Trivial (empty) Spec objects: 52878 Top 20 allocation sites for trivial (empty) Spec objects: COUNT | LOCATION ------------------------------------------------------------------------------------- 26666 | $spack/lib/spack/spack/spec.py:1846 8930 | $spack/lib/spack/spack/spec.py:1917 7535 | $spack/lib/spack/spack/spec.py:761 5210 | $spack/lib/spack/spack/spec.py:4440 3904 | $spack/lib/spack/spack/directives.py:129 532 | $spack/lib/spack/spack/solver/input_analysis.py:88 77 | $spack/lib/spack/spack/spec.py:3771 18 | $spack/lib/spack/spack/spec.py:4396 1 | $spack/lib/spack/spack/solver/requirements.py:203 1 | $spack/lib/spack/spack/solver/requirements.py:67 1 | $spack/bin/spack:110 ``` After this commit: ``` Total Spec objects: 33020 Trivial (empty) Spec objects: 99 Top 20 allocation sites for trivial (empty) Spec objects: COUNT | LOCATION ------------------------------------------------------------------------------------- 77 | /home/harmen/spack/lib/spack/spack/spec.py:3771 16 | /home/harmen/spack/lib/spack/spack/spec.py:4396 1 | /home/harmen/spack/lib/spack/spack/solver/requirements.py:203 1 | /home/harmen/spack/lib/spack/spack/solver/requirements.py:67 1 | /home/harmen/spack/bin/spack:110 ``` Pickle size of `list(e.roots())` * before: 5.0MB * after: 3.0M (-40.0%) Pickle and unpickle speed: * before: 0.1830s * after: 0.0703s (-61.6%) Database and environment loading speed: * before: 0.1853s * after: 0.1471 (-20.64%) Spawned subprocess startup speed, passing the environment as arg * before: 0.5066s * after: 0.2227s (-56.0%) Signed-off-by: Harmen Stoppels --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives.py | 9 +++-- lib/spack/spack/solver/asp.py | 3 +- lib/spack/spack/solver/input_analysis.py | 4 +- lib/spack/spack/spec.py | 48 +++++++++++++++++++++--- lib/spack/spack/spec_parser.py | 14 +++++-- 5 files changed, 62 insertions(+), 16 deletions(-) diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index 26bdb8d3cad79a..24c116a4b2eb6e 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -59,6 +59,7 @@ def _execute_example_directive(pkg, arg1, arg2): from spack.dependency import Dependency from spack.directives_meta import DirectiveError, DirectiveMeta from spack.resource import Resource +from spack.spec import EMPTY_SPEC from spack.version import ( GitVersion, Version, @@ -136,7 +137,7 @@ def _make_when_spec(value: WhenType) -> Optional[spack.spec.Spec]: # represent this by returning the unconstrained `Spec()`, which is # always satisfied. if value is None or value is True: - return spack.spec.Spec() + return EMPTY_SPEC # This is conditional on the spec return spack.spec.Spec(value) @@ -496,6 +497,8 @@ def _execute_provides(pkg: PackageType, specs: Tuple[SpecType, ...], when: WhenT when_spec = _make_when_spec(when) if not when_spec: return + elif when_spec is EMPTY_SPEC: + when_spec = spack.spec.Spec() # this function mutates, so can't use EMPTY_SPEC # ``when`` specs for ``provides()`` need a name, as they are used # to build the ProviderIndex. @@ -939,10 +942,10 @@ def _execute_license(pkg: PackageType, license_identifier: str, when: Optional[U for other_when_spec in pkg.licenses: if when_spec.intersects(other_when_spec): when_message = "" - if when_spec != _make_when_spec(None): + if when_spec != EMPTY_SPEC: when_message = f"when {when_spec}" other_when_message = "" - if other_when_spec != _make_when_spec(None): + if other_when_spec != EMPTY_SPEC: other_when_message = f"when {other_when_spec}" err_msg = ( f"{pkg.name} is specified as being licensed as {license_identifier} " diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 059a69fa548dcd..f6b47d704f5c8a 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -65,6 +65,7 @@ from spack import traverse from spack.compilers.libraries import CompilerPropertyDetector from spack.llnl.util.lang import elide_list +from spack.spec import EMPTY_SPEC from spack.util.compression import GZipFileType from .core import ( @@ -87,8 +88,6 @@ TransformFunction = Callable[[str, spack.spec.Spec, List[AspFunction]], List[AspFunction]] -EMPTY_SPEC = spack.spec.Spec() - class OutputConfiguration(NamedTuple): """Data class that contains configuration on what a clingo solve should output.""" diff --git a/lib/spack/spack/solver/input_analysis.py b/lib/spack/spack/solver/input_analysis.py index 647bb5eb820d67..440188cb881329 100644 --- a/lib/spack/spack/solver/input_analysis.py +++ b/lib/spack/spack/solver/input_analysis.py @@ -17,6 +17,7 @@ import spack.store from spack.error import SpackError from spack.llnl.util import lang, tty +from spack.spec import EMPTY_SPEC class PossibleGraph(NamedTuple): @@ -85,10 +86,9 @@ def is_virtual(self, name: str) -> bool: def is_allowed_on_this_platform(self, *, pkg_name: str) -> bool: """Returns true if a package is allowed on the current host""" pkg_cls = self.repo.get_pkg_class(pkg_name) - no_condition = spack.spec.Spec() for when_spec, conditions in pkg_cls.requirements.items(): # Restrict analysis to unconditional requirements - if when_spec != no_condition: + if when_spec != EMPTY_SPEC: continue for requirements, _, _ in conditions: if not any(x.intersects(self._platform_condition) for x in requirements): diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 3ad0da150fadc9..6089989801f891 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -758,7 +758,7 @@ def __init__( self.virtuals = tuple(sorted(set(virtuals))) self.direct = direct self.propagation = propagation - self.when = when or Spec() + self.when = when or EMPTY_SPEC def update_deptypes(self, depflag: dt.DepFlag) -> bool: """Update the current dependency types""" @@ -1843,7 +1843,7 @@ def _add_dependency( when: optional condition under which dependency holds """ if when is None: - when = Spec() + when = EMPTY_SPEC if spec.name not in self._dependencies or not spec.name: self.add_dependency_edge( @@ -1914,7 +1914,7 @@ def add_dependency_edge( when: if non-None, condition under which dependency holds """ if when is None: - when = Spec() + when = EMPTY_SPEC # Check if we need to update edges that are already present selected = self._dependencies.get(dependency_spec.name, []) @@ -3222,6 +3222,8 @@ def intersects(self, other: Union[str, "Spec"], deps: bool = True) -> bool: def _intersects( self, other: Union[str, "Spec"], deps: bool = True, resolve_virtuals: bool = True ) -> bool: + if other is EMPTY_SPEC: + return True other = self._autospec(other) if other.concrete and self.concrete: @@ -3371,6 +3373,8 @@ def _satisfies( resolve_virtuals: if True, resolve virtuals in self and other. This requires a repository to be available. """ + if other is EMPTY_SPEC: + return True other = self._autospec(other) if other.concrete: @@ -4393,7 +4397,7 @@ def _format_edge_attributes(self, dep: DependencySpec, deptypes=True, virtuals=T if deptypes and dep.depflag else "" ) - when_str = f"when='{(dep.when)}'" if dep.when != Spec() else "" + when_str = f"when='{(dep.when)}'" if dep.when != EMPTY_SPEC else "" virtuals_str = f"virtuals={','.join(dep.virtuals)}" if virtuals and dep.virtuals else "" attrs = " ".join(s for s in (when_str, deptypes_str, virtuals_str) if s) @@ -4437,7 +4441,7 @@ def format_edge(edge: DependencySpec, sigil: str, dep_spec: Optional[Spec] = Non edge_attributes = ( self._format_edge_attributes(edge, deptypes=deptypes, virtuals=False) - if edge.depflag or edge.when != Spec() + if edge.depflag or edge.when != EMPTY_SPEC else "" ) virtuals = f"{','.join(edge.virtuals)}=" if edge.virtuals else "" @@ -6009,3 +6013,37 @@ class InvalidEdgeError(spack.error.SpecError): class SpecMutationError(spack.error.SpecError): """Raised when a mutation is attempted with invalid attributes.""" + + +class _EmptySpec(Spec): + """An immutable empty Spec that prevents a class of accidental mutations.""" + + def __init__(self) -> None: + object.__setattr__(self, "_mutable", True) + super().__init__() + object.__delattr__(self, "_mutable") + + def __setstate__(self, state) -> None: + object.__setattr__(self, "_mutable", True) + super().__setstate__(state) + object.__delattr__(self, "_mutable") + + def constrain(self, *args, **kwargs) -> bool: + raise TypeError("EmptySpec is immutable and cannot be modified") + + def add_dependency_edge(self, *args, **kwargs): + raise TypeError("EmptySpec is immutable and cannot be modified") + + def __setattr__(self, name, value) -> None: + if not getattr(self, "_mutable", False): + raise TypeError("EmptySpec is immutable and cannot be modified") + super().__setattr__(name, value) + + def __delattr__(self, name) -> None: + if name != "_mutable": + raise TypeError("EmptySpec is immutable and cannot be modified") + object.__delattr__(self, name) + + +#: Immutable empty spec, for fast comparisons and reduced memory usage. +EMPTY_SPEC = _EmptySpec() diff --git a/lib/spack/spack/spec_parser.py b/lib/spack/spack/spec_parser.py index 1d5d66691b46c8..c2b067c9f9dd07 100644 --- a/lib/spack/spack/spec_parser.py +++ b/lib/spack/spack/spec_parser.py @@ -439,7 +439,7 @@ def _parse_toolchain(self, name: str) -> "spack.spec.Spec": toolchain = parse_one_or_raise(toolchain_config) self._ensure_all_direct_edges(toolchain) else: - from spack.spec import Spec + from spack.spec import EMPTY_SPEC, Spec toolchain = Spec() for entry in toolchain_config: @@ -447,9 +447,15 @@ def _parse_toolchain(self, name: str) -> "spack.spec.Spec": when = entry.get("when", "") self._ensure_all_direct_edges(toolchain_part) - # Conditions are applied to every edge in the constraint - for edge in toolchain_part.traverse_edges(): - edge.when.constrain(when) + # Apply global "when" to all edges in toolchain part + if when: + when_spec = Spec(when) + for edge in toolchain_part.traverse_edges(): + # EMPTY_SPEC is immutable by convention, so create a mutable instance. + if edge.when is EMPTY_SPEC: + edge.when = when_spec.copy() + else: + edge.when.constrain(when_spec) toolchain.constrain(toolchain_part) return toolchain From 00ed400c763bf31b64f9a3ff386ad1fbdce2a39d Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 13 Jan 2026 16:16:18 +0100 Subject: [PATCH 006/337] spec: make compilers return a valid spec (#51838) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/spec.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 6089989801f891..647b3bcfedc485 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -4513,9 +4513,8 @@ def _short_spec(self, color: Optional[bool] = False) -> str: @property def compilers(self): if self.original_spec_format() < 5: - # These specs don't have compilers as dependencies, return the - # specfile format and compiler - return f"[specfile v{self.original_spec_format()}] {self.compiler}" + # These specs don't have compilers as dependencies, return the compiler node attribute + return f" %{self.compiler}" # TODO: get rid of the space here and make formatting smarter return " " + self._format_dependencies( From a6c511d1f70a8e16a076084373b25d5eda016efa Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 14 Jan 2026 02:28:48 +0100 Subject: [PATCH 007/337] config: relax concurrent_packages to minimum 0 (#51840) In the new installer, the number of concurrent packages is bound by `build_jobs`, and users don't have to bother setting `concurrent_packages` too. As a result, the new installer currently ignores `concurrent_packages`. In Spack v1.2 however, the new installer *will* respect `concurrent_packages` as an upper limit on the number of concurrent package installs. That makes the default value of `1` in config problematic. The suggestion is to use a default value of `0` from Spack v1.2 onwards, and make that a mean "default settings". For the old installer that means no package parallelism, for the new installer that means at most `build_jobs`. This commit allows the value `0` in config, and is primarily meant to be backported to ensure forward compatibilty with newer config files in older Spack version. Signed-off-by: Harmen Stoppels --- lib/spack/spack/installer.py | 4 ++-- lib/spack/spack/schema/config.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index 23a547007efef7..876c76219ee89d 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -1528,8 +1528,8 @@ def __init__( explicit = {pkg.spec.dag_hash() for pkg in packages} if explicit else set() if concurrent_packages is None: - concurrent_packages = spack.config.get("config:concurrent_packages", default=1) - self.concurrent_packages = concurrent_packages + concurrent_packages = int(spack.config.get("config:concurrent_packages", default=1)) + self.concurrent_packages = max(1, concurrent_packages) install_args = { "dependencies_policy": dependencies_policy, diff --git a/lib/spack/spack/schema/config.py b/lib/spack/spack/schema/config.py index 1e3f8864f84d9c..b0b3d5151c7c69 100644 --- a/lib/spack/spack/schema/config.py +++ b/lib/spack/spack/schema/config.py @@ -176,7 +176,7 @@ }, "concurrent_packages": { "type": "integer", - "minimum": 1, + "minimum": 0, "description": "The maximum number of concurrent package builds a single Spack " "instance will run", }, From 796ad06de46959b598cace9df2ae2685c4db5bd5 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 14 Jan 2026 09:43:09 +0100 Subject: [PATCH 008/337] solver: fix variant penalty for variants defined with a validator function (#51844) Variants defined with a validator function can have: ``` pkg_fact(Package, variant_possible_value(VariantID, Value)) ``` that are not associated with a penalty and are not part of the variant definition. In this case set the penalty to a very high value to avoid selecting them accidentally. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 3 ++- lib/spack/spack/solver/concretize.lp | 11 +++++++- lib/spack/spack/test/concretization/core.py | 22 ++++++++++++++- .../variant_function_validator/package.py | 27 +++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/variant_function_validator/package.py diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index f6b47d704f5c8a..1108fbd04cc5f5 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -1587,8 +1587,9 @@ def define_variant( # Deal with variants that use validator functions if variant_def.values_defined_by_validator(): - for value in default_values: + for penalty, value in enumerate(default_values, 1): pkg_fact(fn.variant_possible_value(vid, value)) + pkg_fact(fn.variant_penalty(vid, value, penalty)) self.gen.newline() return diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index ea8d14072d2d65..f0592cb7b4d4ad 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1348,10 +1348,19 @@ variant_default_value(node(NodeID, Package), VariantName, Value) :- node_has_variant(node(NodeID, Package), VariantName, _), attr("variant_default_value_from_cli", node(min_dupe_id, Package), VariantName, Value). +% Penalty from the variant definition +possible_variant_penalty(VariantID, Value, Penalty) :- pkg_fact(Package, variant_penalty(VariantID, Value, Penalty)). + +% Use a very high penalty for variant values that are not defined in the package, +% for instance those defined implicitly by a validator. +possible_variant_penalty(VariantID, Value, 100) :- + pkg_fact(Package, variant_possible_value(VariantID, Value)), + not pkg_fact(Package, variant_penalty(VariantID, Value, _)). + variant_penalty(node(NodeID, Package), Variant, Value, Penalty) :- node_has_variant(node(NodeID, Package), Variant, VariantID), attr("variant_value", node(NodeID, Package), Variant, Value), - pkg_fact(Package, variant_penalty(VariantID, Value, Penalty)), + possible_variant_penalty(VariantID, Value, Penalty), not variant_default_value(node(NodeID, Package), Variant, Value), % variants set explicitly from a directive don't count as non-default not attr("variant_set", node(NodeID, Package), Variant, _), diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 548a774ddd23e8..ad1adf5347e2fc 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4825,7 +4825,7 @@ def test_activating_variant_for_conditional_language_dependency(default_mock_con def test_imposed_spec_dependency_duplication(mock_packages: spack.repo.Repo): - """Tests that imposed dependenies triggered by identical conditions are grouped together, + """Tests that imposed dependencies triggered by identical conditions are grouped together, and that imposed dependencies that differ on a deptype are not grouped together.""" # The trigger-and-effect-deps pkg has 4 conditions, 2 triggers, and 4 effects in total: # +x -> depends on pkg-a with deptype link @@ -4846,3 +4846,23 @@ def test_imposed_spec_dependency_duplication(mock_packages: spack.repo.Repo): assert len([line for line in asp if re.search(r"trigger_id\(\d+\)", line)]) == 2 # There should be 4 effects total assert len([line for line in asp if re.search(r"effect_id\(\d+\)", line)]) == 4 + + +@pytest.mark.regression("51842") +@pytest.mark.parametrize( + "spec_str,expected", + [ + ("variant-function-validator", "generator=make %adios2~bzip2"), + ("variant-function-validator generator=make", "generator=make %adios2~bzip2"), + ("variant-function-validator generator=ninja", "generator=ninja %adios2+bzip2"), + ("variant-function-validator generator=other", "generator=other %adios2+bzip2"), + ], +) +def test_penalties_for_variant_defined_by_function( + default_mock_concretization, spec_str, expected +): + """Tests that we have penalties for variants defined by functions, and that variant values + are consistent with defaults and optimization rules. + """ + s = default_mock_concretization(spec_str) + assert s.satisfies(expected) diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/variant_function_validator/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/variant_function_validator/package.py new file mode 100644 index 00000000000000..a07f131842bd98 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/variant_function_validator/package.py @@ -0,0 +1,27 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +def _allowed_values(x): + return x in {"make", "ninja", "other"} + + +class VariantFunctionValidator(Package): + """This package has a variant with values defined by a function validator.""" + + homepage = "https://www.example.org" + url = "https://example.org/files/v3.4/cmake-3.4.3.tar.gz" + + version("1.0", md5="4cb3ff35b2472aae70f542116d616e63") + + variant("generator", default="make", values=_allowed_values, description="?") + + # Create a situation where, if the penalty for the variant defined by a function + # is not taken into account, then we'll select the non-default value + depends_on("adios2") + conflicts("adios2+bzip2", when="generator=make") + conflicts("adios2~bzip2", when="generator=ninja") From 7306e4813e27c4f0d9aa61be2138504a80bd7b1e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 14 Jan 2026 10:36:11 +0100 Subject: [PATCH 009/337] Use tuple([]) in a few places (#51841) In Python 3.11+ it's better to do `tuple([])`, for older Python it doesn't matter much. The reason is that `tuple(x for x in xs)` creates a generator function where cpython goes between to frames, whereas `tuple([x for x in xs])` is native iteration. Further, remove the indirection of the `HashableMap.__iter__` function call, and do not use splatting in `StandardVersion.from_string`. Before: ``` $ python -m timeit -s 'from spack.spec import Spec; s = Spec("pkg@1.2.3 foo=bar")' 'hash(s)' 100000 loops, best of 5: 2.66 usec per loop $ python -m timeit -s 'from spack.version import ver' 'ver("1.2.3")' 200000 loops, best of 5: 1.82 usec per loop ``` After: ``` $ python -m timeit -s 'from spack.spec import Spec; s = Spec("pkg@1.2.3 foo=bar")' 'hash(s)' 200000 loops, best of 5: 1.58 usec per loop $ python -m timeit -s 'from spack.version import ver' 'ver("1.2.3")' 200000 loops, best of 5: 1.54 usec per loop ``` Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/lang.py | 4 ++-- lib/spack/spack/spec.py | 2 +- lib/spack/spack/version/version_types.py | 7 ++++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/llnl/util/lang.py b/lib/spack/spack/llnl/util/lang.py index f12c2d7e47f8ce..8f9030ca1b7b3a 100644 --- a/lib/spack/spack/llnl/util/lang.py +++ b/lib/spack/spack/llnl/util/lang.py @@ -211,7 +211,7 @@ def setter(name, value): def tuplify(seq): """Helper for lazy_lexicographic_ordering().""" - return tuple((tuplify(x) if callable(x) else x) for x in seq()) + return tuple([(tuplify(x) if callable(x) else x) for x in seq()]) def lazy_eq(lseq, rseq): @@ -456,7 +456,7 @@ def __delitem__(self, key: K) -> None: del self.dict[key] def _cmp_iter(self): - for _, v in sorted(self.items()): + for _, v in sorted(self.dict.items()): yield v diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 647b3bcfedc485..2267100a5ecf20 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -992,7 +992,7 @@ def yaml_entry(self, flag_type): return flag_type, [str(flag) for flag in self[flag_type]] def _cmp_iter(self): - for k, v in sorted(self.items()): + for k, v in sorted(self.dict.items()): yield k def flags(): diff --git a/lib/spack/spack/version/version_types.py b/lib/spack/spack/version/version_types.py index 015afa6715b8f4..63641ecc3a5814 100644 --- a/lib/spack/spack/version/version_types.py +++ b/lib/spack/spack/version/version_types.py @@ -131,7 +131,7 @@ def parse_string_components(string: str) -> Tuple[VersionTuple, SeparatorTuple]: raise ValueError("Bad characters in version string: %s" % string) segments = SEGMENT_REGEX.findall(string) - separators: Tuple[str] = tuple(m[2] for m in segments) + separators: Tuple[str] = tuple([m[2] for m in segments]) prerelease: Tuple[int, ...] # (alpha|beta|rc) @@ -149,7 +149,7 @@ def parse_string_components(string: str) -> Tuple[VersionTuple, SeparatorTuple]: prerelease = (FINAL,) release: VersionComponentTuple = tuple( - int(m[0]) if m[0] else VersionStrComponent.from_string(m[1]) for m in segments + [int(m[0]) if m[0] else VersionStrComponent.from_string(m[1]) for m in segments] ) return (release, prerelease), separators @@ -245,7 +245,8 @@ def __init__(self, string: str, version: VersionTuple, separators: Tuple[str, .. @staticmethod def from_string(string: str) -> "StandardVersion": - return StandardVersion(string, *parse_string_components(string)) + version, separators = parse_string_components(string) + return StandardVersion(string, version, separators) @staticmethod def typemin() -> "StandardVersion": From 885d86977a07fe191b09501a049e77f4f8a3c04b Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 14 Jan 2026 11:48:51 +0100 Subject: [PATCH 010/337] concretize.py: round down in progress (#51830) Round down so 100% appears exactly once at the end of concretization Signed-off-by: Harmen Stoppels --- lib/spack/spack/concretize.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/concretize.py b/lib/spack/spack/concretize.py index ca473535f1ff89..2061b6f4b80114 100644 --- a/lib/spack/spack/concretize.py +++ b/lib/spack/spack/concretize.py @@ -160,9 +160,9 @@ def concretize_separately( ) ): ret.append((i, concrete)) - percentage = (j + 1) / len(args) * 100 + percentage = int((j + 1) / len(args) * 100) tty.verbose( - f"{duration:6.1f}s [{percentage:3.0f}%] {concrete.cformat('{hash:7}')} " + f"{duration:6.1f}s [{percentage:3d}%] {concrete.cformat('{hash:7}')} " f"{to_concretize[i].colored_str}" ) sys.stdout.flush() From c1af8e160aaf840122550d2a1bcb90003bb3226c Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 14 Jan 2026 14:02:47 +0100 Subject: [PATCH 011/337] solver: account for variant penalties correctly when only one of multiple values is "set" (#51847) Before, for a pattern like: ``` variant( "io", values=any_combination_of( "adios2", "cgns", "exodusii", "ffmpeg", "fides", "ioss", "netcdf", "xdmf" ).with_default("cgns,exodusii,ioss,netcdf"), description="Enable IO modules", ) requires("io=adios2", when="io=fides") ``` the fact that `io=adios2` was "set" was causing the penalty on `fides` to be disregarded, due to the projection on values. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index f0592cb7b4d4ad..03beb8549ecfdc 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1363,9 +1363,9 @@ variant_penalty(node(NodeID, Package), Variant, Value, Penalty) :- possible_variant_penalty(VariantID, Value, Penalty), not variant_default_value(node(NodeID, Package), Variant, Value), % variants set explicitly from a directive don't count as non-default - not attr("variant_set", node(NodeID, Package), Variant, _), + not attr("variant_set", node(NodeID, Package), Variant, Value), % variant values forced by propagation don't count as non-default - not propagate(node(NodeID, Package), variant_value(Variant, _, _)). + not propagate(node(NodeID, Package), variant_value(Variant, Value, _)). % -- Associate the definition's possible values with the node variant_possible_value(node(NodeID, Package), VariantName, Value) :- From f988ad759ba844e85a5ceeac356bdd888f4f4a1f Mon Sep 17 00:00:00 2001 From: Peter Scheibel Date: Wed, 14 Jan 2026 14:35:14 -0800 Subject: [PATCH 012/337] Singleton instantiation: properly display AttributeError (#51845) * catch AttributeError and re-raise it as something else to avoid it being silently discarded Signed-off-by: Peter Scheibel add a test Signed-off-by: Peter Scheibel better test Signed-off-by: Peter Scheibel * add test docstr Signed-off-by: Peter Scheibel * style Signed-off-by: Peter Scheibel * better fn name Signed-off-by: Peter Scheibel --------- Signed-off-by: Peter Scheibel --- lib/spack/spack/llnl/util/lang.py | 31 +++++++++++++++++-------- lib/spack/spack/test/llnl/util/lang.py | 32 +++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/lib/spack/spack/llnl/util/lang.py b/lib/spack/spack/llnl/util/lang.py index 8f9030ca1b7b3a..d6155b795106a9 100644 --- a/lib/spack/spack/llnl/util/lang.py +++ b/lib/spack/spack/llnl/util/lang.py @@ -723,16 +723,23 @@ def __init__(self, factory: Callable[[], object]): @property def instance(self): if self._instance is None: - instance = self.factory() - - if isinstance(instance, types.GeneratorType): - # if it's a generator, assign every value - for value in instance: - self._instance = value - else: - # if not, just assign the result like a normal singleton - self._instance = instance - + try: + instance = self.factory() + + if isinstance(instance, types.GeneratorType): + # if it's a generator, assign every value + for value in instance: + self._instance = value + else: + # if not, just assign the result like a normal singleton + self._instance = instance + except AttributeError as e: + # getattr will "absorb" an AttributeError that occurs + # during the execution of the factory method: we'd like + # to show that so wrap it in something that isn't absorbed + raise SingletonInstantiationError( + "AttrbuteError during creation of Singleton instance" + ) from e return self._instance def __getattr__(self, name): @@ -763,6 +770,10 @@ def __repr__(self): return repr(self.instance) +class SingletonInstantiationError(Exception): + """Error that indicates a singleton that cannot instantiate.""" + + def get_entry_points(*, group: str): """Wrapper for ``importlib.metadata.entry_points`` diff --git a/lib/spack/spack/test/llnl/util/lang.py b/lib/spack/spack/test/llnl/util/lang.py index 7effe85ebd0b83..6bcadf17beccf2 100644 --- a/lib/spack/spack/test/llnl/util/lang.py +++ b/lib/spack/spack/test/llnl/util/lang.py @@ -11,7 +11,14 @@ import pytest import spack.llnl.util.lang -from spack.llnl.util.lang import dedupe, match_predicate, memoized, pretty_date +from spack.llnl.util.lang import ( + Singleton, + SingletonInstantiationError, + dedupe, + match_predicate, + memoized, + pretty_date, +) @pytest.fixture() @@ -332,6 +339,29 @@ def test_fnmatch_multiple(): assert not regex.match("libbaz.so") +def _attr_error_factory(): + raise AttributeError("Could not make something") + + +def test_singleton_instantiation_attr_failure(): + """ + If an AttributeError occurs during the instantiation of a Singleton + object, we want to see that error. + """ + x = Singleton(_attr_error_factory) + with pytest.raises(SingletonInstantiationError) as last_exception: + x.something + + def follow_exceptions(e): + while e: + yield e + e = e.__cause__ or e.__context__ + + assert any( + "Could not make something" in str(e) for e in follow_exceptions(last_exception.value) + ) + + class TestPriorityOrderedMapping: @pytest.mark.parametrize( "elements,expected", From c3567d3b12c57f28590a00160c82ec060f7c7ba4 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 15 Jan 2026 11:54:51 +0100 Subject: [PATCH 013/337] solver: requiring at least a subset of default values of a multivalued variant should not influence concretization (#51851) When a package requires `foo=bar` for a multi-valued variant `foo` of a dependency, but the dependency specifies `foo=bar,baz` as a default, we should expect the default `foo=bar,baz` from the concretizer, and not `foo=bar`. The meaning of `foo=bar` is "at least bar", which is consistent with the defaults set by the dependency, so we expect it not to influence concretization -- it's a no-op. If the parent package meant to say "foo=bar" and nothing else, the syntax `foo:=bar` should have been used. This commit fixes a bug related to this (potentially intentional from a time before := syntax). Previously, the rule related to penalties was > Don't give a penalty to if there's any value set in this variant or if any value is propagated. With this change > Don't give a penalty to if is set in this variant or if is propagated. Signed-off-by: Massimiliano Culpo Signed-off-by: Harmen Stoppels Co-authored-by: Harmen Stoppels --- lib/spack/spack/solver/concretize.lp | 4 +-- lib/spack/spack/test/concretization/core.py | 9 +++++++ .../package.py | 25 +++++++++++++++++++ .../package.py | 18 +++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults/package.py create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults_dependent/package.py diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 03beb8549ecfdc..5303d28b22f3f1 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1484,9 +1484,9 @@ variant_default_not_used(node(ID, Package), Variant, Value) node_has_variant(node(ID, Package), Variant, VariantID), variant_type(VariantID, VariantType), VariantType == "multi", not attr("variant_value", node(ID, Package), Variant, Value), - not propagate(node(ID, Package), variant_value(Variant, _, _)), + not propagate(node(ID, Package), variant_value(Variant, Value, _)), % variant set explicitly don't count for this metric - not attr("variant_set", node(ID, Package), Variant, _), + not attr("variant_set", node(ID, Package), Variant, Value), attr("node", node(ID, Package)). % Treat 'none' in a special way - it cannot be combined with other diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index ad1adf5347e2fc..ebf9422b239a7c 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4866,3 +4866,12 @@ def test_penalties_for_variant_defined_by_function( """ s = default_mock_concretization(spec_str) assert s.satisfies(expected) + + +def test_default_values_used_if_subset_required_by_dependent(mock_packages): + """If a dependent requires *at least* a subset of default values of a multi-valued variant of + a dependency, that should not influence concretization; the default values should be used.""" + # multivalue-variant-multi-defaults-dependent requires myvariant=bar without baz. + a = spack.concretize.concretize_one("multivalue-variant-multi-defaults-dependent") + # we still end up using baz, and we don't drop it to avoid an extra dependency. + assert a.satisfies("%multivalue-variant-multi-defaults myvariant=bar,baz") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults/package.py new file mode 100644 index 00000000000000..ef18eabb8a4ea5 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults/package.py @@ -0,0 +1,25 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class MultivalueVariantMultiDefaults(Package): + homepage = "http://www.spack.llnl.gov" + url = "http://www.spack.llnl.gov/mpileaks-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + variant( + "myvariant", + default="bar,baz", + values=("bar", "baz"), + multi=True, + description="Type of libraries to install", + ) + + # conditional dep to incur a cost for packages to build when myvariant includes baz + depends_on("trivial-install-test-package", when="myvariant=baz") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults_dependent/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults_dependent/package.py new file mode 100644 index 00000000000000..baf8c7aa9a789d --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/multivalue_variant_multi_defaults_dependent/package.py @@ -0,0 +1,18 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class MultivalueVariantMultiDefaultsDependent(Package): + homepage = "http://www.spack.llnl.gov" + url = "http://www.spack.llnl.gov/mpileaks-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + # includes a subset of the default values `bar,baz`; we expect the request for myvariant=bar + # not to override the default myvariant=bar,baz + depends_on("multivalue-variant-multi-defaults myvariant=bar") From 5f469b3094aa9fe8ca42a680580b965b7c623795 Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Thu, 15 Jan 2026 10:08:59 -0800 Subject: [PATCH 014/337] release docs: document version update before cherry-picked backports (#51853) Signed-off-by: Gregory Becker --- lib/spack/docs/developer_guide.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/spack/docs/developer_guide.rst b/lib/spack/docs/developer_guide.rst index 0a7b8bba74751b..7bd8da9886aca6 100644 --- a/lib/spack/docs/developer_guide.rst +++ b/lib/spack/docs/developer_guide.rst @@ -865,6 +865,9 @@ The majority of the work is to cherry-pick the bug fixes, which ideally should b The backports pull request is always titled ``Backports vX.Y.Z`` and is labelled ``backports``. It is opened from a branch named ``backports/vX.Y.Z`` and targets the ``releases/vX.Y`` branch. +The first commit on the ``backports/vX.Y.Z`` branch should update the Spack version to ``X.Y.Z.dev0``, and should have the commit message ``set version to X.Y.Z.dev0``. +This ensures that if users check out an intermediate commit between two patch releases, Spack reports the version correctly. + Whenever a pull request labelled ``vX.Y.Z`` is merged, cherry-pick the associated squashed commit on ``develop`` to the ``backports/vX.Y.Z`` branch. For pull requests that were rebased (or not squashed), cherry-pick each associated commit individually. Never force-push to the ``backports/vX.Y.Z`` branch. From 9697895d1c15bcf690b47b26ee4a0730010df153 Mon Sep 17 00:00:00 2001 From: Victor Brunini Date: Thu, 15 Jan 2026 16:56:50 -0500 Subject: [PATCH 015/337] undevelop: Fix --all. (#51848) * undevelop: Fix --all. Which previously failed with: "TypeError: 'NoneType' object is not iterable" Signed-off-by: Victor Brunini * undevelop: Refactor based on review suggestions. Signed-off-by: Victor Brunini --------- Signed-off-by: Victor Brunini --- lib/spack/spack/cmd/undevelop.py | 26 +++++++++------------- lib/spack/spack/test/cmd/undevelop.py | 32 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/lib/spack/spack/cmd/undevelop.py b/lib/spack/spack/cmd/undevelop.py index 93b900b857430f..373f832c8720f6 100644 --- a/lib/spack/spack/cmd/undevelop.py +++ b/lib/spack/spack/cmd/undevelop.py @@ -7,6 +7,7 @@ import spack.cmd import spack.config import spack.llnl.util.tty as tty +import spack.spec from spack.cmd.common import arguments description = "remove specs from an environment" @@ -32,7 +33,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: arguments.add_common_arguments(subparser, ["specs"]) -def _update_config(specs_to_remove, remove_all=False): +def _update_config(specs_to_remove): def change_fn(dev_config): modified = False for spec in specs_to_remove: @@ -40,38 +41,31 @@ def change_fn(dev_config): tty.msg("Undevelop: removing {0}".format(spec.name)) del dev_config[spec.name] modified = True - if remove_all and dev_config: - dev_config.clear() - modified = True return modified spack.config.update_all("develop", change_fn) def undevelop(parser, args): - remove_specs = None - remove_all = False + # TODO: when https://github.com/spack/spack/pull/35307 is merged, + # an active env is not required if a scope is specified + env = spack.cmd.require_active_env(cmd_name="undevelop") + if args.all: - remove_all = True + remove_specs = [spack.spec.Spec(s) for s in env.dev_specs] else: remove_specs = spack.cmd.parse_specs(args.specs) - # TODO: when https://github.com/spack/spack/pull/35307 is merged, - # an active env is not required if a scope is specified - env = spack.cmd.require_active_env(cmd_name="undevelop") with env.write_transaction(): - _update_config(remove_specs, remove_all) + _update_config(remove_specs) if args.apply_changes: for spec in remove_specs: env.apply_develop(spec, path=None) updated_all_dev_specs = set(spack.config.get("develop")) - remove_spec_names = set(x.name for x in remove_specs) - if remove_all: - not_fully_removed = updated_all_dev_specs - else: - not_fully_removed = updated_all_dev_specs & remove_spec_names + remove_spec_names = set(x.name for x in remove_specs) + not_fully_removed = updated_all_dev_specs & remove_spec_names if not_fully_removed: tty.msg( diff --git a/lib/spack/spack/test/cmd/undevelop.py b/lib/spack/spack/test/cmd/undevelop.py index 711f1e8f708972..93a4197dfd88d6 100644 --- a/lib/spack/spack/test/cmd/undevelop.py +++ b/lib/spack/spack/test/cmd/undevelop.py @@ -43,6 +43,38 @@ def test_undevelop(tmp_path: pathlib.Path, mutable_config, mock_packages, mutabl assert not after.satisfies("dev_path=*") +def test_undevelop_all( + tmp_path: pathlib.Path, mutable_config, mock_packages, mutable_mock_env_path +): + # setup environment + envdir = tmp_path / "env" + envdir.mkdir() + with working_dir(str(envdir)): + with open("spack.yaml", "w", encoding="utf-8") as f: + f.write( + """\ +spack: + specs: + - mpich + + develop: + mpich: + spec: mpich@1.0 + path: /fake/path +""" + ) + + env("create", "test", "./spack.yaml") + with ev.read("test"): + before = spack.concretize.concretize_one("mpich") + undevelop("--all") + after = spack.concretize.concretize_one("mpich") + + # Removing dev spec from environment changes concretization + assert before.satisfies("dev_path=*") + assert not after.satisfies("dev_path=*") + + def test_undevelop_nonexistent( tmp_path: pathlib.Path, mutable_config, mock_packages, mutable_mock_env_path ): From f38e413f807f0e7a0de47b7e7da1221d17e0225b Mon Sep 17 00:00:00 2001 From: Peter Scheibel Date: Fri, 16 Jan 2026 01:24:49 -0800 Subject: [PATCH 016/337] solver: disable compiler mixing per-language (#51796) When `compiler_mixing: false` is configured, Spack was forcing the C, C++, and Fortran compilers to be the same across all dependencies. The intent was rather to restrict this on a per-language basis, and this commit does that (and adds a test for it). Signed-off-by: Peter Scheibel --- lib/spack/spack/solver/concretize.lp | 5 +++-- lib/spack/spack/test/concretization/core.py | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 5303d28b22f3f1..a2914f8d695f3a 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1716,8 +1716,9 @@ unification_set_compiler("root", node(CompilerHash, Compiler), Language) :- #defined allow_mixing/1. % You can't have >1 compiler for a given language if mixing is disabled -error(100, "Compiler mixing is disabled") :- - #count { CompilerNode : unification_set_compiler("root", CompilerNode, Language) } > 1. +error(100, "Compiler mixing is disabled for the {0} language", Language) :- + language(Language), + #count { CompilerNode : unification_set_compiler("root", CompilerNode, Language) } > 1. %----------------------------------------------------------------------------- % Runtimes diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index ebf9422b239a7c..43b77b0c390122 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -497,6 +497,10 @@ def test_disable_mixing_prevents_mixing(self): with pytest.raises(spack.error.UnsatisfiableSpecError): spack.concretize.concretize_one("dt-diamond%clang ^dt-diamond-bottom%gcc") + def test_disable_mixing_is_per_language(self): + with spack.config.override("concretizer", {"compiler_mixing": False}): + spack.concretize.concretize_one("openblas %c=llvm %fortran=gcc") + def test_disable_mixing_override_by_package(self): with spack.config.override("concretizer", {"compiler_mixing": ["dt-diamond-bottom"]}): root = spack.concretize.concretize_one("dt-diamond%clang ^dt-diamond-bottom%gcc") From a74fd6c6c8ef29d865ffd71375499d6ed535ffa5 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 16 Jan 2026 14:02:33 +0100 Subject: [PATCH 017/337] input_analysis.py: optimize dependency analysis (#51725) This commit improves the performance of the static analysis used to determine possible dependencies in the solver setup phase. 1. Iterate directly over `PackageBase.dependencies` (keyed by `when_spec`) instead of using `dependencies_by_name`. This avoids the overhead of reconstructing the dependency dictionary, which involves allocating new structures and hashing abstract specs. It also ensures that `unreachable()` checks are performed once per condition group, rather than once per dependency name. 2. Skip reachability checks for conditional dependencies if the dependency is already present in the graph. If a package depends on `foo` unconditionally, subsequent conditional dependencies on `foo` (e.g. with different variants) no longer trigger an `unreachable()` check for that condition. Signed-off-by: Harmen Stoppels * add debug statement Signed-off-by: Harmen Stoppels * print condition Signed-off-by: Harmen Stoppels --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/solver/input_analysis.py | 67 ++++++++++++++---------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/lib/spack/spack/solver/input_analysis.py b/lib/spack/spack/solver/input_analysis.py index 440188cb881329..f12907e07ca528 100644 --- a/lib/spack/spack/solver/input_analysis.py +++ b/lib/spack/spack/solver/input_analysis.py @@ -165,44 +165,55 @@ def possible_dependencies( continue pkg_cls = self.repo.get_pkg_class(pkg_name=pkg_name) - for name, conditions in pkg_cls.dependencies_by_name(when=True).items(): - if all(self.unreachable(pkg_name=pkg_name, when_spec=x) for x in conditions): - tty.debug( - f"[{__name__}] Not adding {name} as a dep of {pkg_name}, because " - f"conditions cannot be met" - ) - continue + for when_spec, dependencies in pkg_cls.dependencies.items(): + # Check if we need to process this condition at all. We can skip the unreachable + # check if all dependencies in this condition are already accounted for. + new_dependencies: List[str] = [] + for name, dep in dependencies.items(): + if strict_depflag: + if dep.depflag != allowed_deps: + continue + elif not (dep.depflag & allowed_deps): + continue - if not self._has_deptypes( - conditions, allowed_deps=allowed_deps, strict=strict_depflag - ): - continue + if name in edges[pkg_name] or name in virtuals: + continue + + new_dependencies.append(name) - if name in virtuals: + if not new_dependencies: continue - dep_names = set() - if self.is_virtual(name): - virtuals.add(name) - if expand_virtuals: - providers = self.providers_for(name) - dep_names = {spec.name for spec in providers} - else: - dep_names = {name} + if self.unreachable(pkg_name=pkg_name, when_spec=when_spec): + tty.debug( + f"[{__name__}] Skipping {', '.join(new_dependencies)} dependencies of " + f"{pkg_name}, because {when_spec} is not met" + ) + continue - edges[pkg_name].update(dep_names) + for name in new_dependencies: + dep_names: Set[str] = set() + if self.is_virtual(name): + virtuals.add(name) + if expand_virtuals: + providers = self.providers_for(name) + dep_names = {spec.name for spec in providers} + else: + dep_names = {name} - if not transitive: - continue + edges[pkg_name].update(dep_names) - for dep_name in dep_names: - if dep_name in edges: + if not transitive: continue - if not self._is_possible(pkg_name=dep_name): - continue + for dep_name in dep_names: + if dep_name in edges: + continue + + if not self._is_possible(pkg_name=dep_name): + continue - stack.append(dep_name) + stack.append(dep_name) real_packages = set(edges) if not transitive: From 9c7f84622495339c3b34d58ac0b7d62758b97f0b Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 16 Jan 2026 14:04:55 +0100 Subject: [PATCH 018/337] directives.py/asp.py: use literal spec in when condition (#51856) Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives.py | 11 ++--------- lib/spack/spack/solver/asp.py | 8 ++++---- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index 24c116a4b2eb6e..bee4449e79e9ae 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -497,12 +497,6 @@ def _execute_provides(pkg: PackageType, specs: Tuple[SpecType, ...], when: WhenT when_spec = _make_when_spec(when) if not when_spec: return - elif when_spec is EMPTY_SPEC: - when_spec = spack.spec.Spec() # this function mutates, so can't use EMPTY_SPEC - - # ``when`` specs for ``provides()`` need a name, as they are used - # to build the ProviderIndex. - when_spec.name = pkg.name spec_objs = [spack.spec.Spec(x) for x in specs] spec_names = [x.name for x in spec_objs] @@ -511,10 +505,9 @@ def _execute_provides(pkg: PackageType, specs: Tuple[SpecType, ...], when: WhenT for provided_spec in spec_objs: if pkg.name == provided_spec.name: - raise CircularReferenceError("Package '%s' cannot provide itself." % pkg.name) + raise CircularReferenceError(f"Package '{pkg.name}' cannot provide itself.") - provided_set = pkg.provided.setdefault(when_spec, set()) - provided_set.add(provided_spec) + pkg.provided.setdefault(when_spec, set()).add(provided_spec) @directive("splice_specs") diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 1108fbd04cc5f5..0ad7b5c825c08e 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -1794,21 +1794,21 @@ def condition( return condition_id - def package_provider_rules(self, pkg): + def package_provider_rules(self, pkg: Type[spack.package_base.PackageBase]) -> None: for vpkg_name in pkg.provided_virtual_names(): if vpkg_name not in self.possible_virtuals: continue self.gen.fact(fn.pkg_fact(pkg.name, fn.possible_provider(vpkg_name))) for when, provided in pkg.provided.items(): - for vpkg in sorted(provided): + for vpkg in sorted(provided): # type: ignore[type-var] if vpkg.name not in self.possible_virtuals: continue - msg = f"{pkg.name} provides {vpkg} when {when}" + msg = f"{pkg.name} provides {vpkg}{'' if when == EMPTY_SPEC else f' when {when}'}" condition_id = self.condition(when, vpkg, required_name=pkg.name, msg=msg) self.gen.fact( - fn.pkg_fact(when.name, fn.provider_condition(condition_id, vpkg.name)) + fn.pkg_fact(pkg.name, fn.provider_condition(condition_id, vpkg.name)) ) self.gen.newline() From 7868be74d84e97c00e1e4677aefab3b98dfb41af Mon Sep 17 00:00:00 2001 From: Victor Brunini Date: Fri, 16 Jan 2026 15:24:58 -0500 Subject: [PATCH 019/337] spec.py: faster __hash__ for abstract specs (#51536) About 95% of the time, `Spec.__hash__` is called on abstract specs without dependencies, like +foo and @1.2. This commit improves the speed of the hash function for this case. The load time of all package classes is reduced by about 4.3%. Signed-off-by: Victor Brunini --- lib/spack/spack/spec.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 2267100a5ecf20..f57432757649f6 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -5052,9 +5052,18 @@ def __hash__(self): self._dunder_hash = self.dag_hash_bit_prefix(64) return self._dunder_hash - # This is the normal hash for lazy_lexicographic_ordering. It's - # slow for large specs because it traverses the whole spec graph, - # so we hope it only runs on abstract specs, which are small. + if not self._dependencies: + return hash( + ( + self.name, + self.namespace, + self.versions, + (self.variants if self.variants.dict else None), + self.architecture, + self.abstract_hash, + ) + ) + return hash(lang.tuplify(self._cmp_iter)) def __getstate__(self): From c516610f2b15e9b73925f95034a8caaa104a7def Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 19 Jan 2026 10:18:51 +0100 Subject: [PATCH 020/337] directives_meta.py: simplify _remove_directives (#51858) * Since the input arg is a tuple/list, start with iteration, avoiding one level of guaranteed recursion and an `isinstance` check. * Avoid the generator expression which makes Python cycle between two stack frames and doing another function call. Instead use native iteration. Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives_meta.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/lib/spack/spack/directives_meta.py b/lib/spack/spack/directives_meta.py index 174ef29ed4500f..3e8b51426d70fd 100644 --- a/lib/spack/spack/directives_meta.py +++ b/lib/spack/spack/directives_meta.py @@ -101,22 +101,23 @@ def pop_default_args() -> dict: return DirectiveMeta._default_args.pop() @staticmethod - def _remove_directives(arg): + def _remove_directives(args): # If any of the arguments are executors returned by a directive passed as an argument, # don't execute them lazily. Instead, let the called directive handle them. This allows # nested directive calls in packages. The caller can return the directive if it should be # queued. Nasty, but it's the best way I can think of to avoid side effects if directive # results are passed as args directives = DirectiveMeta._directives_to_be_executed - if isinstance(arg, (list, tuple)): - # Descend into args that are lists or tuples - for a in arg: - DirectiveMeta._remove_directives(a) - else: - # Remove directives args from the exec queue - remove = next((d for d in directives if d is arg), None) - if remove is not None: - directives.remove(remove) + for arg in args: + if isinstance(arg, (list, tuple)): + # Descend into args that are lists or tuples + DirectiveMeta._remove_directives(arg) + else: + # Remove directives args from the exec queue + for directive in directives: + if arg is directive: + directives.remove(directive) # iterations ends, so mutation is fine + break @staticmethod def directive(dicts: Optional[Union[Sequence[str], str]] = None) -> Callable: From c28dac9b198d6dc4d3e2a7fa3ed674a16fcf2ae8 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Mon, 19 Jan 2026 18:57:29 +0100 Subject: [PATCH 021/337] solver: improve the number of cache hits for triggers and effects (#51863) This is done by using functools.lru_cache on a few functions returning transformation functions, so that they always return the same object whenever they are called with the same argument. Another improvement is to collect triggers and effects for all packages before emitting the rules. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 66 +++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 0ad7b5c825c08e..2f0a7980969c3d 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -4,6 +4,7 @@ import collections import collections.abc import enum +import functools import gzip import io import itertools @@ -202,15 +203,43 @@ def specify(spec): return spack.spec.Spec(spec) +# Caching because the returned function id is used as a cache key +@functools.lru_cache(maxsize=None) def remove_facts(*to_be_removed: str) -> TransformFunction: """Returns a transformation function that removes facts from the input list of facts.""" def _remove(name: str, spec: spack.spec.Spec, facts: List[AspFunction]) -> List[AspFunction]: - return list(filter(lambda x: x.args[0] not in to_be_removed, facts)) + return [x for x in facts if x.args[0] not in to_be_removed] return _remove +def identity_for_facts( + name: str, spec: spack.spec.Spec, facts: List[AspFunction] +) -> List[AspFunction]: + return facts + + +# Caching because the returned function id is used as a cache key +@functools.lru_cache(maxsize=None) +def dependency_holds( + *, dependency_flags: dt.DepFlag, pkg_cls: Type[spack.package_base.PackageBase] +) -> TransformFunction: + def _transform_fn( + name: str, input_spec: spack.spec.Spec, requirements: List[AspFunction] + ) -> List[AspFunction]: + result = remove_facts("node", "virtual_node")(name, input_spec, requirements) + [ + fn.attr("dependency_holds", pkg_cls.name, name, dt.flag_to_string(t)) + for t in dt.ALL_FLAGS + if t & dependency_flags + ] + if name not in pkg_cls.extendees: + return result + return result + [fn.attr("extends", pkg_cls.name, name)] + + return _transform_fn + + def dag_closure_by_deptype( name: str, spec: spack.spec.Spec, facts: List[AspFunction] ) -> List[AspFunction]: @@ -1510,10 +1539,6 @@ def pkg_rules(self, pkg, tests): self.package_requirement_rules(pkg) - # trigger and effect tables - self.trigger_rules() - self.effect_rules() - def trigger_rules(self): """Flushes all the trigger rules collected so far, and clears the cache.""" if not self._trigger_cache: @@ -1825,7 +1850,6 @@ def package_provider_rules(self, pkg: Type[spack.package_base.PackageBase]) -> N def package_dependencies_rules(self, pkg): """Translate ``depends_on`` directives into ASP logic.""" - for cond, deps_by_name in pkg.dependencies.items(): cond_str = str(cond) cond_str_suffix = f" when {cond_str}" if cond_str else "" @@ -1845,35 +1869,13 @@ def package_dependencies_rules(self, pkg): continue msg = f"{pkg.name} depends on {dep.spec}{cond_str_suffix}" - - def dependency_holds( - name: str, input_spec: spack.spec.Spec, requirements: List[AspFunction] - ) -> List[AspFunction]: - # TODO: `dependency_holds` is used as a cache key, and is a unique object in - # every iteration of the loop. This prevents deduplication of identical - # "effects" when unique when specs impose the same dependency. We cannot move - # this out of the loop, because the effect cache is keyed only by a spec, and - # not by the dependency type. - result = remove_facts("node", "virtual_node")( - name, input_spec, requirements - ) + [ - fn.attr("dependency_holds", pkg.name, name, dt.flag_to_string(t)) - for t in dt.ALL_FLAGS - if t & depflag - ] - if name not in pkg.extendees: - return result - return result + [fn.attr("extends", pkg.name, name)] - context = ConditionContext() context.source = ConstraintOrigin.append_type_suffix( pkg.name, ConstraintOrigin.DEPENDS_ON ) context.transform_required = _track_dependencies - context.transform_imposed = dependency_holds - + context.transform_imposed = dependency_holds(dependency_flags=depflag, pkg_cls=pkg) self.condition(cond, dep.spec, required_name=pkg.name, msg=msg, context=context) - self.gen.newline() def _gen_match_variant_splice_constraints( @@ -3021,6 +3023,10 @@ def setup( self.pkg_rules(pkg, tests=self.tests) self.preferred_variants(pkg) + self.gen.h1("Condition Triggers and Imposed Effects") + self.trigger_rules() + self.effect_rules() + self.gen.h1("Special variants") self.define_auto_variant("dev_path", multi=False) self.define_auto_variant("commit", multi=False) @@ -3210,7 +3216,7 @@ def virtual_handler( ) # Default is to remove node-like attrs, override here context.transform_required = virtual_handler - context.transform_imposed = lambda x, y, z: z + context.transform_imposed = identity_for_facts try: subcondition_id = self.condition( From 0b9fd37fdc4a74cba69768056dc6424d4a2d57c6 Mon Sep 17 00:00:00 2001 From: Victor Brunini Date: Mon, 19 Jan 2026 13:00:52 -0500 Subject: [PATCH 022/337] new_installer.py: force new symlink for logs (#51865) When the stage dir is dirty, a symlink for the logs may already exists, so unlink it. Signed-off-by: Victor Brunini --- lib/spack/spack/new_installer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 404da25f7dfc2b..baf92e62dd2a95 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -456,6 +456,13 @@ def _install( stage.destroy() stage.create() + # For develop packages or non-develop packages with --keep-stage there may be a + # pre-existing symlink at pkg.log_path which would cause the new symlink to fail. + # Try removing it if it exists. + try: + os.unlink(pkg.log_path) + except OSError: + pass os.symlink(log_path, pkg.log_path) send_state("staging", state_stream) From 144edfd79116c93f9f101af7f87fc96d6df3f3c8 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 19 Jan 2026 20:38:23 +0100 Subject: [PATCH 023/337] directives_meta.py: reduce iterations (#51866) A directive is callable, so that's a good way to avoid this loop. Turns out, only 0.07% of the time a callable is passed as an argument to a directive, so this saves a whole lot of redundant iterations over existing directives Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives_meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/directives_meta.py b/lib/spack/spack/directives_meta.py index 3e8b51426d70fd..0700883854cd5d 100644 --- a/lib/spack/spack/directives_meta.py +++ b/lib/spack/spack/directives_meta.py @@ -112,7 +112,7 @@ def _remove_directives(args): if isinstance(arg, (list, tuple)): # Descend into args that are lists or tuples DirectiveMeta._remove_directives(arg) - else: + elif callable(arg): # directives are always callable, and very rare # Remove directives args from the exec queue for directive in directives: if arg is directive: From 0a6dc8d0d0aa06330f3df7d1a7bc0429890069f5 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 20 Jan 2026 01:27:47 +0100 Subject: [PATCH 024/337] solver: remove `attr("track_dependencies", ...)` (#51827) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 8 -------- lib/spack/spack/solver/concretize.lp | 7 +++++-- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 2f0a7980969c3d..c8bfed43b0c708 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -1338,12 +1338,6 @@ def impose_context(self) -> ConditionIdContext: return ctxt -def _track_dependencies( - name: str, input_spec: spack.spec.Spec, requirements: List[AspFunction] -) -> List[AspFunction]: - return requirements + [fn.attr("track_dependencies", name)] - - class SpackSolverSetup: """Class to set up and run a Spack concretization solve.""" @@ -1873,7 +1867,6 @@ def package_dependencies_rules(self, pkg): context.source = ConstraintOrigin.append_type_suffix( pkg.name, ConstraintOrigin.DEPENDS_ON ) - context.transform_required = _track_dependencies context.transform_imposed = dependency_holds(dependency_flags=depflag, pkg_cls=pkg) self.condition(cond, dep.spec, required_name=pkg.name, msg=msg, context=context) self.gen.newline() @@ -3444,7 +3437,6 @@ class SpecBuilder: r"^dependency_holds$", r"^package_hash$", r"^root$", - r"^track_dependencies$", r"^uses_virtual$", r"^variant_default_value_from_cli$", r"^virtual_node$", diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index a2914f8d695f3a..7911d88bfc5281 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -839,9 +839,12 @@ concrete(PackageNode) :- attr("hash", PackageNode, _), attr("node", PackageNode) % Dependencies of any type imply that one package "depends on" another depends_on(PackageNode, DependencyNode) :- attr("depends_on", PackageNode, DependencyNode, _). -% a dependency holds if its condition holds and if it is not concrete. % Dependencies of concrete specs don't need to be resolved -- they arise from the concrete specs themselves. -attr("track_dependencies", Node) :- build(Node). +do_not_impose(EffectID, node(X, Package)) :- + trigger_and_effect(Package, _, TriggerID, EffectID), + trigger_condition_holds(TriggerID, node(X, Package)), + imposed_constraint(EffectID, "dependency_holds", Package, _, _), + concrete(node(X, Package)). % If a dependency holds on a package node, there must be one and only one dependency node satisfying it 1 { attr("depends_on", PackageNode, node(0..Y-1, Dependency), Type) : max_dupes(Dependency, Y) } 1 From 2af287a1fa09b7dd736fff993b44df6247b7113e Mon Sep 17 00:00:00 2001 From: Victor Brunini Date: Tue, 20 Jan 2026 03:04:52 -0500 Subject: [PATCH 025/337] directives.py: cache Spec objects by string (#51537) When loading packages, about 27% of the spec strings are duplicates. This commit ensures that only a single Spec instance per spec string is created, which has a few advantages: * Reduces spec parsing * Makes lookup in `when` keyed dictionaries fast (cpython uses `is` before calling `__eq__`) Signed-off-by: Victor Brunini Signed-off-by: Harmen Stoppels Co-authored-by: Harmen Stoppels --- lib/spack/spack/directives.py | 29 +++++++++++++++++++---------- lib/spack/spack/spec.py | 24 +++++++++++++----------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index bee4449e79e9ae..e0e92c8e29f53b 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -45,7 +45,7 @@ def _execute_example_directive(pkg, arg1, arg2): import re import warnings from functools import partial -from typing import Any, Callable, List, Optional, Tuple, Type, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union import spack.deptypes as dt import spack.error @@ -97,6 +97,15 @@ def _execute_example_directive(pkg, arg1, arg2): Patcher = Callable[[Union[PackageType, Dependency]], None] PatchesType = Union[Patcher, str, List[Union[Patcher, str]]] +SPEC_CACHE: Dict[str, spack.spec.Spec] = {} + + +def get_spec(spec_str: str) -> spack.spec.Spec: + """Get a spec from the cache, or create it if not present.""" + if spec_str not in SPEC_CACHE: + SPEC_CACHE[spec_str] = spack.spec._ImmutableSpec(spec_str) + return SPEC_CACHE[spec_str] + def _make_when_spec(value: WhenType) -> Optional[spack.spec.Spec]: """Create a ``Spec`` that indicates when a directive should be applied. @@ -140,7 +149,7 @@ def _make_when_spec(value: WhenType) -> Optional[spack.spec.Spec]: return EMPTY_SPEC # This is conditional on the spec - return spack.spec.Spec(value) + return get_spec(value) SubmoduleCallback = Callable[[spack.package_base.PackageBase], Union[str, List[str], bool]] @@ -293,7 +302,7 @@ def _execute_conflicts(pkg: PackageType, conflict_spec, when, msg): # Save in a list the conflicts and the associated custom messages conflict_spec_list = pkg.conflicts.setdefault(when_spec, []) msg_with_name = f"{pkg.name}: {msg}" if msg is not None else msg - conflict_spec_list.append((spack.spec.Spec(conflict_spec), msg_with_name)) + conflict_spec_list.append((get_spec(conflict_spec), msg_with_name)) @directive("dependencies") @@ -317,7 +326,7 @@ def depends_on( patches: single result of :py:func:`patch` directive, a ``str`` to be passed to ``patch``, or a list of these """ - dep_spec = spack.spec.Spec(spec) + dep_spec = get_spec(spec) return partial(_execute_depends_on, spec=dep_spec, when=when, type=type, patches=patches) @@ -425,7 +434,7 @@ def _execute_redistribute( if not when_spec: return if source is False: - max_constraint = spack.spec.Spec(f"{pkg.name}@{when_spec.versions}") + max_constraint = get_spec(f"{pkg.name}@{when_spec.versions}") if not max_constraint.satisfies(when_spec): raise DirectiveError("Source distribution can only be disabled for versions") @@ -466,14 +475,14 @@ def _execute_extends( if not when_spec: return - dep_spec = spack.spec.Spec(spec) + dep_spec = get_spec(spec) _execute_depends_on(pkg, dep_spec, when=when, type=type, patches=patches) # When extending python, also add a dependency on python-venv. This is done so that # Spack environment views are Python virtual environments. if dep_spec.name == "python" and not pkg.name == "python-venv": - _execute_depends_on(pkg, spack.spec.Spec("python-venv"), when=when, type=("build", "run")) + _execute_depends_on(pkg, get_spec("python-venv"), when=when, type=("build", "run")) pkg.extendees[dep_spec.name] = (dep_spec, when_spec) @@ -498,7 +507,7 @@ def _execute_provides(pkg: PackageType, specs: Tuple[SpecType, ...], when: WhenT if not when_spec: return - spec_objs = [spack.spec.Spec(x) for x in specs] + spec_objs = [get_spec(x) for x in specs] spec_names = [x.name for x in spec_objs] if len(spec_names) > 1: pkg.provided_together.setdefault(when_spec, []).append(set(spec_names)) @@ -545,7 +554,7 @@ def _execute_can_splice( ) if when_spec is None: return - pkg.splice_specs[when_spec] = (spack.spec.Spec(target), match_variants) + pkg.splice_specs[when_spec] = (get_spec(target), match_variants) @directive("patches") @@ -1003,7 +1012,7 @@ def _execute_requires( # Save in a list the requirements and the associated custom messages requirement_list = pkg.requirements.setdefault(when_spec, []) msg_with_name = f"{pkg.name}: {msg}" if msg is not None else msg - requirements = tuple(spack.spec.Spec(s) for s in requirement_specs) + requirements = tuple(get_spec(s) for s in requirement_specs) requirement_list.append((requirements, policy, msg_with_name)) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index f57432757649f6..3f359a4b442f56 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -6023,12 +6023,14 @@ class SpecMutationError(spack.error.SpecError): """Raised when a mutation is attempted with invalid attributes.""" -class _EmptySpec(Spec): - """An immutable empty Spec that prevents a class of accidental mutations.""" +class _ImmutableSpec(Spec): + """An immutable Spec that prevents a class of accidental mutations.""" - def __init__(self) -> None: + _mutable: bool + + def __init__(self, spec_like: Optional[str] = None) -> None: object.__setattr__(self, "_mutable", True) - super().__init__() + super().__init__(spec_like) object.__delattr__(self, "_mutable") def __setstate__(self, state) -> None: @@ -6037,21 +6039,21 @@ def __setstate__(self, state) -> None: object.__delattr__(self, "_mutable") def constrain(self, *args, **kwargs) -> bool: - raise TypeError("EmptySpec is immutable and cannot be modified") + assert self._mutable + return super().constrain(*args, **kwargs) def add_dependency_edge(self, *args, **kwargs): - raise TypeError("EmptySpec is immutable and cannot be modified") + assert self._mutable + return super().add_dependency_edge(*args, **kwargs) def __setattr__(self, name, value) -> None: - if not getattr(self, "_mutable", False): - raise TypeError("EmptySpec is immutable and cannot be modified") + assert self._mutable super().__setattr__(name, value) def __delattr__(self, name) -> None: - if name != "_mutable": - raise TypeError("EmptySpec is immutable and cannot be modified") + assert self._mutable object.__delattr__(self, name) #: Immutable empty spec, for fast comparisons and reduced memory usage. -EMPTY_SPEC = _EmptySpec() +EMPTY_SPEC = _ImmutableSpec() From 22cb467a424d7d7471a340045ae35e2acf383ea5 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 20 Jan 2026 10:06:34 +0100 Subject: [PATCH 026/337] solver: remove dead code (#51868) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index c8bfed43b0c708..199f582a9eb482 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -1368,8 +1368,6 @@ def __init__(self, tests: spack.concretize.TestsType = False): self.version_constraints: Set = set() self.target_constraints: Set = set() self.default_targets: List = [] - self.compiler_version_constraints: Set = set() - self.post_facts: List = [] self.variant_ids_by_def_id: Dict[int, int] = {} self.reusable_and_possible: ConcreteSpecsByHash = ConcreteSpecsByHash() @@ -2710,17 +2708,6 @@ def versions_for(v): for version in sorted(possible_versions): self.possible_versions[pkg_name][version].append(Provenance.VIRTUAL_CONSTRAINT) - def define_compiler_version_constraints(self): - for constraint in sorted(self.compiler_version_constraints): - for compiler_id, compiler in enumerate(self.possible_compilers): - if compiler.spec.satisfies(constraint): - self.gen.fact( - fn.compiler_version_satisfies( - constraint.name, constraint.versions, compiler_id - ) - ) - self.gen.newline() - def define_target_constraints(self): def _all_targets_satisfiying(single_constraint): allowed_targets = [] @@ -3042,9 +3029,6 @@ def setup( self.collect_virtual_constraints() self.define_version_constraints() - self.gen.h1("Compiler Version Constraints") - self.define_compiler_version_constraints() - self.gen.h1("Target Constraints") self.define_target_constraints() From 9cc0348cb2259c9f39959c622fdfbbe79e7da29f Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 20 Jan 2026 11:38:30 +0100 Subject: [PATCH 027/337] spec.py: fix _constrain_dependencies (#51870) When constraining dependencies, dependency specs that do not appear in the left hand-side should be copied as new Spec instances from the right-hand side, to avoid state sharing. Otherwise, a subsequent `constrain` call may mutate not only the spec that is being constrained, but also the spec it was previously constrained with. Signed-off-by: Harmen Stoppels --- lib/spack/spack/spec.py | 59 +++++++++++++++++--------- lib/spack/spack/test/spec_semantics.py | 15 +++++++ 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 3f359a4b442f56..963f0f7b3a7957 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -795,6 +795,26 @@ def copy(self, *, keep_virtuals: bool = True, keep_parent: bool = True) -> "Depe when=self.when, ) + def _constrain(self, other: "DependencySpec") -> bool: + """Constrain this edge with another edge. Precondition: parent and child of self and other + are compatible, and both edges have the same when condition. Used as an internal helper + function in Spec.constrain. + + Args: + other: edge to use as constraint + + Returns: + True if the current edge was changed, False otherwise. + """ + changed = False + changed |= self.spec.constrain(other.spec) + changed |= self.update_deptypes(other.depflag) + changed |= self.update_virtuals(other.virtuals) + if not self.direct and other.direct: + changed = True + self.direct = True + return changed + def _cmp_iter(self): yield self.parent.name if self.parent else None yield self.spec.name if self.spec else None @@ -3166,29 +3186,28 @@ def _constrain_dependencies(self, other: "Spec", resolve_virtuals: bool = True) if not other._intersects_dependencies(self, resolve_virtuals=resolve_virtuals): raise UnsatisfiableDependencySpecError(other, self) - if any(not d.name for d in other.traverse(root=False)): - raise UnconstrainableDependencySpecError(other) - - reference_spec = self.copy(deps=True) - for edge in other.edges_to_dependencies(): - existing = [ - e for e in self.edges_to_dependencies(edge.spec.name) if e.when == edge.when - ] - if existing: - existing[0].spec.constrain(edge.spec) - existing[0].update_deptypes(edge.depflag) - existing[0].update_virtuals(edge.virtuals) - existing[0].direct |= edge.direct + for d in other.traverse(root=False): + if not d.name: + raise UnconstrainableDependencySpecError(other) + changed = False + for other_edge in other.edges_to_dependencies(): + # Find the first edge in self that matches other_edge by name and when clause. + for self_edge in self.edges_to_dependencies(other_edge.spec.name): + if self_edge.when == other_edge.when: + changed |= self_edge._constrain(other_edge) + break else: + # Otherwise, a copy of the edge is added as a constraint to self. + changed = True self.add_dependency_edge( - edge.spec, - depflag=edge.depflag, - virtuals=edge.virtuals, - direct=edge.direct, - propagation=edge.propagation, - when=edge.when, + other_edge.spec.copy(deps=True), + depflag=other_edge.depflag, + virtuals=other_edge.virtuals, + direct=other_edge.direct, + propagation=other_edge.propagation, + when=other_edge.when, # no need to copy; when conditions are immutable ) - return self != reference_spec + return changed def constrained(self, other, deps=True): """Return a constrained copy without modifying this spec.""" diff --git a/lib/spack/spack/test/spec_semantics.py b/lib/spack/spack/test/spec_semantics.py index 7046f1298db4cc..18ff918a53f83d 100644 --- a/lib/spack/spack/test/spec_semantics.py +++ b/lib/spack/spack/test/spec_semantics.py @@ -1978,6 +1978,21 @@ def test_constrain(factory, lhs_str, rhs_str, result, constrained_str): assert rhs == factory(constrained_str) +def test_constrain_dependencies_copies(mock_packages): + """Tests that constraining a spec with new deps makes proper copies, and does not accidentally + share dependency instances, leading to corruption of unrelated Spec instances.""" + x = Spec("root") + y = Spec("^foo") + z = Spec("%foo +bar") + assert x.constrain(y) + assert x == Spec("root ^foo") + assert x.constrain(z) + assert x == Spec("root %foo +bar") + assert not x.constrain(Spec("root %foo +bar")) # no new constraints + # now, double check that we did not mutate `y` after constraining `x` with `z`. + assert y == Spec("^foo") + + def test_abstract_hash_intersects_and_satisfies(default_mock_concretization): concrete: Spec = default_mock_concretization("pkg-a") hash = concrete.dag_hash() From 0480211934884355bd83a2c3d9ea308f2e254563 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 20 Jan 2026 14:47:48 +0100 Subject: [PATCH 028/337] repo.py: fast path without overrides (#51871) Package class attribute overrides are almost never used, so add an early exit. Signed-off-by: Harmen Stoppels --- lib/spack/spack/repo.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index b6e1687cfc8a92..bccd449de4c21d 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -1424,6 +1424,14 @@ def get_pkg_class(self, pkg_name: str) -> Type["spack.package_base.PackageBase"] if not isinstance(cls, type): tty.die(f"{pkg_name}.{class_name} is not a class") + # Early exit if no overrides to apply or undo + if ( + not self.overrides.get(pkg_name) + and not hasattr(cls, "overridden_attrs") + and not hasattr(cls, "attrs_exclusively_from_config") + ): + return cls + def defining_class(myclass, name): return next((c for c in myclass.__mro__ if name in c.__dict__), None) From 855e07abce5c0a7609ed722bd378ff8381a7f357 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 20 Jan 2026 17:58:35 +0100 Subject: [PATCH 029/337] solver: improve version encoding (#51872) Previously we encoded version constraints with a table: ``` pkg_fact(Package, version_satisfies(Constraint, Version)) ``` This gives a number of facts equal to ``` Package x Version x Constraint on Package ``` Now we encode version ordering like: ``` pkg_fact("zlib-ng",version_order("2.0.0",0)). ... pkg_fact("zlib-ng",version_order("2.2.5",10)). pkg_fact("zlib-ng",version_order("2.3.2",11)). ``` and constraints like: ``` pkg_fact("zlib-ng",version_range("2.1.0:",2,11)). ``` This allows us to collapse some rules to be: ``` Package x Constraint ``` instead of: ``` Package x Constraint x Version ``` Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 30 ++++++++++++++---- lib/spack/spack/solver/concretize.lp | 39 ++++++++++++++---------- lib/spack/spack/solver/error_messages.lp | 5 +++ 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 199f582a9eb482..338b3edf489da8 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -2659,22 +2659,40 @@ def target_defaults(self, specs): def define_version_constraints(self): """Define what version_satisfies(...) means in ASP logic.""" - for pkg_name, versions in self.possible_versions.items(): - for v in versions: + sorted_versions = {} + for pkg_name in self.possible_versions: + possible_versions = list(self.possible_versions[pkg_name]) + possible_versions.sort() + sorted_versions[pkg_name] = possible_versions + for idx, v in enumerate(possible_versions): + self.gen.fact(fn.pkg_fact(pkg_name, fn.version_order(v, idx))) if v in self.git_commit_versions[pkg_name]: sha = self.git_commit_versions[pkg_name].get(v) if sha: self.gen.fact(fn.pkg_fact(pkg_name, fn.version_has_commit(v, sha))) else: self.gen.fact(fn.pkg_fact(pkg_name, fn.version_needs_commit(v))) + self.gen.newline() self.gen.newline() for pkg_name, versions in self.version_constraints: - # generate facts for each package constraint and the version - # that satisfies it - for v in self.possible_versions[pkg_name]: + possible_versions = sorted_versions.get(pkg_name) + if possible_versions is None: + continue + # Look for contiguous ranges of versions that satisfy the constraint + start_idx = None + for current_idx, v in enumerate(possible_versions): if v.satisfies(versions): - self.gen.fact(fn.pkg_fact(pkg_name, fn.version_satisfies(versions, v))) + if start_idx is None: + start_idx = current_idx + elif start_idx is not None: + # End of a contiguous satisfying range found + version_range = fn.version_range(versions, start_idx, current_idx - 1) + self.gen.fact(fn.pkg_fact(pkg_name, version_range)) + start_idx = None + if start_idx is not None: + version_range = fn.version_range(versions, start_idx, len(possible_versions) - 1) + self.gen.fact(fn.pkg_fact(pkg_name, version_range)) self.gen.newline() def collect_virtual_constraints(self): diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 7911d88bfc5281..04af9811361d58 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -268,8 +268,7 @@ error(100, multiple_values_error, Attribute, Package) % Version semantics %----------------------------------------------------------------------------- -% versions are declared w/priority -- declared with priority implies declared -pkg_fact(Package, version_declared(Version)) :- pkg_fact(Package, version_declared(Version, _)). +version_declared(Package, Version) :- pkg_fact(Package, version_order(Version, _)). % If something is a package, it has only one version and that must be a % declared version. @@ -278,14 +277,20 @@ pkg_fact(Package, version_declared(Version)) :- pkg_fact(Package, version_declar % against to ensure they cannot be inferred when a non-error solution is % possible +version_constraint_satisfied(node(ID,Package), Constraint) :- + attr("version", node(ID,Package), Version), + pkg_fact(Package, version_order(Version, VersionIdx)), + pkg_fact(Package, version_range(Constraint, MinIdx, MaxIdx)), + VersionIdx >= MinIdx, VersionIdx <= MaxIdx. + % Pick a single version among the possible ones -1 { choose_version(node(ID, Package), Version) : pkg_fact(Package, version_declared(Version)) } 1 :- attr("node", node(ID, Package)). +1 { choose_version(node(ID, Package), Version) : version_declared(Package, Version) } 1 :- attr("node", node(ID, Package)). % To choose the "fake" version of virtual packages, we need a separate rule. % Note that a virtual node may or may not have a version, but cannot have more than one. -{ choose_version(node(ID, Package), Version) : pkg_fact(Package, version_satisfies(Constraint, Version)) } 1 - :- attr("node_version_satisfies", node(ID, Package), Constraint), - attr("virtual_node", node(ID, Package)). +{ choose_version(node(ID, Package), Version) : version_declared(Package, Version) } 1 + :- attr("virtual_node", node(ID, Package)), + virtual(Package). #defined compiler_package/1. @@ -331,18 +336,16 @@ version_deprecation_penalty(node(ID, Package), Penalty) % More specific error message if the version cannot satisfy some constraint % Otherwise covered by `no_version_error` and `versions_conflict_error`. -error(10000, "Cannot satisfy '{0}@{1}' 1({2})", Package, Constraint, Version) - :- attr("node_version_satisfies", node(ID, Package), Constraint), - attr("version", node(ID, Package), Version), - not pkg_fact(Package, version_satisfies(Constraint, Version)). - error(10000, "Cannot satisfy '{0}@{1}'", Package, Constraint) :- attr("node_version_satisfies", node(ID, Package), Constraint), - not attr("version", node(ID, Package), _). + not version_constraint_satisfied(node(ID,Package), Constraint). attr("node_version_satisfies", node(ID, Package), Constraint) :- attr("version", node(ID, Package), Version), - pkg_fact(Package, version_satisfies(Constraint, Version)). + pkg_fact(Package, version_order(Version, VersionIdx)), + pkg_fact(Package, version_range(Constraint, MinIdx, MaxIdx)), + VersionIdx >= MinIdx, VersionIdx <= MaxIdx. + % if a version needs a commit or has one it can use the commit variant can_accept_commit(Package, Version) :- pkg_fact(Package, version_needs_commit(Version)). @@ -500,7 +503,9 @@ satisfied(trigger(node(Hash, Package)), condition_requirement("node_version_sati reused_provider(node(Hash, Package), node(Hash, Language)), hash_attr(Hash, "version", Package, Version), condition_requirement(ID, "node_version_satisfies", Package, VersionConstraint), - pkg_fact(Package, version_satisfies(VersionConstraint, Version)). + pkg_fact(Package, version_order(Version, VersionIdx)), + pkg_fact(Package, version_range(VersionConstraint, MinIdx, MaxIdx)), + VersionIdx >= MinIdx, VersionIdx <= MaxIdx. satisfied(trigger(node(Hash, Package)), condition_requirement(Name, Package, A1, A2)) :- trigger_real_node(ID, node(Hash, Package)), @@ -636,7 +641,7 @@ attr("concrete_variant_set", node(X, A1), Variant, Value, ID) :- attr("direct_dependency", ParentNode, node_requirement("node_version_satisfies", BuildDependency, Constraint)), concrete_build_requirement(ParentNode, BuildDependency), attr("concrete_build_dependency", ParentNode, BuildDependency, BuildDependencyHash), - not 1 { pkg_fact(BuildDependency, version_satisfies(Constraint, Version)) : hash_attr(BuildDependencyHash, "version", BuildDependency, Version) } 1. + not hash_attr(BuildDependencyHash, "node_version_satisfies", BuildDependency, Constraint). :- attr("direct_dependency", ParentNode, node_requirement("provider_set", BuildDependency, Virtual)), concrete_build_requirement(ParentNode, BuildDependency), @@ -1911,7 +1916,9 @@ error(100, "Cannot set multiple {0} values for {1} from cli", FlagType, Package) % hash_attrs are versions, but can_splice_attr are usually node_version_satisfies hash_attr(Hash, "node_version_satisfies", PackageName, Constraint) :- hash_attr(Hash, "version", PackageName, Version), - pkg_fact(PackageName, version_satisfies(Constraint, Version)). + pkg_fact(PackageName, version_order(Version, VersionIdx)), + pkg_fact(PackageName, version_range(Constraint, MinIdx, MaxIdx)), + VersionIdx >= MinIdx, VersionIdx <= MaxIdx. % This recovers the exact semantics for hash reuse hash and depends_on are where % splices are decided, and virtual_on_edge can result in name-changes, which is diff --git a/lib/spack/spack/solver/error_messages.lp b/lib/spack/spack/solver/error_messages.lp index 0e83f3e293ce84..669c2d8961704d 100644 --- a/lib/spack/spack/solver/error_messages.lp +++ b/lib/spack/spack/solver/error_messages.lp @@ -158,6 +158,11 @@ error(0, Msg, startcauses, TriggerID, ID1, ConstraintID, ID2) unification_set(X, node(ID1, TriggerPackage)), build(node(ID, Package)). % ignore conflicts for concrete packages +pkg_fact(Package, version_satisfies(Constraint, Version)) :- + pkg_fact(Package, version_order(Version, VersionIdx)), + pkg_fact(Package, version_range(Constraint, MinIdx, MaxIdx)), + VersionIdx >= MinIdx, VersionIdx <= MaxIdx. + % variables to show #show error/2. #show error/3. From 6e390cb55bc9bd9591cf8d4b4042178e210ee1bd Mon Sep 17 00:00:00 2001 From: Ryan Krattiger <80296582+kwryankrattiger@users.noreply.github.com> Date: Fri, 23 Jan 2026 02:17:54 -0600 Subject: [PATCH 030/337] docs: fix typos, improve consistency (#51882) Signed-off-by: Ryan Krattiger --- lib/spack/docs/binary_caches.rst | 12 +++--- lib/spack/docs/configuration.rst | 6 +-- lib/spack/docs/configuring_compilers.rst | 6 +-- lib/spack/docs/containers.rst | 6 +-- lib/spack/docs/contribution_guide.rst | 2 +- lib/spack/docs/developer_guide.rst | 2 +- lib/spack/docs/environments.rst | 8 ++-- lib/spack/docs/getting_started.rst | 2 +- lib/spack/docs/include_yaml.rst | 2 +- lib/spack/docs/module_file_support.rst | 4 +- lib/spack/docs/package_fundamentals.rst | 14 +++--- lib/spack/docs/package_review_guide.rst | 2 +- lib/spack/docs/packages_yaml.rst | 6 +-- lib/spack/docs/packaging_guide_build.rst | 48 ++++++++++----------- lib/spack/docs/packaging_guide_creation.rst | 10 ++--- lib/spack/docs/packaging_guide_testing.rst | 2 +- lib/spack/docs/pipelines.rst | 27 ++++++------ lib/spack/docs/signing.rst | 4 +- lib/spack/docs/spec_syntax.rst | 4 +- lib/spack/docs/toolchains_yaml.rst | 2 +- lib/spack/docs/windows.rst | 2 +- 21 files changed, 85 insertions(+), 86 deletions(-) diff --git a/lib/spack/docs/binary_caches.rst b/lib/spack/docs/binary_caches.rst index 5a0a6c6e03a288..e869b22d0cebe1 100644 --- a/lib/spack/docs/binary_caches.rst +++ b/lib/spack/docs/binary_caches.rst @@ -150,8 +150,8 @@ Build Cache Index Views Build caches can quickly become large and inefficient to search as binaries are added over time. A common work around to this problem is to break the build cache into stacks that target specific applications or workflows. This allows for curation of binaries as smaller collections of packages that push to their own mirrors that each maintain a smaller search area. -However, this approach comes with the tradeoff of requiring much larger storage and computational footprints due to duplication of common dependencies between stacks. -Splitting build caches can also reduce direct fetch hits by reducing the breadth of binaries availabe in a single mirror. +However, this approach comes with the trade off of requiring much larger storage and computational footprints due to duplication of common dependencies between stacks. +Splitting build caches can also reduce direct fetch hits by reducing the breadth of binaries available in a single mirror. To better address the issues with large search areas, build cache index views (or just "views" in this section) were introduced. A view is a named index which provides a curated view into a larger build cache. @@ -167,7 +167,7 @@ View indices are stored similarly to the top level build cache index, but use an Creating a Build Cache Index View """"""""""""""""""""""""""""""""" -Here is an example of creating a view using an active environent. +Here is an example of creating a view using an active environment. .. code-block:: console @@ -188,7 +188,7 @@ If a list of environments is passed while inside of an active environment, the a Updating a Build Cache Index View """"""""""""""""""""""""""""""""" -To prevent accidently overwriting an existing view, it is required to specify how a view should be updated. +To prevent accidentally overwriting an existing view, it is required to specify how a view should be updated. It is possible to use one of two options for updating a view index: ``--force`` or ``--append``. Using the ``--force`` option will replace the index as if the previous one did not exist. The ``--append`` option will first read the existing index, and then add the new specs to it. @@ -332,13 +332,13 @@ Automatic Push to a Build Cache --------------------------------- Sometimes it is convenient to push packages to a build cache immediately after they are installed. -Spack can do this by setting the autopush flag when adding a mirror: +Spack can do this by setting the ``--autopush`` flag when adding a mirror: .. code-block:: console $ spack mirror add --autopush -Or the autopush flag can be set for an existing mirror: +Or the ``--autopush`` flag can be set for an existing mirror: .. code-block:: console diff --git a/lib/spack/docs/configuration.rst b/lib/spack/docs/configuration.rst index 7ed6ad392e4507..8fe50cd9ded113 100644 --- a/lib/spack/docs/configuration.rst +++ b/lib/spack/docs/configuration.rst @@ -66,7 +66,7 @@ From lowest to highest precedence: #. **system**: Stored in ``/etc/spack/``. These are settings for this machine or for all machines on which this file system is mounted. - The systm scope overrides the defaults scope. + The system scope overrides the defaults scope. It can be used for settings idiosyncratic to a particular machine, such as the locations of compilers or external packages. Be careful when modifying this scope, as changes here affect all Spack users on a machine. Before putting configuration here, instead consider using the ``site`` scope, which only affects the spack instance it's part of. @@ -85,7 +85,7 @@ From lowest to highest precedence: #. **spack**: Stored in ``$(prefix)/etc/spack/``. Settings here affect only *this instance* of Spack, and they override ``user`` and lower configuration scopes. This is intended for project-specific or single-user spack installations. - This is the the topmost built-in spack scope, and modifying it gives you full control over configuration scopes. + This is the topmost built-in spack scope, and modifying it gives you full control over configuration scopes. For example, it defines the ``user``, ``site``, and ``system`` scopes, so you can use it to remove them completely if you want. #. **environment**: When using Spack :ref:`environments`, Spack reads additional configuration from the environment file. @@ -102,7 +102,7 @@ When configurations conflict, settings from higher-precedence scopes override lo All of these except ``spack`` and ``defaults`` are initially empty, so you don't have to think about the others unless you need them. The most commonly used scopes are ``environment``, ``user``, and ``spack``. -If you forget, you can always see the available configuration scopes in order of precedece wiht the ``spack config scopes`` command:: +If you forget, you can always see the available configuration scopes in order of precedence with the ``spack config scopes`` command:: > spack config scopes -p Scope Path diff --git a/lib/spack/docs/configuring_compilers.rst b/lib/spack/docs/configuring_compilers.rst index 973ec8211029a5..3dabcddee98689 100644 --- a/lib/spack/docs/configuring_compilers.rst +++ b/lib/spack/docs/configuring_compilers.rst @@ -17,7 +17,7 @@ Compilers can be made available to Spack by: 1. Specifying them as externals in ``packages.yaml``, or 2. Having them installed in the current Spack store, or -3. Having them available as binaries in a buildcache +3. Having them available as binaries in a build cache For convenience, Spack will automatically detect compilers as externals the first time it needs them, if no compiler is available. @@ -36,7 +36,7 @@ You can see which compilers are available to Spack by running ``spack compiler l [e] gcc@10.5.0 [+] gcc@15.1.0 [+] gcc@14.3.0 Compilers marked with an ``[e]`` are system compilers (externals), and those marked with a ``[+]`` have been installed by Spack. -Compilers from remote buildcaches are marked as ``-``, but are not shown by default. +Compilers from remote build caches are marked as ``-``, but are not shown by default. To see them you need a specific option: .. code-block:: console @@ -300,4 +300,4 @@ To enable mixing for specific packages, specify an allow-list in the ``compiler_ concretizer: compiler_mixing: ["openssl"] -Adding ``openssl`` to the compiler mixing allow-list does not allow mixing for dependencies of ``openssl``. \ No newline at end of file +Adding ``openssl`` to the compiler mixing allow-list does not allow mixing for dependencies of ``openssl``. diff --git a/lib/spack/docs/containers.rst b/lib/spack/docs/containers.rst index 6086fd8549c2a7..c92acc4d4d8949 100644 --- a/lib/spack/docs/containers.rst +++ b/lib/spack/docs/containers.rst @@ -61,7 +61,7 @@ Exporting Spack installations as Container Images The command .. code-block:: text - + spack buildcache push [--base-image BASE_IMAGE] [--tag TAG] mirror [specs...] creates and pushes a container image to an OCI-compatible container registry, with the ``mirror`` argument specifying a registry (see below). @@ -72,7 +72,7 @@ Container images created this way are **minimal**: they contain only runtime dep Spack itself is *not* included in the resulting image. The arguments are as follows: - + ``--base-image BASE_IMAGE`` Specifies the base image to use for the container. This should be a minimal Linux distribution with a libc that is compatible with the host system. @@ -253,7 +253,7 @@ Since recipes need a little more boilerplate than: RUN spack -e /environment install Spack provides a command to generate customizable recipes for container images. -Customizations include minimizing the size of the image, installing packages in the base image using the system package manager, and setting up a proper entrypoint to run the image. +Customizations include minimizing the size of the image, installing packages in the base image using the system package manager, and setting up a proper entry point to run the image. .. _cmd-spack-containerize: diff --git a/lib/spack/docs/contribution_guide.rst b/lib/spack/docs/contribution_guide.rst index fffacfd001ad9e..c617ac8f6ca38c 100644 --- a/lib/spack/docs/contribution_guide.rst +++ b/lib/spack/docs/contribution_guide.rst @@ -282,7 +282,7 @@ Spack uses GitLab CI for managing the orchestration of build jobs. GitLab Entry Point ~~~~~~~~~~~~~~~~~~ -Add a stack entrypoint to ``share/spack/gitlab/cloud_pipelines/.gitlab-ci.yml``. +Add a stack entry point to ``share/spack/gitlab/cloud_pipelines/.gitlab-ci.yml``. There are two stages required for each new stack: the generation stage and the build stage. The generate stage is defined using the job template ``.generate`` configured with environment variables defining the name of the stack in ``SPACK_CI_STACK_NAME``, the platform (``SPACK_TARGET_PLATFORM``) and architecture (``SPACK_TARGET_ARCH``) configuration, and the tags associated with the class of runners to build on. diff --git a/lib/spack/docs/developer_guide.rst b/lib/spack/docs/developer_guide.rst index 7bd8da9886aca6..ee7253e9fa65c3 100644 --- a/lib/spack/docs/developer_guide.rst +++ b/lib/spack/docs/developer_guide.rst @@ -456,7 +456,7 @@ As the action runs, you should observe output similar to: ssh 5RjFs7LPdtwGG8cwSPkGrdMNg@sfo2.tmate.io https://tmate.io/t/5RjFs7LPdtwGG8cwSPkGrdMNg -The first line is the ssh command neccesary to connect to the server, the second line is a tmate web-ui that also provides access to the ssh server on the runner. +The first line is the ssh command necessary to connect to the server, the second line is a tmate web UI that also provides access to the ssh server on the runner. .. note:: The web UI has occasionally been unresponsive, if it does not respond within ~10s, you'll need to use your local ssh utility. diff --git a/lib/spack/docs/environments.rst b/lib/spack/docs/environments.rst index 9ccba530b01ba7..fbf2db10db12d0 100644 --- a/lib/spack/docs/environments.rst +++ b/lib/spack/docs/environments.rst @@ -636,7 +636,7 @@ For example, a ``spack.yaml`` manifest file containing some package preference c mpi: [openmpi] # ... -This configuration sets the default mpi provider to be openmpi. +This configuration sets the default ``mpi`` provider to be ``openmpi``. Included configurations ^^^^^^^^^^^^^^^^^^^^^^^ @@ -876,7 +876,7 @@ The valid variables for a ``when`` clause are: The platform string of the default Spack architecture on the system. #. ``os``. - The os string of the default Spack architecture on the system. + The OS string of the default Spack architecture on the system. #. ``target``. The target string of the default Spack architecture on the system. @@ -1222,7 +1222,7 @@ Adding post-install hooks ^^^^^^^^^^^^^^^^^^^^^^^^^ Another advanced use-case of generated ``Makefile``\s is running a post-install command for each package. -These "hooks" could be anything from printing a post-install message, running tests, or pushing just-built binaries to a buildcache. +These "hooks" could be anything from printing a post-install message, running tests, or pushing just-built binaries to a build cache. This can be accomplished through the generated ``[/]SPACK_PACKAGE_IDS`` variable. Assuming we have an active and concrete environment, we generate the associated ``Makefile`` with a prefix ``example``: @@ -1235,7 +1235,7 @@ And we now include it in a different ``Makefile``, in which we create a target ` This target depends on the particular package installation. In this target we automatically have the target-specific ``HASH`` and ``SPEC`` variables at our disposal. They are respectively the spec hash (excluding leading ``/``), and a human-readable spec. -Finally, we have an entry point target ``push`` that will update the buildcache index once every package is pushed. +Finally, we have an entry point target ``push`` that will update the build cache index once every package is pushed. Note how this target uses the generated ``example/SPACK_PACKAGE_IDS`` variable to define its prerequisites. .. code-block:: Makefile diff --git a/lib/spack/docs/getting_started.rst b/lib/spack/docs/getting_started.rst index cef44679a75434..0a15432c47afe7 100644 --- a/lib/spack/docs/getting_started.rst +++ b/lib/spack/docs/getting_started.rst @@ -89,7 +89,7 @@ If the search was successful, you can now list known compilers, and get an outpu If no compilers were found, you need to either: * Install further prerequisites, see :ref:`verify-spack-prerequisites`, and repeat the search above. -* Register a buildcache that provides a compiler already available as a binary +* Register a build cache that provides a compiler already available as a binary Once a compiler is available, you can proceed installing your first package: diff --git a/lib/spack/docs/include_yaml.rst b/lib/spack/docs/include_yaml.rst index 3761c33f7d4ae8..aa090ef2711f6e 100644 --- a/lib/spack/docs/include_yaml.rst +++ b/lib/spack/docs/include_yaml.rst @@ -112,7 +112,7 @@ Scopes can tell Spack to prefer to edit their included scopes instead, using ``p path: /path/to/scope/we/want-to-write prefer_modify: true -Now, if the including scope is the highest precedence scope and would otherwise be selected automatically by one fo these commands, they will instead prefer to edit ``preferred``. +Now, if the including scope is the highest precedence scope and would otherwise be selected automatically by one of these commands, they will instead prefer to edit ``preferred``. The including scope can still be modified by using the ``--scope`` argument (e.g., ``spack compiler find --scope NAME``). .. warning:: diff --git a/lib/spack/docs/module_file_support.rst b/lib/spack/docs/module_file_support.rst index 86036db649b1c3..1ccefb5f6c4a26 100644 --- a/lib/spack/docs/module_file_support.rst +++ b/lib/spack/docs/module_file_support.rst @@ -399,7 +399,7 @@ For instance, the following config options, will add a ``python3.12`` to module names of packages compiled with Python 3.12, and similarly for all specs depending on ``python@3``. This is useful to know which version of Python a set of Python extensions is associated with. -Likewise, the ``openblas`` string is attached to any program that has openblas in the spec, most likely via the ``+blas`` variant specification. +Likewise, the ``openblas`` string is attached to any program that has ``openblas`` in the spec, most likely via the ``+blas`` variant specification. The most heavyweight solution to module naming is to change the entire naming convention for module files. This uses the projections format covered in :ref:`view_projections`. @@ -413,7 +413,7 @@ This uses the projections format covered in :ref:`view_projections`. all: "{name}/{version}-{compiler.name}-{compiler.version}-module" ^mpi: "{name}/{version}-{^mpi.name}-{^mpi.version}-{compiler.name}-{compiler.version}-module" -will create module files that are nested in directories by package name, contain the version and compiler name and version, and have the word ``module`` before the hash for all specs that do not depend on mpi, and will have the same information plus the MPI implementation name and version for all packages that depend on mpi. +will create module files that are nested in directories by package name, contain the version and compiler name and version, and have the word ``module`` before the hash for all specs that do not depend on ``mpi``, and will have the same information plus the MPI implementation name and version for all packages that depend on ``mpi``. When specifying module names by projection for Lmod modules, we recommend NOT including names of dependencies (e.g., MPI, compilers) that are already in the Lmod hierarchy. diff --git a/lib/spack/docs/package_fundamentals.rst b/lib/spack/docs/package_fundamentals.rst index 8a6e86db9451b0..8817c316354710 100644 --- a/lib/spack/docs/package_fundamentals.rst +++ b/lib/spack/docs/package_fundamentals.rst @@ -176,7 +176,7 @@ We'll talk more about how you can use them to customize an installation in :ref: Reusing installed dependencies ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -By default, when you run ``spack install``, Spack tries hard to reuse existing installations as dependencies, either from a local store or from remote buildcaches, if configured. +By default, when you run ``spack install``, Spack tries hard to reuse existing installations as dependencies, either from a local store or from remote build caches, if configured. This minimizes unwanted rebuilds of common dependencies, in particular if you update Spack frequently. In case you want the latest versions and configurations to be installed instead, you can add the ``--fresh`` option: @@ -539,7 +539,7 @@ If you want to find only libelf versions greater than version 0.8.12, you could -- linux-debian7-x86_64 / gcc@4.4.7 -------------------------------- libelf@0.8.12 libelf@0.8.13 -Finding just the versions of libdwarf built with a particular version of libelf would look like this: +Finding just the versions of ``libdwarf`` built with a particular version of libelf would look like this: .. code-block:: spec @@ -549,7 +549,7 @@ Finding just the versions of libdwarf built with a particular version of libelf libdwarf@20130729-d9b90962 We can also search for packages that have a certain attribute. -For example, ``spack find libdwarf +debug`` will show only installations of libdwarf with the 'debug' compile-time option enabled. +For example, ``spack find libdwarf +debug`` will show only installations of ``libdwarf`` with the 'debug' compile-time option enabled. The full spec syntax is discussed in detail in :ref:`sec-specs`. @@ -656,7 +656,7 @@ You can use this with tools like `jq `_ to quickly create J ^^^^^^^^^^^^^^ It's often the case that you have two versions of a spec that you need to disambiguate. -Let's say that we've installed two variants of zlib, one with and one without the optimize variant: +Let's say that we've installed two variants of ``zlib``, one with and one without the optimize variant: .. code-block:: spec @@ -690,7 +690,7 @@ We run the command and quickly encounter a problem because two versions are inst c) use `spack uninstall --all` to uninstall ALL matching specs. Oh no! -We can see from the above that we have two different versions of zlib installed, and the only difference between the two is the hash. +We can see from the above that we have two different versions of ``zlib`` installed, and the only difference between the two is the hash. This is a good use case for ``spack diff``, which can easily show us the "diff" or set difference between properties for two packages. Let's try it out. Because the only difference we see in the ``spack find`` view is the hash, let's use ``spack diff`` to look for more detail. @@ -721,7 +721,7 @@ Here is an example: Awesome! Now let's read the diff. -It tells us that our first zlib was built with ``~optimize`` (``False``) and the second was built with ``+optimize`` (``True``). +It tells us that our first ``zlib`` was built with ``~optimize`` (``False``) and the second was built with ``+optimize`` (``True``). You can't see it in the docs here, but the output above is also colored based on the content being an addition (+) or subtraction (-). This is a small example, but you will be able to see differences for any attributes on the installation spec. @@ -808,7 +808,7 @@ For example, this will add the ``mpich`` package built with ``gcc`` to your path ~/spack/opt/linux-debian7-x86_64/gcc@4.4.7/mpich@3.0.4/bin/mpicc These commands will add appropriate directories to your ``PATH`` and ``MANPATH`` according to the :ref:`prefix inspections ` defined in your modules configuration. -When you no longer want to use a package, you can type unload or unuse similarly: +When you no longer want to use a package, you can type ``spack unload``: .. code-block:: spec diff --git a/lib/spack/docs/package_review_guide.rst b/lib/spack/docs/package_review_guide.rst index a62066b3a5882f..18806398558fd3 100644 --- a/lib/spack/docs/package_review_guide.rst +++ b/lib/spack/docs/package_review_guide.rst @@ -129,7 +129,7 @@ This commonly happens with Python packages. For example, the case of one or more letters in the package name may change at some point (e.g., `py-sphinx `_). Also, dashes may be replaced with underscores (e.g., `py-scikit-build `_). In some cases, both changes can occur for the same package. -As these examples illlustrate, it is sometimes possible to add a ``url_for_version`` method to override the default derived URL to ensure the correct one is returned. +As these examples illustrate, it is sometimes possible to add a ``url_for_version`` method to override the default derived URL to ensure the correct one is returned. If older versions are no longer available and there is a chance someone has the package in a build cache, the usual approach is to first suggest :ref:`deprecating ` them in the package. diff --git a/lib/spack/docs/packages_yaml.rst b/lib/spack/docs/packages_yaml.rst index 867d18b3793143..d8bce707d971f3 100644 --- a/lib/spack/docs/packages_yaml.rst +++ b/lib/spack/docs/packages_yaml.rst @@ -62,7 +62,7 @@ If Spack is asked to build a package that uses one of these MPIs as a dependency Note that the specified path is the top-level install prefix, not the ``bin`` subdirectory. ``packages.yaml`` can also be used to specify modules to load instead of the installation prefixes. -The following example says that module ``CMake/3.7.2`` provides cmake version 3.7.2. +The following example says that module ``CMake/3.7.2`` provides CMake version 3.7.2. .. code-block:: yaml @@ -155,7 +155,7 @@ Spack can be configured with every MPI provider not buildable individually, but Spack can then use any of the listed external implementations of MPI to satisfy a dependency, and will choose among them depending on the compiler and architecture. -In cases where the concretizer is configured to reuse specs, and other ``mpi`` providers (available via stores or buildcaches) are not desirable, Spack can be configured to require specs matching only the available externals: +In cases where the concretizer is configured to reuse specs, and other ``mpi`` providers (available via stores or build caches) are not desirable, Spack can be configured to require specs matching only the available externals: .. code-block:: yaml @@ -173,7 +173,7 @@ In cases where the concretizer is configured to reuse specs, and other ``mpi`` p - spec: "openmpi@1.4.3+debug" prefix: /opt/openmpi-1.4.3-debug -This configuration prevents any spec using MPI and originating from stores or buildcaches to be reused, unless it matches the requirements under ``packages:mpi:require``. +This configuration prevents any spec using MPI and originating from stores or build caches to be reused, unless it matches the requirements under ``packages:mpi:require``. For more information on requirements see :ref:`package-requirements`. Specifying dependencies among external packages diff --git a/lib/spack/docs/packaging_guide_build.rst b/lib/spack/docs/packaging_guide_build.rst index b36a3322206c8b..026096ce67b7eb 100644 --- a/lib/spack/docs/packaging_guide_build.rst +++ b/lib/spack/docs/packaging_guide_build.rst @@ -85,7 +85,7 @@ From simplest to most complex, the following are the most common ways to customi For example, for ``AutotoolsPackage`` you can specify the command line arguments for ``./configure`` by implementing ``configure_args``: .. code-block:: python - + class MyPkg(AutotoolsPackage): def configure_args(self): # FIXME: Add arguments other than --prefix @@ -96,7 +96,7 @@ From simplest to most complex, the following are the most common ways to customi Similarly for ``CMakePackage`` you can influence how ``cmake`` is invoked by implementing ``cmake_args``: .. code-block:: python - + class MyPkg(CMakePackage): def cmake_args(self): # FIXME: Add arguments other than @@ -113,7 +113,7 @@ From simplest to most complex, the following are the most common ways to customi You can set these variables by overriding the ``setup_build_environment`` method in your package class: .. code-block:: python - + def setup_build_environment(self, env): env.set("MY_ENV_VAR", "value") @@ -126,7 +126,7 @@ From simplest to most complex, the following are the most common ways to customi This is useful for installing additional files missed by the build system, or for running custom scripts. .. code-block:: python - + @run_after("install") def install_missing_files(self): install_tree("extra_files", self.prefix.bin) @@ -147,9 +147,9 @@ In any of the functions above, you can if self.spec.satisfies("+variant_name"): ... - + to check if a variant is enabled, or - + .. code-block:: python self.spec["dependency_name"].prefix @@ -363,7 +363,7 @@ This example adds a flag when the C compiler is from GCC version 8 or higher. The ``%c=gcc`` syntax technically means that ``gcc`` is the provider for the ``c`` language virtual. .. tip:: - + Historically, many packages have been written using ``^dep`` to refer to a dependency. Modern Spack packages should consider using ``%dep`` instead, which is more precise: it can only match direct dependencies, which are listed in the ``depends_on`` statements. @@ -456,20 +456,20 @@ We can get the provider's (e.g. OpenBLAS or Intel MKL) prefixes like this: f"--with-lapack={self.spec['lapack'].prefix}", ] -Many build systems struggle to locate the ``blas`` and ``lapack`` libraries during configure, either because they do not know the exact names of the libraries, or because the libraries are not in typical locations --- they may not even know whether blas and lapack are a single or separate libraries. +Many build systems struggle to locate the ``blas`` and ``lapack`` libraries during configure, either because they do not know the exact names of the libraries, or because the libraries are not in typical locations --- they may not even know whether ``blas`` and ``lapack`` are a single or separate libraries. In those cases, the build system could use some help, for which we give a few examples below: 1. Space separated list of full paths .. code-block:: python - + lapack_blas = spec["lapack"].libs + spec["blas"].libs args.append(f"--with-blas-lapack-lib={lapack_blas.joined()}") 2. Names of libraries and directories which contain them .. code-block:: python - + lapack_blas = spec["lapack"].libs + spec["blas"].libs args.extend( [ @@ -481,7 +481,7 @@ In those cases, the build system could use some help, for which we give a few ex 3. Search and link flags .. code-block:: python - + lapack_blas = spec["lapack"].libs + spec["blas"].libs args.append(f"-DMATH_LIBS={lapack_blas.ld_flags}") @@ -610,7 +610,7 @@ Not all dependencies set up such variables for dependent packages, in which case 1. Use the ``command`` attribute of the dependency. This is a good option, since it refers to an executable provided by a specific dependency. - + .. code-block:: python def install(self, spec: Spec, prefix: Prefix) -> None: @@ -619,7 +619,7 @@ Not all dependencies set up such variables for dependent packages, in which case 2. Use the ``which`` function (from the ``spack.package`` module). Do note that this function relies on the order of the ``PATH`` environment variable, which may be less reliable than the first option. - + .. code-block:: python def install(self, spec: Spec, prefix: Prefix) -> None: @@ -1151,7 +1151,7 @@ The ``compiler-wrapper`` package has several responsibilities: * It sets the ``CC``, ``CXX``, and ``FC`` environment variables in the :ref:`build environment `. These variables point to a wrapper executable in the ``compiler-wrapper``'s bin directory, which is a shell script that ultimately invokes the actual, underlying compiler executable. * It ensures that three kinds of compiler flags are passed to the compiler when it is invoked: - + 1. Flags requested by the user and package author (see :ref:`compiler flags `) 2. Flags needed to locate headers and libraries (during the build as well as at runtime) 3. Target specific flags, like ``-march=x86-64-v3``, translated from the spec's ``target=`` variant. @@ -1198,7 +1198,7 @@ Spack heavily makes use of `RPATHs `_ on Lin Executables are able to find their needed libraries *without* any of the infamous environment variables such as ``LD_LIBRARY_PATH`` on Linux or ``DYLD_LIBRARY_PATH`` on macOS. The :ref:`compiler wrapper ` is the main component that ensures that all binaries built by Spack have the correct RPATHs set. -As a package author, you rarely need to worry about RPATHs: the relevant compiler flags are automatically injected through the compiler wrappers, and the build system is blisfully unaware of them. +As a package author, you rarely need to worry about RPATHs: the relevant compiler flags are automatically injected through the compiler wrappers, and the build system is blissfully unaware of them. This works for most packages and build systems, with the notable exception of CMake, which has its own RPATH handling. CMake has its own RPATH handling, and distinguishes between build and install RPATHs. @@ -1260,7 +1260,7 @@ Loosely, there are three types of MPI builds: 3. CMake's ``FindMPI`` needs the compiler wrappers, but it uses them to extract ``-I`` / ``-L`` / ``-D`` arguments, then treats MPI like a regular library. Note that some CMake builds fall into case 2 because they either don't know about or don't like CMake's ``FindMPI`` support -- they just assume an MPI compiler. -Also, some autotools builds fall into case 3 (e.g., `here is an autotools version of CMake's FindMPI `_). +Also, some Autotools builds fall into case 3 (e.g., `here is an autotools version of CMake's FindMPI `_). Given all of this, we leave the use of the wrappers up to the packager. Spack will support all three ways of building MPI packages. @@ -1298,11 +1298,11 @@ So using the MPI wrappers should really be as simple as the code above. ``spec["mpi"]`` ^^^^^^^^^^^^^^^^^^^^^ -Ok, so how does all this work? +Okay, so how does all this work? If your package has a virtual dependency like ``mpi``, then referring to ``spec["mpi"]`` within ``install()`` will get you the concrete ``mpi`` implementation in your dependency DAG. That is a spec object just like the one passed to install, only the MPI implementations all set some additional properties on it to help you out. -E.g., in openmpi, you'll find this: +E.g., in ``openmpi``, you'll find this: .. literalinclude:: .spack/spack-packages/repos/spack_repo/builtin/packages/openmpi/package.py :pyobject: Openmpi.setup_dependent_package @@ -1318,13 +1318,13 @@ Wrapping wrappers Spack likes to use its own compiler wrappers to make it easy to add ``RPATHs`` to builds, and to try hard to ensure that your builds use the right dependencies. This doesn't play nicely by default with MPI, so we have to do a couple of tricks. -1. If we build MPI with Spack's wrappers, mpicc and friends will be installed with hard-coded paths to Spack's wrappers, and using them from outside of Spack will fail because they only work within Spack. - To fix this, we patch mpicc and friends to use the regular compilers. - Look at the filter_compilers method in mpich, openmpi, or mvapich2 for details. +1. If we build MPI with Spack's wrappers, ``mpicc`` and friends will be installed with hard-coded paths to Spack's wrappers, and using them from outside of Spack will fail because they only work within Spack. + To fix this, we patch ``mpicc`` and friends to use the regular compilers. + Look at the filter_compilers method in ``mpich``, ``openmpi``, or ``mvapich2`` for details. -2. We still want to use the Spack compiler wrappers when Spack is calling mpicc. - Luckily, wrappers in all mainstream MPI implementations provide environment variables that allow us to dynamically set the compiler to be used by mpicc, mpicxx, etc. - Spack's build environment sets ``MPICC``, ``MPICXX``, etc. for mpich derivatives and ``OMPI_CC``, ``OMPI_CXX``, etc. for OpenMPI. +2. We still want to use the Spack compiler wrappers when Spack is calling ``mpicc``. + Luckily, wrappers in all mainstream MPI implementations provide environment variables that allow us to dynamically set the compiler to be used by ``mpicc``, ``mpicxx``, etc. + Spack's build environment sets ``MPICC``, ``MPICXX``, etc. for MPICH derivatives and ``OMPI_CC``, ``OMPI_CXX``, etc. for OpenMPI. This makes the MPI compiler wrappers use the Spack compiler wrappers so that your dependencies still get proper RPATHs even if you use the MPI wrappers. MPI on Cray machines diff --git a/lib/spack/docs/packaging_guide_creation.rst b/lib/spack/docs/packaging_guide_creation.rst index c4669e89e11560..4d1b86792a95f5 100644 --- a/lib/spack/docs/packaging_guide_creation.rst +++ b/lib/spack/docs/packaging_guide_creation.rst @@ -1660,7 +1660,7 @@ Let's take a look at the ``libdwarf`` package to see how it's done: ^^^^^^^^^^^^^^^^ The highlighted ``depends_on("libelf")`` call tells Spack that it needs to build and install the ``libelf`` package before it builds ``libdwarf``. -This means that in your ``install()`` method, you are guaranteed that ``libelf`` has been built and installed successfully, so you can rely on it for your libdwarf build. +This means that in your ``install()`` method, you are guaranteed that ``libelf`` has been built and installed successfully, so you can rely on it for your ``libdwarf`` build. .. _dependency_specs: @@ -2294,7 +2294,7 @@ Only needed for patches fetched from URLs. If supplied, this is a spec that tells Spack when to apply the patch. If the installed package spec matches this spec, the patch will be applied. -In our example above, the patch is applied when mvapich is at version ``1.9`` or higher. +In our example above, the patch is applied when ``mvapich`` is at version ``1.9`` or higher. ``level`` """"""""" @@ -2325,7 +2325,7 @@ Lines 1-2 show paths with synthetic ``a/`` and ``b/`` prefixes. These are placeholders for the two ``mvapich2`` source directories that ``diff`` compared when it created the patch file. This is git's default behavior when creating patch files, but other programs may behave differently. -``-p1`` strips off the first level of the prefix in both paths, allowing the patch to be applied from the root of an expanded mvapich2 archive. +``-p1`` strips off the first level of the prefix in both paths, allowing the patch to be applied from the root of an expanded ``mvapich2`` archive. If you set level to ``2``, it would strip off ``src``, and so on. It's generally easier to just structure your patch file so that it applies cleanly with ``-p1``, but if you're using a patch you didn't create yourself, ``level`` can be handy. @@ -2482,7 +2482,7 @@ This ensures that Python in a view can always locate its Python packages, even w A package can only extend one other package at a time. To support packages that may extend one of a list of other packages, Spack supports multiple ``extends`` directives as long as at most one of them is selected as a dependency during concretization. -For example, a lua package could extend either lua or luajit, but not both: +For example, a lua package could extend either ``lua`` or ``lua-luajit``, but not both: .. code-block:: python @@ -2493,7 +2493,7 @@ For example, a lua package could extend either lua or luajit, but not both: extends("lua-luajit", when="~use_lua") ... -Now, a user can install, and activate, the ``lua-lpeg`` package for either lua or luajit. +Now, a user can install, and activate, the ``lua-lpeg`` package for either lua or ``lua-luajit``. Adding additional constraints ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/lib/spack/docs/packaging_guide_testing.rst b/lib/spack/docs/packaging_guide_testing.rst index 0d8f6516da2c33..a4bc2a8b60b65f 100644 --- a/lib/spack/docs/packaging_guide_testing.rst +++ b/lib/spack/docs/packaging_guide_testing.rst @@ -49,7 +49,7 @@ Success is assumed if anything (e.g., a file or directory) is written after ``in Otherwise, the build is assumed to have failed. However, the presence of install prefix contents is not a sufficient indicator of success so Spack supports the addition of tests that can be performed during `spack install` processing. -Consider a simple autotools build using the following commands: +Consider a simple Autotools build using the following commands: .. code-block:: console diff --git a/lib/spack/docs/pipelines.rst b/lib/spack/docs/pipelines.rst index befee35c0f4f28..91ee717926010a 100644 --- a/lib/spack/docs/pipelines.rst +++ b/lib/spack/docs/pipelines.rst @@ -87,7 +87,7 @@ Here's the ``.gitlab-ci.yml`` file from that example that builds and runs the pi job: generate-pipeline -The key thing to note above is that there are two jobs: The first job to run, ``generate-pipeline``, runs the ``spack ci generate`` command to generate a dynamic child pipeline and write it to a yaml file, which is then picked up by the second job, ``build-jobs``, and used to trigger the downstream pipeline. +The key thing to note above is that there are two jobs: The first job to run, ``generate-pipeline``, runs the ``spack ci generate`` command to generate a dynamic child pipeline and write it to a YAML file, which is then picked up by the second job, ``build-jobs``, and used to trigger the downstream pipeline. And here's the Spack environment built by the pipeline represented as a ``spack.yaml`` file: @@ -161,7 +161,7 @@ This file, ``mirrors.yaml`` looks like this: Note the name of the mirror is ``buildcache-destination``, which is required as of Spack 0.23 (see below for more information). -The mirror url simply points to the container registry associated with the project, while ``id_variable`` and ``secret_variable`` refer to environment variables containing the access credentials for the mirror. +The mirror URL simply points to the container registry associated with the project, while ``id_variable`` and ``secret_variable`` refer to environment variables containing the access credentials for the mirror. When Spack builds packages for this example project, they will be pushed to the project container registry, where they will be available for subsequent jobs to install as dependencies or for other pipelines to use to build runnable container images. @@ -184,9 +184,8 @@ Super-command for functionality related to generating pipelines and executing pi ^^^^^^^^^^^^^^^^^^^^^ Throughout this documentation, references to the "mirror" mean the target mirror which is checked for the presence of up-to-date specs, and where any scheduled jobs should push built binary packages. -In the past, this defaulted to the mirror at index 0 in the mirror configs, and could be overridden using the ``--buildcache-destination`` argument. -Starting with Spack 0.23, ``spack ci generate`` will require you to identify this mirror by the name "buildcache-destination". -While you can configure any number of mirrors as sources for your pipelines, you will need to identify the destination mirror by name. +When running ``spack ci generate`` it is required to configure a mirror named ``buildcache-destination`` to be used as the target mirror. +It is permitted to configure any number of other mirrors as sources for your pipelines, but only the ``buildcache-destination`` mirror will be used as the destination mirror. Concretizes the specs in the active environment, stages them (as described in :ref:`staging_algorithm`), and writes the resulting ``.gitlab-ci.yml`` to disk. During concretization of the environment, ``spack ci generate`` also writes a ``spack.lock`` file which is then provided to generated child jobs and made available in all generated job artifacts to aid in reproducing failed builds in a local environment. @@ -197,9 +196,9 @@ In the :ref:`functional_example` section, we only mentioned one path in the ``ar Using ``--prune-dag`` or ``--no-prune-dag`` configures whether or not jobs are generated for specs that are already up to date on the mirror. If enabling DAG pruning using ``--prune-dag``, more information may be required in your ``spack.yaml`` file, see the :ref:`noop_jobs` section below regarding ``noop-job``. -The optional ``--check-index-only`` argument can be used to speed up pipeline generation by telling Spack to consider only remote buildcache indices when checking the remote mirror to determine if each spec in the DAG is up to date or not. +The optional ``--check-index-only`` argument can be used to speed up pipeline generation by telling Spack to consider only remote build cache indices when checking the remote mirror to determine if each spec in the DAG is up to date or not. The default behavior is for Spack to fetch the index and check it, but if the spec is not found in the index, it also performs a direct check for the spec on the mirror. -If the remote buildcache index is out of date, which can easily happen if it is not updated frequently, this behavior ensures that Spack has a way to know for certain about the status of any concrete spec on the remote mirror, but can slow down pipeline generation significantly. +If the remote build cache index is out of date, which can easily happen if it is not updated frequently, this behavior ensures that Spack has a way to know for certain about the status of any concrete spec on the remote mirror, but can slow down pipeline generation significantly. The optional ``--output-file`` argument should be an absolute path (including file name) to the generated pipeline, and if not given, the default is ``./.gitlab-ci.yml``. @@ -264,7 +263,7 @@ You can find them under Spack's `stacks `_ for the ci section of the Spack environment file, to see precisely what syntax is allowed there. +Take a look at the `schema `_ for the ``ci`` section of the Spack environment file, to see precisely what syntax is allowed there. .. _reserved_tags: @@ -702,6 +701,6 @@ Only needed to report build groups to CDash. ^^^^^^^^^^^^^^^^^^^^^ Optional. -Only needed if you want ``spack ci rebuild`` to trust the key you store in this variable, in which case, it will subsequently be used to sign and verify binary packages (when installing or creating buildcaches). -You could also have already trusted a key Spack knows about, or if no key is present anywhere, Spack will install specs using ``--no-check-signature`` and create buildcaches using ``-u`` (for unsigned binaries). +Only needed if you want ``spack ci rebuild`` to trust the key you store in this variable, in which case, it will subsequently be used to sign and verify binary packages (when installing or creating build caches). +You could also have already trusted a key Spack knows about, or if no key is present anywhere, Spack will install specs using ``--no-check-signature`` and create build caches using ``-u`` (for unsigned binaries). diff --git a/lib/spack/docs/signing.rst b/lib/spack/docs/signing.rst index de34abeae57cb6..eb11dfbba4f1ed 100644 --- a/lib/spack/docs/signing.rst +++ b/lib/spack/docs/signing.rst @@ -217,7 +217,7 @@ Procedurally the ``spack-intermediate-ci-signing-key`` secret is used in the fol 1. A ``large-arm-prot`` or ``large-x86-prot`` protected runner picks up a job tagged ``protected`` from a protected GitLab branch. (See :ref:`protected_runners`). -2. Based on its configuration, the runner creates a job Pod in the pipeline namespace and mounts the spack-intermediate-ci-signing-key Kubernetes secret into the build container +2. Based on its configuration, the runner creates a job Pod in the pipeline namespace and mounts the ``spack-intermediate-ci-signing-key`` Kubernetes secret into the build container 3. The Intermediate CI Key, affiliated institutions' public key and the Reputational Public Key are imported into a keyring by the ``spack gpg ...`` sub-command. This is initiated by the job's build script which is created by the generate job at the beginning of the pipeline. 4. Assuming the package has dependencies those spec manifests are verified using the keyring. @@ -269,7 +269,7 @@ Protected Runners and Reserved Tags Spack has a large number of Gitlab Runners operating in its build farm. These include runners deployed in the AWS Kubernetes cluster as well as runners deployed at affiliated institutions. -The majority of runners are shared runners that operate across projects in gitlab.spack.io. +The majority of runners are shared runners that operate across projects in `gitlab.spack.io `_. These runners pick up jobs primarily from the spack/spack project and execute them in PR pipelines. A small number of runners operating on AWS and at affiliated institutions are registered as specific *protected* runners on the spack/spack project. diff --git a/lib/spack/docs/spec_syntax.rst b/lib/spack/docs/spec_syntax.rst index 7cdd9af21b068f..27396b9c78f657 100644 --- a/lib/spack/docs/spec_syntax.rst +++ b/lib/spack/docs/spec_syntax.rst @@ -18,7 +18,7 @@ Spack uses specs to: 1. Refer to a particular build configuration of a package, or 2. Express requirements, or preferences, on packages via configuration files, or -3. Query installed packages, or buildcaches +3. Query installed packages, or build caches Specs are more than a package name and a version; you can use them to specify the compiler, compiler version, architecture, compile options, and dependency options for a build. In this section, we'll go over the full syntax of specs. @@ -644,7 +644,7 @@ To work around this without quoting, you can avoid whitespace between the packag mpileaks ~debug # shell may expand this to `mpileaks /home/debug` mpileaks~debug # use this instead - + Alternatively, you can use a hyphen ``-`` character to disable a variant, but be aware that this *requires* a space between the package name and the variant: .. code-block:: spec diff --git a/lib/spack/docs/toolchains_yaml.rst b/lib/spack/docs/toolchains_yaml.rst index 4d322cf69082bc..e0b6a934cef5b6 100644 --- a/lib/spack/docs/toolchains_yaml.rst +++ b/lib/spack/docs/toolchains_yaml.rst @@ -45,7 +45,7 @@ The spec ``cflags=-O3`` is *always* applied, because there is no ``when`` clause The toolchain can be referenced using .. code-block:: spec - + $ spack install my-package %llvm_gfortran Toolchains are useful for three reasons: diff --git a/lib/spack/docs/windows.rst b/lib/spack/docs/windows.rst index 178cafcd4cefab..843d705b1686a0 100644 --- a/lib/spack/docs/windows.rst +++ b/lib/spack/docs/windows.rst @@ -106,7 +106,7 @@ In order to install Spack with Windows support, run the following one-liner in a Step 3: Run and configure Spack ------------------------------- -On Windows, Spack supports both primary native shells, Powershell and the traditional command prompt. +On Windows, Spack supports both primary native shells, PowerShell and the traditional command prompt. To use Spack, pick your favorite shell, and run ``bin\spack_cmd.bat`` or ``share/spack/setup-env.ps1`` (you may need to Run as Administrator) from the top-level Spack directory. This will provide a Spack-enabled shell. If you receive a warning message that Python is not in your ``PATH`` (which may happen if you installed Python from the website and not the Windows Store), add the location of the Python executable to your ``PATH`` now. From 9ed4c5afe1dfbcd3fd7b6368f4378ece1cb0b9ed Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 23 Jan 2026 17:03:48 +0100 Subject: [PATCH 031/337] index: avoid quadratic complexity through bulk update (#48771) Refactors `ProviderIndex` and `TagIndex` to support bulk updates. Previously, updating the index with N packages involved iterating through the entire index for each package to remove stale entries, resulting in O(N * IndexSize) complexity. This change splits the update process into two steps: 1. Bulk removal of stale entries for all updated packages. 2. Bulk insertion of new entries. This reduces the complexity of full index generation from quadratic to linear. Signed-off-by: Harmen Stoppels --- lib/spack/spack/patch.py | 42 ++++++------ lib/spack/spack/provider_index.py | 92 +++++++++++--------------- lib/spack/spack/repo.py | 28 ++++---- lib/spack/spack/tag.py | 36 +++++----- lib/spack/spack/test/provider_index.py | 12 ++++ lib/spack/spack/test/tag.py | 3 +- 6 files changed, 107 insertions(+), 106 deletions(-) diff --git a/lib/spack/spack/patch.py b/lib/spack/spack/patch.py index 8f9c5decd41e05..7fe92a43a99c87 100644 --- a/lib/spack/spack/patch.py +++ b/lib/spack/spack/patch.py @@ -6,7 +6,7 @@ import os import pathlib import sys -from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union +from typing import TYPE_CHECKING, Any, Dict, Optional, Set, Tuple, Type, Union import spack import spack.error @@ -455,36 +455,38 @@ def patch_for_package(self, sha256: str, pkg: "spack.package_base.PackageBase") patch_dict["sha256"] = sha256 return from_dict(patch_dict, repository=self.repository) - def update_package(self, pkg_fullname: str) -> None: + def update_packages(self, pkgs_fullname: Set[str]) -> None: """Update the patch cache. Args: pkg_fullname: package to update. """ # remove this package from any patch entries that reference it. - empty = [] - for sha256, package_to_patch in self.index.items(): - remove = [] - for fullname, patch_dict in package_to_patch.items(): - if patch_dict["owner"] == pkg_fullname: - remove.append(fullname) + if self.index: + empty = [] + for sha256, package_to_patch in self.index.items(): + remove = [] + for fullname, patch_dict in package_to_patch.items(): + if patch_dict["owner"] in pkgs_fullname: + remove.append(fullname) - for fullname in remove: - package_to_patch.pop(fullname) + for fullname in remove: + package_to_patch.pop(fullname) - if not package_to_patch: - empty.append(sha256) + if not package_to_patch: + empty.append(sha256) - # remove any entries that are now empty - for sha256 in empty: - del self.index[sha256] + # remove any entries that are now empty + for sha256 in empty: + del self.index[sha256] # update the index with per-package patch indexes - pkg_cls = self.repository.get_pkg_class(pkg_fullname) - partial_index = self._index_patches(pkg_cls, self.repository) - for sha256, package_to_patch in partial_index.items(): - p2p = self.index.setdefault(sha256, {}) - p2p.update(package_to_patch) + for pkg_fullname in pkgs_fullname: + pkg_cls = self.repository.get_pkg_class(pkg_fullname) + partial_index = self._index_patches(pkg_cls, self.repository) + for sha256, package_to_patch in partial_index.items(): + p2p = self.index.setdefault(sha256, {}) + p2p.update(package_to_patch) def update(self, other: "PatchCache") -> None: """Update this cache with the contents of another. diff --git a/lib/spack/spack/provider_index.py b/lib/spack/spack/provider_index.py index 886a89c0cb0831..7fe5ac9be49c8d 100644 --- a/lib/spack/spack/provider_index.py +++ b/lib/spack/spack/provider_index.py @@ -53,18 +53,8 @@ def __init__( self.repository = repository self.restrict = restrict self.providers = {} - - specs = specs or [] - for spec in specs: - if isinstance(spec, str): - from spack.spec import Spec - - spec = Spec(spec) - - if self.repository.is_virtual_safe(spec.name): - continue - - self.update(spec) + if specs: + self.update_packages(specs) def providers_for(self, virtual: Union[str, "spack.spec.Spec"]) -> List["spack.spec.Spec"]: """Return a list of specs of all packages that provide virtual packages with the supplied @@ -122,57 +112,55 @@ def __str__(self): def __repr__(self): return repr(self.providers) - def update(self, spec: Union[str, "spack.spec.Spec"]) -> None: + def update_packages(self, specs: Iterable[Union[str, "spack.spec.Spec"]]): """Update the provider index with additional virtual specs. Args: spec: spec potentially providing additional virtual specs """ - if isinstance(spec, str): - from spack.spec import Spec - - spec = Spec(spec) + from spack.spec import Spec - if not spec.name: - # Empty specs do not have a package - return + for spec in specs: + if not isinstance(spec, Spec): + spec = Spec(spec) - msg = "cannot update an index passing the virtual spec '{}'".format(spec.name) - assert not self.repository.is_virtual_safe(spec.name), msg + if not spec.name or self.repository.is_virtual_safe(spec.name): + # Only non-virtual packages with name can provide virtual specs. + continue - pkg_cls = self.repository.get_pkg_class(spec.name) - for provider_spec_readonly, provided_specs in pkg_cls.provided.items(): - for provided_spec in provided_specs: - # TODO: fix this comment. - # We want satisfaction other than flags - provider_spec = provider_spec_readonly.copy() - provider_spec.compiler_flags = spec.compiler_flags.copy() + pkg_cls = self.repository.get_pkg_class(spec.name) + for provider_spec_readonly, provided_specs in pkg_cls.provided.items(): + for provided_spec in provided_specs: + # TODO: fix this comment. + # We want satisfaction other than flags + provider_spec = provider_spec_readonly.copy() + provider_spec.compiler_flags = spec.compiler_flags.copy() - if spec.intersects(provider_spec, deps=False): - provided_name = provided_spec.name + if spec.intersects(provider_spec, deps=False): + provided_name = provided_spec.name - provider_map = self.providers.setdefault(provided_name, {}) - if provided_spec not in provider_map: - provider_map[provided_spec] = set() + provider_map = self.providers.setdefault(provided_name, {}) + if provided_spec not in provider_map: + provider_map[provided_spec] = set() - if self.restrict: - provider_set = provider_map[provided_spec] + if self.restrict: + provider_set = provider_map[provided_spec] - # If this package existed in the index before, - # need to take the old versions out, as they're - # now more constrained. - old = set([s for s in provider_set if s.name == spec.name]) - provider_set.difference_update(old) + # If this package existed in the index before, + # need to take the old versions out, as they're + # now more constrained. + old = {s for s in provider_set if s.name == spec.name} + provider_set.difference_update(old) - # Now add the new version. - provider_set.add(spec) + # Now add the new version. + provider_set.add(spec) - else: - # Before putting the spec in the map, constrain - # it so that it provides what was asked for. - constrained = spec.copy() - constrained.constrain(provider_spec) - provider_map[provided_spec].add(constrained) + else: + # Before putting the spec in the map, constrain + # it so that it provides what was asked for. + constrained = spec.copy() + constrained.constrain(provider_spec) + provider_map[provided_spec].add(constrained) def to_json(self, stream=None): """Dump a JSON representation of this object. @@ -207,14 +195,14 @@ def merge(self, other): spdict[provided_spec] = spdict[provided_spec].union(opdict[provided_spec]) - def remove_provider(self, pkg_name): + def remove_providers(self, pkg_names: Set[str]): """Remove a provider from the ProviderIndex.""" empty_pkg_dict = [] for pkg, pkg_dict in self.providers.items(): empty_pset = [] for provided, pset in pkg_dict.items(): - same_name = set(p for p in pset if p.fullname == pkg_name) - pset.difference_update(same_name) + to_remove = {spec for spec in pset if spec.name in pkg_names} + pset.difference_update(to_remove) if not pset: empty_pset.append(provided) diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index bccd449de4c21d..db5f83bbd3a0cd 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -485,7 +485,7 @@ def read(self, stream): """Read this index from a provided file object.""" @abc.abstractmethod - def update(self, pkg_fullname): + def update(self, pkgs_fullname: Set[str]): """Update the index in memory with information about a package.""" @abc.abstractmethod @@ -502,8 +502,8 @@ def _create(self) -> spack.tag.TagIndex: def read(self, stream): self.index = spack.tag.TagIndex.from_json(stream) - def update(self, pkg_fullname): - self.index.update_package(pkg_fullname.split(".")[-1], self.repository) + def update(self, pkgs_fullname: Set[str]): + self.index.update_packages({p.split(".")[-1] for p in pkgs_fullname}, self.repository) def write(self, stream): self.index.to_json(stream) @@ -518,15 +518,15 @@ def _create(self) -> "spack.provider_index.ProviderIndex": def read(self, stream): self.index = spack.provider_index.ProviderIndex.from_json(stream, self.repository) - def update(self, pkg_fullname): - name = pkg_fullname.split(".")[-1] + def update(self, pkgs_fullname: Set[str]): is_virtual = ( - not self.repository.exists(name) or self.repository.get_pkg_class(name).virtual + lambda name: not self.repository.exists(name) + or self.repository.get_pkg_class(name).virtual ) - if is_virtual: - return - self.index.remove_provider(pkg_fullname) - self.index.update(pkg_fullname) + non_virtual_pkgs_fullname = {p for p in pkgs_fullname if not is_virtual(p.split(".")[-1])} + non_virtual_pkgs_names = {p.split(".")[-1] for p in non_virtual_pkgs_fullname} + self.index.remove_providers(non_virtual_pkgs_names) + self.index.update_packages(non_virtual_pkgs_fullname) def write(self, stream): self.index.to_json(stream) @@ -551,8 +551,8 @@ def read(self, stream): def write(self, stream): self.index.to_json(stream) - def update(self, pkg_fullname): - self.index.update_package(pkg_fullname) + def update(self, pkgs_fullname: Set[str]): + self.index.update_packages(pkgs_fullname) class RepoIndex: @@ -644,9 +644,7 @@ def _build_index(self, name: str, indexer: Indexer): if new_index_mtime != index_mtime: needs_update = self.checker.modified_since(new_index_mtime) - for pkg_name in needs_update: - indexer.update(f"{self.namespace}.{pkg_name}") - + indexer.update({f"{self.namespace}.{pkg_name}" for pkg_name in needs_update}) indexer.write(new) return indexer.index diff --git a/lib/spack/spack/tag.py b/lib/spack/spack/tag.py index 0e40759337fdf3..ecb1d15db898b9 100644 --- a/lib/spack/spack/tag.py +++ b/lib/spack/spack/tag.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Classes and functions to manage package tags""" -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING, Dict, List, Set import spack.error import spack.util.spack_json as sjson @@ -51,26 +51,28 @@ def merge(self, other: "TagIndex") -> None: else: self.tags[tag] = sorted({*self.tags[tag], *pkgs}) - def update_package(self, pkg_name: str, repo: "spack.repo.Repo") -> None: - """Updates a package in the tag index. + def update_packages(self, pkg_names: Set[str], repo: "spack.repo.Repo") -> None: + """Updates packages in the tag index. Args: - pkg_name: name of the package to be updated + pkg_names: names of the packages to be updated + repo: the repository to get package classes from """ - pkg_cls = repo.get_pkg_class(pkg_name) - - # Remove the package from the list of packages, if present + # Remove the packages from the list of packages, if present for pkg_list in self.tags.values(): - if pkg_name in pkg_list: - pkg_list.remove(pkg_name) - - # Add it again under the appropriate tags - for tag in getattr(pkg_cls, "tags", []): - tag = tag.lower() - if tag not in self.tags: - self.tags[tag] = [pkg_cls.name] - else: - self.tags[tag].append(pkg_cls.name) + if pkg_names.isdisjoint(pkg_list): + continue + pkg_list[:] = [pkg for pkg in pkg_list if pkg not in pkg_names] + + # Add them again under the appropriate tags + for pkg_name in pkg_names: + pkg_cls = repo.get_pkg_class(pkg_name) + for tag in getattr(pkg_cls, "tags", []): + tag = tag.lower() + if tag not in self.tags: + self.tags[tag] = [pkg_cls.name] + else: + self.tags[tag].append(pkg_cls.name) class TagIndexError(spack.error.SpackError): diff --git a/lib/spack/spack/test/provider_index.py b/lib/spack/spack/test/provider_index.py index 40062fed041fed..c13c1e77a9b39e 100644 --- a/lib/spack/spack/test/provider_index.py +++ b/lib/spack/spack/test/provider_index.py @@ -72,3 +72,15 @@ def test_copy(mock_packages): p = ProviderIndex(specs=spack.repo.all_package_names(), repository=spack.repo.PATH) q = p.copy() assert p == q + + +def test_remove_providers(mock_packages): + """Test removing providers from the index.""" + p = ProviderIndex(specs=["mpich"], repository=spack.repo.PATH) + # Check that mpich is a provider for mpi + providers = p.providers_for("mpi") + assert any(spec.name == "mpich" for spec in providers) + p.remove_providers({"mpich"}) + # After removal, mpich should no longer be a provider for mpi + providers = p.providers_for("mpi") + assert not any(spec.name == "mpich" for spec in providers) diff --git a/lib/spack/spack/test/tag.py b/lib/spack/spack/test/tag.py index 8b9d565cf29189..a3092e285e63d5 100644 --- a/lib/spack/spack/test/tag.py +++ b/lib/spack/spack/test/tag.py @@ -146,7 +146,6 @@ def test_tag_no_tags(mock_packages): def test_tag_update_package(mock_packages): mock_index = mock_packages.tag_index index = spack.tag.TagIndex() - for name in spack.repo.all_package_names(): - index.update_package(name, repo=mock_packages) + index.update_packages(set(spack.repo.all_package_names()), repo=mock_packages) ensure_tags_results_equal(mock_index.tags, index.tags) From a56361a3b11830724ef297a5a6763cd5d268025d Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 27 Jan 2026 00:23:37 +0100 Subject: [PATCH 032/337] directives_meta.py: more use of immutable specs (#51869) * directives_meta.py: more use of immutable specs * Both `@when(...)` and `with when(...)` now use immutable Specs * Avoid the `Spec -> str -> Spec` conversion between `when` and `directives_meta` * Avoid copy of `kwargs` in the common case where there is no `default_args` set * Remove vestigial `if isinstance(result, Sequence)` check on the return value of a directive -- it's always a callable. * Avoid `constrain` call if there's only a single when condition. Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives.py | 16 +-- lib/spack/spack/directives_meta.py | 199 +++++++++++++++-------------- lib/spack/spack/multimethod.py | 14 +- lib/spack/spack/test/directives.py | 18 +++ 4 files changed, 134 insertions(+), 113 deletions(-) diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index e0e92c8e29f53b..ff3820187c9f29 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -45,7 +45,7 @@ def _execute_example_directive(pkg, arg1, arg2): import re import warnings from functools import partial -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union +from typing import Any, Callable, List, Optional, Tuple, Type, Union import spack.deptypes as dt import spack.error @@ -57,7 +57,7 @@ def _execute_example_directive(pkg, arg1, arg2): import spack.util.crypto import spack.variant from spack.dependency import Dependency -from spack.directives_meta import DirectiveError, DirectiveMeta +from spack.directives_meta import DirectiveError, directive, get_spec from spack.resource import Resource from spack.spec import EMPTY_SPEC from spack.version import ( @@ -97,15 +97,6 @@ def _execute_example_directive(pkg, arg1, arg2): Patcher = Callable[[Union[PackageType, Dependency]], None] PatchesType = Union[Patcher, str, List[Union[Patcher, str]]] -SPEC_CACHE: Dict[str, spack.spec.Spec] = {} - - -def get_spec(spec_str: str) -> spack.spec.Spec: - """Get a spec from the cache, or create it if not present.""" - if spec_str not in SPEC_CACHE: - SPEC_CACHE[spec_str] = spack.spec._ImmutableSpec(spec_str) - return SPEC_CACHE[spec_str] - def _make_when_spec(value: WhenType) -> Optional[spack.spec.Spec]: """Create a ``Spec`` that indicates when a directive should be applied. @@ -153,10 +144,9 @@ def _make_when_spec(value: WhenType) -> Optional[spack.spec.Spec]: SubmoduleCallback = Callable[[spack.package_base.PackageBase], Union[str, List[str], bool]] -directive = DirectiveMeta.directive -@directive("versions") +@directive("versions", supports_when=False) def version( ver: Union[str, int], # this positional argument is deprecated, use sha256=... instead diff --git a/lib/spack/spack/directives_meta.py b/lib/spack/spack/directives_meta.py index 0700883854cd5d..48634e4d30714c 100644 --- a/lib/spack/spack/directives_meta.py +++ b/lib/spack/spack/directives_meta.py @@ -2,9 +2,9 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) -import collections.abc import functools -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Type, Union +import itertools +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union import spack.error import spack.llnl.util.lang @@ -15,17 +15,29 @@ #: Some directives leverage others and in that case are not automatically added. directive_names = ["build_system"] +SPEC_CACHE: Dict[str, spack.spec.Spec] = {} + + +def get_spec(spec_str: str) -> spack.spec.Spec: + """Get a spec from the cache, or create it if not present.""" + if spec_str not in SPEC_CACHE: + SPEC_CACHE[spec_str] = spack.spec._ImmutableSpec(spec_str) + return SPEC_CACHE[spec_str] + class DirectiveMeta(type): """Flushes the directives that were temporarily stored in the staging area into the package. """ - # Set of all known directives + #: Set of all known directive dictionary names from `@directive(dicts=...)` _directive_dict_names: Set[str] = set() + #: List of directives to be executed at class initialization time _directives_to_be_executed: List[Callable] = [] - _when_constraints_from_context: List[spack.spec.Spec] = [] - _default_args: List[dict] = [] + #: Stack of when constraints from `with when(...)` context managers + _when_constraints_stack: List[spack.spec.Spec] = [] + #: Stack of default args from `with default_args(...)` context managers + _default_args_stack: List[dict] = [] def __new__( cls: Type["DirectiveMeta"], name: str, bases: tuple, attr_dict: dict @@ -81,24 +93,24 @@ def __init__(cls: "DirectiveMeta", name: str, bases: tuple, attr_dict: dict): super(DirectiveMeta, cls).__init__(name, bases, attr_dict) @staticmethod - def push_to_context(when_spec: spack.spec.Spec) -> None: + def push_when_constraint(when_spec: spack.spec.Spec) -> None: """Add a spec to the context constraints.""" - DirectiveMeta._when_constraints_from_context.append(when_spec) + DirectiveMeta._when_constraints_stack.append(when_spec) @staticmethod - def pop_from_context() -> spack.spec.Spec: + def pop_when_constraint() -> spack.spec.Spec: """Pop the last constraint from the context""" - return DirectiveMeta._when_constraints_from_context.pop() + return DirectiveMeta._when_constraints_stack.pop() @staticmethod def push_default_args(default_args: Dict[str, Any]) -> None: """Push default arguments""" - DirectiveMeta._default_args.append(default_args) + DirectiveMeta._default_args_stack.append(default_args) @staticmethod def pop_default_args() -> dict: """Pop default arguments""" - return DirectiveMeta._default_args.pop() + return DirectiveMeta._default_args_stack.pop() @staticmethod def _remove_directives(args): @@ -119,117 +131,114 @@ def _remove_directives(args): directives.remove(directive) # iterations ends, so mutation is fine break - @staticmethod - def directive(dicts: Optional[Union[Sequence[str], str]] = None) -> Callable: - """Decorator for Spack directives. - Spack directives allow you to modify a package while it is being - defined, e.g. to add version or dependency information. Directives - are one of the key pieces of Spack's package "language", which is - embedded in python. +def _combine_when( + when: Optional[str] = None, + when_stack: List[spack.spec.Spec] = DirectiveMeta._when_constraints_stack, +) -> spack.spec.Spec: + """Compute the combined when constraints from the context and the directive keyword argument. - Here's an example directive: + Arguments: + when: The when constraint from the directive's keyword argument as a raw string (if any). + when_stack: The stack of parsed when constraints from ``with when(...)`` context managers. + """ + # In the following case + # with when("+foo"): # single constraint on the stack + # depends_on("foo") # unconditional directive + # avoid creating a new spec and just return the one from the stack + if len(when_stack) == 1 and not when: + return when_stack[0] + + # Otherwise, combine all when constraints by mutating a new spec + when_spec = spack.spec.Spec(when) + for current in when_stack: + when_spec._constrain_symbolically(current, deps=True) + return when_spec + + +class directive: + def __init__( + self, dicts: Union[Tuple[str, ...], str] = (), supports_when: bool = True + ) -> None: + """Decorator for Spack directives. - .. code-block:: python + Spack directives allow you to modify a package while it is being defined, e.g. to add + version or dependency information. Directives are one of the key pieces of Spack's + package "language", which is embedded in python. + + Here's an example directive:: @directive(dicts="versions") def version(pkg, ...): ... - This directive allows you write: - - .. code-block:: python + This directive allows you write:: class Foo(Package): version(...) The ``@directive`` decorator handles a couple things for you: - 1. Adds the class scope (pkg) as an initial parameter when - called, like a class method would. This allows you to modify - a package from within a directive, while the package is still - being defined. + 1. Adds the class scope (pkg) as an initial parameter when called, like a class method + would. This allows you to modify a package from within a directive, while the package is + still being defined. - 2. It automatically adds a dictionary called ``versions`` to the - package so that you can refer to pkg.versions. + 2. It automatically adds a dictionary called ``versions`` to the package so that you can + refer to pkg.versions. - The ``(dicts="versions")`` part ensures that ALL packages in Spack - will have a ``versions`` attribute after they're constructed, and - that if no directive actually modified it, it will just be an - empty dict. - - This is just a modular way to add storage attributes to the - Package class, and it's how Spack gets information from the - packages to the core. + Arguments: + dicts: A list of names of dictionaries to add to the package class if they don't + already exist. + supports_when: If True, the directive can be used within a ``with when(...)`` context + manager. (To be removed when all directives support ``when=`` arguments.) """ if isinstance(dicts, str): dicts = (dicts,) - if not isinstance(dicts, collections.abc.Sequence): - message = "dicts arg must be list, tuple, or string. Found {0}" - raise TypeError(message.format(type(dicts))) - # Add the dictionary names if not already there - DirectiveMeta._directive_dict_names |= set(dicts) + DirectiveMeta._directive_dict_names.update(dicts) + + self.supports_when = supports_when - # This decorator just returns the directive functions - def _decorator(decorated_function: Callable) -> Callable: - directive_names.append(decorated_function.__name__) + def __call__(self, decorated_function: Callable) -> Callable: + directive_names.append(decorated_function.__name__) - @functools.wraps(decorated_function) - def _wrapper(*args, **_kwargs): - # First merge default args with kwargs - kwargs = dict() - for default_args in DirectiveMeta._default_args: + # Do not capture `self` in the wrapper + supports_when = self.supports_when + + @functools.wraps(decorated_function) + def _wrapper(*args, **_kwargs): + # First merge default args with kwargs + if DirectiveMeta._default_args_stack: + kwargs = {} + for default_args in DirectiveMeta._default_args_stack: kwargs.update(default_args) kwargs.update(_kwargs) + else: + kwargs = _kwargs + + # Inject when arguments from the context + if DirectiveMeta._when_constraints_stack: + if not supports_when: + raise DirectiveError( + f'directive "{decorated_function.__name__}" cannot be used within a ' + '"when" context since it does not support a "when=" argument' + ) + kwargs["when"] = _combine_when(kwargs.get("when")) + + # Remove directives passed as arguments, so they are not executed as part of this + # class's directive execution, but handled by the called directive instead + DirectiveMeta._remove_directives(itertools.chain(args, kwargs.values())) + + result = decorated_function(*args, **kwargs) + + DirectiveMeta._directives_to_be_executed.append(result) + + # wrapped function returns same result as original so that we can nest directives + return result - # Inject when arguments from the context - if DirectiveMeta._when_constraints_from_context: - # Check that directives not yet supporting the when= argument - # are not used inside the context manager - if decorated_function.__name__ == "version": - msg = ( - 'directive "{0}" cannot be used within a "when"' - ' context since it does not support a "when=" ' - "argument" - ) - msg = msg.format(decorated_function.__name__) - raise DirectiveError(msg) - - when_constraints = [ - spack.spec.Spec(x) for x in DirectiveMeta._when_constraints_from_context - ] - if kwargs.get("when"): - when_constraints.append(spack.spec.Spec(kwargs["when"])) - - when_spec = spack.spec.Spec() - for current in when_constraints: - when_spec._constrain_symbolically(current, deps=True) - kwargs["when"] = when_spec - - DirectiveMeta._remove_directives(args) - DirectiveMeta._remove_directives(list(kwargs.values())) - - # A directive returns either something that is callable on a - # package or a sequence of them - result = decorated_function(*args, **kwargs) - - # ...so if it is not a sequence make it so - values = result - if not isinstance(values, collections.abc.Sequence): - values = (values,) - - DirectiveMeta._directives_to_be_executed.extend(values) - - # wrapped function returns same result as original so - # that we can nest directives - return result - - return _wrapper - - return _decorator + return _wrapper class DirectiveError(spack.error.SpackError): diff --git a/lib/spack/spack/multimethod.py b/lib/spack/spack/multimethod.py index df96f4870b62ae..afbd4f9fc29698 100644 --- a/lib/spack/spack/multimethod.py +++ b/lib/spack/spack/multimethod.py @@ -25,7 +25,7 @@ """ import functools from contextlib import contextmanager -from typing import Union +from typing import Optional, Union import spack.directives_meta import spack.error @@ -237,6 +237,8 @@ def install(self, prefix): override all of the decorated versions. This is a limitation of the Python language. """ + spec: Optional[spack.spec.Spec] + def __init__(self, condition: Union[str, bool]): """Can be used both as a decorator, for multimethods, or as a context manager to group ``when=`` arguments together. @@ -246,9 +248,9 @@ def __init__(self, condition: Union[str, bool]): condition (str): condition to be met """ if isinstance(condition, bool): - self.spec = spack.spec.Spec() if condition else None + self.spec = spack.spec.EMPTY_SPEC if condition else None else: - self.spec = spack.spec.Spec(condition) + self.spec = spack.directives_meta.get_spec(condition) def __call__(self, method): assert ( @@ -266,10 +268,12 @@ def __call__(self, method): return original_method def __enter__(self): - spack.directives_meta.DirectiveMeta.push_to_context(str(self.spec)) + if self.spec is not None: + spack.directives_meta.DirectiveMeta.push_when_constraint(self.spec) def __exit__(self, exc_type, exc_val, exc_tb): - spack.directives_meta.DirectiveMeta.pop_from_context() + if self.spec is not None: + spack.directives_meta.DirectiveMeta.pop_when_constraint() @contextmanager diff --git a/lib/spack/spack/test/directives.py b/lib/spack/spack/test/directives.py index 8a129140ad95e6..02421fdf57b28f 100644 --- a/lib/spack/spack/test/directives.py +++ b/lib/spack/spack/test/directives.py @@ -10,6 +10,8 @@ import spack.repo import spack.spec import spack.version +from spack.directives_meta import _combine_when +from spack.spec import Spec def test_false_directives_do_not_exist(mock_packages): @@ -212,3 +214,19 @@ def test_direct_dependencies_from_when_context_are_retained(mock_packages): assert spack.spec.Spec("%pkg-c") in pkg_cls.dependencies # Nested ^foo followed by ^foo %gcc assert spack.spec.Spec("^pkg-c %gcc") in pkg_cls.dependencies + + +def test_directives_meta_combine_when(): + x, y = Spec("+x ^dep +a"), Spec("+y ^dep +b") + + # Check that specs are combined, and do not mutate inputs + assert _combine_when("+z", [x, y]) == Spec("+x +y +z ^dep +a +b") + assert x == Spec("+x ^dep +a") + assert y == Spec("+y ^dep +b") + + assert _combine_when(None, [x, y]) == Spec("+x +y ^dep +a +b") + assert x == Spec("+x ^dep +a") + assert y == Spec("+y ^dep +b") + + # Check the optimization for single stack with no when + assert _combine_when(None, [x]) is x From 4e4bf079d2639360517ffb601142d5439cf10f15 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 27 Jan 2026 00:32:13 +0100 Subject: [PATCH 033/337] main.py: less aggressive gc (#51879) Signed-off-by: Harmen Stoppels --- lib/spack/spack/main.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index 5ec3d8fc574a59..25825b2141656b 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -8,6 +8,7 @@ after the system path is set up. """ import argparse +import gc import inspect import operator import os @@ -1120,6 +1121,8 @@ def main(argv=None): """ try: + g0, g1, g2 = gc.get_threshold() + gc.set_threshold(50 * g0, g1, g2) return _main(argv) except spack.solver.asp.OutputDoesNotSatisfyInputError as e: From 32deb8a268b7ad0d9fb669f85ac347b8f48095a4 Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Mon, 26 Jan 2026 23:21:32 -0800 Subject: [PATCH 034/337] Forward port of changelog from 1.1.1 release (#51886) Signed-off-by: Gregory Becker --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c18dbee05d75a7..2d0b68bc5f7718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +# v1.1.1 (2026-01-14) + +## Usability and performance enhancements + +* solver: do a precheck for non-existing and deprecated versions #51555 +* improvements to solver performance (PRs 51591, 51605, 51612, 51625) +* python 3.14 support (PRs 51686, 51687, 51688, 51689, 51663) +* display when conditions with dependencies in spack info #51588 +* spack repo remove: allow removing from unspecified scope #51563 +* spack compiler info: show non-external compilers too #51718 + +## Improvements to the experimental new installer + +* support forkserver #51788 (for python 3.14 support) +* support --dirty, --keep-stage, and `skip patch` arguments #51558 +* implement --use-buildcache, --cache-only, --use-cache and --only arguments #51593 +* implement overwrite, keep_prefix #51622 +* implement --dont-restage #51623 +* fix logging #51787 + +## Bugfixes + +* repo.py: support rhel 7 #51617 +* solver: match glibc constraints by hash #51559 +* buildache list: list the component prefix not the root #51635 +* solver: fix issue with conditional language dependencies #51692 +* repo.py: fix checking out commits #51695 +* spec parser: ensure toolchains are expanded to different objects #51731 +* RHEL7 git 1.8.3.1 fix #51779 +* RewireTask.complete: return value from \_process\_binary\_cache\_tarball #51825 + +## Documentation + +* docs: fix default projections setting discrepancy #51640 + + # v1.1.0 (2025-11-14) `v1.1.0` features major improvements to **compiler handling** and **configuration management**, a significant refactoring of **externals**, and exciting new **experimental features** like a console UI for parallel installations and concretization caching. From e68db58e64a24d8fbedd3d59d4e2f24bf4e5cdf6 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 27 Jan 2026 09:01:46 +0100 Subject: [PATCH 035/337] environment: consolidate a few attributes (#51857) Mapping from user specs to concrete specs in environments is currently spread over 3 attributes: - `new_specs`: specs that have not been persisted yet (must be in `concretized_user_specs`) - `concretized_user_specs`: user specs that have been concretized - `concretized_order`: list of hashes corresponding to `concretized_user_specs` (must have the same order) These attributes are updated separately in different methods but have logical dependencies among them. This makes it easy to introduce bugs by creating accidental inconsistencies. Here we consolidate the 3 attributes into a single one, which is a list of: ```python class ConcretizedRootInfo: """Data on root specs that have been concretized""" __slots__ = ("root", "hash", "new") def __init__(self, *, root_spec: spack.spec.Spec, root_hash: str, new: bool = False): self.root = root_spec self.hash = root_hash self.new = new ``` A similar consolidation for included environments is left for a following PR. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/environment/environment.py | 328 +++++++++++---------- lib/spack/spack/test/env.py | 29 +- 2 files changed, 195 insertions(+), 162 deletions(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index dda534ea62a3e3..6769dd28dd0016 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -40,6 +40,7 @@ import spack.variant as vt from spack import traverse from spack.llnl.util.filesystem import copy_tree, islink, readlink, symlink +from spack.llnl.util.lang import stable_partition from spack.llnl.util.link_tree import ConflictingSpecsError from spack.schema.env import TOP_LEVEL_KEY from spack.spec import Spec @@ -963,6 +964,20 @@ def env_subdir_path(manifest_dir: Union[str, pathlib.Path]) -> str: return os.path.join(str(manifest_dir), env_subdir_name) +class ConcretizedRootInfo: + """Data on root specs that have been concretized""" + + __slots__ = ("root", "hash", "new") + + def __init__(self, *, root_spec: spack.spec.Spec, root_hash: str, new: bool = False): + self.root = root_spec + self.hash = root_hash + self.new = new + + def __str__(self): + return f"{self.root} -> {self.hash} [new={self.new}]" + + class Environment: """A Spack environment, which bundles together configuration and a list of specs.""" @@ -980,17 +995,14 @@ def __init__(self, manifest_dir: Union[str, pathlib.Path]) -> None: self.txlock = lk.Lock(self._transaction_lock_path) self._unify = None - self.new_specs: List[Spec] = [] self.views: Dict[str, ViewDescriptor] = {} #: Parser for spec lists self._spec_lists_parser = SpecListParser() #: Specs from "spack.yaml" self.spec_lists: Dict[str, SpecList] = {} - #: User specs from the last concretization - self.concretized_user_specs: List[Spec] = [] - #: Roots associated with the last concretization, in order - self.concretized_order: List[str] = [] + #: Information on concretized roots + self.concretized_roots: List[ConcretizedRootInfo] = [] #: Concretized specs by hash self.specs_by_hash: Dict[str, Spec] = {} #: Repository for this environment (memoized) @@ -1046,7 +1058,7 @@ def __setstate__(self, state): def _re_read(self): """Reinitialize the environment object.""" - self.clear(re_read=True) + self.clear() self._load_manifest_file() def _read(self): @@ -1151,7 +1163,7 @@ def _sync_speclists(self): def all_concretized_user_specs(self) -> List[Spec]: """Returns all of the concretized user specs of the environment and its included environment(s).""" - concretized_user_specs = self.concretized_user_specs[:] + concretized_user_specs = self.concretized_user_specs for included_specs in self.included_concretized_user_specs.values(): for included in included_specs: # Don't duplicate included spec(s) @@ -1163,7 +1175,7 @@ def all_concretized_user_specs(self) -> List[Spec]: def all_concretized_orders(self) -> List[str]: """Returns all of the concretized order of the environment and its included environment(s).""" - concretized_order = self.concretized_order[:] + concretized_order = [x.hash for x in self.concretized_roots] for included_concretized_order in self.included_concretized_order.values(): for included in included_concretized_order: # Don't duplicate included spec(s) @@ -1210,17 +1222,11 @@ def add_root_specs(included_concrete_specs): add_root_specs(self.included_concrete_spec_data) return spec_list - def clear(self, re_read=False): - """Clear the contents of the environment - - Arguments: - re_read: If ``True``, do not clear ``new_specs``. This value cannot be read from yaml, - and needs to be maintained when re-reading an existing environment. - """ + def clear(self): + """Clear the contents of the environment""" self.spec_lists = {} self._dev_specs = {} - self.concretized_order = [] # roots of last concretize, in order - self.concretized_user_specs = [] # user specs from last concretize + self.concretized_roots = [] self.specs_by_hash = {} # concretized specs by hash self.included_concrete_spec_data = {} # concretized specs from lockfile of included envs @@ -1230,9 +1236,6 @@ def clear(self, re_read=False): self.invalidate_repository_cache() self._previous_active = None # previously active environment - if not re_read: - # things that cannot be recreated from file - self.new_specs = [] # write packages for these on write() self.manifest.clear() @@ -1317,7 +1320,7 @@ def include_concrete_envs(self): if transitive: self.included_concrete_spec_data[env_path]["include_concrete"] = transitive - self._read_lockfile_dict(self._to_lockfile_dict()) + self._unify_specs() self.write() def destroy(self): @@ -1426,10 +1429,8 @@ def remove(self, query_spec, list_name=user_speclist_name, force=False): matches = [s for s in list_to_change if s.satisfies(query_spec)] else: - # concrete specs match against concrete specs in the env - # by dag hash. - specs_hashes = zip(self.concretized_user_specs, self.concretized_order) - matches = [s for s, h in specs_hashes if query_spec.dag_hash() == h] + # concrete specs match against concrete specs in the env by dag hash. + matches = [x.root for x in self.concretized_roots if query_spec.dag_hash() == x.hash] if not matches: raise SpackEnvironmentError(f"{err_msg_header}, no spec matches") @@ -1458,14 +1459,13 @@ def remove(self, query_spec, list_name=user_speclist_name, force=False): new_specs = set(self.user_specs) # If 'force', update stale concretized specs - for spec in old_specs - new_specs: - if force and spec in self.concretized_user_specs: - i = self.concretized_user_specs.index(spec) - del self.concretized_user_specs[i] - - dag_hash = self.concretized_order[i] - del self.concretized_order[i] - del self.specs_by_hash[dag_hash] + if force: + stale_specs = old_specs - new_specs + self.concretized_roots, removed = stable_partition( + self.concretized_roots, lambda x: x.root not in stale_specs + ) + for x in removed: + del self.specs_by_hash[x.hash] def is_develop(self, spec): """Returns true when the spec is built from local sources""" @@ -1535,25 +1535,31 @@ def mutate( parent.clear_caches() # Compute new hashes and update the env list of specs + hash_mutations = {} for root, old_hash in modified_roots: + # New hash must be computed after we finalize concretization root._finalize_concretization() - self.concretized_order[self.concretized_order.index(old_hash)] = root.dag_hash() + new_hash = root.dag_hash() self.specs_by_hash.pop(old_hash) - self.specs_by_hash[root.dag_hash()] = root + self.specs_by_hash[new_hash] = root + hash_mutations[old_hash] = new_hash + + for x in self.concretized_roots: + if x.hash in hash_mutations: + x.hash = hash_mutations[x.hash] if modified_roots: self.write() def concretize( - self, force: Optional[bool] = None, tests: Union[bool, Sequence] = False + self, *, force: Optional[bool] = None, tests: Union[bool, Sequence[str]] = False ) -> Sequence[SpecPair]: """Concretize user_specs in this environment. - Only concretizes specs that haven't been concretized yet unless - force is ``True``. + Only concretizes specs that haven't been concretized yet unless force is ``True``. - This only modifies the environment in memory. ``write()`` will - write out a lockfile containing concretized specs. + This only modifies the environment in memory. ``write()`` will write out a lockfile + containing concretized specs. Arguments: force: re-concretize ALL specs, even those that were already concretized; @@ -1568,33 +1574,54 @@ def concretize( if force is None: force = spack.config.get("concretizer:force") - if force: - # Clear previously concretized specs - self.concretized_user_specs = [] - self.concretized_order = [] - self.specs_by_hash = {} - - # Remove concrete specs that no longer correlate to a user spec - for spec in set(self.concretized_user_specs) - set(self.user_specs): - self.deconcretize(spec, concrete=False) + self._prepare_for_concretization(force=force) - # If a combined env, check updated spec is in the linked envs - if self.included_concrete_envs: - self.include_concrete_envs() + # Exit early if the set of concretized specs is the set of user specs + new_user_specs, kept_user_specs, specs_to_concretize = self._get_specs_to_concretize() + if not new_user_specs: + return [] # Pick the right concretization strategy if self.unify == "when_possible": - return self._concretize_together_where_possible(tests=tests) + return self._concretize_together_where_possible( + new_user_specs, kept_user_specs, specs_to_concretize, tests=tests + ) if self.unify is True: - return self._concretize_together(tests=tests) + return self._concretize_together( + new_user_specs, kept_user_specs, specs_to_concretize, tests=tests + ) if self.unify is False: - return self._concretize_separately(tests=tests) + return self._concretize_separately( + new_user_specs, kept_user_specs, specs_to_concretize, tests=tests + ) msg = "concretization strategy not implemented [{0}]" raise SpackEnvironmentError(msg.format(self.unify)) + def _prepare_for_concretization(self, *, force: bool): + """Reset the environment concrete state and ensure consistency with user specs.""" + if force: + self.clear_concretized_specs() + else: + self.sync_concretized_specs() + + # If a combined env, check updated spec is in the linked envs + if self.included_concrete_envs: + self.include_concrete_envs() + + def sync_concretized_specs(self) -> None: + """Removes concrete specs that no longer correlate to a user spec""" + to_deconcretize = [x.root for x in self.concretized_roots if x.root not in self.user_specs] + for spec in to_deconcretize: + self.deconcretize(spec, concrete=False) + + def clear_concretized_specs(self) -> None: + """Clears the currently concretized specs""" + self.concretized_roots = [] + self.specs_by_hash = {} + def deconcretize(self, spec: spack.spec.Spec, concrete: bool = True): """ Remove specified spec from environment concretization @@ -1607,24 +1634,24 @@ def deconcretize(self, spec: spack.spec.Spec, concrete: bool = True): # spec has to be a root of the environment if concrete: dag_hash = spec.dag_hash() - - pairs = zip(self.concretized_user_specs, self.concretized_order) - filtered = [(spec, h) for spec, h in pairs if h != dag_hash] - # Cannot use zip and unpack two values; it fails if filtered is empty - self.concretized_user_specs = [s for s, _ in filtered] - self.concretized_order = [h for _, h in filtered] + self.concretized_roots = [x for x in self.concretized_roots if x.hash != dag_hash] else: - index = self.concretized_user_specs.index(spec) - dag_hash = self.concretized_order.pop(index) - - del self.concretized_user_specs[index] + self.concretized_roots, discarded = stable_partition( + self.concretized_roots, lambda x: x.root != spec + ) + assert ( + len({x.hash for x in discarded}) == 1 + ), "More than one hash associated with a single user spec" + dag_hash = discarded[0].hash # If this was the only user spec that concretized to this concrete spec, remove it - if dag_hash not in self.concretized_order: - # if we deconcretized a dependency that doesn't correspond to a root, it - # won't be here. - if dag_hash in self.specs_by_hash: - del self.specs_by_hash[dag_hash] + if not self.user_spec_with_hash(dag_hash) and dag_hash in self.specs_by_hash: + # if we deconcretized a dependency that doesn't correspond to a root, it won't be here. + del self.specs_by_hash[dag_hash] + + def user_spec_with_hash(self, dag_hash: str) -> bool: + """Returns True if any user spec is associated with a concrete spec with the given hash""" + return any(x.hash == dag_hash for x in self.concretized_roots) def _get_specs_to_concretize( self, @@ -1638,8 +1665,10 @@ def _get_specs_to_concretize( """ # Exit early if the set of concretized specs is the set of user specs - new_user_specs = list(set(self.user_specs) - set(self.concretized_user_specs)) - kept_user_specs = list(set(self.user_specs) & set(self.concretized_user_specs)) + concretized_user_specs = {x.root for x in self.concretized_roots} + kept_user_specs, new_user_specs = stable_partition( + self.user_specs, lambda x: x in concretized_user_specs + ) kept_user_specs += self.included_user_specs if not new_user_specs: return new_user_specs, kept_user_specs, [] @@ -1652,45 +1681,34 @@ def _get_specs_to_concretize( return new_user_specs, kept_user_specs, specs_to_concretize def _concretize_together_where_possible( - self, tests: Union[bool, Sequence] = False + self, + new_user_specs: List[Spec], + kept_user_specs: List[Spec], + specs_to_concretize: List[SpecPair], + *, + tests: Union[bool, Sequence[str]] = False, ) -> Sequence[SpecPair]: # Exit early if the set of concretized specs is the set of user specs - new_user_specs, _, specs_to_concretize = self._get_specs_to_concretize() - if not new_user_specs: - return [] - - self.concretized_user_specs = [] - self.concretized_order = [] - self.specs_by_hash = {} - - ret = [] result = spack.concretize.concretize_together_when_possible( specs_to_concretize, tests=tests ) + result = [x for x in result if x[0] in new_user_specs] for abstract, concrete in result: - # Only add to the environment if it's from this environment (not included in) - if abstract in self.user_specs: - self._add_concrete_spec(abstract, concrete) - - # Return only the new specs - if abstract in new_user_specs: - ret.append((abstract, concrete)) + self._add_concrete_spec(abstract, concrete, new=True) - return ret + return result - def _concretize_together(self, tests: Union[bool, Sequence] = False) -> Sequence[SpecPair]: + def _concretize_together( + self, + new_user_specs: List[Spec], + kept_user_specs: List[Spec], + specs_to_concretize: List[SpecPair], + *, + tests: Union[bool, Sequence[str]] = False, + ) -> Sequence[SpecPair]: """Concretization strategy that concretizes all the specs in the same DAG. """ - # Exit early if the set of concretized specs is the set of user specs - new_user_specs, kept_user_specs, specs_to_concretize = self._get_specs_to_concretize() - if not new_user_specs: - return [] - - self.concretized_user_specs = [] - self.concretized_order = [] - self.specs_by_hash = {} - try: concretized_specs = spack.concretize.concretize_together( specs_to_concretize, tests=tests @@ -1711,46 +1729,40 @@ def _concretize_together(self, tests: Union[bool, Sequence] = False) -> Sequence ) raise - for abstract, concrete in concretized_specs: - # Don't add if it's just included - if abstract in self.user_specs: - self._add_concrete_spec(abstract, concrete) - # Return the portion of the return value that is new - return concretized_specs[: len(new_user_specs)] + result = concretized_specs[: len(new_user_specs)] + for abstract, concrete in result: + self._add_concrete_spec(abstract, concrete, new=True) + return result - def _concretize_separately(self, tests: Union[bool, Sequence] = False): + def _concretize_separately( + self, + new_user_specs: List[Spec], + kept_user_specs: List[Spec], + specs_to_concretize: List[SpecPair], + *, + tests: Union[bool, Sequence[str]] = False, + ): """Concretization strategy that concretizes separately one user spec after the other. """ - # keep any concretized specs whose user specs are still in the manifest - old_concretized_user_specs = self.concretized_user_specs - old_concretized_order = self.concretized_order - old_specs_by_hash = self.specs_by_hash - - self.concretized_user_specs = [] - self.concretized_order = [] - self.specs_by_hash = {} + to_concretize = [(root, None) for root in new_user_specs] - for s, h in zip(old_concretized_user_specs, old_concretized_order): - if s in self.user_specs: - concrete = old_specs_by_hash[h] - self._add_concrete_spec(s, concrete, new=False) - - to_concretize = [ - (root, None) for root in self.user_specs if root not in old_concretized_user_specs - ] concretized_specs = spack.concretize.concretize_separately(to_concretize, tests=tests) - by_hash = {} for abstract, concrete in concretized_specs: - self._add_concrete_spec(abstract, concrete) - by_hash[concrete.dag_hash()] = concrete + self._add_concrete_spec(abstract, concrete, new=True) # Unify the specs objects, so we get correct references to all parents - self._read_lockfile_dict(self._to_lockfile_dict()) + self._unify_specs() return concretized_specs + def _unify_specs(self) -> None: + # Keep the information on new specs by copying the concretized roots + old_concretized_roots = self.concretized_roots + self._read_lockfile_dict(self._to_lockfile_dict()) + self.concretized_roots = old_concretized_roots + @property def default_view(self): if not self.has_view(default_view_name): @@ -1902,31 +1914,31 @@ def rm_view_from_env( return env_mod - def _add_concrete_spec(self, spec, concrete, new=True): + def _add_concrete_spec( + self, spec: spack.spec.Spec, concrete: spack.spec.Spec, *, new: bool = True + ): """Called when a new concretized spec is added to the environment. This ensures that all internal data structures are kept in sync. Arguments: - spec (Spec): user spec that resulted in the concrete spec - concrete (Spec): spec concretized within this environment - new (bool): whether to write this spec's package to the env - repo on write() + spec: user spec that resulted in the concrete spec + concrete: spec concretized within this environment + new: whether to write this spec's package to the env repo on write() """ assert concrete.concrete - - # when a spec is newly concretized, we need to make a note so - # that we can write its package to the env repo on write() - if new: - self.new_specs.append(concrete) - - # update internal lists of specs - self.concretized_user_specs.append(spec) - h = concrete.dag_hash() - self.concretized_order.append(h) + self.concretized_roots.append(ConcretizedRootInfo(root_spec=spec, root_hash=h, new=new)) self.specs_by_hash[h] = concrete + @property + def concretized_order(self) -> List[str]: + return [x.hash for x in self.concretized_roots] + + @property + def concretized_user_specs(self) -> List[Spec]: + return [x.root for x in self.concretized_roots] + def _dev_specs_that_need_overwrite(self): """Return the hashes of all specs that need to be reinstalled due to source code change.""" changed_dev_specs = [ @@ -2235,8 +2247,7 @@ def _concrete_specs_dict(self): return concrete_specs def _concrete_roots_dict(self): - hash_spec_list = zip(self.concretized_order, self.concretized_user_specs) - return [{"hash": h, "spec": str(s)} for h, s in hash_spec_list] + return [{"hash": x.hash, "spec": str(x.root)} for x in self.concretized_roots] def _to_lockfile_dict(self): """Create a dictionary to store a lockfile for this environment.""" @@ -2324,8 +2335,12 @@ def _read_lockfile_dict(self, d): self.included_concretized_order = {} roots = d["roots"] - self.concretized_user_specs = [Spec(r["spec"]) for r in roots] - self.concretized_order = [r["hash"] for r in roots] + + self.concretized_roots = [ + ConcretizedRootInfo(root_spec=Spec(r["spec"]), root_hash=r["hash"], new=False) + for r in roots + ] + json_specs_by_hash = d["concrete_specs"] included_json_specs_by_hash = {} @@ -2349,18 +2364,19 @@ def _read_lockfile_dict(self, d): msg += " You need to use a newer Spack version." raise SpackEnvironmentError(msg) - first_seen, self.concretized_order = self.filter_specs( - reader, json_specs_by_hash, self.concretized_order + concretized_order = [x.hash for x in self.concretized_roots] + first_seen, concretized_order = self._filter_specs( + reader, json_specs_by_hash, concretized_order ) - - for spec_dag_hash in self.concretized_order: + for idx, spec_dag_hash in enumerate(concretized_order): + self.concretized_roots[idx].hash = spec_dag_hash self.specs_by_hash[spec_dag_hash] = first_seen[spec_dag_hash] if any(self.included_concretized_order.values()): first_seen = {} for env_name, concretized_order in self.included_concretized_order.items(): - filtered_spec, self.included_concretized_order[env_name] = self.filter_specs( + filtered_spec, self.included_concretized_order[env_name] = self._filter_specs( reader, included_json_specs_by_hash, concretized_order ) first_seen.update(filtered_spec) @@ -2372,8 +2388,8 @@ def _read_lockfile_dict(self, d): {spec_dag_hash: first_seen[spec_dag_hash]} ) - def filter_specs(self, reader, json_specs_by_hash, order_concretized): - # Track specs by their lockfile key. Currently spack uses the finest + def _filter_specs(self, reader, json_specs_by_hash, order_concretized): + # Track specs by their lockfile key. Currently, spack uses the finest # grained hash as the lockfile key, while older formats used the build # hash or a previous incarnation of the DAG hash (one that did not # include build deps or package hash). @@ -2455,7 +2471,8 @@ def write(self, regenerate: bool = True) -> None: if regenerate: self.regenerate_views() - self.new_specs.clear() + for x in self.concretized_roots: + x.new = False def update_lockfile(self) -> None: with fs.write_tmp_and_move(self.lock_path, encoding="utf-8") as f: @@ -2473,7 +2490,8 @@ def ensure_env_directory_exists(self, dot_env: bool = False) -> None: def update_environment_repository(self) -> None: """Updates the repository associated with the environment.""" - for spec in traverse.traverse_nodes(self.new_specs): + new_specs = [self.specs_by_hash[x.hash] for x in self.concretized_roots if x.new] + for spec in traverse.traverse_nodes(new_specs): if not spec.concrete: raise ValueError("specs passed to environment.write() must be concrete!") diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index 1aed072fc4fd5d..e089c0aea1119d 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -59,10 +59,9 @@ def test_hash_change_no_rehash_concrete(tmp_path: pathlib.Path, config): env.concretize() # rewrite the hash - old_hash = env.concretized_order[0] - new_hash = "abc" + old_hash, new_hash = env.concretized_roots[0].hash, "abc" env.specs_by_hash[old_hash]._hash = new_hash # type: ignore[attr-defined] - env.concretized_order[0] = new_hash + env.concretized_roots[0].hash = new_hash env.specs_by_hash[new_hash] = env.specs_by_hash[old_hash] del env.specs_by_hash[old_hash] env.write() @@ -795,14 +794,15 @@ def test_env_with_include_def_missing(mutable_mock_env_path): @pytest.mark.regression("41292") -def test_deconcretize_then_concretize_does_not_error(mutable_mock_env_path): +@pytest.mark.parametrize("unify", ["true", "false", "when_possible"]) +def test_deconcretize_then_concretize_does_not_error(mutable_mock_env_path, unify): """Tests that, after having deconcretized a spec, we can reconcretize an environment which has 2 or more user specs mapping to the same concrete spec. """ mutable_mock_env_path.mkdir() spack_yaml = mutable_mock_env_path / ev.manifest_name spack_yaml.write_text( - """spack: + f"""spack: specs: # These two specs concretize to the same hash - pkg-c @@ -810,15 +810,30 @@ def test_deconcretize_then_concretize_does_not_error(mutable_mock_env_path): # Spec used to trigger the bug - pkg-a concretizer: - unify: true + unify: {unify} """ ) e = ev.Environment(mutable_mock_env_path) + # Initial state + assert len(e.user_specs) == 3 + assert len(e.concretized_roots) == 0 + with e: e.concretize() + assert len(e.user_specs) == 3 + assert len(e.concretized_roots) == 3 + assert all(x.new for x in e.concretized_roots) + e.deconcretize(spack.spec.Spec("pkg-a"), concrete=False) + assert len(e.user_specs) == 3 + assert len(e.concretized_roots) == 2 + assert all(x.new for x in e.concretized_roots) + e.concretize() - assert len(e.concrete_roots()) == 3 + assert len(e.user_specs) == 3 + assert len(e.concretized_roots) == 3 + assert all(x.new for x in e.concretized_roots) + all_root_hashes = {x.dag_hash() for x in e.concrete_roots()} assert len(all_root_hashes) == 2 From 801ec809e9c1bc2559b137fdec0d0711187b6b15 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 27 Jan 2026 10:12:19 +0100 Subject: [PATCH 036/337] gha: fix typo (#51885) Signed-off-by: Harmen Stoppels --- .github/workflows/ci.yaml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a59c628e19d424..5d5303d124b9cd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,12 +88,10 @@ jobs: steps: - name: Success run: | - if [ "${{ needs.prechecks.result }}" == "failure" ] || [ "${{ needs.prechecks.result }}" == "canceled" ]; then - echo "Unit tests failed." - exit 1 - else - exit 0 - fi + [ "${{ needs.prechecks.result }}" = "success" ] && exit 0 + [ "${{ needs.prechecks.result }}" = "skipped" ] && exit 0 + echo "Unit tests failed." + exit 1 coverage: needs: [ unit-tests, prechecks ] @@ -109,12 +107,14 @@ jobs: steps: - name: Status summary run: | - if [ "${{ needs.unit-tests.result }}" == "failure" ] || [ "${{ needs.unit-tests.result }}" == "canceled" ]; then + if [ "${{ needs.unit-tests.result }}" = "success" ] || [ "${{ needs.unit-tests.result }}" = "skipped" ]; then + if [ "${{ needs.bootstrap.result }}" = "success" ] || [ "${{ needs.bootstrap.result }}" = "skipped" ]; then + exit 0 + else + echo "Bootstrap tests failed." + exit 1 + fi + else echo "Unit tests failed." exit 1 - elif [ "${{ needs.bootstrap.result }}" == "failure" ] || [ "${{ needs.bootstrap.result }}" == "canceled" ]; then - echo "Bootstrap tests failed." - exit 1 - else - exit 0 fi From 5734eea91f0917ea035d6a1f2c3ba04d12d8c967 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 27 Jan 2026 14:11:21 +0100 Subject: [PATCH 037/337] spec.py: VariantMap/FlagMap: drop backref to spec (#51875) The circularity `spec.variants.spec is spec` is problematic as it makes the object harder to garbage collect. Without cycles, refcounting is used and no GC is needed at all. Signed-off-by: Harmen Stoppels --- lib/spack/spack/spec.py | 197 ++++++++++++++++---------------- lib/spack/spack/test/variant.py | 38 +++--- 2 files changed, 116 insertions(+), 119 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 963f0f7b3a7957..486dfc2dcdab98 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -928,11 +928,6 @@ def _shared_subset_pair_iterate(container1, container2): class FlagMap(lang.HashableMap[str, List[CompilerFlag]]): - __slots__ = ("spec",) - - def __init__(self, spec): - super().__init__() - self.spec = spec def satisfies(self, other): return all(f in self and set(self[f]) >= set(other[f]) for f in other) @@ -975,7 +970,7 @@ def valid_compiler_flags(): return _valid_compiler_flags def copy(self): - clone = FlagMap(self.spec) + clone = FlagMap() for name, compiler_flag in self.items(): clone[name] = compiler_flag return clone @@ -1554,9 +1549,9 @@ def __init__(self, spec_like=None, *, external_path=None, external_modules=None) # init an empty spec that matches anything. self.name: str = "" self.versions = vn.VersionList.any() - self.variants = VariantMap(self) + self.variants = VariantMap() self.architecture = None - self.compiler_flags = FlagMap(self) + self.compiler_flags = FlagMap() self._dependents = {} self._dependencies = {} self.namespace = None @@ -3156,7 +3151,7 @@ def _constrain(self, other, deps=True, *, resolve_virtuals: bool): changed = True changed |= self.versions.intersect(other.versions) - changed |= self.variants.constrain(other.variants) + changed |= self._constrain_variants(other) changed |= self.compiler_flags.constrain(other.compiler_flags) @@ -3310,7 +3305,7 @@ def _intersects( if not self.versions.intersects(other.versions): return False - if not self.variants.intersects(other.variants): + if not self._intersects_variants(other): return False if self.architecture and other.architecture: @@ -3442,7 +3437,7 @@ def _satisfies( if not self.versions.satisfies(other.versions): return False - if not self.variants.satisfies(other.variants): + if not self._satisfies_variants(other): return False if self.architecture and other.architecture: @@ -3619,6 +3614,94 @@ def _satisfies( return True + def _satisfies_variants(self, other: "Spec") -> bool: + if self.concrete: + return self._satisfies_variants_when_self_concrete(other) + return self._satisfies_variants_when_self_abstract(other) + + def _satisfies_variants_when_self_concrete(self, other: "Spec") -> bool: + non_propagating, propagating = other.variants.partition_variants() + result = all( + name in self.variants and self.variants[name].satisfies(other.variants[name]) + for name in non_propagating + ) + if not propagating: + return result + + for node in self.traverse(): + if not all( + node.variants[name].satisfies(other.variants[name]) + for name in propagating + if name in node.variants + ): + return False + return result + + def _satisfies_variants_when_self_abstract(self, other: "Spec") -> bool: + other_non_propagating, other_propagating = other.variants.partition_variants() + self_non_propagating, self_propagating = self.variants.partition_variants() + + # First check variants without propagation set + result = all( + name in self_non_propagating + and ( + self.variants[name].propagate + or self.variants[name].satisfies(other.variants[name]) + ) + for name in other_non_propagating + ) + if result is False or (not other_propagating and not self_propagating): + return result + + # Check that self doesn't contradict variants propagated by other + if other_propagating: + for node in self.traverse(): + if not all( + node.variants[name].satisfies(other.variants[name]) + for name in other_propagating + if name in node.variants + ): + return False + + # Check that other doesn't contradict variants propagated by self + if self_propagating: + for node in other.traverse(): + if not all( + node.variants[name].satisfies(self.variants[name]) + for name in self_propagating + if name in node.variants + ): + return False + + return result + + def _intersects_variants(self, other: "Spec") -> bool: + self_dict = self.variants.dict + other_dict = other.variants.dict + return all(self_dict[k].intersects(other_dict[k]) for k in other_dict if k in self_dict) + + def _constrain_variants(self, other: "Spec") -> bool: + """Add all variants in other that aren't in self to self. Also constrain all multi-valued + variants that are already present. Return True iff self changed""" + if other is not None and other._concrete: + for k in self.variants: + if k not in other.variants: + raise vt.UnsatisfiableVariantSpecError(self.variants[k], "") + + changed = False + for k in other.variants: + if k in self.variants: + if not self.variants[k].intersects(other.variants[k]): + raise vt.UnsatisfiableVariantSpecError(self.variants[k], other.variants[k]) + # If they are compatible merge them + changed |= self.variants[k].constrain(other.variants[k]) + else: + # If it is not present copy it straight away + self.variants[k] = other.variants[k].copy() + changed = True + + return changed + @property # type: ignore[misc] # decorated prop not supported in mypy def patches(self): """Return patch objects for any patch sha256 sums on this Spec. @@ -5115,8 +5198,8 @@ def __setstate__(self, state): self._package = None # Reconstruct variants and compiler_flags - self.variants = VariantMap(self) - self.compiler_flags = FlagMap(self) + self.variants = VariantMap() + self.compiler_flags = FlagMap() if variants_data is not None: self.variants.dict = variants_data if compiler_flags_data is not None: @@ -5152,10 +5235,6 @@ class VariantMap(lang.HashableMap[str, vt.VariantValue]): """Map containing variant instances. New values can be added only if the key is not already present.""" - def __init__(self, spec: Spec): - super().__init__() - self.spec = spec - def __setitem__(self, name, vspec): # Raise a TypeError if vspec is not of the right type if not isinstance(vspec, vt.VariantValue): @@ -5197,90 +5276,8 @@ def partition_variants(self): prop = [x.name for x in prop] return non_prop, prop - def satisfies(self, other: "VariantMap") -> bool: - if self.spec.concrete: - return self._satisfies_when_self_concrete(other) - return self._satisfies_when_self_abstract(other) - - def _satisfies_when_self_concrete(self, other: "VariantMap") -> bool: - non_propagating, propagating = other.partition_variants() - result = all( - name in self and self[name].satisfies(other[name]) for name in non_propagating - ) - if not propagating: - return result - - for node in self.spec.traverse(): - if not all( - node.variants[name].satisfies(other[name]) - for name in propagating - if name in node.variants - ): - return False - return result - - def _satisfies_when_self_abstract(self, other: "VariantMap") -> bool: - other_non_propagating, other_propagating = other.partition_variants() - self_non_propagating, self_propagating = self.partition_variants() - - # First check variants without propagation set - result = all( - name in self_non_propagating - and (self[name].propagate or self[name].satisfies(other[name])) - for name in other_non_propagating - ) - if result is False or (not other_propagating and not self_propagating): - return result - - # Check that self doesn't contradict variants propagated by other - if other_propagating: - for node in self.spec.traverse(): - if not all( - node.variants[name].satisfies(other[name]) - for name in other_propagating - if name in node.variants - ): - return False - - # Check that other doesn't contradict variants propagated by self - if self_propagating: - for node in other.spec.traverse(): - if not all( - node.variants[name].satisfies(self[name]) - for name in self_propagating - if name in node.variants - ): - return False - - return result - - def intersects(self, other): - return all(self[k].intersects(other[k]) for k in other if k in self) - - def constrain(self, other: "VariantMap") -> bool: - """Add all variants in other that aren't in self to self. Also constrain all multi-valued - variants that are already present. Return True iff self changed""" - if other.spec is not None and other.spec._concrete: - for k in self: - if k not in other: - raise vt.UnsatisfiableVariantSpecError(self[k], "") - - changed = False - for k in other: - if k in self: - if not self[k].intersects(other[k]): - raise vt.UnsatisfiableVariantSpecError(self[k], other[k]) - # If they are compatible merge them - changed |= self[k].constrain(other[k]) - else: - # If it is not present copy it straight away - self[k] = other[k].copy() - changed = True - - return changed - def copy(self) -> "VariantMap": - clone = VariantMap(self.spec) + clone = VariantMap() for name, variant in self.items(): clone[name] = variant.copy() return clone diff --git a/lib/spack/spack/test/variant.py b/lib/spack/spack/test/variant.py index a6de9fc91eb9aa..b43794e5777547 100644 --- a/lib/spack/spack/test/variant.py +++ b/lib/spack/spack/test/variant.py @@ -416,7 +416,7 @@ def test_str(self): class TestVariantMapTest: def test_invalid_values(self) -> None: # Value with invalid type - a = VariantMap(Spec()) + a = VariantMap() with pytest.raises(TypeError): a["foo"] = 2 @@ -437,7 +437,7 @@ def test_invalid_values(self) -> None: def test_set_item(self) -> None: # Check that all the three types of variants are accepted - a = VariantMap(Spec()) + a = VariantMap() a["foo"] = BoolValuedVariant("foo", True) a["bar"] = SingleValuedVariant("bar", "baz") @@ -445,7 +445,7 @@ def test_set_item(self) -> None: def test_substitute(self) -> None: # Check substitution of a key that exists - a = VariantMap(Spec()) + a = VariantMap() a["foo"] = BoolValuedVariant("foo", True) a.substitute(SingleValuedVariant("foo", "bar")) @@ -456,34 +456,34 @@ def test_substitute(self) -> None: def test_satisfies_and_constrain(self) -> None: # foo=bar foobar=fee feebar=foo - a = VariantMap(Spec()) - a["foo"] = MultiValuedVariant("foo", ("bar",)) - a["foobar"] = SingleValuedVariant("foobar", "fee") - a["feebar"] = SingleValuedVariant("feebar", "foo") + a = Spec() + a.variants["foo"] = MultiValuedVariant("foo", ("bar",)) + a.variants["foobar"] = SingleValuedVariant("foobar", "fee") + a.variants["feebar"] = SingleValuedVariant("feebar", "foo") # foo=bar,baz foobar=fee shared=True - b = VariantMap(Spec()) - b["foo"] = MultiValuedVariant("foo", ("bar", "baz")) - b["foobar"] = SingleValuedVariant("foobar", "fee") - b["shared"] = BoolValuedVariant("shared", True) + b = Spec() + b.variants["foo"] = MultiValuedVariant("foo", ("bar", "baz")) + b.variants["foobar"] = SingleValuedVariant("foobar", "fee") + b.variants["shared"] = BoolValuedVariant("shared", True) # concrete, different values do not intersect / satisfy each other assert not a.intersects(b) and not b.intersects(a) assert not a.satisfies(b) and not b.satisfies(a) # foo=bar,baz foobar=fee feebar=foo shared=True - c = VariantMap(Spec()) - c["foo"] = MultiValuedVariant("foo", ("bar", "baz")) - c["foobar"] = SingleValuedVariant("foobar", "fee") - c["feebar"] = SingleValuedVariant("feebar", "foo") - c["shared"] = BoolValuedVariant("shared", True) + c = Spec() + c.variants["foo"] = MultiValuedVariant("foo", ("bar", "baz")) + c.variants["foobar"] = SingleValuedVariant("foobar", "fee") + c.variants["feebar"] = SingleValuedVariant("feebar", "foo") + c.variants["shared"] = BoolValuedVariant("shared", True) # concrete values cannot be constrained with pytest.raises(spack.variant.UnsatisfiableVariantSpecError): - a.constrain(b) + a._constrain_variants(b) def test_copy(self) -> None: - a = VariantMap(Spec()) + a = VariantMap() a["foo"] = BoolValuedVariant("foo", True) a["bar"] = SingleValuedVariant("bar", "baz") a["foobar"] = MultiValuedVariant("foobar", ("a", "b", "c", "d", "e")) @@ -492,7 +492,7 @@ def test_copy(self) -> None: assert a == c def test_str(self) -> None: - c = VariantMap(Spec()) + c = VariantMap() c["foo"] = MultiValuedVariant("foo", ("bar", "baz")) c["foobar"] = SingleValuedVariant("foobar", "fee") c["feebar"] = SingleValuedVariant("feebar", "foo") From 09dc474a6e875b0ab8699ffa711ac21b528221be Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 27 Jan 2026 19:15:33 +0100 Subject: [PATCH 038/337] environment: split `deconcretize` into two functions (#51887) The `Environment.deconcretize` method has an argument to tell whether the first spec is meant to be concrete or not. Since that argument is never used in a dynamic way, but always with True or False literals, split the method into two. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/bootstrap/environment.py | 2 +- lib/spack/spack/cmd/deconcretize.py | 2 +- lib/spack/spack/environment/environment.py | 36 +++++++++++----------- lib/spack/spack/test/env.py | 2 +- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/spack/spack/bootstrap/environment.py b/lib/spack/spack/bootstrap/environment.py index 144bd0bd7cf019..999db5d1f8cab3 100644 --- a/lib/spack/spack/bootstrap/environment.py +++ b/lib/spack/spack/bootstrap/environment.py @@ -32,7 +32,7 @@ def __init__(self) -> None: # Remove python package roots created before python-venv was introduced for s in self.concrete_roots(): if "python" in s.package.extendees and not s.dependencies("python-venv"): - self.deconcretize(s) + self.deconcretize_by_hash(s.dag_hash()) @classmethod def spack_dev_requirements(cls) -> List[str]: diff --git a/lib/spack/spack/cmd/deconcretize.py b/lib/spack/spack/cmd/deconcretize.py index d1afec66bd86df..a0b899d1736507 100644 --- a/lib/spack/spack/cmd/deconcretize.py +++ b/lib/spack/spack/cmd/deconcretize.py @@ -86,7 +86,7 @@ def deconcretize_specs(args, specs): with env.write_transaction(): for spec in deconcretize_list: - env.deconcretize(spec) + env.deconcretize_by_hash(spec.dag_hash()) env.write() diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 6769dd28dd0016..b5092e80888c0c 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -1615,35 +1615,35 @@ def sync_concretized_specs(self) -> None: """Removes concrete specs that no longer correlate to a user spec""" to_deconcretize = [x.root for x in self.concretized_roots if x.root not in self.user_specs] for spec in to_deconcretize: - self.deconcretize(spec, concrete=False) + self.deconcretize_by_user_spec(spec) def clear_concretized_specs(self) -> None: """Clears the currently concretized specs""" self.concretized_roots = [] self.specs_by_hash = {} - def deconcretize(self, spec: spack.spec.Spec, concrete: bool = True): - """ - Remove specified spec from environment concretization + def deconcretize_by_hash(self, dag_hash: str) -> None: + """Removes a concrete spec from the environment concretization""" + self.concretized_roots = [x for x in self.concretized_roots if x.hash != dag_hash] + self._maybe_remove_dag_hash(dag_hash) + + def deconcretize_by_user_spec(self, spec: spack.spec.Spec) -> None: + """Remove a user spec from the environment concretization Arguments: - spec: Spec to deconcretize. This must be a root of the environment - concrete: If True, find all instances of spec as concrete in the environment. - If False, find a single instance of the abstract spec as root of the environment. + spec: user spec to deconcretize """ # spec has to be a root of the environment - if concrete: - dag_hash = spec.dag_hash() - self.concretized_roots = [x for x in self.concretized_roots if x.hash != dag_hash] - else: - self.concretized_roots, discarded = stable_partition( - self.concretized_roots, lambda x: x.root != spec - ) - assert ( - len({x.hash for x in discarded}) == 1 - ), "More than one hash associated with a single user spec" - dag_hash = discarded[0].hash + self.concretized_roots, discarded = stable_partition( + self.concretized_roots, lambda x: x.root != spec + ) + assert ( + len({x.hash for x in discarded}) == 1 + ), "More than one hash associated with a single user spec" + dag_hash = discarded[0].hash + self._maybe_remove_dag_hash(dag_hash) + def _maybe_remove_dag_hash(self, dag_hash: str): # If this was the only user spec that concretized to this concrete spec, remove it if not self.user_spec_with_hash(dag_hash) and dag_hash in self.specs_by_hash: # if we deconcretized a dependency that doesn't correspond to a root, it won't be here. diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index e089c0aea1119d..36d106a6ccbb3b 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -824,7 +824,7 @@ def test_deconcretize_then_concretize_does_not_error(mutable_mock_env_path, unif assert len(e.concretized_roots) == 3 assert all(x.new for x in e.concretized_roots) - e.deconcretize(spack.spec.Spec("pkg-a"), concrete=False) + e.deconcretize_by_user_spec(spack.spec.Spec("pkg-a")) assert len(e.user_specs) == 3 assert len(e.concretized_roots) == 2 assert all(x.new for x in e.concretized_roots) From 2c8e3265016e467143d0fba7b1c359bd6f4d6c41 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:10:52 -0800 Subject: [PATCH 039/337] remote_file_cache.local_path(): pass destination as string, not callable (#51895) Signed-off-by: tldahlgren --- lib/spack/spack/config.py | 2 +- lib/spack/spack/test/util/remote_file_cache.py | 7 +++---- lib/spack/spack/util/remote_file_cache.py | 15 +++++++++------ 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 8f14e958704ef1..13b2ada527ada6 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -1157,7 +1157,7 @@ def work_dir(): return os.getcwd() with filesystem.working_dir(work_dir()): - config_path = rfc_util.local_path(self.path, self.sha256, _include_cache_location) + config_path = rfc_util.local_path(self.path, self.sha256, _include_cache_location()) assert config_path self.destination = config_path diff --git a/lib/spack/spack/test/util/remote_file_cache.py b/lib/spack/spack/test/util/remote_file_cache.py index 4c5e81332acae9..b7f8188f06d9aa 100644 --- a/lib/spack/spack/test/util/remote_file_cache.py +++ b/lib/spack/spack/test/util/remote_file_cache.py @@ -94,16 +94,15 @@ def _has_content(filename): tty.debug(f"Expected {element} in '{filename}'") return False - def _dest_dir(): - return join_path(str(tmp_path), "cache") + dest_dir = join_path(str(tmp_path), "cache") if err is not None: with spack.config.override("config:url_fetch_method", "curl"): with pytest.raises(err, match=msg): - rfc_util.local_path(url, sha256, _dest_dir) + rfc_util.local_path(url, sha256, dest_dir) else: with spack.config.override("config:url_fetch_method", "curl"): - path = rfc_util.local_path(url, sha256, _dest_dir) + path = rfc_util.local_path(url, sha256, dest_dir) assert os.path.exists(path) # Ensure correct file is "fetched" assert os.path.basename(path) == os.path.basename(url) diff --git a/lib/spack/spack/util/remote_file_cache.py b/lib/spack/spack/util/remote_file_cache.py index 0897e33ca20778..6624da8ce9a1af 100644 --- a/lib/spack/spack/util/remote_file_cache.py +++ b/lib/spack/spack/util/remote_file_cache.py @@ -9,7 +9,7 @@ import tempfile import urllib.parse import urllib.request -from typing import Callable, Optional +from typing import Optional import spack.llnl.util.tty as tty import spack.util.crypto @@ -60,13 +60,13 @@ def fetch_remote_text_file(url: str, dest_dir: str) -> str: return fetch_url_text(raw_url, dest_dir=dest_dir) -def local_path(raw_path: str, sha256: str, make_dest: Optional[Callable[[], str]] = None) -> str: +def local_path(raw_path: str, sha256: str, dest: Optional[str] = None) -> str: """Determine the actual path and, if remote, stage its contents locally. Args: raw_path: raw path with possible variables needing substitution - sha256: the expected sha256 for the file - make_dest: function to create a stage for remote files, if needed (e.g., ``mkdtemp``) + sha256: the expected sha256 if the file is remote + dest: destination path Returns: resolved, normalized local path @@ -98,8 +98,11 @@ def local_path(raw_path: str, sha256: str, make_dest: Optional[Callable[[], str] if validate_scheme(url.scheme): # Fetch files from supported URL schemes. if url.scheme in ("http", "https", "ftp"): - if make_dest is None: + if not dest: raise ValueError("Requires the destination argument to cache remote files") + assert os.path.isabs( + dest + ), f"Remote file destination '{dest}' must be an absolute path" # Stage the remote configuration file tmpdir = tempfile.mkdtemp() @@ -118,7 +121,7 @@ def local_path(raw_path: str, sha256: str, make_dest: Optional[Callable[[], str] raise ValueError(f"Requires sha256 ('{checksum}') to cache remote files.") # Copy the file to the destination directory - dest_dir = join_path(make_dest(), checksum) + dest_dir = join_path(dest, checksum) if not os.path.exists(dest_dir): mkdirp(dest_dir) From c8939cb0a378eaf3f331cb5dfebd819aa6e04337 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 29 Jan 2026 17:15:11 +0100 Subject: [PATCH 040/337] concretize.py: verbose unify: when_possible (#51890) Show the same progress as `unify: false` does. Signed-off-by: Harmen Stoppels --- lib/spack/spack/concretize.py | 17 +++++++++++++++-- lib/spack/spack/solver/asp.py | 4 ++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/spack/spack/concretize.py b/lib/spack/spack/concretize.py index 2061b6f4b80114..efa477f69f03d4 100644 --- a/lib/spack/spack/concretize.py +++ b/lib/spack/spack/concretize.py @@ -5,7 +5,7 @@ import importlib import sys import time -from typing import Iterable, List, Optional, Sequence, Tuple, Union +from typing import Dict, Iterable, List, Optional, Sequence, Tuple, Union import spack.compilers import spack.compilers.config @@ -76,12 +76,25 @@ def concretize_together_when_possible( concrete: abstract for (abstract, concrete) in spec_list if concrete } - result_by_user_spec = {} + result_by_user_spec: Dict[Spec, Spec] = {} allow_deprecated = spack.config.get("config:deprecated", False) + j = 0 + start = time.monotonic() for result in Solver().solve_in_rounds( to_concretize, tests=tests, allow_deprecated=allow_deprecated ): + now = time.monotonic() + duration = now - start + percentage = int((j + 1) / len(to_concretize) * 100) + for abstract, concrete in result.specs_by_input.items(): + tty.verbose( + f"{duration:6.1f}s [{percentage:3d}%] {concrete.cformat('{hash:7}')} " + f"{abstract.colored_str}" + ) + j += 1 + sys.stdout.flush() result_by_user_spec.update(result.specs_by_input) + start = now # If the "abstract" spec is a concrete spec from the previous concretization # translate it back to an abstract spec. Otherwise, keep the abstract spec diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 338b3edf489da8..941088467adba5 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -379,10 +379,10 @@ def unsolved_specs(self): return self._unsolved_specs @property - def specs_by_input(self): + def specs_by_input(self) -> Dict[spack.spec.Spec, spack.spec.Spec]: if self._concrete_specs_by_input is None: self._compute_specs_from_answer_set() - return self._concrete_specs_by_input + return self._concrete_specs_by_input # type: ignore def _compute_specs_from_answer_set(self): if not self.satisfiable: From b7d04dbab9579b83cf547e85b51ea1b95b29be65 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:04:52 -0800 Subject: [PATCH 041/337] fetch_url_text: ensure check method in curl check (#51893) Signed-off-by: tldahlgren --- lib/spack/spack/util/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/util/web.py b/lib/spack/spack/util/web.py index 550cb5225690a9..40a6a625ccbfa0 100644 --- a/lib/spack/spack/util/web.py +++ b/lib/spack/spack/util/web.py @@ -411,7 +411,7 @@ def fetch_url_text(url, curl: Optional[Executable] = None, dest_dir="."): fetch_method = spack.config.get("config:url_fetch_method") tty.debug("Using '{0}' to fetch {1} into {2}".format(fetch_method, url, path)) - if fetch_method.startswith("curl"): + if fetch_method and fetch_method.startswith("curl"): curl_exe = curl or require_curl() curl_args = fetch_method.split()[1:] + ["-O"] curl_args.extend(base_curl_fetch_args(url)) From 53aab9f6b32619964bb4c8f03793e5469492bcea Mon Sep 17 00:00:00 2001 From: Alec Scott Date: Thu, 29 Jan 2026 17:16:21 -0800 Subject: [PATCH 042/337] fix locking issue in pull_checkout_branch (#51854) Signed-off-by: Alec Scott --- lib/spack/spack/util/git.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/util/git.py b/lib/spack/spack/util/git.py index 23510670113b39..ca6a07d1912941 100644 --- a/lib/spack/spack/util/git.py +++ b/lib/spack/spack/util/git.py @@ -202,7 +202,7 @@ def pull_checkout_branch( raise ValueError("depth must be a positive integer") fetch_args.append(f"--depth={depth}") - git_exe("fetch", *fetch_args, remote, f"{branch}:refs/remotes/{remote}/{branch}") + git_exe("fetch", *fetch_args, remote, f"refs/heads/{branch}:refs/remotes/{remote}/{branch}") git_exe("checkout", "--quiet", branch) try: From 07e72a9fee3b8f5187a52cbbee16ac07657cb10c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 3 Feb 2026 20:09:09 +0100 Subject: [PATCH 043/337] Revert "env: drop default venv search path from `PYTHONPATH` when activating Spack env (#51743)" (#51910) Two packages don't work as expected as a result of dropping PYTHONPATH from `spack env activate`: * `py-jupyterlab` can't locate `ipykernel_launcher` because it runs `python-venv/bin/python3` directly. Also, when using jupyterlab, you probably wanna be able to `import foo` from the Spack environment. * `paraview` has `pvpython`, a binary with a hard-coded path to `python-venv/bin/python3` which also doesn't know how to import things from the environment view. This reverts commit 8266c6926ed83370113f0e41b517b638826e5d42. Signed-off-by: Harmen Stoppels --- lib/spack/spack/user_environment.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/lib/spack/spack/user_environment.py b/lib/spack/spack/user_environment.py index dca1e5437f1011..5dfd50f311f684 100644 --- a/lib/spack/spack/user_environment.py +++ b/lib/spack/spack/user_environment.py @@ -116,21 +116,4 @@ def environment_modifications_for_specs( if view: project_env_mods(*topo_ordered, view=view, env=env) - # we don't want to set PYTHONPATH to the default search path in virtual environments - view_python_pattern = re.compile( - r"^" + re.escape(os.path.join(view.root, "lib")) + r"/python[^/]+/site-packages$" - ) - - mods = [ - mod.value - for mod in env.env_modifications - if ( - isinstance(mod, environment.PrependPath) - and mod.name == "PYTHONPATH" - and view_python_pattern.match(mod.value) - ) - ] - - for modif in mods: - env.remove_path("PYTHONPATH", modif) return env From efa80217c53f248c5a40f6267fcfb6357e17f1ec Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Tue, 3 Feb 2026 16:55:41 -0800 Subject: [PATCH 044/337] spack style: change criteria for determining whether file is a package (#51339) Signed-off-by: Gregory Becker --- lib/spack/spack/cmd/style.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/cmd/style.py b/lib/spack/spack/cmd/style.py index 49111afc1d145f..ca843ca7951689 100644 --- a/lib/spack/spack/cmd/style.py +++ b/lib/spack/spack/cmd/style.py @@ -58,7 +58,7 @@ def is_package(f): packages, since we allow ``from spack.package import *`` and poking globals into packages. """ - return f.startswith("var/spack/") and f.endswith("package.py") + return "spack_repo" in f and f.endswith("package.py") #: decorator for adding tools to the list From 14bb6810d2f30bd7d7f47dd6b28190f08e587dea Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 4 Feb 2026 11:22:55 +0100 Subject: [PATCH 045/337] directives: lazy execution for package metadata (#51881) This commit refactors the directive system to execute directives lazily. Directives are now queued during class definition and only executed when the corresponding dictionary on the package class is accessed (e.g., `pkg.dependencies`, `pkg.versions`). This reduces overhead during cache population, and in non-forking build sub-processes where `spec.package` is used and metadata isn't needed. - `DirectiveDictDescriptor` is a singleton descriptor shared across package classes. It handles the lazy initialization of package attributes. - For each dictionary, the set of directives affecting it is computed automatically. For example, accessing `pkg.extendees` automatically triggers the execution of both `extends` and `depends_on` directives. - The actual dictionary is now stored at `pkg._dependencies` and so on. It is initially set to `None` to indicate the directives have not run yet. - `_execute_depends_on` now applies nested patches immediately via `_execute_patch` instead of going through `patch(...)` internally. This prevents state corruption in the global directive queue when `depends_on` is evaluated lazily. - `maintainers` directives remain eager to ensure backward compatibility with packages that define maintainers as a simple class-level list. - `PackageBase._patches_dependencies = True/False` is set without evaluation of `depends_on` and `extends` directives. This allows us to optimize patch indexing by avoiding evaluation of these directives if none of them produce patches. The commit has two very minor "breaking" changes, that do not affect `spack-packages`, and are for that reason considered OK: 1. `patches=...` is kwarg-only in the `depends_on` and `extends` directives. Previously this was a positional argument. 2. Nested directives are only deleted from the `patches` kwarg of `depends_on` and `extends`. Previously all args and kwarg values where searched for nested directives. The descriptor indirection does not seem to impact the "setup" phase of the solver. The first use of Spack is significantly faster. --- lib/spack/spack/audit.py | 16 +++ lib/spack/spack/directives.py | 40 +++--- lib/spack/spack/directives_meta.py | 212 +++++++++++++++++++---------- lib/spack/spack/patch.py | 3 + lib/spack/spack/test/conftest.py | 2 +- lib/spack/spack/test/directives.py | 75 +++++++++- 6 files changed, 258 insertions(+), 90 deletions(-) diff --git a/lib/spack/spack/audit.py b/lib/spack/spack/audit.py index 0efad6291d8653..44cdac6bd10a89 100644 --- a/lib/spack/spack/audit.py +++ b/lib/spack/spack/audit.py @@ -434,6 +434,22 @@ def _check_build_test_callbacks(pkgs, error_cls): return errors +@package_directives +def _directives_can_be_evaluated(pkgs, error_cls): + """Ensure that all directives in a package can be evaluated.""" + errors = [] + for pkg_name in pkgs: + pkg_cls = spack.repo.PATH.get_pkg_class(pkg_name) + for attr in pkg_cls._dict_to_directives: + try: + getattr(pkg_cls, attr) + except Exception as e: + error_msg = f"Package '{pkg_name}' has invalid directive '{attr}'" + details = [str(e)] + errors.append(error_cls(error_msg, details)) + return errors + + @package_directives def _check_patch_urls(pkgs, error_cls): """Ensure that patches fetched from GitHub and GitLab have stable sha256 diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index ff3820187c9f29..bfe2e11e5dc975 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -295,11 +295,12 @@ def _execute_conflicts(pkg: PackageType, conflict_spec, when, msg): conflict_spec_list.append((get_spec(conflict_spec), msg_with_name)) -@directive("dependencies") +@directive("dependencies", can_patch_dependencies=True) def depends_on( spec: SpecType, when: WhenType = None, type: DepType = dt.DEFAULT_TYPES, + *, patches: Optional[PatchesType] = None, ): """Declare a dependency on another package. @@ -316,18 +317,18 @@ def depends_on( patches: single result of :py:func:`patch` directive, a ``str`` to be passed to ``patch``, or a list of these """ - dep_spec = get_spec(spec) - return partial(_execute_depends_on, spec=dep_spec, when=when, type=type, patches=patches) + return partial(_execute_depends_on, spec=spec, when=when, type=type, patches=patches) def _execute_depends_on( pkg: PackageType, - spec: spack.spec.Spec, + spec: Union[str, spack.spec.Spec], *, when: WhenType = None, type: DepType = dt.DEFAULT_TYPES, patches: Optional[PatchesType] = None, ): + spec = get_spec(spec) if isinstance(spec, str) else spec when_spec = _make_when_spec(when) if not when_spec: return @@ -362,10 +363,6 @@ def _execute_depends_on( elif not isinstance(patches, (list, tuple)): patches = [patches] - # auto-call patch() directive on any strings in patch list - patches = [patch(p) if isinstance(p, str) else p for p in patches] - assert all(callable(p) for p in patches) - # this is where we actually add the dependency to this package deps_by_name = pkg.dependencies.setdefault(when_spec, {}) dependency = deps_by_name.get(spec.name) @@ -388,8 +385,12 @@ def _execute_depends_on( dependency.depflag |= depflag # apply patches to the dependency - for execute_patch in patches: - execute_patch(dependency) + for patch in patches: + if isinstance(patch, str): + _execute_patch(dependency, url_or_filename=patch) + else: + assert callable(patch), f"Invalid patch argument: {patch!r}" + patch(dependency) @directive("disable_redistribute") @@ -440,11 +441,12 @@ def _execute_redistribute( ) -@directive(("extendees", "dependencies")) +@directive(("extendees", "dependencies"), can_patch_dependencies=True) def extends( spec: str, when: WhenType = None, type: DepType = ("build", "run"), + *, patches: Optional[PatchesType] = None, ): """Same as :func:`depends_on`, but also adds this package to the extendee list. @@ -472,7 +474,7 @@ def _execute_extends( # When extending python, also add a dependency on python-venv. This is done so that # Spack environment views are Python virtual environments. if dep_spec.name == "python" and not pkg.name == "python-venv": - _execute_depends_on(pkg, get_spec("python-venv"), when=when, type=("build", "run")) + _execute_depends_on(pkg, "python-venv", when=when, type=("build", "run")) pkg.extendees[dep_spec.name] = (dep_spec, when_spec) @@ -591,12 +593,12 @@ def patch( def _execute_patch( pkg_or_dep: Union[PackageType, Dependency], url_or_filename: str, - level: int, - when: WhenType, - working_dir: str, - reverse: bool, - sha256: Optional[str], - archive_sha256: Optional[str], + level: int = 1, + when: WhenType = None, + working_dir: str = ".", + reverse: bool = False, + sha256: Optional[str] = None, + archive_sha256: Optional[str] = None, ) -> None: pkg = pkg_or_dep.pkg if isinstance(pkg_or_dep, Dependency) else pkg_or_dep @@ -901,7 +903,7 @@ def maintainers(*names: str): def _execute_maintainer(pkg: PackageType, names: Tuple[str, ...]): - maintainers = set(getattr(pkg, "maintainers", [])) + maintainers = set(pkg.maintainers) maintainers.update(names) pkg.maintainers = sorted(maintainers) diff --git a/lib/spack/spack/directives_meta.py b/lib/spack/spack/directives_meta.py index 48634e4d30714c..2caeabb7092431 100644 --- a/lib/spack/spack/directives_meta.py +++ b/lib/spack/spack/directives_meta.py @@ -2,14 +2,14 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) +import collections import functools -import itertools from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union import spack.error -import spack.llnl.util.lang import spack.repo import spack.spec +from spack.llnl.util.lang import dedupe #: Names of possible directives. This list is mostly populated using the @directive decorator. #: Some directives leverage others and in that case are not automatically added. @@ -30,67 +30,83 @@ class DirectiveMeta(type): area into the package. """ + #: Registry of {directive_name: [list_of_dicts_it_modifies]} populated by @directive + _directive_to_dicts: Dict[str, Tuple[str, ...]] = {} + #: Inverted index of {dict_name: [list_of_directives_modifying_it]} + _dict_to_directives: Dict[str, List[str]] = collections.defaultdict(list) + #: Maps dictionary name to its descriptor instance + _descriptor_cache: Dict[str, "DirectiveDictDescriptor"] = {} #: Set of all known directive dictionary names from `@directive(dicts=...)` _directive_dict_names: Set[str] = set() - #: List of directives to be executed at class initialization time - _directives_to_be_executed: List[Callable] = [] + #: Lists of directives to be executed for the class being defined, grouped by directive + #: function name (e.g. "depends_on", "version", etc.) + _directives_to_be_executed: Dict[str, List[Callable]] = collections.defaultdict(list) #: Stack of when constraints from `with when(...)` context managers _when_constraints_stack: List[spack.spec.Spec] = [] #: Stack of default args from `with default_args(...)` context managers _default_args_stack: List[dict] = [] + #: This property is set *automatically* during class definition as directives are invoked, + #: if any ``depends_on`` or ``extends`` calls include patches for dependencies. This flag can + #: be used as an optimization to detect whether a package provides patches for dependencies, + #: without triggering the expensive deferred execution of those directives (without populating + #: the ``dependencies`` dictionary). + _patches_dependencies: bool = False def __new__( cls: Type["DirectiveMeta"], name: str, bases: tuple, attr_dict: dict ) -> "DirectiveMeta": - # Initialize the attribute containing the list of directives - # to be executed. Here we go reversed because we want to execute - # commands: - # 1. in the order they were defined - # 2. following the MRO - attr_dict["_directives_to_be_executed"] = [] - for base in reversed(bases): - try: - directive_from_base = base._directives_to_be_executed - attr_dict["_directives_to_be_executed"].extend(directive_from_base) - except AttributeError: - # The base class didn't have the required attribute. - # Continue searching - pass - - # De-duplicates directives from base classes - attr_dict["_directives_to_be_executed"] = [ - x for x in spack.llnl.util.lang.dedupe(attr_dict["_directives_to_be_executed"]) - ] - - # Move things to be executed from module scope (where they - # are collected first) to class scope - if DirectiveMeta._directives_to_be_executed: - attr_dict["_directives_to_be_executed"].extend( - DirectiveMeta._directives_to_be_executed - ) - DirectiveMeta._directives_to_be_executed = [] + attr_dict["_patches_dependencies"] = DirectiveMeta._patches_dependencies + # Initialize the attribute containing the list of directives to be executed. Here we go + # reversed because we want to execute commands in the order they were defined, following + # the MRO. + merged: Dict[str, List[Callable]] = {} + sources = [getattr(b, "_directives_to_be_executed", None) or {} for b in reversed(bases)] + for source in sources: + for key, directive_list in source.items(): + merged.setdefault(key, []).extend(directive_list) + + merged = {key: list(dedupe(directive_list)) for key, directive_list in merged.items()} + + # Add current class's directives (no deduplication needed here) + for key, directive_list in DirectiveMeta._directives_to_be_executed.items(): + merged.setdefault(key, []).extend(directive_list) + + attr_dict["_directives_to_be_executed"] = merged + + DirectiveMeta._directives_to_be_executed.clear() + DirectiveMeta._patches_dependencies = False + + # Add descriptors for all known directive dictionaries + for dict_name in DirectiveMeta._directive_dict_names: + # Where the actual data will be stored + attr_dict[f"_{dict_name}"] = None + # Descriptor to lazily initialize and populate the dictionary + attr_dict[dict_name] = DirectiveMeta._get_descriptor(dict_name) return super(DirectiveMeta, cls).__new__(cls, name, bases, attr_dict) def __init__(cls: "DirectiveMeta", name: str, bases: tuple, attr_dict: dict): - # The instance is being initialized: if it is a package we must ensure - # that the directives are called to set it up. - if spack.repo.is_package_module(cls.__module__): - # Ensure the presence of the dictionaries associated with the directives. - # All dictionaries are defaultdicts that create lists for missing keys. - for d in DirectiveMeta._directive_dict_names: - setattr(cls, d, {}) - - # Lazily execute directives - for directive in cls._directives_to_be_executed: + # Historically, maintainers was not a directive. They were simply set as class + # attributes `maintainers = ["alice", "bob"]`. Therefore, we execute these directives + # eagerly. + for directive in cls._directives_to_be_executed.get("maintainers", ()): directive(cls) + super(DirectiveMeta, cls).__init__(name, bases, attr_dict) - # Ignore any directives executed *within* top-level - # directives by clearing out the queue they're appended to - DirectiveMeta._directives_to_be_executed = [] + @staticmethod + def register_directive(name: str, dicts: Tuple[str, ...]) -> None: + """Called by @directive to register relationships.""" + DirectiveMeta._directive_to_dicts[name] = dicts + for d in dicts: + DirectiveMeta._dict_to_directives[d].append(name) - super(DirectiveMeta, cls).__init__(name, bases, attr_dict) + @staticmethod + def _get_descriptor(name: str) -> "DirectiveDictDescriptor": + """Returns a singleton descriptor for the given dictionary name.""" + if name not in DirectiveMeta._descriptor_cache: + DirectiveMeta._descriptor_cache[name] = DirectiveDictDescriptor(name) + return DirectiveMeta._descriptor_cache[name] @staticmethod def push_when_constraint(when_spec: spack.spec.Spec) -> None: @@ -113,24 +129,74 @@ def pop_default_args() -> dict: return DirectiveMeta._default_args_stack.pop() @staticmethod - def _remove_directives(args): - # If any of the arguments are executors returned by a directive passed as an argument, - # don't execute them lazily. Instead, let the called directive handle them. This allows - # nested directive calls in packages. The caller can return the directive if it should be - # queued. Nasty, but it's the best way I can think of to avoid side effects if directive - # results are passed as args - directives = DirectiveMeta._directives_to_be_executed - for arg in args: - if isinstance(arg, (list, tuple)): - # Descend into args that are lists or tuples - DirectiveMeta._remove_directives(arg) - elif callable(arg): # directives are always callable, and very rare - # Remove directives args from the exec queue - for directive in directives: - if arg is directive: - directives.remove(directive) # iterations ends, so mutation is fine + def _remove_kwarg_value_directives_from_queue(value) -> None: + """Remove directives found in a kwarg value from the execution queue.""" + # Certain keyword argument values of directives may themselves be (lists of) directives. An + # example of this is ``depends_on(..., patches=[patch(...), ...])``. In that case, we + # should not execute those directives as part of the current package, but let the called + # directive handle them. This function removes such directives from the execution queue. + if isinstance(value, (list, tuple)): + for item in value: + DirectiveMeta._remove_kwarg_value_directives_from_queue(item) + elif callable(value): # directives are always callable + # Remove directives args from the exec queue + for lst in DirectiveMeta._directives_to_be_executed.values(): + for directive in lst: + if value is directive: + lst.remove(directive) # iterations ends, so mutation is fine break + @staticmethod + def _get_execution_plan(target_dict: str) -> Tuple[List[str], List[str]]: + """Calculates the closure of dicts and directives needed to populate target_dict.""" + dicts_involved = {target_dict} + directives_involved = set() + stack = [target_dict] + + while stack: + current_dict = stack.pop() + + for directive_name in DirectiveMeta._dict_to_directives.get(current_dict, ()): + if directive_name in directives_involved: + continue + + directives_involved.add(directive_name) + + for other_dict in DirectiveMeta._directive_to_dicts[directive_name]: + if other_dict not in dicts_involved: + dicts_involved.add(other_dict) + stack.append(other_dict) + + return sorted(dicts_involved), sorted(directives_involved) + + +class DirectiveDictDescriptor: + """A descriptor that lazily executes directives on first access.""" + + def __init__(self, name: str): + self.name = name + self.private_name = f"_{name}" + self.dicts_to_init, self.directives_to_run = DirectiveMeta._get_execution_plan(name) + + def __get__(self, obj, objtype=None): + val = getattr(objtype, self.private_name) + if val is not None: + return val + + # The None value is a sentinel for "not yet initialized". + for dictionary in self.dicts_to_init: + if getattr(objtype, f"_{dictionary}") is None: + setattr(objtype, f"_{dictionary}", {}) + + # Populate these dictionaries by running all directives that modify them + for directive_name in self.directives_to_run: + directives = objtype._directives_to_be_executed.get(directive_name) + if directives: + for directive in directives: + directive(objtype) + + return getattr(objtype, self.private_name) + def _combine_when( when: Optional[str] = None, @@ -158,7 +224,10 @@ def _combine_when( class directive: def __init__( - self, dicts: Union[Tuple[str, ...], str] = (), supports_when: bool = True + self, + dicts: Union[Tuple[str, ...], str] = (), + supports_when: bool = True, + can_patch_dependencies: bool = False, ) -> None: """Decorator for Spack directives. @@ -187,10 +256,13 @@ class Foo(Package): refer to pkg.versions. Arguments: - dicts: A list of names of dictionaries to add to the package class if they don't + dicts: A tuple of names of dictionaries to add to the package class if they don't already exist. supports_when: If True, the directive can be used within a ``with when(...)`` context manager. (To be removed when all directives support ``when=`` arguments.) + can_patch_dependencies: If True, the directive can patch dependencies. This is used to + identify nested directives so they can be removed from the execution queue, and to + mark the package as patching dependencies. """ if isinstance(dicts, str): @@ -200,12 +272,12 @@ class Foo(Package): DirectiveMeta._directive_dict_names.update(dicts) self.supports_when = supports_when + self.can_patch_dependencies = can_patch_dependencies + self.dicts = tuple(dicts) def __call__(self, decorated_function: Callable) -> Callable: directive_names.append(decorated_function.__name__) - - # Do not capture `self` in the wrapper - supports_when = self.supports_when + DirectiveMeta.register_directive(decorated_function.__name__, self.dicts) @functools.wraps(decorated_function) def _wrapper(*args, **_kwargs): @@ -220,7 +292,7 @@ def _wrapper(*args, **_kwargs): # Inject when arguments from the context if DirectiveMeta._when_constraints_stack: - if not supports_when: + if not self.supports_when: raise DirectiveError( f'directive "{decorated_function.__name__}" cannot be used within a ' '"when" context since it does not support a "when=" argument' @@ -229,11 +301,13 @@ def _wrapper(*args, **_kwargs): # Remove directives passed as arguments, so they are not executed as part of this # class's directive execution, but handled by the called directive instead - DirectiveMeta._remove_directives(itertools.chain(args, kwargs.values())) + if self.can_patch_dependencies and "patches" in kwargs: + DirectiveMeta._remove_kwarg_value_directives_from_queue(kwargs["patches"]) + DirectiveMeta._patches_dependencies = True result = decorated_function(*args, **kwargs) - DirectiveMeta._directives_to_be_executed.append(result) + DirectiveMeta._directives_to_be_executed[decorated_function.__name__].append(result) # wrapped function returns same result as original so that we can nest directives return result diff --git a/lib/spack/spack/patch.py b/lib/spack/spack/patch.py index 7fe92a43a99c87..f482841638e6f3 100644 --- a/lib/spack/spack/patch.py +++ b/lib/spack/spack/patch.py @@ -520,6 +520,9 @@ def _index_patches( patch_dict.pop("sha256") # save some space index[patch.sha256] = {pkg_class.fullname: patch_dict} + if not pkg_class._patches_dependencies: + return index + for deps_by_name in pkg_class.dependencies.values(): for dependency in deps_by_name.values(): for patch_list in dependency.patches.values(): diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index bf7d7dd1a62ec0..72533dd21a25c9 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -2016,7 +2016,7 @@ def clear_directive_functions(): # Make sure any directive functions overidden by tests are cleared before # proceeding with subsequent tests that may depend on the original # functions. - spack.directives_meta.DirectiveMeta._directives_to_be_executed = [] + spack.directives_meta.DirectiveMeta._directives_to_be_executed.clear() @pytest.fixture diff --git a/lib/spack/spack/test/directives.py b/lib/spack/spack/test/directives.py index 02421fdf57b28f..46dc4526fd705a 100644 --- a/lib/spack/spack/test/directives.py +++ b/lib/spack/spack/test/directives.py @@ -10,7 +10,8 @@ import spack.repo import spack.spec import spack.version -from spack.directives_meta import _combine_when +from spack.directives import depends_on, extends, patch +from spack.directives_meta import DirectiveDictDescriptor, DirectiveMeta, _combine_when from spack.spec import Spec @@ -230,3 +231,75 @@ def test_directives_meta_combine_when(): # Check the optimization for single stack with no when assert _combine_when(None, [x]) is x + + +def test_directive_descriptor_init(): + # when `pkg.variants` is initialized, only the `variant` directive should run + variants = DirectiveDictDescriptor("variants") + assert variants.directives_to_run == ["variant"] + assert variants.dicts_to_init == ["variants"] + + # when `pkg.dependencies` is initialized, `depends_on` and `extends` should run, and also + # `pkg.extendees` should be initialized + dependencies = DirectiveDictDescriptor("dependencies") + assert dependencies.directives_to_run == ["depends_on", "extends"] + assert dependencies.dicts_to_init == ["dependencies", "extendees"] + + # when `pkg.provided` is initialized, so should `pkg.provided_together`, and only the + # provides directive should run + provided = DirectiveDictDescriptor("provided") + assert provided.directives_to_run == ["provides"] + assert provided.dicts_to_init == ["provided", "provided_together"] + + # idem for `pkg.provided_together` + provided_together = DirectiveDictDescriptor("provided_together") + assert provided_together.directives_to_run == ["provides"] + assert provided_together.dicts_to_init == ["provided", "provided_together"] + + # when specifying patches on dependencies with `depends_on` and `extends`, the `pkg.patches` + # dict is not affects -- they are stored on a Dependency object. + patches = DirectiveDictDescriptor("patches") + assert patches.directives_to_run == ["patch"] + assert patches.dicts_to_init == ["patches"] + + +def test_directive_laziness(): + class ExamplePackage(metaclass=DirectiveMeta): + name = "example-package" + depends_on("foo") + extends("bar", when="+bar") + + # Initially, no directive dicts are initialized + assert ExamplePackage._dependencies is None # type: ignore + assert ExamplePackage._extendees is None # type: ignore + assert ExamplePackage._variants is None # type: ignore + + # Only when we access the dependencies descriptor, the relevant dicts (dependencies, extendees) + # are initialized, while others remain None + dependencies = ExamplePackage.dependencies # type: ignore + assert type(ExamplePackage._dependencies) is dict # type: ignore + assert type(ExamplePackage._extendees) is dict # type: ignore + assert ExamplePackage._variants is None # type: ignore + + # The dependencies dict is populated with the expected entries + assert "foo" in dependencies[spack.spec.Spec()] + assert "bar" in dependencies[spack.spec.Spec("+bar")] + + +def test_patched_dependencies_sets_class_attribute(): + sha256 = "a" * 64 + + class PatchesDependencies(metaclass=DirectiveMeta): + name = "patches-dependencies" + depends_on("dependency", patches=patch("https://example.com/diff.patch", sha256=sha256)) + + assert PatchesDependencies._patches_dependencies is True + assert not PatchesDependencies.patches # type: ignore + + class DoesNotPatchDependencies(metaclass=DirectiveMeta): + name = "does-not-patch-dependencies" + fullname = "does-not-patch-dependencies" + patch("https://example.com/diff.patch", sha256=sha256) + + assert DoesNotPatchDependencies._patches_dependencies is False + assert DoesNotPatchDependencies.patches # type: ignore From 0d99be382e592d6f4f2f5e314610585cbf0a716a Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 4 Feb 2026 18:16:40 +0100 Subject: [PATCH 046/337] directives: lazy with when(...) (#51884) This is primarily a cleanup of the code after #51881. The when-stack is now a tuple of strings, instead of a mix of parsed and unparsed specs or even None. Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives.py | 17 +++++++++++-- lib/spack/spack/directives_meta.py | 39 +++++++----------------------- lib/spack/spack/multimethod.py | 18 +++++++------- lib/spack/spack/test/directives.py | 21 +++++----------- 4 files changed, 39 insertions(+), 56 deletions(-) diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index bfe2e11e5dc975..6057d3c198c223 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -98,7 +98,7 @@ def _execute_example_directive(pkg, arg1, arg2): PatchesType = Union[Patcher, str, List[Union[Patcher, str]]] -def _make_when_spec(value: WhenType) -> Optional[spack.spec.Spec]: +def _make_when_spec(value: Union[WhenType, Tuple[str, ...]]) -> Optional[spack.spec.Spec]: """Create a ``Spec`` that indicates when a directive should be applied. Directives with ``when`` specs, e.g.: @@ -122,12 +122,25 @@ def _make_when_spec(value: WhenType) -> Optional[spack.spec.Spec]: Arguments: value: a conditional Spec, constant ``bool``, or None if not supplied - value indicating when a directive should be applied. + value indicating when a directive should be applied. It can also be a tuple of when + conditions (as strings) to be combined together. """ + # This branch is never taken, but our WhenType type annotation allows it, so handle it too. if isinstance(value, spack.spec.Spec): return value + if isinstance(value, tuple): + assert value, "when stack cannot be empty" + # avoid a copy when there's only one condition + if len(value) == 1: + return get_spec(value[0]) + # reduce the when-stack to a single spec by combining all constraints. + combined_spec = spack.spec.Spec(value[0]) + for cond in value[1:]: + combined_spec._constrain_symbolically(get_spec(cond)) + return combined_spec + # Unsatisfiable conditions are discarded by the caller, and never # added to the package class if value is False: diff --git a/lib/spack/spack/directives_meta.py b/lib/spack/spack/directives_meta.py index 2caeabb7092431..58eeb6d1dc1d47 100644 --- a/lib/spack/spack/directives_meta.py +++ b/lib/spack/spack/directives_meta.py @@ -4,7 +4,7 @@ import collections import functools -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, Union +from typing import Any, Callable, Dict, List, Set, Tuple, Type, Union import spack.error import spack.repo @@ -42,7 +42,7 @@ class DirectiveMeta(type): #: function name (e.g. "depends_on", "version", etc.) _directives_to_be_executed: Dict[str, List[Callable]] = collections.defaultdict(list) #: Stack of when constraints from `with when(...)` context managers - _when_constraints_stack: List[spack.spec.Spec] = [] + _when_constraints_stack: List[str] = [] #: Stack of default args from `with default_args(...)` context managers _default_args_stack: List[dict] = [] #: This property is set *automatically* during class definition as directives are invoked, @@ -109,12 +109,12 @@ def _get_descriptor(name: str) -> "DirectiveDictDescriptor": return DirectiveMeta._descriptor_cache[name] @staticmethod - def push_when_constraint(when_spec: spack.spec.Spec) -> None: + def push_when_constraint(when_spec: str) -> None: """Add a spec to the context constraints.""" DirectiveMeta._when_constraints_stack.append(when_spec) @staticmethod - def pop_when_constraint() -> spack.spec.Spec: + def pop_when_constraint() -> str: """Pop the last constraint from the context""" return DirectiveMeta._when_constraints_stack.pop() @@ -198,30 +198,6 @@ def __get__(self, obj, objtype=None): return getattr(objtype, self.private_name) -def _combine_when( - when: Optional[str] = None, - when_stack: List[spack.spec.Spec] = DirectiveMeta._when_constraints_stack, -) -> spack.spec.Spec: - """Compute the combined when constraints from the context and the directive keyword argument. - - Arguments: - when: The when constraint from the directive's keyword argument as a raw string (if any). - when_stack: The stack of parsed when constraints from ``with when(...)`` context managers. - """ - # In the following case - # with when("+foo"): # single constraint on the stack - # depends_on("foo") # unconditional directive - # avoid creating a new spec and just return the one from the stack - if len(when_stack) == 1 and not when: - return when_stack[0] - - # Otherwise, combine all when constraints by mutating a new spec - when_spec = spack.spec.Spec(when) - for current in when_stack: - when_spec._constrain_symbolically(current, deps=True) - return when_spec - - class directive: def __init__( self, @@ -290,14 +266,17 @@ def _wrapper(*args, **_kwargs): else: kwargs = _kwargs - # Inject when arguments from the context + # Inject when arguments from the `with when(...)` stack. if DirectiveMeta._when_constraints_stack: if not self.supports_when: raise DirectiveError( f'directive "{decorated_function.__name__}" cannot be used within a ' '"when" context since it does not support a "when=" argument' ) - kwargs["when"] = _combine_when(kwargs.get("when")) + if "when" in kwargs: + kwargs["when"] = (*DirectiveMeta._when_constraints_stack, kwargs["when"]) + else: + kwargs["when"] = tuple(DirectiveMeta._when_constraints_stack) # Remove directives passed as arguments, so they are not executed as part of this # class's directive execution, but handled by the called directive instead diff --git a/lib/spack/spack/multimethod.py b/lib/spack/spack/multimethod.py index afbd4f9fc29698..e8fe74a15a6604 100644 --- a/lib/spack/spack/multimethod.py +++ b/lib/spack/spack/multimethod.py @@ -247,10 +247,7 @@ def __init__(self, condition: Union[str, bool]): Args: condition (str): condition to be met """ - if isinstance(condition, bool): - self.spec = spack.spec.EMPTY_SPEC if condition else None - else: - self.spec = spack.directives_meta.get_spec(condition) + self.when = condition def __call__(self, method): assert ( @@ -262,17 +259,20 @@ def __call__(self, method): if not isinstance(original_method, SpecMultiMethod): original_method = SpecMultiMethod(original_method) - if self.spec is not None: - original_method.register(self.spec, method) + if self.when is True: + original_method.register(spack.spec.EMPTY_SPEC, method) + elif self.when is not False: + original_method.register(spack.directives_meta.get_spec(self.when), method) return original_method def __enter__(self): - if self.spec is not None: - spack.directives_meta.DirectiveMeta.push_when_constraint(self.spec) + # TODO: support when=False. + if isinstance(self.when, str): + spack.directives_meta.DirectiveMeta.push_when_constraint(self.when) def __exit__(self, exc_type, exc_val, exc_tb): - if self.spec is not None: + if isinstance(self.when, str): spack.directives_meta.DirectiveMeta.pop_when_constraint() diff --git a/lib/spack/spack/test/directives.py b/lib/spack/spack/test/directives.py index 46dc4526fd705a..d5c181c5c07d22 100644 --- a/lib/spack/spack/test/directives.py +++ b/lib/spack/spack/test/directives.py @@ -10,8 +10,8 @@ import spack.repo import spack.spec import spack.version -from spack.directives import depends_on, extends, patch -from spack.directives_meta import DirectiveDictDescriptor, DirectiveMeta, _combine_when +from spack.directives import _make_when_spec, depends_on, extends, patch +from spack.directives_meta import DirectiveDictDescriptor, DirectiveMeta from spack.spec import Spec @@ -218,19 +218,10 @@ def test_direct_dependencies_from_when_context_are_retained(mock_packages): def test_directives_meta_combine_when(): - x, y = Spec("+x ^dep +a"), Spec("+y ^dep +b") - - # Check that specs are combined, and do not mutate inputs - assert _combine_when("+z", [x, y]) == Spec("+x +y +z ^dep +a +b") - assert x == Spec("+x ^dep +a") - assert y == Spec("+y ^dep +b") - - assert _combine_when(None, [x, y]) == Spec("+x +y ^dep +a +b") - assert x == Spec("+x ^dep +a") - assert y == Spec("+y ^dep +b") - - # Check the optimization for single stack with no when - assert _combine_when(None, [x]) is x + x, y, z = "+x ^dep +a", "+y ^dep +b", "+z" + assert _make_when_spec((x, y, z)) == Spec("+x +y +z ^dep +a +b") + assert _make_when_spec((x, y)) == Spec("+x +y ^dep +a +b") + assert _make_when_spec((x,)) == Spec("+x ^dep +a") def test_directive_descriptor_init(): From ef116cbda6776b0db7b850364b223d8dba7fbc5c Mon Sep 17 00:00:00 2001 From: Ryan Krattiger <80296582+kwryankrattiger@users.noreply.github.com> Date: Wed, 4 Feb 2026 11:52:23 -0600 Subject: [PATCH 047/337] CI: Add an environment option for build cache index update behavior (#51817) * CI: Add an environment option for build cache index update behavior Signed-off-by: Ryan Krattiger * Remove unused default cleanup-job The default cleanup job was using gitlab specific variables and variables that were unexpanded by gitlab CI. Drop this as it is related to a long since removed legacy feature "artfacts-buildcache" which has been deprecated and removed for a few years. Signed-off-by: Ryan Krattiger * Add mirror metadata formatting. Display view in pruning Views are optional metadata. Provide a way to conditionally format mirror metadata to print view information if it exists. Signed-off-by: Ryan Krattiger * Add docs on new environment variable Signed-off-by: Ryan Krattiger --------- Signed-off-by: Ryan Krattiger --- lib/spack/docs/pipelines.rst | 12 ++++++ lib/spack/spack/ci/__init__.py | 2 +- lib/spack/spack/ci/common.py | 42 ++++++++++++------ lib/spack/spack/ci/gitlab.py | 26 ++++++++--- lib/spack/spack/test/binary_distribution.py | 48 +++++++++++++++++++++ lib/spack/spack/test/cmd/ci.py | 23 +++++----- lib/spack/spack/url_buildcache.py | 36 ++++++++++++++-- 7 files changed, 155 insertions(+), 34 deletions(-) diff --git a/lib/spack/docs/pipelines.rst b/lib/spack/docs/pipelines.rst index 91ee717926010a..059fc339800ae8 100644 --- a/lib/spack/docs/pipelines.rst +++ b/lib/spack/docs/pipelines.rst @@ -704,3 +704,15 @@ Optional. Only needed if you want ``spack ci rebuild`` to trust the key you store in this variable, in which case, it will subsequently be used to sign and verify binary packages (when installing or creating build caches). You could also have already trusted a key Spack knows about, or if no key is present anywhere, Spack will install specs using ``--no-check-signature`` and create build caches using ``-u`` (for unsigned binaries). +``SPACK_CI_BUILDCACHE_VIEW`` +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Optional. +Only needed when using a ``buildcache-destination`` mirror that points at a build cache view. +This option affects the behavior the ``reindex`` job (:ref:`rebuild_index`) can have the values ``force`` or ``append`` which mirror behavior described by ref:`cmd-spack-buildcache-update-view`. +The default option is ``append`` because that is what is used by the Spack build farm. + +.. warning:: + + Using the ``append`` option with build cache index views is a non-atomic operation. + It is up to the CI maintainer to ensure that concurrent writes to the build cache are handled appropriately. diff --git a/lib/spack/spack/ci/__init__.py b/lib/spack/spack/ci/__init__.py index 8906e9aa6d966c..a77df6ef0f77ce 100644 --- a/lib/spack/spack/ci/__init__.py +++ b/lib/spack/spack/ci/__init__.py @@ -255,7 +255,7 @@ def rebuild_filter(s: spack.spec.Spec) -> RebuildDecision: if not spec_locations: return RebuildDecision(True, "not found anywhere") - urls = ",".join(f"{loc.url}@v{loc.version}" for loc in spec_locations) + urls = ",".join(f"{loc:_url@v_version? (view: _view)}" for loc in spec_locations) message = f"up-to-date [{urls}]" return RebuildDecision(False, message) diff --git a/lib/spack/spack/ci/common.py b/lib/spack/spack/ci/common.py index 26b75f59b47988..081468570959fd 100644 --- a/lib/spack/spack/ci/common.py +++ b/lib/spack/spack/ci/common.py @@ -570,7 +570,12 @@ def init_pipeline_jobs(self, pipeline: PipelineDag): # Generate IR from the configs def generate_ir(self): - """Generate the IR from the Spack CI configurations.""" + """Generate the IR from the Spack CI configurations. + + Generate makes use of special strings that need to be expanded by python format. + + env_dir: The concrete environment directory used in downstream jobs + """ jobs = self.ir["jobs"] @@ -578,28 +583,39 @@ def generate_ir(self): defaults = [ { "build-job": { - "script": [ - "cd {env_dir}", - "spack env activate --without-view .", - "spack ci rebuild", - ] + "script": ["spack env activate --without-view {env_dir}", "spack ci rebuild"] } }, {"noop-job": {"script": ['echo "All specs already up to date, nothing to rebuild."']}}, ] + pipeline_mirrors = spack.mirrors.mirror.MirrorCollection(binary=True) + buildcache_destination = pipeline_mirrors["buildcache-destination"] + update_index_extra_args = [] + if buildcache_destination.push_view: + update_index_extra_args.extend(["--name", buildcache_destination.push_view]) + option = os.environ.get("SPACK_CI_BUILDCACHE_VIEW", "append") + if option == "append": + # Running this in CI relies on a guarentee from the calling context that there is + # only a single writter or the build cache view doesn't require a complete view + # after each append. + tty.warn("Using --append to update buildcache-destination mirror index view") + update_index_extra_args.extend(["-y", "--append"]) + elif option == "force": + update_index_extra_args.append("--force") + else: + raise SpackCIError(f"Unrecognized value: SPACK_CI_BUILDCACHE_VIEW={option}") + # Job overrides overrides = [ # Reindex script { "reindex-job": { - "script:": ["spack buildcache update-index --keys {index_target_mirror}"] - } - }, - # Cleanup script - { - "cleanup-job": { - "script:": ["spack -d mirror destroy {mirror_prefix}/$CI_PIPELINE_ID"] + "script:": [ + "spack env activate --without-view {env_dir}", + "spack buildcache update-index --keys " + + f"{' '.join(update_index_extra_args)} buildcache-destination", + ] } }, # Add signing job tags diff --git a/lib/spack/spack/ci/gitlab.py b/lib/spack/spack/ci/gitlab.py index 161608ca8a0448..3dc848c499c512 100644 --- a/lib/spack/spack/ci/gitlab.py +++ b/lib/spack/spack/ci/gitlab.py @@ -378,21 +378,35 @@ def main_script_replacements(cmd): output_object["sign-pkgs"] = signing_job if options.rebuild_index: + # Create a dummy job that runs as the stage before reindex. + # This job will be used to ensure reindex doesn't run until + # the other build jobs complete. + stage_names.append("stage-wait") + wait_job = spack_ci_ir["jobs"]["noop"]["attributes"] + wait_job["stage"] = "stage-wait" + wait_job["retry"] = 0 + wait_job["when"] = "always" + wait_job["script"] = ["echo 'Open the pod bay doors HAL'"] + wait_job["dependencies"] = [] + + output_object["wait-for-build-jobs"] = wait_job + # Add a final job to regenerate the index stage_names.append("stage-rebuild-index") final_job = spack_ci_ir["jobs"]["reindex"]["attributes"] final_job["stage"] = "stage-rebuild-index" - target_mirror = options.buildcache_destination.push_url - final_job["script"] = unpack_script( - final_job["script"], - op=lambda cmd: cmd.replace("{index_target_mirror}", target_mirror), - ) + final_job["script"] = unpack_script(final_job["script"], op=main_script_replacements) final_job["when"] = "always" final_job["retry"] = service_job_retries final_job["interruptible"] = True - final_job["dependencies"] = [] + # update-index needs to download generate artifacts + # it also needs to wait until all of the other stages complete. + final_job["needs"] = [ + {"job": generate_job_name, "pipeline": f"{generate_pipeline_id}"}, + "wait-for-build-jobs", + ] output_object["rebuild-index"] = final_job diff --git a/lib/spack/spack/test/binary_distribution.py b/lib/spack/spack/test/binary_distribution.py index d677a53c35bf08..deed87a2c9ef54 100644 --- a/lib/spack/spack/test/binary_distribution.py +++ b/lib/spack/spack/test/binary_distribution.py @@ -1449,6 +1449,54 @@ def test_mirror_metadata(): spack.binary_distribution.MirrorMetadata.from_string("https://dummy.io/__v3@@4") +def mirror_metadata_check_format(data, fmt, result): + assert fmt.format(data) == result.format(data) + + +def test_mirror_metadata_format(): + mirror_metadata = spack.binary_distribution.MirrorMetadata("https://dummy.io/__v3", 3) + + # Check pass-through formatting + mirror_metadata_check_format(mirror_metadata, "{0:_url}", "{0.url}") + mirror_metadata_check_format(mirror_metadata, "{0:_version}", "{0.version}") + mirror_metadata_check_format(mirror_metadata, "{0:_view}", "{0.view}") + + # Empty view + mirror_metadata_check_format(mirror_metadata, "{0:?_view}", "") + mirror_metadata_check_format( + mirror_metadata, "{0:_url?^_view^_version?^_version}", "{0.url}^{0.version}" + ) + mirror_metadata_check_format( + mirror_metadata, + "{0:_url?^_view^_version?^_version?^_view^_version?^_url}", + "{0.url}^{0.version}^{0.url}", + ) + + +def test_mirror_metadata_format_with_view(): + mirror_metadata = spack.binary_distribution.MirrorMetadata( + "https://dummy.io/__v3__@aview", 3, "aview" + ) + + # Check pass-through formatting + mirror_metadata_check_format(mirror_metadata, "{0:_url}", "{0.url}") + mirror_metadata_check_format(mirror_metadata, "{0:_version}", "{0.version}") + mirror_metadata_check_format(mirror_metadata, "{0:_view}", "{0.view}") + + # View exists + mirror_metadata_check_format(mirror_metadata, "{0:?_view}", "{0.view}") + mirror_metadata_check_format( + mirror_metadata, + "{0:_url?^_view^_version?^_version}", + "{0.url}^{0.view}^{0.version}^{0.version}", + ) + mirror_metadata_check_format( + mirror_metadata, + "{0:_url?^_view^_version?^_version?^_view^_version?^_url}", + "{0.url}^{0.view}^{0.version}^{0.version}^{0.view}^{0.version}^{0.url}", + ) + + def test_mirror_metadata_with_view(): mirror_metadata = spack.binary_distribution.MirrorMetadata( "https://dummy.io/__v3__@aview", 3, "aview" diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index 5255aa838d72de..0eeb917b0aa567 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -193,14 +193,15 @@ def test_ci_generate_with_env(ci_generate_test, tmp_path: pathlib.Path, mock_bin assert yaml_contents["workflow"]["rules"] == [{"when": "always"}] assert "stages" in yaml_contents - assert len(yaml_contents["stages"]) == 6 + assert len(yaml_contents["stages"]) == 7 assert yaml_contents["stages"][0] == "stage-0" - assert yaml_contents["stages"][5] == "stage-rebuild-index" + assert yaml_contents["stages"][5] == "stage-wait" + assert yaml_contents["stages"][6] == "stage-rebuild-index" assert "rebuild-index" in yaml_contents rebuild_job = yaml_contents["rebuild-index"] assert ( - rebuild_job["script"][0] == f"spack buildcache update-index --keys {mirror_url.as_uri()}" + rebuild_job["script"][1] == "spack buildcache update-index --keys buildcache-destination" ) assert rebuild_job["custom_attribute"] == "custom!" @@ -332,12 +333,11 @@ def test_ci_generate_with_custom_settings( "git checkout ${SPACK_REF}", "popd", ] - assert ci_obj["script"][1].startswith("cd ") - ci_obj["script"][1] = "cd ENV" + assert ci_obj["script"][1].startswith("spack env activate --without-view ") + ci_obj["script"][1] = "spack env activate --without-view ENV" assert ci_obj["script"] == [ "spack -d ci rebuild", - "cd ENV", - "spack env activate --without-view .", + "spack env activate --without-view ENV", "spack ci rebuild", ] assert ci_obj["after_script"] == ["rm -rf /some/path/spack"] @@ -1674,7 +1674,8 @@ def test_ci_generate_mirror_config( with open(tmp_path / ".gitlab-ci.yml", encoding="utf-8") as f: pipeline_doc = syaml.load(f) assert fst not in pipeline_doc["rebuild-index"]["script"][0] - assert snd in pipeline_doc["rebuild-index"]["script"][0] + assert "env activate" in pipeline_doc["rebuild-index"]["script"][0] + assert "buildcache-destination" in pipeline_doc["rebuild-index"]["script"][1] def dynamic_mapping_setup(tmp_path: pathlib.Path): @@ -1855,11 +1856,13 @@ def test_ci_generate_copy_only( # Make sure there are only two jobs and two stages stages = pipeline_doc["stages"] copy_stage = "copy" + wait_stage = "stage-wait" rebuild_index_stage = "stage-rebuild-index" - assert len(stages) == 2 + assert len(stages) == 3 assert stages[0] == copy_stage - assert stages[1] == rebuild_index_stage + assert stages[1] == wait_stage + assert stages[2] == rebuild_index_stage rebuild_index_job = pipeline_doc["rebuild-index"] assert rebuild_index_job["stage"] == rebuild_index_stage diff --git a/lib/spack/spack/url_buildcache.py b/lib/spack/spack/url_buildcache.py index 24a3c0cca61822..12e940d77aeb3e 100644 --- a/lib/spack/spack/url_buildcache.py +++ b/lib/spack/spack/url_buildcache.py @@ -1365,10 +1365,7 @@ def __init__(self, url: str, version: int, view: Optional[str] = None): self.view = view def __str__(self): - s = f"{self.url}__v{self.version}" - if self.view: - s += f"__{self.view}" - return s + return f"{self:_url__v_version?___view}" def __eq__(self, other): if not isinstance(other, MirrorMetadata): @@ -1378,6 +1375,37 @@ def __eq__(self, other): def __hash__(self): return hash((self.url, self.version, self.view)) + def __format__(self, format_spec): + """Format the mirror metadata + + Format Spec: + _url: metadata.url + _version: metadata.version + _view: metadata.view + ?: delimiter to wrap conditional printing based on optional view + + Example + + f"{meta_data:_url?^_view?@v_version}" + + Expansion without a view: + https://my-mirror.com/prefix@v3 + + Expansion with a view: + https://my-mirror.com/prefix^my-view@v3 + """ + if not format_spec: + format_spec = "_url@v3?-_view" + return + out = format_spec.replace("_url", self.url) + out = out.replace("_version", str(self.version)) + out = out.replace("_view", str(self.view)) + parts = out.split("?") + if self.view: + return "".join(parts) + else: + return "".join(parts[0::2]) + @classmethod def from_string(cls, s: str): m = re.match(r"^(.*)__v([0-9]+)(?:__(.*))?$", s) From 9c457ff66ae8ace4be67b42297cec653a791c7ec Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 5 Feb 2026 10:42:00 +0100 Subject: [PATCH 048/337] Extract concretization code to EnvironmentConcretizer (#51919) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/environment/environment.py | 271 ++++++++++----------- 1 file changed, 125 insertions(+), 146 deletions(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index b5092e80888c0c..1de8b1ce0f39c8 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -1320,7 +1320,7 @@ def include_concrete_envs(self): if transitive: self.included_concrete_spec_data[env_path]["include_concrete"] = transitive - self._unify_specs() + self.unify_specs() self.write() def destroy(self): @@ -1571,45 +1571,7 @@ def concretize( List of specs that have been concretized. Each entry is a tuple of the user spec and the corresponding concretized spec. """ - if force is None: - force = spack.config.get("concretizer:force") - - self._prepare_for_concretization(force=force) - - # Exit early if the set of concretized specs is the set of user specs - new_user_specs, kept_user_specs, specs_to_concretize = self._get_specs_to_concretize() - if not new_user_specs: - return [] - - # Pick the right concretization strategy - if self.unify == "when_possible": - return self._concretize_together_where_possible( - new_user_specs, kept_user_specs, specs_to_concretize, tests=tests - ) - - if self.unify is True: - return self._concretize_together( - new_user_specs, kept_user_specs, specs_to_concretize, tests=tests - ) - - if self.unify is False: - return self._concretize_separately( - new_user_specs, kept_user_specs, specs_to_concretize, tests=tests - ) - - msg = "concretization strategy not implemented [{0}]" - raise SpackEnvironmentError(msg.format(self.unify)) - - def _prepare_for_concretization(self, *, force: bool): - """Reset the environment concrete state and ensure consistency with user specs.""" - if force: - self.clear_concretized_specs() - else: - self.sync_concretized_specs() - - # If a combined env, check updated spec is in the linked envs - if self.included_concrete_envs: - self.include_concrete_envs() + return EnvironmentConcretizer(self).concretize(force=force, tests=tests) def sync_concretized_specs(self) -> None: """Removes concrete specs that no longer correlate to a user spec""" @@ -1653,111 +1615,7 @@ def user_spec_with_hash(self, dag_hash: str) -> bool: """Returns True if any user spec is associated with a concrete spec with the given hash""" return any(x.hash == dag_hash for x in self.concretized_roots) - def _get_specs_to_concretize( - self, - ) -> Tuple[List[spack.spec.Spec], List[spack.spec.Spec], List[SpecPair]]: - """Compute specs to concretize for unify:true and unify:when_possible. - - This includes new user specs and any already concretized specs. - - Returns: - Tuple of new user specs, user specs to keep, and the specs to concretize. - - """ - # Exit early if the set of concretized specs is the set of user specs - concretized_user_specs = {x.root for x in self.concretized_roots} - kept_user_specs, new_user_specs = stable_partition( - self.user_specs, lambda x: x in concretized_user_specs - ) - kept_user_specs += self.included_user_specs - if not new_user_specs: - return new_user_specs, kept_user_specs, [] - - specs_to_concretize = [(s, None) for s in new_user_specs] + [ - (abstract, concrete) - for abstract, concrete in self.concretized_specs() - if abstract in kept_user_specs - ] - return new_user_specs, kept_user_specs, specs_to_concretize - - def _concretize_together_where_possible( - self, - new_user_specs: List[Spec], - kept_user_specs: List[Spec], - specs_to_concretize: List[SpecPair], - *, - tests: Union[bool, Sequence[str]] = False, - ) -> Sequence[SpecPair]: - # Exit early if the set of concretized specs is the set of user specs - result = spack.concretize.concretize_together_when_possible( - specs_to_concretize, tests=tests - ) - result = [x for x in result if x[0] in new_user_specs] - for abstract, concrete in result: - self._add_concrete_spec(abstract, concrete, new=True) - - return result - - def _concretize_together( - self, - new_user_specs: List[Spec], - kept_user_specs: List[Spec], - specs_to_concretize: List[SpecPair], - *, - tests: Union[bool, Sequence[str]] = False, - ) -> Sequence[SpecPair]: - """Concretization strategy that concretizes all the specs - in the same DAG. - """ - try: - concretized_specs = spack.concretize.concretize_together( - specs_to_concretize, tests=tests - ) - except spack.error.UnsatisfiableSpecError as e: - # "Enhance" the error message for multiple root specs, suggest a less strict - # form of concretization. - if len(self.user_specs) > 1: - e.message += ". " - if kept_user_specs: - e.message += ( - "Couldn't concretize without changing the existing environment. " - "If you are ok with changing it, try `spack concretize --force`. " - ) - e.message += ( - "You could consider setting `concretizer:unify` to `when_possible` " - "or `false` to allow multiple versions of some packages." - ) - raise - - # Return the portion of the return value that is new - result = concretized_specs[: len(new_user_specs)] - for abstract, concrete in result: - self._add_concrete_spec(abstract, concrete, new=True) - return result - - def _concretize_separately( - self, - new_user_specs: List[Spec], - kept_user_specs: List[Spec], - specs_to_concretize: List[SpecPair], - *, - tests: Union[bool, Sequence[str]] = False, - ): - """Concretization strategy that concretizes separately one - user spec after the other. - """ - to_concretize = [(root, None) for root in new_user_specs] - - concretized_specs = spack.concretize.concretize_separately(to_concretize, tests=tests) - - for abstract, concrete in concretized_specs: - self._add_concrete_spec(abstract, concrete, new=True) - - # Unify the specs objects, so we get correct references to all parents - self._unify_specs() - return concretized_specs - - def _unify_specs(self) -> None: + def unify_specs(self) -> None: # Keep the information on new specs by copying the concretized roots old_concretized_roots = self.concretized_roots self._read_lockfile_dict(self._to_lockfile_dict()) @@ -1914,7 +1772,7 @@ def rm_view_from_env( return env_mod - def _add_concrete_spec( + def add_concrete_spec( self, spec: spack.spec.Spec, concrete: spack.spec.Spec, *, new: bool = True ): """Called when a new concretized spec is added to the environment. @@ -2552,6 +2410,127 @@ def __exit__(self, exc_type, exc_val, exc_tb): activate(self._previous_active) +class EnvironmentConcretizer: + def __init__(self, env: Environment): + self.env = env + + def concretize( + self, *, force: Optional[bool] = None, tests: Union[bool, Sequence[str]] = False + ) -> List[SpecPair]: + if force is None: + force = spack.config.get("concretizer:force") + + # Exit early if the set of concretized specs is the set of user specs + self._prepare_environment_for_concretization(force=force) + new_user_specs, kept_user_specs = self._partition_user_specs() + if not new_user_specs: + return [] + + # Pick the right concretization strategy + unify = self.env.unify + if unify == "when_possible": + return self._concretize_together_where_possible( + new_user_specs, kept_user_specs, tests=tests + ) + + if unify is True: + return self._concretize_together(new_user_specs, kept_user_specs, tests=tests) + + if unify is False: + return self._concretize_separately(new_user_specs, kept_user_specs, tests=tests) + + raise SpackEnvironmentError(f"concretization strategy not implemented [{unify}]") + + def _prepare_environment_for_concretization(self, *, force: bool): + """Reset the environment concrete state and ensure consistency with user specs.""" + if force: + self.env.clear_concretized_specs() + else: + self.env.sync_concretized_specs() + + # If a combined env, check updated spec is in the linked envs + if self.env.included_concrete_envs: + self.env.include_concrete_envs() + + def _partition_user_specs(self) -> Tuple[List[spack.spec.Spec], List[spack.spec.Spec]]: + """Splits the users specs in the list of the ones to be computed, and the list of + the ones to retain. + """ + concretized_user_specs = {x.root for x in self.env.concretized_roots} + kept_user_specs, new_user_specs = stable_partition( + self.env.user_specs, lambda x: x in concretized_user_specs + ) + kept_user_specs += self.env.included_user_specs + return new_user_specs, kept_user_specs + + def _user_spec_pairs( + self, user_specs_to_compute: List[Spec], user_specs_to_keep: List[Spec] + ) -> List[SpecPair]: + specs_to_concretize = [(s, None) for s in user_specs_to_compute] + [ + (abstract, concrete) + for abstract, concrete in self.env.concretized_specs() + if abstract in user_specs_to_keep + ] + return specs_to_concretize + + def _concretize_together_where_possible( + self, to_compute: List[Spec], to_keep: List[Spec], *, tests: Union[bool, Sequence] = False + ) -> List[SpecPair]: + specs_to_concretize = self._user_spec_pairs(to_compute, to_keep) + result = spack.concretize.concretize_together_when_possible( + specs_to_concretize, tests=tests + ) + result = [x for x in result if x[0] in to_compute] + for abstract, concrete in result: + self.env.add_concrete_spec(abstract, concrete, new=True) + + return result + + def _concretize_together( + self, to_compute: List[Spec], to_keep: List[Spec], *, tests: Union[bool, Sequence] = False + ) -> List[SpecPair]: + to_concretize = self._user_spec_pairs(to_compute, to_keep) + try: + concrete_pairs = spack.concretize.concretize_together(to_concretize, tests=tests) + except spack.error.UnsatisfiableSpecError as e: + # "Enhance" the error message for multiple root specs, suggest a less strict + # form of concretization. + if len(self.env.user_specs) > 1: + e.message += ". " + if to_keep: + e.message += ( + "Couldn't concretize without changing the existing environment. " + "If you are ok with changing it, try `spack concretize --force`. " + ) + e.message += ( + "You could consider setting `concretizer:unify` to `when_possible` " + "or `false` to allow multiple versions of some packages." + ) + raise + + # Return the portion of the return value that is new + result = concrete_pairs[: len(to_compute)] + for abstract, concrete in result: + self.env.add_concrete_spec(abstract, concrete, new=True) + return result + + def _concretize_separately( + self, to_compute: List[Spec], to_keep: List[Spec], *, tests: Union[bool, Sequence] = False + ) -> List[SpecPair]: + """Concretization strategy that concretizes separately one + user spec after the other. + """ + to_concretize = [(x, None) for x in to_compute] + concrete_pairs = spack.concretize.concretize_separately(to_concretize, tests=tests) + + for abstract, concrete in concrete_pairs: + self.env.add_concrete_spec(abstract, concrete, new=True) + + # Unify the specs objects, so we get correct references to all parents + self.env.unify_specs() + return concrete_pairs + + def yaml_equivalent(first, second) -> bool: """Returns whether two spack yaml items are equivalent, including overrides""" # YAML has timestamps and dates, but we don't use them yet in schemas From f201918cd5072a2258fbf161c5002ce32b83ac8e Mon Sep 17 00:00:00 2001 From: finkandreas Date: Thu, 5 Feb 2026 23:15:19 +0100 Subject: [PATCH 049/337] sbang: get shebang limits from linux headers on linux platform (#43290) This PR tries to get the shebang limits onlinux platforms. It could fail getting this number for numerous reasons (e.g. linux-headers not installed, the define looks different). But any failure would result in the current behaviour of having 127 characters as hardcoded value. If it finds the correct value, it will use it. Signed-off-by: finkandreas Signed-off-by: Todd Gamblin Co-authored-by: Victor Brunini --- lib/spack/spack/hooks/sbang.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/spack/spack/hooks/sbang.py b/lib/spack/spack/hooks/sbang.py index 0ba85100e0c760..a41688ecf12820 100644 --- a/lib/spack/spack/hooks/sbang.py +++ b/lib/spack/spack/hooks/sbang.py @@ -26,6 +26,18 @@ system_shebang_limit = 511 else: system_shebang_limit = 127 + try: + # searching for line '#define BINPRM_BUF_SIZE 256' in /usr/include/linux/binfmts.h + # the nbr-1 is the sbang limit on the linux platform + sbang_limit_re = re.compile("#define BINPRM_BUF_SIZE ([0-9]+)") + with open("/usr/include/linux/binfmts.h", "r", encoding="utf-8") as f: + for line in f: + m = sbang_limit_re.match(line) + if m: + system_shebang_limit = int(m.group(1)) - 1 + except Exception: + # ignore any error a sane default is set already + pass #: Groupdb does not exist on Windows, prevent imports #: on supported systems From ed88b1f0946e4da90fa57eb67de2ceaa4ab7b736 Mon Sep 17 00:00:00 2001 From: Michael Kuhn Date: Fri, 6 Feb 2026 08:56:38 +0100 Subject: [PATCH 050/337] Fix package name suggestions (#51732) This fixes the suggestions when mistyping package names: ``` $ spack info jula ==> Error: Package 'spack_repo.builtin.packages.jula.package' not found in repository '/root/.spack/package_repos/fncqgg4/repos/spack_repo/builtin' Use 'spack create' to create a new package. Did you mean one of the following packages? julia julea tauola ``` Signed-off-by: Michael Kuhn --- lib/spack/spack/repo.py | 23 +++++++++++++++++++++-- lib/spack/spack/test/cmd/info.py | 7 +++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index db5f83bbd3a0cd..c8a590e929cb96 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -103,6 +103,25 @@ def namespace_from_fullname(fullname: str) -> str: return fullname +def name_from_fullname(fullname: str) -> str: + """Return the package name for the full module name. + + For instance:: + + name_from_fullname("spack.pkg.builtin.hdf5") == "hdf5" + name_from_fullname("spack_repo.x.y.z.packages.pkg_name.package") == "pkg_name" + + Args: + fullname: full name for the Python module + """ + if fullname.startswith(PKG_MODULE_PREFIX_V1): + _, _, pkg_module = fullname.rpartition(".") + return pkg_module + elif fullname.startswith(PKG_MODULE_PREFIX_V2) and fullname.endswith(".package"): + return fullname.rsplit(".", 2)[-2] + return fullname + + class _PrependFileLoader(importlib.machinery.SourceFileLoader): def __init__(self, fullname: str, repo: "Repo", package_name: str) -> None: self.repo = repo @@ -2136,9 +2155,9 @@ def __init__( repo = PATH.ensure_unwrapped() # We need to compare the base package name - pkg_name = name.rsplit(".", 1)[-1] + pkg_name = name_from_fullname(name) similar = [] - if isinstance(repo, RepoPath): + if isinstance(repo, (Repo, RepoPath)): try: similar = get_close_matches(pkg_name, repo.all_package_names()) except Exception: diff --git a/lib/spack/spack/test/cmd/info.py b/lib/spack/spack/test/cmd/info.py index 812a6630d17e70..92007712794a86 100644 --- a/lib/spack/spack/test/cmd/info.py +++ b/lib/spack/spack/test/cmd/info.py @@ -7,12 +7,19 @@ import pytest from spack.main import SpackCommand, SpackCommandError +from spack.repo import UnknownPackageError pytestmark = [pytest.mark.usefixtures("mock_packages")] info = SpackCommand("info") +def test_package_suggestion(): + with pytest.raises(UnknownPackageError) as exc_info: + info("vtk") + assert "Did you mean one of the following packages?" in str(exc_info.value) + + def test_deprecated_option_warns(): info("--variants-by-name", "vtk-m") assert "--variants-by-name is deprecated" in info.output From 6249e6c8252bcf88d67d48411a9ae45c9106974e Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Sat, 7 Feb 2026 09:20:27 +0100 Subject: [PATCH 051/337] environment: remove unnecessary methods after refactor (#51921) Also fix a bug where we may associated the wrong user spec to a concrete root Signed-off-by: Massimiliano Culpo --- lib/spack/spack/cmd/env.py | 7 +- lib/spack/spack/cmd/find.py | 3 +- lib/spack/spack/cmd/gc.py | 4 +- lib/spack/spack/environment/environment.py | 100 +++------- lib/spack/spack/test/cmd/deconcretize.py | 4 +- lib/spack/spack/test/cmd/env.py | 202 +++++++++++++-------- lib/spack/spack/test/env.py | 7 +- 7 files changed, 172 insertions(+), 155 deletions(-) diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py index 5f75d1b328084d..3d1e74765e94eb 100644 --- a/lib/spack/spack/cmd/env.py +++ b/lib/spack/spack/cmd/env.py @@ -28,6 +28,7 @@ from spack.llnl.util.filesystem import islink, symlink from spack.llnl.util.tty.colify import colify from spack.llnl.util.tty.color import cescape, colorize +from spack.traverse import traverse_nodes from spack.util.environment import EnvironmentModifications description = "manage environments" @@ -870,8 +871,10 @@ def env_loads(args): loads_file = fs.join_path(env.path, "loads") with open(loads_file, "w", encoding="utf-8") as f: - specs = env._get_environment_specs(recurse_dependencies=recurse_dependencies) - + if not recurse_dependencies: + specs = [env.specs_by_hash[x.hash] for x in env.concretized_roots] + else: + specs = list(traverse_nodes(env.concrete_roots(), deptype=("link", "run"))) spack.cmd.modules.loads(module_type, specs, args, f) print("To load this environment, type:") diff --git a/lib/spack/spack/cmd/find.py b/lib/spack/spack/cmd/find.py index 9d877eb51dbde3..6f9bbe515a298b 100644 --- a/lib/spack/spack/cmd/find.py +++ b/lib/spack/spack/cmd/find.py @@ -263,9 +263,10 @@ def display_env(env, args, decorator, results): num_roots = len(env.user_specs) or "No" tty.msg(f"{num_roots} root specs") + concretized_user_specs = [x.root for x in env.concretized_roots] concrete_specs = { root: concrete_root - for root, concrete_root in zip(env.concretized_user_specs, env.concrete_roots()) + for root, concrete_root in zip(concretized_user_specs, env.concrete_roots()) } def root_decorator(spec, string): diff --git a/lib/spack/spack/cmd/gc.py b/lib/spack/spack/cmd/gc.py index 72d9a126498915..e0f6f538a804bf 100644 --- a/lib/spack/spack/cmd/gc.py +++ b/lib/spack/spack/cmd/gc.py @@ -67,7 +67,7 @@ def roots_from_environments(args, active_env): # add root hashes from all considered environments to list of roots root_hashes = set() for env in all_environments: - root_hashes |= set(env.concretized_order) + root_hashes |= {x.hash for x in env.concretized_roots} return root_hashes @@ -91,7 +91,7 @@ def gc(parser, args): tty.msg(f"Restricting garbage collection to environment '{active_env.name}'") root_hashes = set(spack.store.STORE.db.all_hashes()) # keep everything root_hashes -= set(active_env.all_hashes()) # except this env - root_hashes |= set(active_env.concretized_order) # but keep its roots + root_hashes |= {x.hash for x in active_env.concretized_roots} # but keep its roots else: # consider all explicit specs roots (the default for db.unused_specs()) root_hashes = None diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 1de8b1ce0f39c8..6518781885a02d 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -977,6 +977,17 @@ def __init__(self, *, root_spec: spack.spec.Spec, root_hash: str, new: bool = Fa def __str__(self): return f"{self.root} -> {self.hash} [new={self.new}]" + def __eq__(self, other: object) -> bool: + return ( + isinstance(other, ConcretizedRootInfo) + and self.root == other.root + and self.hash == other.hash + and self.new == other.new + ) + + def __hash__(self) -> int: + return hash((self.root, self.hash, self.new)) + class Environment: """A Spack environment, which bundles together configuration and a list of specs.""" @@ -1160,30 +1171,6 @@ def _sync_speclists(self): name=user_speclist_name, yaml_list=spec_list ) - def all_concretized_user_specs(self) -> List[Spec]: - """Returns all of the concretized user specs of the environment and - its included environment(s).""" - concretized_user_specs = self.concretized_user_specs - for included_specs in self.included_concretized_user_specs.values(): - for included in included_specs: - # Don't duplicate included spec(s) - if included not in concretized_user_specs: - concretized_user_specs.append(included) - - return concretized_user_specs - - def all_concretized_orders(self) -> List[str]: - """Returns all of the concretized order of the environment and - its included environment(s).""" - concretized_order = [x.hash for x in self.concretized_roots] - for included_concretized_order in self.included_concretized_order.values(): - for included in included_concretized_order: - # Don't duplicate included spec(s) - if included not in concretized_order: - concretized_order.append(included) - - return concretized_order - @property def user_specs(self): return self.spec_lists[user_speclist_name] @@ -1789,14 +1776,6 @@ def add_concrete_spec( self.concretized_roots.append(ConcretizedRootInfo(root_spec=spec, root_hash=h, new=new)) self.specs_by_hash[h] = concrete - @property - def concretized_order(self) -> List[str]: - return [x.hash for x in self.concretized_roots] - - @property - def concretized_user_specs(self) -> List[Spec]: - return [x.root for x in self.concretized_roots] - def _dev_specs_that_need_overwrite(self): """Return the hashes of all specs that need to be reinstalled due to source code change.""" changed_dev_specs = [ @@ -1828,22 +1807,8 @@ def _partition_roots_by_install_status(self): installer, and those that should be, taking into account development specs. This is done in a single read transaction per environment instead of per spec.""" - installed, uninstalled = [], [] with spack.store.STORE.db.read_transaction(): - for concretized_hash in self.all_concretized_orders(): - if concretized_hash in self.specs_by_hash: - spec = self.specs_by_hash[concretized_hash] - else: - for env_path in self.included_specs_by_hash.keys(): - if concretized_hash in self.included_specs_by_hash[env_path]: - spec = self.included_specs_by_hash[env_path][concretized_hash] - break - if not spec.installed or ( - spec.satisfies("dev_path=*") or spec.satisfies("^dev_path=*") - ): - uninstalled.append(spec) - else: - installed.append(spec) + uninstalled, installed = stable_partition(self.concrete_roots(), _is_uninstalled) return installed, uninstalled def uninstalled_specs(self): @@ -1942,14 +1907,19 @@ def added_specs(self): def concretized_specs(self): """Tuples of (user spec, concrete spec) for all concrete specs.""" - for s, h in zip(self.all_concretized_user_specs(), self.all_concretized_orders()): - if h in self.specs_by_hash: - yield (s, self.specs_by_hash[h]) - else: - for env_path in self.included_specs_by_hash.keys(): - if h in self.included_specs_by_hash[env_path]: - yield (s, self.included_specs_by_hash[env_path][h]) - break + for x in self.concretized_roots: + yield x.root, self.specs_by_hash[x.hash] + + seen = {(x.root, x.hash) for x in self.concretized_roots} + for included_env in self.included_concretized_user_specs: + for s, h in zip( + self.included_concretized_user_specs[included_env], + self.included_concretized_order[included_env], + ): + if (s, h) in seen: + continue + seen.add((s, h)) + yield s, self.included_specs_by_hash[included_env][h] def concrete_roots(self): """Same as concretized_specs, except it returns the list of concrete @@ -2072,22 +2042,6 @@ def removed_specs(self): if d not in needed: yield d - def _get_environment_specs(self, recurse_dependencies=True): - """Returns the specs of all the packages in an environment. - - If these specs appear under different user_specs, only one copy - is added to the list returned. - """ - specs = [self.specs_by_hash[h] for h in self.all_concretized_orders()] - if recurse_dependencies: - specs.extend( - traverse.traverse_nodes( - specs, root=False, deptype=("link", "run"), key=traverse.by_dag_hash - ) - ) - - return specs - def _concrete_specs_dict(self): concrete_specs = {} for s in traverse.traverse_nodes(self.specs_by_hash.values(), key=traverse.by_dag_hash): @@ -2410,6 +2364,10 @@ def __exit__(self, exc_type, exc_val, exc_tb): activate(self._previous_active) +def _is_uninstalled(spec): + return not spec.installed or (spec.satisfies("dev_path=*") or spec.satisfies("^dev_path=*")) + + class EnvironmentConcretizer: def __init__(self, env: Environment): self.env = env diff --git a/lib/spack/spack/test/cmd/deconcretize.py b/lib/spack/spack/test/cmd/deconcretize.py index 4c72a8ca4ea0f7..fdceed7d73f5ca 100644 --- a/lib/spack/spack/test/cmd/deconcretize.py +++ b/lib/spack/spack/test/cmd/deconcretize.py @@ -43,7 +43,7 @@ def test_deconcretize_root(test_env): with ev.read("test") as e: output = deconcretize("-y", "--root", "pkg-b@1.0") assert "No matching specs to deconcretize" in output - assert len(e.concretized_order) == 2 + assert len(e.concretized_roots) == 2 deconcretize("-y", "--root", "pkg-a@2.0") specs = [s for s, _ in e.concretized_specs()] @@ -59,7 +59,7 @@ def test_deconcretize_all_root(test_env): output = deconcretize("-y", "--root", "--all", "pkg-b") assert "No matching specs to deconcretize" in output - assert len(e.concretized_order) == 2 + assert len(e.concretized_roots) == 2 deconcretize("-y", "--root", "--all", "pkg-a") specs = [s for s, _ in e.concretized_specs()] diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 8ea37d3997d7b4..1378125861f058 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -39,10 +39,12 @@ from spack.cmd.env import _env_create from spack.installer import PackageInstaller from spack.llnl.util.filesystem import readlink +from spack.llnl.util.lang import dedupe from spack.main import SpackCommand, SpackCommandError from spack.spec import Spec from spack.stage import stage_prefix from spack.test.conftest import RepoBuilder +from spack.traverse import traverse_nodes from spack.util.executable import Executable from spack.util.path import substitute_path_variables from spack.version import Version @@ -291,14 +293,12 @@ def test_change_multiple_matches(): def test_env_add_virtual(): env("create", "test") - e = ev.read("test") e.add("mpi") e.concretize() - hashes = e.concretized_order - assert len(hashes) == 1 - spec = e.specs_by_hash[hashes[0]] + assert len(e.concretized_roots) == 1 + spec = e.specs_by_hash[e.concretized_roots[0].hash] assert spec.intersects("mpi") @@ -475,8 +475,9 @@ def test_concretize(): e = ev.create("test") e.add("mpileaks") e.concretize() - env_specs = e._get_environment_specs() - assert any(x.name == "mpileaks" for x in env_specs) + + assert len(e.concretized_roots) == 1 + assert e.concretized_roots[0].root == Spec("mpileaks") def test_env_specs_partition(install_mockery, mock_fetch): @@ -512,8 +513,7 @@ def test_env_install_all(install_mockery, mock_fetch): e.add("cmake-client") e.concretize() e.install_all(fake=True) - env_specs = e._get_environment_specs() - spec = next(x for x in env_specs if x.name == "cmake-client") + spec = next(x for x in e.all_specs_generator() if x.name == "cmake-client") assert spec.installed @@ -526,9 +526,12 @@ def test_env_install_single_spec(install_mockery, mock_fetch): install("--fake", "--add", "cmake-client") e = ev.read("test") - assert e.user_specs[0].name == "cmake-client" - assert e.concretized_user_specs[0].name == "cmake-client" - assert e.specs_by_hash[e.concretized_order[0]].name == "cmake-client" + assert len(e.concretized_roots) == 1 + + item = e.concretized_roots[0] + assert list(e.user_specs) == [Spec("cmake-client")] + assert item.root == Spec("cmake-client") + assert e.specs_by_hash[item.hash].name == "cmake-client" @pytest.mark.parametrize("unify", [True, False, "when_possible"]) @@ -546,23 +549,24 @@ def test_env_install_include_concrete_env(unify, install_mockery, mock_fetch, mu with combined: install("--fake") - test1_roots = test1.concretized_order - test2_roots = test2.concretized_order + test1_user_spec_hashes = [x.hash for x in test1.concretized_roots] + test2_user_spec_hashes = [x.hash for x in test2.concretized_roots] combined_included_roots = combined.included_concretized_order for spec in combined.all_specs(): assert spec.installed - assert test1_roots == combined_included_roots[test1.path] - assert test2_roots == combined_included_roots[test2.path] + assert test1_user_spec_hashes == combined_included_roots[test1.path] + assert test2_user_spec_hashes == combined_included_roots[test2.path] - mpileaks = combined.specs_by_hash[combined.concretized_order[0]] + mpileaks_hash = combined.concretized_roots[0].hash + mpileaks = combined.specs_by_hash[mpileaks_hash] if unify: - assert mpileaks["mpi"].dag_hash() in test1_roots - assert mpileaks["libelf"].dag_hash() in test2_roots + assert mpileaks["mpi"].dag_hash() in test1_user_spec_hashes + assert mpileaks["libelf"].dag_hash() in test2_user_spec_hashes else: # check that unification is not by accident - assert mpileaks["mpi"].dag_hash() not in test1_roots + assert mpileaks["mpi"].dag_hash() not in test1_user_spec_hashes def test_env_roots_marked_explicit(install_mockery, mock_fetch): @@ -687,16 +691,14 @@ def test_remove_after_concretize(): e.remove("mpileaks") assert Spec("mpileaks") not in e.user_specs - env_specs = e._get_environment_specs() - assert any(s.name == "mpileaks" for s in env_specs) + assert any(s.name == "mpileaks" for s in e.all_specs_generator()) e.add("mpileaks") assert any(s.name == "mpileaks" for s in e.user_specs) e.remove("mpileaks", force=True) assert Spec("mpileaks") not in e.user_specs - env_specs = e._get_environment_specs() - assert not any(s.name == "mpileaks" for s in env_specs) + assert not any(s.name == "mpileaks" for s in e.all_specs_generator()) def test_remove_before_concretize(): @@ -938,9 +940,7 @@ def test_user_removed_spec(environment_from_manifest): after.write() read = ev.read("test") - env_specs = read._get_environment_specs() - - assert not any(x.name == "hypre" for x in env_specs) + assert not any(x.name == "hypre" for x in read.all_specs_generator()) def test_lockfile_spliced_specs(environment_from_manifest, install_mockery): @@ -995,12 +995,10 @@ def test_init_from_lockfile(environment_from_manifest): for s1, s2 in zip(e1.user_specs, e2.user_specs): assert s1 == s2 - for h1, h2 in zip(e1.concretized_order, e2.concretized_order): - assert h1 == h2 - assert e1.specs_by_hash[h1] == e2.specs_by_hash[h2] + for r1, r2 in zip(e1.concretized_roots, e2.concretized_roots): + assert r1 == r2 - for s1, s2 in zip(e1.concretized_user_specs, e2.concretized_user_specs): - assert s1 == s2 + assert e1.specs_by_hash == e2.specs_by_hash def test_init_from_yaml(environment_from_manifest): @@ -1022,8 +1020,7 @@ def test_init_from_yaml(environment_from_manifest): for s1, s2 in zip(e1.user_specs, e2.user_specs): assert s1 == s2 - assert not e2.concretized_order - assert not e2.concretized_user_specs + assert not e2.concretized_roots assert not e2.specs_by_hash @@ -1058,8 +1055,7 @@ def test_init_from_env(use_name, environment_from_manifest): for s1, s2 in zip(e1.user_specs, e2.user_specs): assert s1 == s2 - assert e2.concretized_order == e1.concretized_order - assert e2.concretized_user_specs == e1.concretized_user_specs + assert e2.concretized_roots == e1.concretized_roots assert e2.specs_by_hash == e1.specs_by_hash assert os.path.exists(os.path.join(e2.path, "libelf")) @@ -1226,7 +1222,9 @@ def test_env_with_config(environment_from_manifest): with e: e.concretize() - assert any(x.intersects("mpileaks@2.2") for x in e._get_environment_specs()) + mpileaks_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("mpileaks")) + mpileaks = e.specs_by_hash[mpileaks_hash] + assert mpileaks.satisfies("mpileaks@2.2") def test_with_config_bad_include_create(environment_from_manifest): @@ -1310,10 +1308,13 @@ def test_env_with_include_config_files_same_basename( with e: e.concretize() - environment_specs = e._get_environment_specs(False) + mpileaks_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("mpileaks")) + mpileaks = e.specs_by_hash[mpileaks_hash] + assert mpileaks.satisfies("mpileaks@2.2") - assert environment_specs[0].satisfies("libelf@0.8.10") - assert environment_specs[1].satisfies("mpileaks@2.2") + libelf_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("libelf")) + libelf = e.specs_by_hash[libelf_hash] + assert libelf.satisfies("libelf@0.8.10") @pytest.fixture(scope="function") @@ -1370,7 +1371,9 @@ def test_env_with_included_config_file(mutable_mock_env_path, packages_file): with e: e.concretize() - assert any(x.satisfies("mpileaks@2.2") for x in e._get_environment_specs()) + mpileaks_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("mpileaks")) + mpileaks = e.specs_by_hash[mpileaks_hash] + assert mpileaks.satisfies("mpileaks@2.2") def test_config_change_existing( @@ -1534,7 +1537,9 @@ def test_env_with_included_config_scope(mutable_mock_env_path, packages_file): with e: e.concretize() - assert any(x.satisfies("mpileaks@2.2") for x in e._get_environment_specs()) + mpileaks_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("mpileaks")) + mpileaks = e.specs_by_hash[mpileaks_hash] + assert mpileaks.satisfies("mpileaks@2.2") def test_env_with_included_config_var_path(tmp_path: pathlib.Path, packages_file): @@ -1555,7 +1560,9 @@ def test_env_with_included_config_var_path(tmp_path: pathlib.Path, packages_file with e: e.concretize() - assert any(x.satisfies("mpileaks@2.2") for x in e._get_environment_specs()) + mpileaks_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("mpileaks")) + mpileaks = e.specs_by_hash[mpileaks_hash] + assert mpileaks.satisfies("mpileaks@2.2") def test_env_with_included_config_precedence(tmp_path: pathlib.Path): @@ -1592,13 +1599,15 @@ def test_env_with_included_config_precedence(tmp_path: pathlib.Path): e = ev.Environment(tmp_path) with e: e.concretize() - specs = e._get_environment_specs() + + mpileaks_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("mpileaks")) + mpileaks = e.specs_by_hash[mpileaks_hash] # ensure included scope took effect - assert any(x.satisfies("mpileaks@2.2") for x in specs) + assert mpileaks.satisfies("mpileaks@2.2") # ensure env file takes precedence - assert any(x.satisfies("libelf@0.8.12") for x in specs) + assert mpileaks["libelf"].satisfies("libelf@0.8.12") def test_env_with_included_configs_precedence(tmp_path: pathlib.Path): @@ -1641,13 +1650,15 @@ def test_env_with_included_configs_precedence(tmp_path: pathlib.Path): e = ev.Environment(tmp_path) with e: e.concretize() - specs = e._get_environment_specs() - # ensure included package spec took precedence over manifest spec - assert any(x.satisfies("mpileaks@2.2") for x in specs) + mpileaks_hash = next(x.hash for x in e.concretized_roots if x.root == Spec("mpileaks")) + mpileaks = e.specs_by_hash[mpileaks_hash] - # ensure first included package spec took precedence over one from second - assert any(x.satisfies("libelf@0.8.10") for x in specs) + # ensure the included package spec took precedence over manifest spec + assert mpileaks.satisfies("mpileaks@2.2") + + # ensure the first included package spec took precedence over one from second + assert mpileaks["libelf"].satisfies("libelf@0.8.10") @pytest.mark.regression("39248") @@ -1847,15 +1858,15 @@ def test_uninstall_keeps_in_env(mock_stage, mock_fetch, install_mockery): test = ev.read("test") # Save this spec to check later if it is still in the env (mpileaks_hash,) = list(x for x, y in test.specs_by_hash.items() if y.name == "mpileaks") - orig_user_specs = test.user_specs - orig_concretized_specs = test.concretized_order + user_specs_before = test.user_specs + user_spec_hashes_before = {x.hash for x in test.concretized_roots} with ev.read("test"): uninstall("-ya") test = ev.read("test") - assert test.concretized_order == orig_concretized_specs - assert test.user_specs.specs == orig_user_specs.specs + assert {x.hash for x in test.concretized_roots} == user_spec_hashes_before + assert test.user_specs.specs == user_specs_before.specs assert mpileaks_hash in test.specs_by_hash assert not test.specs_by_hash[mpileaks_hash].installed @@ -1873,7 +1884,7 @@ def test_uninstall_removes_from_env(mock_stage, mock_fetch, install_mockery): test = ev.read("test") assert not test.specs_by_hash - assert not test.concretized_order + assert not test.concretized_roots assert not test.user_specs @@ -1897,8 +1908,8 @@ def test_indirect_build_dep(repo_builder: RepoBuilder): e.write() e_read = ev.read("test") - (x_env_hash,) = e_read.concretized_order - + assert len(e_read.concretized_roots) == 1 + x_env_hash = e_read.concretized_roots[0].hash x_env_spec = e_read.specs_by_hash[x_env_hash] assert x_env_spec == x_concretized @@ -1939,7 +1950,7 @@ def test_store_different_build_deps(repo_builder: RepoBuilder): e.write() e_read = ev.read("test") - y_env_hash, x_env_hash = e_read.concretized_order + y_env_hash, x_env_hash = [x.hash for x in e_read.concretized_roots] y_read = e_read.specs_by_hash[y_env_hash] x_read = e_read.specs_by_hash[x_env_hash] @@ -2296,19 +2307,24 @@ def test_env_include_concrete_env_reconcretized(unify): def test_concretize_include_concrete_env(): + """Tests that if we update an included environment, and later we re-concretize the environment + that includes it, we use the latest version of the concrete specs. + """ test1, _, combined = setup_combined_multiple_env() + # Update test1 environment with test1: add("mpileaks") test1.concretize() test1.write() - assert Spec("mpileaks") in test1.concretized_user_specs + # Check the test1 environment includes mpileaks, while the combined environment does not + assert Spec("mpileaks") in {x.root for x in test1.concretized_roots} assert Spec("mpileaks") not in combined.included_concretized_user_specs[test1.path] + # If we update the combined environment, it will include mpileaks too combined.concretize() combined.write() - assert Spec("mpileaks") in combined.included_concretized_user_specs[test1.path] @@ -2755,18 +2771,18 @@ def test_stack_yaml_force_remove_from_matrix(tmp_path: pathlib.Path): e.concretize() before_user = e.user_specs.specs - before_conc = e.concretized_user_specs + concretized_roots_before = e.concretized_roots remove("-f", "-l", "packages", "mpileaks") after_user = e.user_specs.specs - after_conc = e.concretized_user_specs + concretized_roots_after = e.concretized_roots assert before_user == after_user mpileaks_spec = Spec("mpileaks target=default_target") - assert mpileaks_spec in before_conc - assert mpileaks_spec not in after_conc + assert mpileaks_spec in {x.root for x in concretized_roots_before} + assert mpileaks_spec not in {x.root for x in concretized_roots_after} def test_stack_definition_extension(tmp_path: pathlib.Path): @@ -2970,7 +2986,7 @@ def test_stack_combinatorial_view( """Tests creating a default view for a combinatorial stack.""" view_dir = tmp_path / "view" with installed_environment(template_combinatorial_env.format(view_config="")) as test: - for spec in test._get_environment_specs(): + for spec in traverse_nodes(test.concrete_roots(), deptype=("link", "run")): if spec.name == "gcc-runtime": continue current_dir = view_dir / f"{spec.architecture.target}" / f"{spec.name}-{spec.version}" @@ -2983,7 +2999,7 @@ def test_stack_view_select( view_dir = tmp_path / "view" content = template_combinatorial_env.format(view_config="select: ['target=x86_64']\n") with installed_environment(content) as test: - for spec in test._get_environment_specs(): + for spec in traverse_nodes(test.concrete_roots(), deptype=("link", "run")): if spec.name == "gcc-runtime": continue current_dir = view_dir / f"{spec.architecture.target}" / f"{spec.name}-{spec.version}" @@ -2996,7 +3012,7 @@ def test_stack_view_exclude( view_dir = tmp_path / "view" content = template_combinatorial_env.format(view_config="exclude: [callpath]\n") with installed_environment(content) as test: - for spec in test._get_environment_specs(): + for spec in traverse_nodes(test.concrete_roots(), deptype=("link", "run")): if spec.name == "gcc-runtime": continue current_dir = view_dir / f"{spec.architecture.target}" / f"{spec.name}-{spec.version}" @@ -3013,7 +3029,7 @@ def test_stack_view_select_and_exclude( """ ) with installed_environment(content) as test: - for spec in test._get_environment_specs(): + for spec in traverse_nodes(test.concrete_roots(), deptype=("link", "run")): if spec.name == "gcc-runtime": continue current_dir = view_dir / f"{spec.architecture.target}" / f"{spec.name}-{spec.version}" @@ -3033,7 +3049,7 @@ def test_view_link_roots( """ ) with installed_environment(content) as test: - for spec in test._get_environment_specs(): + for spec in traverse_nodes(test.concrete_roots(), deptype=("link", "run")): if spec.name == "gcc-runtime": continue current_dir = view_dir / f"{spec.architecture.target}" / f"{spec.name}-{spec.version}" @@ -3115,7 +3131,7 @@ def test_view_link_all(installed_environment, template_combinatorial_env, tmp_pa ) with installed_environment(content) as test: - for spec in test._get_environment_specs(): + for spec in traverse_nodes(test.concrete_roots(), deptype=("link", "run")): if spec.name == "gcc-runtime": continue current_dir = view_dir / f"{spec.architecture.target}" / f"{spec.name}-{spec.version}" @@ -3254,7 +3270,7 @@ def test_stack_view_multiple_views(installed_environment, tmp_path: pathlib.Path with installed_environment(content) as e: assert os.path.exists(str(default_dir / "bin")) - for spec in e._get_environment_specs(): + for spec in traverse_nodes(e.concrete_roots(), deptype=("link", "run")): if spec.name == "gcc-runtime": continue current_dir = comb_dir / f"{spec.architecture.target}" / f"{spec.name}-{spec.version}" @@ -3612,8 +3628,8 @@ def test_modules_relative_to_views(environment_from_manifest, install_mockery, m with ev.read("test") as e: install("--fake") - - spec = e.specs_by_hash[e.concretized_order[0]] + user_spec_hash = e.concretized_roots[0].hash + spec = e.specs_by_hash[user_spec_hash] view_prefix = e.default_view.get_projection_for_spec(spec) modules_glob = "%s/modules/**/*/*" % e.path modules = glob.glob(modules_glob) @@ -4564,7 +4580,7 @@ def test_stack_view_multiple_views_same_name( # the view root in the included view should NOT exist assert not os.path.exists(str(default_dir)) - for spec in e._get_environment_specs(): + for spec in traverse_nodes(e.concrete_roots(), deptype=("link", "run")): # no specs will exist in the included view projection base_dir = view_dir / f"{spec.architecture.target}" included_dir = base_dir / f"{spec.name}-{spec.version}-from-view" @@ -4635,3 +4651,41 @@ def test_non_str_repos(installed_environment): branch: develop""" ): pass + + +def test_concretized_specs_and_include_concrete(mutable_config): + """Tests the consistency of concretized specs when there are either + duplicate input specs or duplicate hashes. + """ + # Create a structure like this one + # + # Local specs: + # - mpileaks -> hash1 + # - libelf@0.8.12 -> hash2 + # - pkg-a -> hash3 + # + # Included specs: + # - mpileaks -> hash4 + # - libelf -> hash2 + # - pkg-a -> hash3 + env("create", "included-env") + with ev.read("included-env") as e: + e.add("mpileaks") + e.add("libelf") + e.add("pkg-a") + mutable_config.set( + "packages", {"mpileaks": {"require": ["@2.2"]}, "libelf": {"require": ["@0.8.12"]}} + ) + included_pairs = e.concretize() + e.write() + + env("create", "--include-concrete", "included-env", "main-env") + with ev.read("main-env") as e: + e.add("mpileaks") + e.add("libelf@0.8.12") + e.add("pkg-a") + mutable_config.set("packages", {"mpileaks": {"require": ["@2.3"]}}) + spec_pairs = e.concretize() + concretized_specs = list(e.concretized_specs()) + assert list(dedupe(spec_pairs + included_pairs)) == concretized_specs + assert len(concretized_specs) == 5 diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index 36d106a6ccbb3b..e635496826b13c 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -70,9 +70,10 @@ def test_hash_change_no_rehash_concrete(tmp_path: pathlib.Path, config): read_in = ev.Environment(env_path) # Ensure read hashes are used (rewritten hash seen on read) - assert read_in.concretized_order - assert read_in.concretized_order[0] in read_in.specs_by_hash - _hash = read_in.specs_by_hash[read_in.concretized_order[0]]._hash # type: ignore[attr-defined] + hashes = [x.hash for x in read_in.concretized_roots] + assert hashes + assert hashes[0] in read_in.specs_by_hash + _hash = read_in.specs_by_hash[hashes[0]]._hash # type: ignore[attr-defined] assert _hash == new_hash From b1da4992bb1cf01945b380e3a10fa1d8ad7c189b Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 9 Feb 2026 09:32:05 +0100 Subject: [PATCH 052/337] asp.py: make %foo=bar in when condition strict (#51923) Currently, `depends_on("x", when="%foo=bar")` triggers the dependency if `foo` is provided by `bar` to *any* of its dependents, and the current package just happens to depend on `bar` without depending on the virtual. That's causes concretization failure in `CMakePackage` on Windows, where we want to express: ```python depends_on("cmake@4.1:", when="%cxx,fortran=msvc", type="build") ``` Now, most packages don't depend on `fortran` but do have `msvc` as a provider for `c` or `cxx`. If only one of their dependencies happens to depend on `msvc` for `fortran` and the same or another for `cxx`, the `when` condition would trigger. With this commit, the when condition only triggers if the *current* package depends on `msvc` for `cxx` and `fortran`. Signed-off-by: Harmen Stoppels --- lib/spack/spack/solver/asp.py | 12 ++++++++--- lib/spack/spack/solver/concretize.lp | 6 ++++++ lib/spack/spack/test/concretization/core.py | 21 +++++++++++++++++++ .../packages/conflict_virtual/package.py | 16 ++++++++++++++ .../direct_dep_virtuals_one/package.py | 14 +++++++++++++ .../direct_dep_virtuals_two/package.py | 13 ++++++++++++ 6 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/conflict_virtual/package.py create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_one/package.py create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_two/package.py diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 941088467adba5..bacf4eb6448da2 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -2317,15 +2317,18 @@ def _spec_clauses( if spec.external: clauses.append(fn.attr("external", name)) + # TODO: a loop over `edges_to_dependencies` is preferred over `edges_from_dependents` + # since dependents can point to specs out of scope for the solver. edges = spec.edges_from_dependents() - virtuals = sorted( - {x for x in itertools.chain.from_iterable([edge.virtuals for edge in edges])} - ) if not body and not spec.concrete: + virtuals = sorted(set(itertools.chain.from_iterable(edge.virtuals for edge in edges))) for virtual in virtuals: clauses.append(fn.attr("provider_set", name, virtual)) clauses.append(fn.attr("virtual_node", virtual)) else: + # direct dependencies are handled under `edges_to_dependencies()` + virtual_iter = (edge.virtuals for edge in edges if not edge.direct) + virtuals = sorted(set(itertools.chain.from_iterable(virtual_iter))) for virtual in virtuals: clauses.append(fn.attr("virtual_on_incoming_edges", name, virtual)) @@ -2425,6 +2428,9 @@ def _spec_clauses( for dependency_type in dt.flag_to_tuple(dspec.depflag): edge_clauses.append(fn.attr("depends_on", name, dep.name, dependency_type)) + for virtual in dspec.virtuals: + dependency_clauses.append(fn.attr("virtual_on_edge", name, dep.name, virtual)) + # By default, wrap head of rules, unless the context says otherwise wrap_node_requirement = body is False if context and context.wrap_node_requirement is not None: diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 04af9811361d58..1c2dcadf24e413 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -473,6 +473,12 @@ satisfied(trigger(PackageNode), condition_requirement("closure", A1, A2, A3)) :- generic_condition_requirement("closure", A1, A2, A3), condition_nodes(PackageNode, node(X, A1)). +satisfied(trigger(PackageNode), condition_requirement("virtual_on_edge", A1, A2, A3)) :- + attr("virtual_on_edge", node(X, A1), node(Y, A2), A3), + generic_condition_requirement("virtual_on_edge", A1, A2, A3), + condition_nodes(PackageNode, node(X, A1)), + condition_nodes(PackageNode, node(Y, A2)). + %%%% % Conditions verified on pure build deps of reused nodes %%%% diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 43b77b0c390122..b2bb71d84cadb5 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4828,6 +4828,27 @@ def test_activating_variant_for_conditional_language_dependency(default_mock_con assert s.satisfies("+fortran") +def test_when_condition_with_direct_dependency_on_virtual_provider(default_mock_concretization): + """If a when condition contains a direct dependency on a provider of a virtual, it should only + trigger if the provider is used for that current package, and not if the provider happens to be + a dependency, without its virtual being depended on.""" + s = default_mock_concretization("direct-dep-virtuals-one") + assert s.satisfies("%netlib-blas") + assert s["direct-dep-virtuals-two"].satisfies("%blas=netlib-blas") + + +def test_conflict_with_direct_dependency_on_virtual_provider(default_mock_concretization): + """Test that conflicts on virtual providers as direct dependencies work""" + s = default_mock_concretization("conflict-virtual") + assert s.satisfies("%blas=netlib-blas") + + with pytest.raises(spack.solver.asp.UnsatisfiableSpecError): + default_mock_concretization("conflict-virtual +conflict_direct") + + with pytest.raises(spack.solver.asp.UnsatisfiableSpecError): + default_mock_concretization("conflict-virtual +conflict_transitive") + + def test_imposed_spec_dependency_duplication(mock_packages: spack.repo.Repo): """Tests that imposed dependencies triggered by identical conditions are grouped together, and that imposed dependencies that differ on a deptype are not grouped together.""" diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/conflict_virtual/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/conflict_virtual/package.py new file mode 100644 index 00000000000000..94946e13581869 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/conflict_virtual/package.py @@ -0,0 +1,16 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class ConflictVirtual(Package): + version("1.0") + variant("conflict_direct", default=False, description="Enable conflict") + variant("conflict_transitive", default=False, description="Enable conflict") + + depends_on("blas") + requires("%blas=netlib-blas") + + conflicts("%blas=netlib-blas", when="+conflict_direct") + conflicts("^blas=netlib-blas", when="+conflict_transitive") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_one/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_one/package.py new file mode 100644 index 00000000000000..34fe4c36b5a0b5 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_one/package.py @@ -0,0 +1,14 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class DirectDepVirtualsOne(Package): + version("1.0") + # These two statements imply that %blas=netlib-blas must be false. + depends_on("direct-dep-virtuals-two +variant", when="%blas=netlib-blas") + depends_on("direct-dep-virtuals-two ~variant") + + # The provider is a direct dependency, but its virtual is *not* depended on. + depends_on("netlib-blas") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_two/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_two/package.py new file mode 100644 index 00000000000000..e8ad566e523ce3 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/direct_dep_virtuals_two/package.py @@ -0,0 +1,13 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class DirectDepVirtualsTwo(Package): + version("1.0") + variant("variant", default=False) + # Pick netlib-blas as a provider for blas. + depends_on("blas") + # Require that netlib-blas is a dependency (and thus the provider of blas). + requires("%netlib-blas") From bb34b6aec7664adfac7cb78b86bb979201292b3e Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Tue, 10 Feb 2026 00:35:49 -0800 Subject: [PATCH 053/337] bugfix: db records for build_specs (#51833) Currently, the spack database doesn't record build specs for spliced installed specs. In the case that the build_spec was never installed (the spliced spec was installed directly from a buildcache of the build_spec), this leads to the build_spec being absent in the database, and the downstream effect is that the Spec.build_spec property erroneously reports the spec as its own build_spec. This commit fixes this by reading/writing build_specs from the database. Signed-off-by: Gregory Becker Signed-off-by: Massimiliano Culpo Co-authored-by: Massimiliano Culpo --- lib/spack/spack/database.py | 26 ++++++++++++++++++++++++++ lib/spack/spack/spec.py | 6 ++++++ lib/spack/spack/test/database.py | 16 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/lib/spack/spack/database.py b/lib/spack/spack/database.py index 1e87a3fee5ed2f..299a14b373d712 100644 --- a/lib/spack/spack/database.py +++ b/lib/spack/spack/database.py @@ -16,6 +16,7 @@ provides a cache and a sanity checking mechanism for what is in the filesystem. """ + import contextlib import datetime import os @@ -751,6 +752,27 @@ def query_local_by_spec_hash(self, hash_key: str) -> Optional[InstallRecord]: with self.read_transaction(): return self._data.get(hash_key, None) + def _assign_build_spec( + self, + spec_reader: Type["spack.spec.SpecfileReaderBase"], + hash_key: str, + installs: dict, + data: Dict[str, InstallRecord], + ): + # Add dependencies from other records in the install DB to + # form a full spec. + spec = data[hash_key].spec + spec_node_dict = installs[hash_key]["spec"] + if "name" not in spec_node_dict: + # old format + spec_node_dict = spec_node_dict[spec.name] + if "build_spec" in spec_node_dict: + assert spec_reader.SPEC_VERSION >= 2, "SpecfileV1 spec cannot have build_spec" + _, bhash, _ = spec_reader.extract_build_spec_info_from_node_dict(spec_node_dict) + _, build_spec = self.query_by_spec_hash(bhash, data=data) + assert build_spec is not None, f"build_spec with hash {bhash} not found in database" + spec._build_spec = build_spec.spec + def _assign_dependencies( self, spec_reader: Type["spack.spec.SpecfileReaderBase"], @@ -865,6 +887,7 @@ def invalid_record(hash_key, error): # Pass 2: Assign dependencies once all specs are created. for hash_key in data: try: + self._assign_build_spec(spec_reader, hash_key, installs, data) self._assign_dependencies(spec_reader, hash_key, installs, data) except MissingDependenciesError: raise @@ -1191,6 +1214,9 @@ def _add( allow_missing=allow_missing or edge.depflag & (dt.BUILD | dt.TEST) == edge.depflag, ) + if spec.spliced: + self._add(spec.build_spec, explicit=False, allow_missing=True) + # Make sure the directory layout agrees whether the spec is installed if not spec.external and self.layout: path = self.layout.path_for_spec(spec) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 486dfc2dcdab98..f75eb23c983ea0 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -5423,6 +5423,8 @@ def reconstruct_virtuals_on_edges(spec: Spec) -> None: class SpecfileReaderBase: + SPEC_VERSION: int + @classmethod def from_node_dict(cls, node): spec = Spec() @@ -5564,6 +5566,10 @@ def _load(cls, data): return hash_dict[root_spec_hash]["node_spec"] + @classmethod + def extract_build_spec_info_from_node_dict(cls, node, hash_type=ht.dag_hash.name): + raise NotImplementedError("Subclasses must implement this method.") + @classmethod def read_specfile_dep_specs(cls, deps, hash_type=ht.dag_hash.name): raise NotImplementedError("Subclasses must implement this method.") diff --git a/lib/spack/spack/test/database.py b/lib/spack/spack/test/database.py index 7e208f6fb736b0..42fdde1b9010f6 100644 --- a/lib/spack/spack/test/database.py +++ b/lib/spack/spack/test/database.py @@ -592,6 +592,22 @@ def test_015_write_and_read(mutable_database): assert new_rec.installed == rec.installed +def test_016_roundtrip_spliced_spec(mutable_database): + build_spec = spack.concretize.concretize_one("splice-t") + replacement = spack.concretize.concretize_one("splice-h+foo") + spec = build_spec.splice(replacement) + + spack.store.STORE.db.add(spec) + spack.store.STORE.db._state_is_inconsistent = True # force re-read + + _, spec_record = spack.store.STORE.db.query_by_spec_hash(spec.dag_hash()) + _, buildspec_record = spack.store.STORE.db.query_by_spec_hash(spec.build_spec.dag_hash()) + + assert spec_record.spec == spec + assert spec_record.spec.build_spec == spec.build_spec + assert buildspec_record # buildspec needs to be recorded in db + + def test_017_write_and_read_without_uuid(mutable_database, monkeypatch): monkeypatch.setattr(spack.database, "_use_uuid", False) # write and read DB From ac68adaaa369a9c881d695b40b0ba2358453c250 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 11 Feb 2026 13:23:31 +0100 Subject: [PATCH 054/337] concretizer.lp: unify build environment (#51915) Fixes a bug where the build environment does not unify properly. This is triggered when a package has two build deps `A` and `B@1`, and `A` has a conflicting *runtime* dependency on `B@2`. That should be a concretization error, but currently Spack allows this when `B` is tagged as `build-tool` and has `max_dupes: 2` or higher. The reason for this bug is that the unification `"generic_build"` contains the items `{A, B@2}` while the special build unification set is independent and contains `{B@1}`. The fix is to put all sibling build dependencies into the same `"build"` unification set. To avoid a combinatorial explosion, the total number of these `"build"` unification sets is limited to a constant 4. Finally, this change relaxes the trigger of `"build"` unification set from "pure build dependencies" to "build dependencies", as I don't see a reason why not. Performance does not seem to be affected by it. --- lib/spack/spack/solver/concretize.lp | 28 +++++++++++-------- lib/spack/spack/test/concretization/core.py | 20 +++++++++++++ .../packages/py_numpy/package.py | 2 +- .../packages/unify_build_deps_a/package.py | 22 +++++++++++++++ .../packages/unify_build_deps_b/package.py | 11 ++++++++ .../packages/unify_build_deps_c/package.py | 11 ++++++++ 6 files changed, 81 insertions(+), 13 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_a/package.py create mode 100644 var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_b/package.py create mode 100644 var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_c/package.py diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 1c2dcadf24e413..30738696f30dd5 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -82,21 +82,19 @@ error(1, "Cannot have multiple nodes for {0} in the same unification set {1}", P unification_set("root", PackageNode) :- attr("root", PackageNode). unification_set(SetID, ChildNode) :- attr("depends_on", ParentNode, ChildNode, Type), Type != "build", unification_set(SetID, ParentNode). -build_only_dependency(ParentNode, node(X, Child)) :- - attr("depends_on", ParentNode, node(X, Child), "build"), - not attr("depends_on", ParentNode, node(X, Child), "link"), - not attr("depends_on", ParentNode, node(X, Child), "run"). - -unification_set(("build", node(X, Child)), node(X, Child)) - :- build_only_dependency(ParentNode, node(X, Child)), - multiple_unification_sets(Child), - unification_set(_, ParentNode). +% A separate unification set can be created if any of the build dependencies can be duplicated +needs_build_unification_set(ParentNode) :- attr("depends_on", ParentNode, _, "build"). -unification_set("generic_build", node(X, Child)) - :- build_only_dependency(ParentNode, node(X, Child)), - not multiple_unification_sets(Child), +% If any of the build dependencies can be duplicated, they can go into any ("build", ID) set +unification_set(("build", ID), node(X, Child)) + :- attr("depends_on", ParentNode, node(X, Child), "build"), + build_set_id(ParentNode, ID), unification_set(_, ParentNode). +% Limit the number of unification sets to a reasonable number to avoid combinatorial explosion +#const max_build_unification_sets = 4. +1 { build_set_id(ParentNode, 0..max_build_unification_sets-1) } 1 :- needs_build_unification_set(ParentNode). + unification_set(SetID, VirtualNode) :- provider(PackageNode, VirtualNode), unification_set(SetID, PackageNode). @@ -2058,6 +2056,12 @@ opt_criterion(100, "number of nodes from the same package"). #minimize { ID@100,Package : attr("virtual_node", node(ID, Package)) }. #defined optimize_for_reuse/0. +% Minimize the unification set ID used for build dependencies. This reduces the number of optimal +% solutions that differ only by which node belongs to which unification set. +opt_criterion(90, "build unification sets"). +#minimize{ 0@90: #true }. +#minimize{ ID@90,ParentNode : build_set_id(ParentNode, ID) }. + % Minimize the number of deprecated versions being used opt_criterion(73, "deprecated versions used"). #minimize{ 0@273: #true }. diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index b2bb71d84cadb5..3361818e2bbce8 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -2966,6 +2966,26 @@ def test_no_multiple_solutions_with_different_edges_same_nodes(self): assert len(edges) == 1 assert edges[0].spec.satisfies("@=60") + def test_build_environment_is_unified(self): + """A pure build dep that is marked build-tool can creates its own unification set. This + test ensures that its sibling build dependencies are unified with it, together with their + runtime dependencies. It ensures the same package cannot appear multiple times in a single + build environment, for example when it's both a direct build dep, as well as pulled in as + a transitive runtime dep of a sibling build dep.""" + spack.config.CONFIG.set("concretizer:duplicates", {"max_dupes": {"unify-build-deps-c": 2}}) + + # Fails because unify-build-deps-c version @1 and @2 are needed in the build environment + with pytest.raises(spack.solver.asp.UnsatisfiableSpecError): + spack.concretize.concretize_one("unify-build-deps-a@1.0") + + # Succeeds because unify-build-deps-c version @2 is not needed in the build environment + spack.concretize.concretize_one("unify-build-deps-a@2.0") + + # Lastly, a sanity check that max_dupes is a requirement for this to work. + spack.config.CONFIG.set("concretizer:duplicates", {"max_dupes": {"unify-build-deps-c": 1}}) + with pytest.raises(spack.solver.asp.UnsatisfiableSpecError): + spack.concretize.concretize_one("unify-build-deps-a@2.0") + @pytest.mark.regression("43647") def test_specifying_different_versions_build_deps(self): """Tests that we can concretize a spec with nodes using the same build diff --git a/var/spack/test_repos/spack_repo/duplicates_test/packages/py_numpy/package.py b/var/spack/test_repos/spack_repo/duplicates_test/packages/py_numpy/package.py index f668c97cfc0dd8..3f99f3068433b1 100644 --- a/var/spack/test_repos/spack_repo/duplicates_test/packages/py_numpy/package.py +++ b/var/spack/test_repos/spack_repo/duplicates_test/packages/py_numpy/package.py @@ -17,5 +17,5 @@ class PyNumpy(Package): version("1.25.0", md5="0123456789abcdef0123456789abcdef") extends("python") - depends_on("py-setuptools@=59", type=("build", "run")) + depends_on("py-setuptools@=59", type="build") depends_on("gmake@4.1", type="build") diff --git a/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_a/package.py b/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_a/package.py new file mode 100644 index 00000000000000..35cd84fd15dbe5 --- /dev/null +++ b/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_a/package.py @@ -0,0 +1,22 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class UnifyBuildDepsA(Package): + """Used to test that we cannot have a build environment with two conflicting versions of + a package (unify-build-deps-c), even if that package is tagged as a build-tool with duplicates + allowed.""" + + url = "http://example.com/unify-build-deps-a-1.0.tar.gz" + version("2.0", sha256="d41d8cd98f00b204e9800998ecf8427ed41d8cd98f00b204e9800998ecf8427e") + version("1.0", sha256="d41d8cd98f00b204e9800998ecf8427ed41d8cd98f00b204e9800998ecf8427e") + + depends_on("unify-build-deps-c@1", type="build") + + # If unify-build-deps-b is used as a build dependency, we cannot unify the build environment. + depends_on("unify-build-deps-b", type=("build", "run"), when="@1") + + # If unify-build-deps-b is not used as build dependency, we can unify the build environment + depends_on("unify-build-deps-b", type=("link", "run"), when="@2") diff --git a/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_b/package.py b/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_b/package.py new file mode 100644 index 00000000000000..1add87feb72470 --- /dev/null +++ b/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_b/package.py @@ -0,0 +1,11 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class UnifyBuildDepsB(Package): + url = "http://example.com/unify-build-deps-b-1.0.tar.gz" + version("1.0", sha256="d41d8cd98f00b204e9800998ecf8427ed41d8cd98f00b204e9800998ecf8427e") + + depends_on("unify-build-deps-c@2", type="run") diff --git a/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_c/package.py b/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_c/package.py new file mode 100644 index 00000000000000..9214f8c10efdf9 --- /dev/null +++ b/var/spack/test_repos/spack_repo/duplicates_test/packages/unify_build_deps_c/package.py @@ -0,0 +1,11 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class UnifyBuildDepsC(Package): + tags = ["build-tools"] + url = "http://example.com/unify-build-deps-c-1.0.tar.gz" + version("2.0", sha256="d41d8cd98f00b204e9800998ecf8427ed41d8cd98f00b204e9800998ecf8427e") + version("1.0", sha256="d41d8cd98f00b204e9800998ecf8427ed41d8cd98f00b204e9800998ecf8427e") From 8d42bddde1216ef9a76276675ca2e2951ca7f0b4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 11 Feb 2026 17:02:22 +0100 Subject: [PATCH 055/337] audit.py: remove _search_for_deprecated_package_methods (#51908) This audit is very slow due to `inspect` calls. Signed-off-by: Harmen Stoppels --- lib/spack/spack/audit.py | 49 ------------------- lib/spack/spack/test/audit.py | 2 - lib/spack/spack/test/cmd/test.py | 1 - .../fail_test_audit_deprecated/package.py | 33 ------------- 4 files changed, 85 deletions(-) delete mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/fail_test_audit_deprecated/package.py diff --git a/lib/spack/spack/audit.py b/lib/spack/spack/audit.py index 44cdac6bd10a89..2b0fa346816712 100644 --- a/lib/spack/spack/audit.py +++ b/lib/spack/spack/audit.py @@ -385,15 +385,6 @@ def _make_config_error(config_data, summary, error_cls): kwargs=("pkgs",), ) - -package_deprecated_attributes = AuditClass( - group="packages", - tag="PKG-DEPRECATED-ATTRIBUTES", - description="Sanity checks to preclude use of deprecated package attributes", - kwargs=("pkgs",), -) - - package_properties = AuditClass( group="packages", tag="PKG-PROPERTIES", @@ -542,46 +533,6 @@ def _search_for_reserved_attributes_names_in_packages(pkgs, error_cls): return errors -@package_deprecated_attributes -def _search_for_deprecated_package_methods(pkgs, error_cls): - """Ensure the package doesn't define or use deprecated methods""" - DEPRECATED_METHOD = (("test", "a name starting with 'test_'"),) - DEPRECATED_USE = ( - ("self.cache_extra_test_sources(", "cache_extra_test_sources(self, ..)"), - ("self.install_test_root(", "install_test_root(self, ..)"), - ("self.run_test(", "test_part(self, ..)"), - ) - errors = [] - for pkg_name in pkgs: - pkg_cls = spack.repo.PATH.get_pkg_class(pkg_name) - methods = inspect.getmembers(pkg_cls, predicate=lambda x: inspect.isfunction(x)) - method_errors = collections.defaultdict(list) - for name, function in methods: - for deprecated_name, alternate in DEPRECATED_METHOD: - if name == deprecated_name: - msg = f"Rename '{deprecated_name}' method to {alternate} instead." - method_errors[name].append(msg) - - source = inspect.getsource(function) - for deprecated_name, alternate in DEPRECATED_USE: - if deprecated_name in source: - msg = f"Change '{deprecated_name}' to '{alternate}' in '{name}' method." - method_errors[name].append(msg) - - num_methods = len(method_errors) - if num_methods > 0: - methods = plural(num_methods, "method", show_n=False) - error_msg = ( - f"Package '{pkg_name}' implements or uses unsupported deprecated {methods}." - ) - instr = [f"Make changes to '{pkg_cls.__module__}':"] - for name in sorted(method_errors): - instr.extend([f" {msg}" for msg in method_errors[name]]) - errors.append(error_cls(error_msg, instr)) - - return errors - - @package_properties def _ensure_all_package_names_are_lowercase(pkgs, error_cls): """Ensure package names are lowercase and consistent""" diff --git a/lib/spack/spack/test/audit.py b/lib/spack/spack/test/audit.py index 5c47388674d834..db12424e0848cd 100644 --- a/lib/spack/spack/test/audit.py +++ b/lib/spack/spack/test/audit.py @@ -28,8 +28,6 @@ (["invalid-selfhosted-gitlab-patch-url"], ["PKG-DIRECTIVES", "PKG-PROPERTIES"]), # This package has a stand-alone test method in build-time callbacks (["fail-test-audit"], ["PKG-PROPERTIES"]), - # This package implements and uses several deprecated stand-alone test methods - (["fail-test-audit-deprecated"], ["PKG-DEPRECATED-ATTRIBUTES"]), # This package has stand-alone test methods without non-trivial docstrings (["fail-test-audit-docstring"], ["PKG-PROPERTIES"]), # This package has a stand-alone test method without an implementation diff --git a/lib/spack/spack/test/cmd/test.py b/lib/spack/spack/test/cmd/test.py index 609d1f79e718cc..2389d337d828a7 100644 --- a/lib/spack/spack/test/cmd/test.py +++ b/lib/spack/spack/test/cmd/test.py @@ -198,7 +198,6 @@ def test_test_list_all(mock_packages): assert set(pkgs) == { "py-numpy", "fail-test-audit", - "fail-test-audit-deprecated", "fail-test-audit-docstring", "fail-test-audit-impl", "mpich", diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/fail_test_audit_deprecated/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/fail_test_audit_deprecated/package.py deleted file mode 100644 index d448fd65defbc9..00000000000000 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/fail_test_audit_deprecated/package.py +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright Spack Project Developers. See COPYRIGHT file for details. -# -# SPDX-License-Identifier: (Apache-2.0 OR MIT) -from spack_repo.builtin_mock.build_systems.makefile import MakefilePackage - -from spack.package import * - - -class FailTestAuditDeprecated(MakefilePackage): - """Simple package attempting to implement and use deprecated stand-alone test methods.""" - - homepage = "http://github.com/dummy/fail-test-audit-deprecated" - url = "https://github.com/dummy/fail-test-audit-deprecated/archive/v1.0.tar.gz" - - version("2.0", sha256="c3e5e9fdd5004dcb542feda5ee4f0ff0744628baf8ed2dd5d66f8ca1197cb1a1") - version("1.0", sha256="abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234") - - @run_after("install") - def copy_test_files(self): - """test that uses the deprecated install_test_root method""" - self.cache_extra_test_sources(".") - - def test(self): - """this is a deprecated reserved method for stand-alone testing""" - pass - - def test_use_install_test_root(self): - """use the deprecated install_test_root method""" - print(f"install test root = {self.install_test_root()}") - - def test_run_test(self): - """use the deprecated run_test method""" - self.run_test("which", ["make"]) From e3d920f2e8c781b1eeda60153a516520214ef2d8 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 12 Feb 2026 10:48:49 +0100 Subject: [PATCH 056/337] reuse.py: fix reuse logic to handle oneapi (#51932) * Fix a bug where intel-oneapi-compilers was not reused by Spack * Remove hard-coded package names in `spack/spack` * Avoid post-concretization bug with `foo %bar` where `bar` is not a compiler and `foo` is a reused v4 type spec Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/reuse.py | 11 ++---- lib/spack/spack/test/cmd/buildcache.py | 5 ++- lib/spack/spack/test/cmd/env.py | 2 +- lib/spack/spack/test/cmd/spec.py | 2 +- lib/spack/spack/test/concretization/core.py | 34 +++++++------------ .../spack/test/concretization/splicing.py | 8 +---- 6 files changed, 22 insertions(+), 40 deletions(-) diff --git a/lib/spack/spack/solver/reuse.py b/lib/spack/spack/solver/reuse.py index 81c2274f0b0362..49898f17699f0e 100644 --- a/lib/spack/spack/solver/reuse.py +++ b/lib/spack/spack/solver/reuse.py @@ -120,15 +120,8 @@ def from_packages_yaml( def _has_runtime_dependencies(spec: spack.spec.Spec) -> bool: - # TODO (compiler as nodes): this function contains specific names from builtin, and should - # be made more general - if "gcc" in spec and "gcc-runtime" not in spec: - return False - - if "intel-oneapi-compilers" in spec and "intel-oneapi-runtime" not in spec: - return False - - return True + # Spack v1.0 specs and later + return spec.original_spec_format() >= 5 def _is_reusable(spec: spack.spec.Spec, packages_with_externals, local: bool) -> bool: diff --git a/lib/spack/spack/test/cmd/buildcache.py b/lib/spack/spack/test/cmd/buildcache.py index 4090d0e4037750..7b4b43166ed0b8 100644 --- a/lib/spack/spack/test/cmd/buildcache.py +++ b/lib/spack/spack/test/cmd/buildcache.py @@ -556,7 +556,9 @@ def test_check_mirror_for_layout(v2_buildcache_layout, mutable_config, capfd): assert all([word in err for word in ["Warning", "missing", "layout"]]) -def test_url_buildcache_entry_v2_exists(v2_buildcache_layout, mock_packages, mutable_config): +def test_url_buildcache_entry_v2_exists( + v2_buildcache_layout, mock_packages, mutable_config, do_not_check_runtimes_on_reuse +): """Test existence check for v2 buildcache entries""" test_mirror_path = v2_buildcache_layout("unsigned") mirror_url = pathlib.Path(test_mirror_path).as_uri() @@ -603,6 +605,7 @@ def test_install_v2_layout( mutable_mock_env_path, install_mockery, mock_gnupghome, + do_not_check_runtimes_on_reuse, ): """Ensure we can still install from signed and unsigned v2 buildcache""" test_mirror_path = v2_buildcache_layout(signing) diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 1378125861f058..e43b85e87d2cb6 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -2231,7 +2231,7 @@ def configure_reuse(reuse_mode, combined_env) -> Optional[ev.Environment]: "from_environment_raise", ], ) -def test_env_include_concrete_reuse(do_not_check_runtimes_on_reuse, reuse_mode): +def test_env_include_concrete_reuse(reuse_mode): # The default mpi version is 3.x provided by mpich in the mock repo. # This test verifies that concretizing with an included concrete # environment with "concretizer:reuse:true" the included diff --git a/lib/spack/spack/test/cmd/spec.py b/lib/spack/spack/test/cmd/spec.py index a8cb2d88de9ef3..56446340a7704d 100644 --- a/lib/spack/spack/test/cmd/spec.py +++ b/lib/spack/spack/test/cmd/spec.py @@ -32,7 +32,7 @@ def test_spec(): assert "mpich@3.0.4" in output -def test_spec_concretizer_args(mutable_database, do_not_check_runtimes_on_reuse): +def test_spec_concretizer_args(mutable_database): """End-to-end test of CLI concretizer prefs. It's here to make sure that everything works from CLI diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 3361818e2bbce8..158b6d819ff580 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -327,7 +327,7 @@ def weights_from_result(result: Result, *, name: str) -> Dict[str, int]: # This must use the mutable_config fixture because the test # adjusting_default_target_based_on_compiler uses the current_host fixture, # which changes the config. -@pytest.mark.usefixtures("mutable_config", "mock_packages", "do_not_check_runtimes_on_reuse") +@pytest.mark.usefixtures("mutable_config", "mock_packages") class TestConcretize: def test_concretize(self, spec): check_concretize(spec) @@ -3264,7 +3264,7 @@ def test_concretization_version_order(): ), ], ) -@pytest.mark.usefixtures("mutable_database", "mock_store", "do_not_check_runtimes_on_reuse") +@pytest.mark.usefixtures("mutable_database", "mock_store") @pytest.mark.not_on_windows("Expected length is different on Windows") def test_filtering_reused_specs( roots, reuse_yaml, expected, not_expected, expected_length, mutable_config @@ -3309,9 +3309,7 @@ def test_filtering_reused_specs( ], ) @pytest.mark.not_on_windows("Expected length is different on Windows") -def test_selecting_reused_sources( - reuse_yaml, expected_length, mutable_config, do_not_check_runtimes_on_reuse -): +def test_selecting_reused_sources(reuse_yaml, expected_length, mutable_config): """Tests that we can turn on/off sources of reusable specs""" # Assume all specs have a runtime dependency mutable_config.set("concretizer:reuse", reuse_yaml) @@ -3350,7 +3348,7 @@ def test_spec_filters(specs, include, exclude, expected): @pytest.mark.regression("38484") -def test_git_ref_version_can_be_reused(install_mockery, do_not_check_runtimes_on_reuse): +def test_git_ref_version_can_be_reused(install_mockery): first_spec = spack.concretize.concretize_one( spack.spec.Spec("git-ref-package@git.2.1.5=2.1.5~opt") ) @@ -3371,9 +3369,7 @@ def test_git_ref_version_can_be_reused(install_mockery, do_not_check_runtimes_on @pytest.mark.parametrize("standard_version", ["2.0.0", "2.1.5", "2.1.6"]) -def test_reuse_prefers_standard_over_git_versions( - standard_version, install_mockery, do_not_check_runtimes_on_reuse -): +def test_reuse_prefers_standard_over_git_versions(standard_version, install_mockery): """ order matters in this test. typically reuse would pick the highest versioned installed match but we want to prefer the standard version over git ref based versions @@ -3419,7 +3415,7 @@ def test_parallel_concretization(mutable_config, mock_packages): assert {s.name for s, _ in result} == {"pkg-a", "pkg-b"} -@pytest.mark.usefixtures("mutable_config", "mock_packages", "do_not_check_runtimes_on_reuse") +@pytest.mark.usefixtures("mutable_config", "mock_packages") @pytest.mark.parametrize( "spec_str, error_type", [ @@ -3439,7 +3435,7 @@ def test_spec_containing_commit_variant(spec_str, error_type): spack.concretize.concretize_one(spec) -@pytest.mark.usefixtures("mutable_config", "mock_packages", "do_not_check_runtimes_on_reuse") +@pytest.mark.usefixtures("mutable_config", "mock_packages") @pytest.mark.parametrize( "spec_str", [ @@ -3459,7 +3455,7 @@ def test_spec_with_commit_interacts_with_lookup(mock_git_version_info, monkeypat spack.concretize.concretize_one(spec) -@pytest.mark.usefixtures("mutable_config", "mock_packages", "do_not_check_runtimes_on_reuse") +@pytest.mark.usefixtures("mutable_config", "mock_packages") @pytest.mark.parametrize("version_str", [f"git.{'a' * 40}=main", "git.2.1.5=main"]) def test_relationship_git_versions_and_commit_variant(version_str): """ @@ -3474,7 +3470,7 @@ def test_relationship_git_versions_and_commit_variant(version_str): assert "commit" not in spec.variants -@pytest.mark.usefixtures("install_mockery", "do_not_check_runtimes_on_reuse") +@pytest.mark.usefixtures("install_mockery") def test_abstract_commit_spec_reuse(): commit = "abcd" * 10 spec_str_1 = f"git-ref-package@develop commit={commit}" @@ -3487,7 +3483,7 @@ def test_abstract_commit_spec_reuse(): assert spec2.dag_hash() == spec1.dag_hash() -@pytest.mark.usefixtures("install_mockery", "do_not_check_runtimes_on_reuse") +@pytest.mark.usefixtures("install_mockery") @pytest.mark.parametrize( "installed_commit, incoming_commit, reusable", [("a" * 40, "b" * 40, False), (None, "b" * 40, False), ("a" * 40, None, True)], @@ -3703,7 +3699,7 @@ def test_specifying_compilers_with_virtuals_syntax(default_mock_concretization): @pytest.mark.regression("49847") @pytest.mark.xfail(sys.platform == "win32", reason="issues with install mockery") -def test_reuse_when_input_specifies_build_dep(install_mockery, do_not_check_runtimes_on_reuse): +def test_reuse_when_input_specifies_build_dep(install_mockery): """Test that we can reuse a spec when specifying build dependencies in the input""" pkgb_old = spack.concretize.concretize_one(spack.spec.Spec("pkg-b@0.9 %gcc@9")) PackageInstaller([pkgb_old.package], fake=True, explicit=True).install() @@ -3721,9 +3717,7 @@ def test_reuse_when_input_specifies_build_dep(install_mockery, do_not_check_runt @pytest.mark.regression("49847") -def test_reuse_when_requiring_build_dep( - install_mockery, do_not_check_runtimes_on_reuse, mutable_config -): +def test_reuse_when_requiring_build_dep(install_mockery, mutable_config): """Test that we can reuse a spec when specifying build dependencies in requirements""" mutable_config.set("packages:all:require", "%gcc") pkgb_old = spack.concretize.concretize_one(spack.spec.Spec("pkg-b@0.9")) @@ -3810,9 +3804,7 @@ def test_using_externals_with_compilers(mutable_config, mock_packages, tmp_path: @pytest.mark.regression("50161") -def test_installed_compiler_and_better_external( - install_mockery, do_not_check_runtimes_on_reuse, mutable_config -): +def test_installed_compiler_and_better_external(install_mockery, mutable_config): """Tests that we always prefer a higher-priority external compiler, when we have a lower-priority compiler installed, and we try to concretize a spec without specifying the compiler dependency. diff --git a/lib/spack/spack/test/concretization/splicing.py b/lib/spack/spack/test/concretization/splicing.py index 0b331d89011fcb..f93b20b74f7d65 100644 --- a/lib/spack/spack/test/concretization/splicing.py +++ b/lib/spack/spack/test/concretization/splicing.py @@ -22,13 +22,7 @@ def _make_specs_non_buildable(specs: List[str]): @pytest.fixture -def install_specs( - mutable_database, - mock_packages, - mutable_config, - do_not_check_runtimes_on_reuse, - install_mockery, -): +def install_specs(mutable_database, mock_packages, mutable_config, install_mockery): """Returns a function that concretizes and installs a list of abstract specs""" mutable_config.set("concretizer:reuse", True) From d8701b1b2c78be17cc2f1ce756742104b5420b94 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 12 Feb 2026 15:40:45 +0100 Subject: [PATCH 057/337] environment: activate environment in tests (#51936) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/test/cmd/env.py | 190 ++++++++++++++++---------------- 1 file changed, 93 insertions(+), 97 deletions(-) diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index e43b85e87d2cb6..7f72131118c575 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -78,19 +78,18 @@ def setup_combined_multiple_env(): test1 = ev.read("test1") with test1: add("mpich@1.0") - test1.concretize() - test1.write() + test1.concretize() + test1.write() env("create", "test2") test2 = ev.read("test2") with test2: add("libelf") - test2.concretize() - test2.write() + test2.concretize() + test2.write() env("create", "--include-concrete", "test1", "--include-concrete", "test2", "combined_env") combined = ev.read("combined_env") - return test1, test2, combined @@ -535,18 +534,21 @@ def test_env_install_single_spec(install_mockery, mock_fetch): @pytest.mark.parametrize("unify", [True, False, "when_possible"]) -def test_env_install_include_concrete_env(unify, install_mockery, mock_fetch, mutable_config): +@pytest.mark.parametrize("reuse", [True, False]) +def test_env_install_include_concrete_env( + unify, reuse, install_mockery, mock_fetch, mutable_config +): test1, test2, combined = setup_combined_multiple_env() - combined.unify = unify - if not unify: + if unify is False: combined.manifest.set_default_view(False) - combined.add("mpileaks") - combined.concretize() - combined.write() - with combined: + mutable_config.set("concretizer:unify", unify) + mutable_config.set("concretizer:reuse", reuse) + combined.add("mpileaks") + combined.concretize() + combined.write() install("--fake") test1_user_spec_hashes = [x.hash for x in test1.concretized_roots] @@ -561,12 +563,12 @@ def test_env_install_include_concrete_env(unify, install_mockery, mock_fetch, mu mpileaks_hash = combined.concretized_roots[0].hash mpileaks = combined.specs_by_hash[mpileaks_hash] - if unify: - assert mpileaks["mpi"].dag_hash() in test1_user_spec_hashes - assert mpileaks["libelf"].dag_hash() in test2_user_spec_hashes - else: + if unify is False and reuse is False: # check that unification is not by accident assert mpileaks["mpi"].dag_hash() not in test1_user_spec_hashes + else: + assert mpileaks["mpi"].dag_hash() in test1_user_spec_hashes + assert mpileaks["libelf"].dag_hash() in test2_user_spec_hashes def test_env_roots_marked_explicit(install_mockery, mock_fetch): @@ -701,17 +703,18 @@ def test_remove_after_concretize(): assert not any(s.name == "mpileaks" for s in e.all_specs_generator()) -def test_remove_before_concretize(): - e = ev.create("test") - e.unify = True - - e.add("mpileaks") - e.concretize() - - e.remove("mpileaks") - e.concretize() +def test_remove_before_concretize(mutable_config): + """Tests the effect of concretization after adding and removing specs""" + with ev.create("test") as e: + mutable_config.set("concretizer:unify", True) + e.add("mpileaks") + e.concretize() + assert len(e.concretized_roots) == 1 + assert e.concrete_roots()[0].satisfies("mpileaks") - assert not list(e.concretized_specs()) + e.remove("mpileaks") + e.concretize() + assert not e.concretized_roots def test_remove_command(): @@ -2282,22 +2285,22 @@ def test_env_include_concrete_reuse(reuse_mode): @pytest.mark.parametrize("unify", [True, False, "when_possible"]) -def test_env_include_concrete_env_reconcretized(unify): +def test_env_include_concrete_env_reconcretized(mutable_config, unify): """Double check to make sure that concrete_specs for the local specs is empty after reconcretizing. """ _, _, combined = setup_combined_multiple_env() - combined.unify = unify - with open(combined.lock_path, encoding="utf-8") as f: lockfile_as_dict = combined._read_lockfile(f) assert not lockfile_as_dict["roots"] assert not lockfile_as_dict["concrete_specs"] - combined.concretize() - combined.write() + with combined: + mutable_config.set("concretizer:unify", unify) + combined.concretize() + combined.write() with open(combined.lock_path, encoding="utf-8") as f: lockfile_as_dict = combined._read_lockfile(f) @@ -3351,53 +3354,52 @@ def test_env_activate_custom_view(tmp_path: pathlib.Path, mock_packages): assert os.path.join(nondefaultdir, "bin") in shell -def test_concretize_user_specs_together(): - e = ev.create("coconcretization") - e.unify = True +def test_concretize_user_specs_together(mutable_config): + with ev.create("coconcretization") as e: + mutable_config.set("concretizer:unify", True) - # Concretize a first time using 'mpich' as the MPI provider - e.add("mpileaks") - e.add("mpich") - e.concretize() - - assert all("mpich" in spec for _, spec in e.concretized_specs()) - assert all("mpich2" not in spec for _, spec in e.concretized_specs()) + # Concretize a first time using 'mpich' as the MPI provider + e.add("mpileaks") + e.add("mpich") + e.concretize() - # Concretize a second time using 'mpich2' as the MPI provider - e.remove("mpich") - e.add("mpich2") + assert all("mpich" in spec for _, spec in e.concretized_specs()) + assert all("mpich2" not in spec for _, spec in e.concretized_specs()) - exc_cls = spack.error.UnsatisfiableSpecError + # Concretize a second time using 'mpich2' as the MPI provider + e.remove("mpich") + e.add("mpich2") - # Concretizing without invalidating the concrete spec for mpileaks fails - with pytest.raises(exc_cls): - e.concretize() - e.concretize(force=True) + exc_cls = spack.error.UnsatisfiableSpecError - assert all("mpich2" in spec for _, spec in e.concretized_specs()) - assert all("mpich" not in spec for _, spec in e.concretized_specs()) + # Concretizing without invalidating the concrete spec for mpileaks fails + with pytest.raises(exc_cls): + e.concretize() + e.concretize(force=True) - # Concretize again without changing anything, check everything - # stays the same - e.concretize() + assert all("mpich2" in spec for _, spec in e.concretized_specs()) + assert all("mpich" not in spec for _, spec in e.concretized_specs()) - assert all("mpich2" in spec for _, spec in e.concretized_specs()) - assert all("mpich" not in spec for _, spec in e.concretized_specs()) + # Concretize again without changing anything, check everything + # stays the same + e.concretize() + assert all("mpich2" in spec for _, spec in e.concretized_specs()) + assert all("mpich" not in spec for _, spec in e.concretized_specs()) -def test_duplicate_packages_raise_when_concretizing_together(): - e = ev.create("coconcretization") - e.unify = True - e.add("mpileaks+opt") - e.add("mpileaks~opt") - e.add("mpich") +def test_duplicate_packages_raise_when_concretizing_together(mutable_config): + with ev.create("coconcretization") as e: + mutable_config.set("concretizer:unify", True) + e.add("mpileaks+opt") + e.add("mpileaks~opt") + e.add("mpich") - exc_cls = spack.error.UnsatisfiableSpecError - match = r"You could consider setting `concretizer:unify`" + exc_cls = spack.error.UnsatisfiableSpecError + match = r"You could consider setting `concretizer:unify`" - with pytest.raises(exc_cls, match=match): - e.concretize() + with pytest.raises(exc_cls, match=match): + e.concretize() def test_env_write_only_non_default(): @@ -3597,18 +3599,16 @@ def test_does_not_rewrite_rel_dev_path_when_keep_relative_is_set(tmp_path: pathl @pytest.mark.regression("23440") -def test_custom_version_concretize_together(): +def test_custom_version_concretize_together(mutable_config): # Custom versions should be permitted in specs when # concretizing together - e = ev.create("custom_version") - e.unify = True - - # Concretize a first time using 'mpich' as the MPI provider - e.add("hdf5@=myversion") - e.add("mpich") - e.concretize() - - assert any(spec.satisfies("hdf5@myversion") for _, spec in e.concretized_specs()) + with ev.create("custom_version") as e: + mutable_config.set("concretizer:unify", True) + # Concretize a first time using 'mpich' as the MPI provider + e.add("hdf5@=myversion") + e.add("mpich") + e.concretize() + assert any(spec.satisfies("hdf5@myversion") for _, spec in e.concretized_specs()) def test_modules_relative_to_views(environment_from_manifest, install_mockery, mock_fetch): @@ -3724,15 +3724,13 @@ def _always_fail(cls, *args, **kwargs): @pytest.mark.regression("24148") -def test_virtual_spec_concretize_together(): +def test_virtual_spec_concretize_together(mutable_config): # An environment should permit to concretize "mpi" - e = ev.create("virtual_spec") - e.unify = True - - e.add("mpi") - e.concretize() - - assert any(s.package.provides("mpi") for _, s in e.concretized_specs()) + with ev.create("virtual_spec") as e: + mutable_config.set("concretizer:unify", True) + e.add("mpi") + e.concretize() + assert any(s.package.provides("mpi") for _, s in e.concretized_specs()) def test_query_develop_specs(tmp_path: pathlib.Path): @@ -4362,20 +4360,18 @@ def test_depfile_empty_does_not_error(tmp_path: pathlib.Path): assert make.returncode == 0 -def test_unify_when_possible_works_around_conflicts(): - e = ev.create("coconcretization") - e.unify = "when_possible" - - e.add("mpileaks+opt") - e.add("mpileaks~opt") - e.add("mpich") - - e.concretize() +def test_unify_when_possible_works_around_conflicts(mutable_config): + with ev.create("coconcretization") as e: + mutable_config.set("concretizer:unify", "when_possible") + e.add("mpileaks+opt") + e.add("mpileaks~opt") + e.add("mpich") + e.concretize() - assert len([x for x in e.all_specs() if x.satisfies("mpileaks")]) == 2 - assert len([x for x in e.all_specs() if x.satisfies("mpileaks+opt")]) == 1 - assert len([x for x in e.all_specs() if x.satisfies("mpileaks~opt")]) == 1 - assert len([x for x in e.all_specs() if x.satisfies("mpich")]) == 1 + assert len([x for x in e.all_specs() if x.satisfies("mpileaks")]) == 2 + assert len([x for x in e.all_specs() if x.satisfies("mpileaks+opt")]) == 1 + assert len([x for x in e.all_specs() if x.satisfies("mpileaks~opt")]) == 1 + assert len([x for x in e.all_specs() if x.satisfies("mpich")]) == 1 # Using mock_include_cache to ensure the "remote" file is cached in a temporary From f5acde14522fbd50bd9c2bd0776a775326b939a7 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 12 Feb 2026 16:03:58 +0100 Subject: [PATCH 058/337] spec.py: fix satisfies for v4 specs with %pkg (#51934) Currently if you do ``` spack spec foo %bar ``` and `foo` is a reused v4 type spec, and `bar` is a direct but non-compiler dependency of `foo`, you hit a concretization error claiming input does not satisfy output. The reason is that in the v4 case `satisfies` ONLY considers compilers dependencies. It should really extend the possible dependencies with compilers reconstructed from node attributes. Signed-off-by: Harmen Stoppels --- lib/spack/spack/spec.py | 84 +++++++++++++------------------ lib/spack/spack/test/spec_yaml.py | 3 +- 2 files changed, 36 insertions(+), 51 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index f75eb23c983ea0..029ff36a3dff20 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -3465,7 +3465,6 @@ def _satisfies( # verify the edge properties, cause everything is encoded in the hash of the nodes that # will be verified later. lhs_edges: Dict[str, Set[DependencySpec]] = collections.defaultdict(set) - mock_nodes_from_old_specfiles = set() for rhs_edge in other.traverse_edges(root=False, cover="edges"): # Check satisfaction of the dependency only if its when condition can apply if not rhs_edge.parent.name or rhs_edge.parent.name == self.name: @@ -3501,58 +3500,43 @@ def _satisfies( except KeyError: return False - if current_node.original_spec_format() < 5 or ( - # If the current external node has dependencies, it has no annotations - current_node.original_spec_format() >= 5 - and current_node.external - and not current_node._dependencies - ): - compiler_spec = current_node.annotations.compiler_node_attribute - if compiler_spec is None: - return False + # If the branch is % or ^, check if we have a corresponding + # branch in the lhs + candidate_edges = [] + if resolve_virtuals and spack.repo.PATH.is_virtual(rhs_edge.spec.name): + candidate_edges = current_node.edges_to_dependencies(name=rhs_edge.spec.name) - mock_nodes_from_old_specfiles.add(compiler_spec) - # This checks that the single node compiler spec satisfies the request - # of a direct dependency. The check is not perfect, but based on heuristic. - if not compiler_spec._satisfies( - rhs_edge.spec, resolve_virtuals=resolve_virtuals - ): - return False + name = ( + None + if resolve_virtuals and spack.repo.PATH.is_virtual(rhs_edge.spec.name) + else rhs_edge.spec.name + ) + candidate_edges.extend( + current_node.edges_to_dependencies( + name=name, virtuals=rhs_edge.virtuals or None + ) + ) - else: - # If the branch is % or ^, check if we have a corresponding - # branch in the lhs - candidate_edges = [] - if resolve_virtuals and spack.repo.PATH.is_virtual(rhs_edge.spec.name): - candidate_edges = current_node.edges_to_dependencies( - name=rhs_edge.spec.name - ) + # Select at least the deptypes on the rhs_edge, and conditional edges that + # constrain a bigger portion of the search space (so it's rhs.when <= lhs.when) + candidates = [ + lhs_edge.spec + for lhs_edge in candidate_edges + if ((lhs_edge.depflag & rhs_edge.depflag) ^ rhs_edge.depflag) == 0 + and rhs_edge.when._satisfies(lhs_edge.when, resolve_virtuals=resolve_virtuals) + ] - name = ( - None - if resolve_virtuals and spack.repo.PATH.is_virtual(rhs_edge.spec.name) - else rhs_edge.spec.name - ) - candidate_edges.extend( - current_node.edges_to_dependencies( - name=name, virtuals=rhs_edge.virtuals or None - ) - ) - # Select at least the deptypes on the rhs_edge, and conditional edges that - # constrain a bigger portion of the search space (so it's rhs.when <= lhs.when) - candidates = [ - lhs_edge.spec - for lhs_edge in candidate_edges - if ((lhs_edge.depflag & rhs_edge.depflag) ^ rhs_edge.depflag) == 0 - and rhs_edge.when._satisfies( - lhs_edge.when, resolve_virtuals=resolve_virtuals - ) - ] - if not candidates or not any( - x._satisfies(rhs_edge.spec, resolve_virtuals=resolve_virtuals) - for x in candidates - ): - return False + # For old specs, consider compiler dependencies from annotations + if current_node.original_spec_format() < 5: + compiler_spec = current_node.annotations.compiler_node_attribute + if compiler_spec is not None: + candidates.append(compiler_spec) + + if not candidates or not any( + x._satisfies(rhs_edge.spec, resolve_virtuals=resolve_virtuals) + for x in candidates + ): + return False continue diff --git a/lib/spack/spack/test/spec_yaml.py b/lib/spack/spack/test/spec_yaml.py index ade35eca9bea72..ac2015876b33ec 100644 --- a/lib/spack/spack/test/spec_yaml.py +++ b/lib/spack/spack/test/spec_yaml.py @@ -440,9 +440,10 @@ def test_load_json_specfiles(specfile, expected_hash, reader_cls): assert s2.format("{compiler.name}") == "gcc" assert s2.format("{compiler.version}") != "none" - # Ensure satisfies still works with compilers + # Ensure satisfies works with compilers and direct dependencies assert s2.satisfies("%gcc") assert s2.satisfies("%gcc@9.4.0") + assert s2.satisfies("%zlib") def test_anchorify_1(): From c60ff6628e7d61bab14737dfc57386276d5019d6 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 12 Feb 2026 16:04:41 +0100 Subject: [PATCH 059/337] SpecBuildInterface: inherit from Spec (#51935) Ensure that SpecBuildInterface inherits from Spec, which fixes lots of type issues. Signed-off-by: Harmen Stoppels --- lib/spack/spack/spec.py | 82 ++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 029ff36a3dff20..3a6cf2e7189f9e 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -1309,47 +1309,6 @@ def __set__(self, instance, value): QueryState = collections.namedtuple("QueryState", ["name", "extra_parameters", "isvirtual"]) -class SpecBuildInterface(lang.ObjectWrapper): - # home is available in the base Package so no default is needed - home = ForwardQueryToPackage("home", default_handler=None) - headers = ForwardQueryToPackage("headers", default_handler=_headers_default_handler) - libs = ForwardQueryToPackage("libs", default_handler=_libs_default_handler) - command = ForwardQueryToPackage("command", default_handler=None, _indirect=True) - - def __init__( - self, - spec: "Spec", - name: str, - query_parameters: List[str], - _parent: "Spec", - is_virtual: bool, - ): - super().__init__(spec) - # Adding new attributes goes after super() call since the ObjectWrapper - # resets __dict__ to behave like the passed object - original_spec = getattr(spec, "wrapped_obj", spec) - self.wrapped_obj = original_spec - self.token = original_spec, name, query_parameters, _parent, is_virtual - self.last_query = QueryState( - name=name, extra_parameters=query_parameters, isvirtual=is_virtual - ) - - # TODO: this ad-hoc logic makes `spec["python"].command` return - # `spec["python-venv"].command` and should be removed when `python` is a virtual. - self.indirect_spec = None - if spec.name == "python": - python_venvs = _parent.dependencies("python-venv") - if not python_venvs: - return - self.indirect_spec = python_venvs[0] - - def __reduce__(self): - return SpecBuildInterface, self.token - - def copy(self, *args, **kwargs): - return self.wrapped_obj.copy(*args, **kwargs) - - def tree( specs: List["Spec"], *, @@ -5295,6 +5254,47 @@ def partition_keys(self) -> Tuple[List[str], List[str]]: return bool_keys, kv_keys +class SpecBuildInterface(lang.ObjectWrapper, Spec): + # home is available in the base Package so no default is needed + home = ForwardQueryToPackage("home", default_handler=None) + headers = ForwardQueryToPackage("headers", default_handler=_headers_default_handler) + libs = ForwardQueryToPackage("libs", default_handler=_libs_default_handler) + command = ForwardQueryToPackage("command", default_handler=None, _indirect=True) + + def __init__( + self, + spec: "Spec", + name: str, + query_parameters: List[str], + _parent: "Spec", + is_virtual: bool, + ): + lang.ObjectWrapper.__init__(self, spec) + # Adding new attributes goes after ObjectWrapper.__init__ call since the ObjectWrapper + # resets __dict__ to behave like the passed object + original_spec = getattr(spec, "wrapped_obj", spec) + self.wrapped_obj = original_spec + self.token = original_spec, name, query_parameters, _parent, is_virtual + self.last_query = QueryState( + name=name, extra_parameters=query_parameters, isvirtual=is_virtual + ) + + # TODO: this ad-hoc logic makes `spec["python"].command` return + # `spec["python-venv"].command` and should be removed when `python` is a virtual. + self.indirect_spec = None + if spec.name == "python": + python_venvs = _parent.dependencies("python-venv") + if not python_venvs: + return + self.indirect_spec = python_venvs[0] + + def __reduce__(self): + return SpecBuildInterface, self.token + + def copy(self, *args, **kwargs): + return self.wrapped_obj.copy(*args, **kwargs) + + def substitute_abstract_variants(spec: Spec): """Uses the information in ``spec.package`` to turn any variant that needs it into a SingleValuedVariant or BoolValuedVariant. From 6c79f6bc2ba3f9f2fb92875a29f86347d1cc989b Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 12 Feb 2026 17:28:38 +0100 Subject: [PATCH 060/337] solver: don't give a penalty for compiler reuse on compilers (#51744) In the solver we give a penalty if "reuse" introduces a node compiled with a compiler that is not used otherwise. This is to avoid that extraneous compilers are introduced in the DAG when reusing binaries. Here we make an exception if such a node is a compiler itself. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 30738696f30dd5..b36dca1abc45cc 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -652,10 +652,12 @@ attr("concrete_variant_set", node(X, A1), Variant, Value, ID) attr("concrete_build_dependency", ParentNode, BuildDependency, BuildDependencyHash), not attr("virtual_on_build_edge", ParentNode, BuildDependency, Virtual). -% Give a penalty if reuse introduces a node compiled with a compiler that is not used otherwise +% Give a penalty if reuse introduces a node compiled with a compiler that is not used otherwise. +% The only exception is if the current node is a compiler itself. compiler_from_reuse(Hash, DependencyPackage) :- attr("concrete_build_dependency", ParentNode, DependencyPackage, Hash), attr("virtual_on_build_edge", ParentNode, DependencyPackage, Virtual), + not node_compiler(_, ParentNode), language(Virtual). compiler_penalty_from_reuse(Hash) :- From 5300a10861884267eb344edb41ca9f264a720668 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:05:51 -0800 Subject: [PATCH 061/337] Add a test to ensure `get_section` returns None for non-existing directory scope (#51897) Signed-off-by: tldahlgren --- lib/spack/spack/test/config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index 68fd5d9fb20e9c..2a70f12ae5456b 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -1960,3 +1960,10 @@ def test_missing_include_scope_write_file(mock_missing_file_include_scopes): assert os.path.exists(spack.config.CONFIG.scopes["sub_base"].path) install_root = spack.config.CONFIG.get("config:install_tree:root", scope="sub_base") assert install_root == "$spack/tmp/spack" + + +def test_config_scope_empty_write(tmp_path: pathlib.Path): + """Confirm skipping attempt to write non-existent scope section.""" + config_scope = spack.config.DirectoryConfigScope("test", str(tmp_path)) + + assert config_scope.get_section("include") is None From e47b3b9ad6da302de110c282347d03a996967e71 Mon Sep 17 00:00:00 2001 From: Alec Scott Date: Fri, 13 Feb 2026 00:45:53 -0800 Subject: [PATCH 062/337] .gitattribures: enforce normalized line-endings for *.py (#51929) Signed-off-by: Alec Scott Co-authored-by: Harmen Stoppels --- .gitattributes | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 66bb25bd092d65..1cf55221f145b4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ +*.bat text eol=crlf *.py diff=python +*.py text eol=lf lib/spack/spack/vendor/* linguist-vendored -*.bat text eol=crlf From 291644eb783a403d824aedc8970467b00002791c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 13 Feb 2026 10:49:07 +0100 Subject: [PATCH 063/337] directives_meta.py: fix type erasure (#51938) Ensure that directives can be type checked by using ParamSpec and TypeVar for the wrapped function, otherwise there's type erasure of generic `*args` and `**kwargs` Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives_meta.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/directives_meta.py b/lib/spack/spack/directives_meta.py index 58eeb6d1dc1d47..fb5ed49a726e7f 100644 --- a/lib/spack/spack/directives_meta.py +++ b/lib/spack/spack/directives_meta.py @@ -4,13 +4,18 @@ import collections import functools -from typing import Any, Callable, Dict, List, Set, Tuple, Type, Union +from typing import Any, Callable, Dict, List, Set, Tuple, Type, TypeVar, Union + +from spack.vendor.typing_extensions import ParamSpec import spack.error import spack.repo import spack.spec from spack.llnl.util.lang import dedupe +P = ParamSpec("P") +R = TypeVar("R") + #: Names of possible directives. This list is mostly populated using the @directive decorator. #: Some directives leverage others and in that case are not automatically added. directive_names = ["build_system"] @@ -251,7 +256,7 @@ class Foo(Package): self.can_patch_dependencies = can_patch_dependencies self.dicts = tuple(dicts) - def __call__(self, decorated_function: Callable) -> Callable: + def __call__(self, decorated_function: Callable[P, R]) -> Callable[P, R]: directive_names.append(decorated_function.__name__) DirectiveMeta.register_directive(decorated_function.__name__, self.dicts) From c41b4f4bf72d2adc87d526fa76c912d5054c0de0 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Feb 2026 10:11:26 +0100 Subject: [PATCH 064/337] package_base.py: PackageBase.versions typehint (#51941) * Make `PackageBase.version` of type `dict[StandardVersion, ...]`. * Use `StandardVersion.from_string` for parsing in `version("...", ...)` * Do not parse possible `GitVersion` in the `version("...", ...)` string. I think allowing `GitVersion` to be parsed in the `version` directive was an oversight and really a bug. I think that because (a) it's not tested, (b) it's not used in spack-packages, (c) all of PackageBase has typehints except `.versions`, and (d) historically: * `Version` was a class, *not* a function call producing a separate `StandardVersion` or `GitVersion` instance. * The original `class Version` was extended to support git versions, meaning there was a single type for both sorts of versions, so we couldn't distinguish the two on the type level. * At that point the `version` directive was considered not to produce git versions by convention (since there were no tests added), that was an oversight. * Only when `GitVersion` was made a separate type, it was realized that the `version` directive could parse a `GitVersion` df44045fdb68e429bd1accdb7e7c9b947521d75c and some minimal validation was added, which I think was just "defensive coding", but again, the use case was not tested. If GitVersion parsing is allowed, you get two sources of truth, namely the version string and the kwargs: ```python version("git.foo=1.2.3", branch="bar") ``` So, I would argue that we should have `StandardVersion` and not `Union[StandardVersion, GitVersion]` as the type here, and I don't think this is a breaking change, but a bug fix. With that, it's much easier to add typing to `fetch_strategy.py`, since we don't have to `assert isinstance(v, StandardVersion)` etc. --- Signed-off-by: Harmen Stoppels --- lib/spack/spack/directives.py | 18 ++---------------- lib/spack/spack/package_base.py | 2 +- lib/spack/spack/solver/asp.py | 12 ++++++------ 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index 6057d3c198c223..d35752b14970e6 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -60,13 +60,7 @@ def _execute_example_directive(pkg, arg1, arg2): from spack.directives_meta import DirectiveError, directive, get_spec from spack.resource import Resource from spack.spec import EMPTY_SPEC -from spack.version import ( - GitVersion, - Version, - VersionChecksumError, - VersionError, - VersionLookupError, -) +from spack.version import StandardVersion, VersionChecksumError, VersionError __all__ = [ "DirectiveError", @@ -261,15 +255,7 @@ def _execute_version(pkg: PackageType, ver: Union[str, int], kwargs: dict): f"{pkg.name}: declared version '{ver!r}' in package should be a string or int." ) - # Declared versions are concrete - version = Version(ver) - - if isinstance(version, GitVersion) and not hasattr(pkg, "git") and "git" not in kwargs: - args = ", ".join(f"{argname}='{value}'" for argname, value in kwargs.items()) - raise VersionLookupError( - f"{pkg.name}: spack version directives cannot include git hashes fetched from URLs.\n" - f" version('{ver}', {args})" - ) + version = StandardVersion.from_string(str(ver)) # Store kwargs for the package to later with a fetch_strategy. pkg.versions[version] = kwargs diff --git a/lib/spack/spack/package_base.py b/lib/spack/spack/package_base.py index a53aa39aff3f06..b1a935cd49010d 100644 --- a/lib/spack/spack/package_base.py +++ b/lib/spack/spack/package_base.py @@ -547,7 +547,7 @@ class PackageBase(WindowsRPath, PackageViewMixin, metaclass=PackageMeta): compiler = DeprecatedCompiler() #: Class level dictionary populated by :func:`~spack.directives.version` directives - versions: dict + versions: Dict[StandardVersion, Dict[str, Any]] #: Class level dictionary populated by :func:`~spack.directives.resource` directives resources: Dict[spack.spec.Spec, List[Resource]] #: Class level dictionary populated by :func:`~spack.directives.depends_on` and diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index bacf4eb6448da2..3293aaaa45cfdd 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -2479,17 +2479,17 @@ def define_package_versions_and_validate_preferences( from_packages_yaml: List[GitOrStandardVersion] = [] for vstr in packages_yaml[pkg_name]["version"]: - v = vn.ver(vstr) + cfg_ver = vn.ver(vstr) - if isinstance(v, vn.GitVersion): - if not require_checksum or v.is_commit: - from_packages_yaml.append(v) + if isinstance(cfg_ver, vn.GitVersion): + if not require_checksum or cfg_ver.is_commit: + from_packages_yaml.append(cfg_ver) else: - matches = [x for x in self.possible_versions[pkg_name] if x.satisfies(v)] + matches = [x for x in self.possible_versions[pkg_name] if x.satisfies(cfg_ver)] matches.sort(reverse=True) if not matches: raise spack.error.ConfigError( - f"Preference for version {v} does not match any known " + f"Preference for version {cfg_ver} does not match any known " f"version of {pkg_name}" ) from_packages_yaml.extend(matches) From d776b1d24e59a5192befa28a697a3addbab5767a Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Feb 2026 10:41:47 +0100 Subject: [PATCH 065/337] solver: improve version constraint error messages (#51926) Before: ``` Cannot satisfy 'mbedtls@3:' 4(2.28.9) ``` After: ``` Cannot satisfy 'mbedtls@3:' (version 2.28.9 does not match) ``` Signed-off-by: Harmen Stoppels --- lib/spack/spack/solver/error_messages.lp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/solver/error_messages.lp b/lib/spack/spack/solver/error_messages.lp index 669c2d8961704d..a79fd643ca804a 100644 --- a/lib/spack/spack/solver/error_messages.lp +++ b/lib/spack/spack/solver/error_messages.lp @@ -88,7 +88,7 @@ condition_cause(Condition2, ID2, Condition1, ID1) :- % More specific error message if the version cannot satisfy some constraint % Otherwise covered by `no_version_error` and `versions_conflict_error`. -error(10000, "Cannot satisfy '{0}@{1}' 3({2})", Package, Constraint, Version, startcauses, ConstraintCause, CauseID) +error(10000, "Cannot satisfy '{0}@{1}' (selected version {2} does not match)", Package, Constraint, Version, startcauses, ConstraintCause, CauseID) :- attr("node_version_satisfies", node(ID, Package), Constraint), pkg_fact(TriggerPkg, condition_effect(ConstraintCause, EffectID)), imposed_constraint(EffectID, "node_version_satisfies", Package, Constraint), @@ -97,7 +97,7 @@ error(10000, "Cannot satisfy '{0}@{1}' 3({2})", Package, Constraint, Version, st not pkg_fact(Package, version_satisfies(Constraint, Version)), choose_version(node(ID, Package), Version). -error(100, "Cannot satisfy '{0}@{1}' 4({2})", Package, Constraint, Version, startcauses, ConstraintCause, CauseID) +error(100, "Cannot satisfy '{0}@{1}' (version {2} does not match)", Package, Constraint, Version, startcauses, ConstraintCause, CauseID) :- attr("node_version_satisfies", node(ID, Package), Constraint), pkg_fact(TriggerPkg, condition_effect(ConstraintCause, EffectID)), imposed_constraint(EffectID, "node_version_satisfies", Package, Constraint), From a429ffcb690fc2098657e6ed14e12ad6af48c928 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Feb 2026 15:13:09 +0100 Subject: [PATCH 066/337] =?UTF-8?q?Revert=20"CI:=20Add=20an=20environment?= =?UTF-8?q?=20option=20for=20build=20cache=20index=20update=20behavior=20(?= =?UTF-8?q?=E2=80=A6"=20(#51943)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit ef116cbda6776b0db7b850364b223d8dba7fbc5c. Signed-off-by: Harmen Stoppels --- lib/spack/docs/pipelines.rst | 12 ------ lib/spack/spack/ci/__init__.py | 2 +- lib/spack/spack/ci/common.py | 42 ++++++------------ lib/spack/spack/ci/gitlab.py | 26 +++-------- lib/spack/spack/test/binary_distribution.py | 48 --------------------- lib/spack/spack/test/cmd/ci.py | 23 +++++----- lib/spack/spack/url_buildcache.py | 36 ++-------------- 7 files changed, 34 insertions(+), 155 deletions(-) diff --git a/lib/spack/docs/pipelines.rst b/lib/spack/docs/pipelines.rst index 059fc339800ae8..91ee717926010a 100644 --- a/lib/spack/docs/pipelines.rst +++ b/lib/spack/docs/pipelines.rst @@ -704,15 +704,3 @@ Optional. Only needed if you want ``spack ci rebuild`` to trust the key you store in this variable, in which case, it will subsequently be used to sign and verify binary packages (when installing or creating build caches). You could also have already trusted a key Spack knows about, or if no key is present anywhere, Spack will install specs using ``--no-check-signature`` and create build caches using ``-u`` (for unsigned binaries). -``SPACK_CI_BUILDCACHE_VIEW`` -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Optional. -Only needed when using a ``buildcache-destination`` mirror that points at a build cache view. -This option affects the behavior the ``reindex`` job (:ref:`rebuild_index`) can have the values ``force`` or ``append`` which mirror behavior described by ref:`cmd-spack-buildcache-update-view`. -The default option is ``append`` because that is what is used by the Spack build farm. - -.. warning:: - - Using the ``append`` option with build cache index views is a non-atomic operation. - It is up to the CI maintainer to ensure that concurrent writes to the build cache are handled appropriately. diff --git a/lib/spack/spack/ci/__init__.py b/lib/spack/spack/ci/__init__.py index a77df6ef0f77ce..8906e9aa6d966c 100644 --- a/lib/spack/spack/ci/__init__.py +++ b/lib/spack/spack/ci/__init__.py @@ -255,7 +255,7 @@ def rebuild_filter(s: spack.spec.Spec) -> RebuildDecision: if not spec_locations: return RebuildDecision(True, "not found anywhere") - urls = ",".join(f"{loc:_url@v_version? (view: _view)}" for loc in spec_locations) + urls = ",".join(f"{loc.url}@v{loc.version}" for loc in spec_locations) message = f"up-to-date [{urls}]" return RebuildDecision(False, message) diff --git a/lib/spack/spack/ci/common.py b/lib/spack/spack/ci/common.py index 081468570959fd..26b75f59b47988 100644 --- a/lib/spack/spack/ci/common.py +++ b/lib/spack/spack/ci/common.py @@ -570,12 +570,7 @@ def init_pipeline_jobs(self, pipeline: PipelineDag): # Generate IR from the configs def generate_ir(self): - """Generate the IR from the Spack CI configurations. - - Generate makes use of special strings that need to be expanded by python format. - - env_dir: The concrete environment directory used in downstream jobs - """ + """Generate the IR from the Spack CI configurations.""" jobs = self.ir["jobs"] @@ -583,39 +578,28 @@ def generate_ir(self): defaults = [ { "build-job": { - "script": ["spack env activate --without-view {env_dir}", "spack ci rebuild"] + "script": [ + "cd {env_dir}", + "spack env activate --without-view .", + "spack ci rebuild", + ] } }, {"noop-job": {"script": ['echo "All specs already up to date, nothing to rebuild."']}}, ] - pipeline_mirrors = spack.mirrors.mirror.MirrorCollection(binary=True) - buildcache_destination = pipeline_mirrors["buildcache-destination"] - update_index_extra_args = [] - if buildcache_destination.push_view: - update_index_extra_args.extend(["--name", buildcache_destination.push_view]) - option = os.environ.get("SPACK_CI_BUILDCACHE_VIEW", "append") - if option == "append": - # Running this in CI relies on a guarentee from the calling context that there is - # only a single writter or the build cache view doesn't require a complete view - # after each append. - tty.warn("Using --append to update buildcache-destination mirror index view") - update_index_extra_args.extend(["-y", "--append"]) - elif option == "force": - update_index_extra_args.append("--force") - else: - raise SpackCIError(f"Unrecognized value: SPACK_CI_BUILDCACHE_VIEW={option}") - # Job overrides overrides = [ # Reindex script { "reindex-job": { - "script:": [ - "spack env activate --without-view {env_dir}", - "spack buildcache update-index --keys " - + f"{' '.join(update_index_extra_args)} buildcache-destination", - ] + "script:": ["spack buildcache update-index --keys {index_target_mirror}"] + } + }, + # Cleanup script + { + "cleanup-job": { + "script:": ["spack -d mirror destroy {mirror_prefix}/$CI_PIPELINE_ID"] } }, # Add signing job tags diff --git a/lib/spack/spack/ci/gitlab.py b/lib/spack/spack/ci/gitlab.py index 3dc848c499c512..161608ca8a0448 100644 --- a/lib/spack/spack/ci/gitlab.py +++ b/lib/spack/spack/ci/gitlab.py @@ -378,35 +378,21 @@ def main_script_replacements(cmd): output_object["sign-pkgs"] = signing_job if options.rebuild_index: - # Create a dummy job that runs as the stage before reindex. - # This job will be used to ensure reindex doesn't run until - # the other build jobs complete. - stage_names.append("stage-wait") - wait_job = spack_ci_ir["jobs"]["noop"]["attributes"] - wait_job["stage"] = "stage-wait" - wait_job["retry"] = 0 - wait_job["when"] = "always" - wait_job["script"] = ["echo 'Open the pod bay doors HAL'"] - wait_job["dependencies"] = [] - - output_object["wait-for-build-jobs"] = wait_job - # Add a final job to regenerate the index stage_names.append("stage-rebuild-index") final_job = spack_ci_ir["jobs"]["reindex"]["attributes"] final_job["stage"] = "stage-rebuild-index" - final_job["script"] = unpack_script(final_job["script"], op=main_script_replacements) + target_mirror = options.buildcache_destination.push_url + final_job["script"] = unpack_script( + final_job["script"], + op=lambda cmd: cmd.replace("{index_target_mirror}", target_mirror), + ) final_job["when"] = "always" final_job["retry"] = service_job_retries final_job["interruptible"] = True - # update-index needs to download generate artifacts - # it also needs to wait until all of the other stages complete. - final_job["needs"] = [ - {"job": generate_job_name, "pipeline": f"{generate_pipeline_id}"}, - "wait-for-build-jobs", - ] + final_job["dependencies"] = [] output_object["rebuild-index"] = final_job diff --git a/lib/spack/spack/test/binary_distribution.py b/lib/spack/spack/test/binary_distribution.py index deed87a2c9ef54..d677a53c35bf08 100644 --- a/lib/spack/spack/test/binary_distribution.py +++ b/lib/spack/spack/test/binary_distribution.py @@ -1449,54 +1449,6 @@ def test_mirror_metadata(): spack.binary_distribution.MirrorMetadata.from_string("https://dummy.io/__v3@@4") -def mirror_metadata_check_format(data, fmt, result): - assert fmt.format(data) == result.format(data) - - -def test_mirror_metadata_format(): - mirror_metadata = spack.binary_distribution.MirrorMetadata("https://dummy.io/__v3", 3) - - # Check pass-through formatting - mirror_metadata_check_format(mirror_metadata, "{0:_url}", "{0.url}") - mirror_metadata_check_format(mirror_metadata, "{0:_version}", "{0.version}") - mirror_metadata_check_format(mirror_metadata, "{0:_view}", "{0.view}") - - # Empty view - mirror_metadata_check_format(mirror_metadata, "{0:?_view}", "") - mirror_metadata_check_format( - mirror_metadata, "{0:_url?^_view^_version?^_version}", "{0.url}^{0.version}" - ) - mirror_metadata_check_format( - mirror_metadata, - "{0:_url?^_view^_version?^_version?^_view^_version?^_url}", - "{0.url}^{0.version}^{0.url}", - ) - - -def test_mirror_metadata_format_with_view(): - mirror_metadata = spack.binary_distribution.MirrorMetadata( - "https://dummy.io/__v3__@aview", 3, "aview" - ) - - # Check pass-through formatting - mirror_metadata_check_format(mirror_metadata, "{0:_url}", "{0.url}") - mirror_metadata_check_format(mirror_metadata, "{0:_version}", "{0.version}") - mirror_metadata_check_format(mirror_metadata, "{0:_view}", "{0.view}") - - # View exists - mirror_metadata_check_format(mirror_metadata, "{0:?_view}", "{0.view}") - mirror_metadata_check_format( - mirror_metadata, - "{0:_url?^_view^_version?^_version}", - "{0.url}^{0.view}^{0.version}^{0.version}", - ) - mirror_metadata_check_format( - mirror_metadata, - "{0:_url?^_view^_version?^_version?^_view^_version?^_url}", - "{0.url}^{0.view}^{0.version}^{0.version}^{0.view}^{0.version}^{0.url}", - ) - - def test_mirror_metadata_with_view(): mirror_metadata = spack.binary_distribution.MirrorMetadata( "https://dummy.io/__v3__@aview", 3, "aview" diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index 0eeb917b0aa567..5255aa838d72de 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -193,15 +193,14 @@ def test_ci_generate_with_env(ci_generate_test, tmp_path: pathlib.Path, mock_bin assert yaml_contents["workflow"]["rules"] == [{"when": "always"}] assert "stages" in yaml_contents - assert len(yaml_contents["stages"]) == 7 + assert len(yaml_contents["stages"]) == 6 assert yaml_contents["stages"][0] == "stage-0" - assert yaml_contents["stages"][5] == "stage-wait" - assert yaml_contents["stages"][6] == "stage-rebuild-index" + assert yaml_contents["stages"][5] == "stage-rebuild-index" assert "rebuild-index" in yaml_contents rebuild_job = yaml_contents["rebuild-index"] assert ( - rebuild_job["script"][1] == "spack buildcache update-index --keys buildcache-destination" + rebuild_job["script"][0] == f"spack buildcache update-index --keys {mirror_url.as_uri()}" ) assert rebuild_job["custom_attribute"] == "custom!" @@ -333,11 +332,12 @@ def test_ci_generate_with_custom_settings( "git checkout ${SPACK_REF}", "popd", ] - assert ci_obj["script"][1].startswith("spack env activate --without-view ") - ci_obj["script"][1] = "spack env activate --without-view ENV" + assert ci_obj["script"][1].startswith("cd ") + ci_obj["script"][1] = "cd ENV" assert ci_obj["script"] == [ "spack -d ci rebuild", - "spack env activate --without-view ENV", + "cd ENV", + "spack env activate --without-view .", "spack ci rebuild", ] assert ci_obj["after_script"] == ["rm -rf /some/path/spack"] @@ -1674,8 +1674,7 @@ def test_ci_generate_mirror_config( with open(tmp_path / ".gitlab-ci.yml", encoding="utf-8") as f: pipeline_doc = syaml.load(f) assert fst not in pipeline_doc["rebuild-index"]["script"][0] - assert "env activate" in pipeline_doc["rebuild-index"]["script"][0] - assert "buildcache-destination" in pipeline_doc["rebuild-index"]["script"][1] + assert snd in pipeline_doc["rebuild-index"]["script"][0] def dynamic_mapping_setup(tmp_path: pathlib.Path): @@ -1856,13 +1855,11 @@ def test_ci_generate_copy_only( # Make sure there are only two jobs and two stages stages = pipeline_doc["stages"] copy_stage = "copy" - wait_stage = "stage-wait" rebuild_index_stage = "stage-rebuild-index" - assert len(stages) == 3 + assert len(stages) == 2 assert stages[0] == copy_stage - assert stages[1] == wait_stage - assert stages[2] == rebuild_index_stage + assert stages[1] == rebuild_index_stage rebuild_index_job = pipeline_doc["rebuild-index"] assert rebuild_index_job["stage"] == rebuild_index_stage diff --git a/lib/spack/spack/url_buildcache.py b/lib/spack/spack/url_buildcache.py index 12e940d77aeb3e..24a3c0cca61822 100644 --- a/lib/spack/spack/url_buildcache.py +++ b/lib/spack/spack/url_buildcache.py @@ -1365,7 +1365,10 @@ def __init__(self, url: str, version: int, view: Optional[str] = None): self.view = view def __str__(self): - return f"{self:_url__v_version?___view}" + s = f"{self.url}__v{self.version}" + if self.view: + s += f"__{self.view}" + return s def __eq__(self, other): if not isinstance(other, MirrorMetadata): @@ -1375,37 +1378,6 @@ def __eq__(self, other): def __hash__(self): return hash((self.url, self.version, self.view)) - def __format__(self, format_spec): - """Format the mirror metadata - - Format Spec: - _url: metadata.url - _version: metadata.version - _view: metadata.view - ?: delimiter to wrap conditional printing based on optional view - - Example - - f"{meta_data:_url?^_view?@v_version}" - - Expansion without a view: - https://my-mirror.com/prefix@v3 - - Expansion with a view: - https://my-mirror.com/prefix^my-view@v3 - """ - if not format_spec: - format_spec = "_url@v3?-_view" - return - out = format_spec.replace("_url", self.url) - out = out.replace("_version", str(self.version)) - out = out.replace("_view", str(self.view)) - parts = out.split("?") - if self.view: - return "".join(parts) - else: - return "".join(parts[0::2]) - @classmethod def from_string(cls, s: str): m = re.match(r"^(.*)__v([0-9]+)(?:__(.*))?$", s) From 7fa9b4bb4016657fbff26ab004e7d9a5cc1e8091 Mon Sep 17 00:00:00 2001 From: Phil Sakievich Date: Mon, 16 Feb 2026 13:25:53 -0500 Subject: [PATCH 067/337] git fetch: fix get_full_repo (#51813) * git fetch: fix get_full_repo Update command so the get_full_repo property is properly detected and skip code paths that don't support the feature when it is enabled. Signed-off-by: psakievich * Add tests Signed-off-by: psakievich * Delineate clone artifact permutations for unit-test Signed-off-by: psakievich * Fix failing tests - Makes package creation from version accept git attribute on pkg or version instead of just pkg - Specify branch for orphaned commit that was not accessible directly from the main branch for the mock_git_package fixture - Configure `init_fetch` logic to configure for pulling all branches and tags from the remote Signed-off-by: psakievich * Update expected error Signed-off-by: psakievich --------- Signed-off-by: psakievich --- lib/spack/spack/fetch_strategy.py | 17 +++++++++--- lib/spack/spack/test/cmd/spec.py | 2 +- lib/spack/spack/test/conftest.py | 4 ++- lib/spack/spack/test/git_fetch.py | 44 +++++++++++++++++++++++++++---- lib/spack/spack/util/git.py | 6 +++-- 5 files changed, 60 insertions(+), 13 deletions(-) diff --git a/lib/spack/spack/fetch_strategy.py b/lib/spack/spack/fetch_strategy.py index 1d2d5b4d1b2297..2a592063feb4fe 100644 --- a/lib/spack/spack/fetch_strategy.py +++ b/lib/spack/spack/fetch_strategy.py @@ -961,12 +961,19 @@ def _clone_src(self) -> None: kwargs = {"debug": spack.config.get("config:debug"), "git_exe": self.git, "dest": name} + # TODO(psakievich) The use of the minimal clone need clearer justification via package API + # or something. There is a trade space of storage minimization vs available git information + # that grows to non-trivial proportions for larger projects + minimal_clone = self.commit and name and not self.get_full_repo + with temp_cwd(ignore_cleanup_errors=True): - if self.commit and name: + if minimal_clone: try: spack.util.git.git_init_fetch(self.url, self.commit, depth, **kwargs) except spack.util.executable.ProcessError: - spack.util.git.git_clone(self.url, fetch_ref, True, depth, **kwargs) + spack.util.git.git_clone( + self.url, fetch_ref, self.get_full_repo, depth, **kwargs + ) else: spack.util.git.git_clone(self.url, fetch_ref, self.get_full_repo, depth, **kwargs) repo_name = get_single_file(".") @@ -1598,7 +1605,8 @@ def _for_package_version(pkg, version=None): commit = commit_var.value if commit_var else None tag = None if isinstance(version, spack.version.GitVersion) or commit: - if not hasattr(pkg, "git"): + git_url = pkg.version_or_package_attr("git", version) + if not git_url: raise spack.error.FetchError( f"Cannot fetch git version for {pkg.name}. Package has no 'git' attribute" ) @@ -1630,9 +1638,10 @@ def _for_package_version(pkg, version=None): tag = version_meta_data.get("tag") or version_meta_data.get("branch") kwargs = {"commit": commit, "tag": tag, "no_cache": bool(not commit)} - kwargs["git"] = pkg.version_or_package_attr("git", version) + kwargs["git"] = git_url kwargs["submodules"] = pkg.version_or_package_attr("submodules", version, False) kwargs["git_sparse_paths"] = pkg.version_or_package_attr("git_sparse_paths", version, None) + kwargs["get_full_repo"] = pkg.version_or_package_attr("get_full_repo", version, False) # if the ref_version is a known version from the package, use that version's # attributes diff --git a/lib/spack/spack/test/cmd/spec.py b/lib/spack/spack/test/cmd/spec.py index 56446340a7704d..28a7d64b5671a8 100644 --- a/lib/spack/spack/test/cmd/spec.py +++ b/lib/spack/spack/test/cmd/spec.py @@ -171,7 +171,7 @@ def test_env_aware_spec(mutable_mock_env_path): [ ("develop-branch-version", "f3c7206350ac8ee364af687deaae5c574dcfca2c=develop", None), ("develop-branch-version", "git." + "a" * 40 + "=develop", None), - ("callpath", "f3c7206350ac8ee364af687deaae5c574dcfca2c=1.0", spack.error.FetchError), + ("callpath", "f3c7206350ac8ee364af687deaae5c574dcfca2c=1.0", spack.error.PackageError), ("develop-branch-version", "git.foo=0.2.15", None), ], ) diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index 72533dd21a25c9..f3578695746e31 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -1756,7 +1756,9 @@ def mock_git_repository(git, tmp_path_factory: pytest.TempPathFactory): revision=tag_branch, file=tag_file, args={"git": url, "branch": tag_branch} ), "tag": Bunch(revision=tag, file=tag_file, args={"git": url, "tag": tag}), - "commit": Bunch(revision=r1, file=r1_file, args={"git": url, "commit": r1}), + "commit": Bunch( + revision=r1, file=r1_file, args={"git": url, "branch": branch, "commit": r1} + ), "annotated-tag": Bunch(revision=a_tag, file=r2_file, args={"git": url, "tag": a_tag}), # In this case, the version() args do not include a 'git' key: # this is the norm for packages, so this tests how the fetching logic diff --git a/lib/spack/spack/test/git_fetch.py b/lib/spack/spack/test/git_fetch.py index 717bb0a1ead6e8..4f5270ced30bc2 100644 --- a/lib/spack/spack/test/git_fetch.py +++ b/lib/spack/spack/test/git_fetch.py @@ -27,6 +27,7 @@ _mock_transport_error = "Mock HTTP transport error" min_opt_string = ".".join(map(str, spack.util.git.MIN_OPT_VERSION)) +min_direct_commit = ".".join(map(str, spack.util.git.MIN_DIRECT_COMMIT_FETCH)) @pytest.fixture(params=[None, "1.8.5.2", "1.8.5.1", "1.7.10", "1.7.1", "1.7.0"]) @@ -111,6 +112,9 @@ def test_fetch( s = default_mock_concretization("git-test") monkeypatch.setitem(s.package.versions, Version("git"), t.args) + if type_of_test == "commit": + s.variants["commit"] = SingleValuedVariant("commit", t.args["commit"]) + # Enter the stage directory and check some properties with s.package.stage: with spack.config.override("config:verify_ssl", secure): @@ -241,8 +245,10 @@ def test_needs_stage(git): @pytest.mark.parametrize("get_full_repo", [True, False]) +@pytest.mark.parametrize("use_commit", [True, False]) def test_get_full_repo( get_full_repo, + use_commit, git_version, mock_git_repository, default_mock_concretization, @@ -254,16 +260,28 @@ def test_get_full_repo( if git_version < Version(min_opt_string): pytest.skip("Not testing get_full_repo for older git {0}".format(git_version)) + # newer git allows for direct commit fetching + can_use_direct_commit = git_version >= Version(min_direct_commit) + secure = True type_of_test = "tag-branch" t = mock_git_repository.checks[type_of_test] - s = default_mock_concretization("git-test") + spec_string = "git-test" + + s = default_mock_concretization(spec_string) + args = copy.copy(t.args) args["get_full_repo"] = get_full_repo monkeypatch.setitem(s.package.versions, Version("git"), args) + if use_commit: + git_exe = mock_git_repository.git_exe + url = mock_git_repository.url + commit = git_exe("ls-remote", url, t.revision, output=str).strip().split()[0] + s.variants["commit"] = SingleValuedVariant("commit", commit) + with s.package.stage: with spack.config.override("config:verify_ssl", secure): s.package.do_stage() @@ -280,11 +298,27 @@ def test_get_full_repo( ncommits = len(commits) if get_full_repo: - assert nbranches >= 5 - assert ncommits == 2 + # default branch commit, plus checkout commit + assert ncommits == 2, commits + assert nbranches >= 5, branches else: - assert nbranches == 2 - assert ncommits == 1 + assert ncommits == 1, commits + if can_use_direct_commit: + if use_commit: + # only commit (detached state) + assert nbranches == 1, branches + else: + # tag, commit (detached state) + assert nbranches == 2, branches + else: + if use_commit: + # default branch, tag, commit (detached state) + # git does not have a rewind, avoid messing with git history by + # accepting detachment + assert nbranches == 3, branches + else: + # default branch plus tag + assert nbranches == 2, branches @pytest.mark.disable_clean_stage_check diff --git a/lib/spack/spack/util/git.py b/lib/spack/spack/util/git.py index ca6a07d1912941..4cbddc340d89ac 100644 --- a/lib/spack/spack/util/git.py +++ b/lib/spack/spack/util/git.py @@ -78,6 +78,7 @@ def __call__(self, exe_version, value=None) -> List: # git@1.8.5 is when branch could also accept tag so we don't have to track ref types as closely # This also corresponds to system git on RHEL7 MIN_OPT_VERSION = (1, 8, 5, 2) +MIN_DIRECT_COMMIT_FETCH = (2, 5, 0) # Technically the flags existed earlier but we are pruning our logic to 1.8.5 or greater BRANCH = VersionConditionalOption("--branch", min_version=MIN_OPT_VERSION) @@ -308,10 +309,11 @@ def git_init_fetch(url, ref, depth=None, debug=False, dest=None, git_exe=None): # minimum criteria for fetching a single commit, but also requires server to be configured # fall-back to a process error so an old git version or a fetch failure from an nonsupporting # server can be caught the same way. - if ref and is_git_commit_sha(ref) and version < (2, 5, 0): + if ref and is_git_commit_sha(ref) and version < MIN_DIRECT_COMMIT_FETCH: raise exe.ProcessError("Git older than 2.5 detected, can't fetch commit directly") init = ["init"] remote = ["remote", "add", "origin", url] + config = ["config", "remote.origin.fetch", "+refs/heads/*:origin/refs/*"] fetch = ["fetch"] if not debug: @@ -320,7 +322,7 @@ def git_init_fetch(url, ref, depth=None, debug=False, dest=None, git_exe=None): fetch.extend(DEPTH(version, str(depth))) fetch.extend([*FILTER_BLOB_NONE(version), url, ref]) - cmds = [init, remote, fetch] + cmds = [init, remote, config, fetch] _exec_git_commands_unique_dir(git_exe, cmds, debug, dest) From f458750fe8d747b17a5c9ff1629f0c10cc30f8b1 Mon Sep 17 00:00:00 2001 From: John Gouwar Date: Wed, 18 Feb 2026 01:18:10 -0500 Subject: [PATCH 068/337] new_installer.py: ensure sys.stdin can be epolled (#51954) Signed-off-by: John Gouwar --- lib/spack/spack/new_installer.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index baf92e62dd2a95..f9335b18689fc7 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1303,17 +1303,16 @@ def install(self) -> None: def _installer(self) -> None: jobserver = JobServer(self.jobs) + selector = selectors.DefaultSelector() # Set stdin to non-blocking for key press detection if sys.stdin.isatty(): old_stdin_settings = termios.tcgetattr(sys.stdin) tty.setcbreak(sys.stdin.fileno()) + selector.register(sys.stdin.fileno(), selectors.EVENT_READ, "stdin") else: old_stdin_settings = None - selector = selectors.DefaultSelector() - selector.register(sys.stdin.fileno(), selectors.EVENT_READ, "stdin") - # Setup the database write lock. TODO: clean this up if isinstance(spack.store.STORE.db.lock, spack.util.lock.Lock): spack.store.STORE.db.lock._ensure_parent_directory() From 8ba3c1c49f266fe86ab54c26013bab63281a4c40 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Wed, 18 Feb 2026 23:59:34 -0800 Subject: [PATCH 069/337] tests: fix typos in test_include_overrides (#51965) Signed-off-by: tldahlgren --- lib/spack/spack/test/cmd/config.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/test/cmd/config.py b/lib/spack/spack/test/cmd/config.py index 406d1bbeaf1dce..8fab44786724d9 100644 --- a/lib/spack/spack/test/cmd/config.py +++ b/lib/spack/spack/test/cmd/config.py @@ -111,7 +111,7 @@ def test_include_overrides(mutable_config): mutable_config.push_scope(spack.config.InternalConfigScope("override", {"include:": []})) - # overridden scopes are not shown wtihout `-v` + # overridden scopes are not shown without `-v` output = config("scopes").strip() lines = output.split("\n") assert "user" not in lines @@ -121,7 +121,7 @@ def test_include_overrides(mutable_config): # scopes with ConfigScopePriority.DEFAULTS remain assert "_builtin" in lines - # overridden scopes are shown wtih `-v` and marked 'override' + # overridden scopes are shown with `-v` and marked 'override' output = config("scopes", "-v").strip() lines = output.split("\n") assert "override" in next(line for line in lines if line.startswith("user")) From a369fbb7fb66ca5571d261b2283d84372811efde Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 19 Feb 2026 18:15:38 +0100 Subject: [PATCH 070/337] new_installer.py: handler SIGTERM/SIGINT better (#51967) When a build sub-process receives `SIGTERM`, it exits without running `finally` and `__exit__`. As a result, install prefixes are not cleared, and worse, failed overwrite installs do not restore the original prefix. To fix this, add two ingredients: 1. a `SIGTERM` handler 2. a process group for the build process The `SIGTERM` handler forwards `SIGTERM` to its process group, which should kill child processes (typically `cmake`, `make`, etc). It avoids the signal itself by temporarily ignoring the signal. Afterwards, the default handler is restored and life goes on; no exception is raised. The consequence of this is that the Python process returns to its `waitpid` syscall after handling `SIGTERM`, and this should finish quickly, because the `make`, `./configure` etc executables should exit with error code right away. In the unlikely case they trap `SIGTERM` and continue running, a subsequent `SIGTERM` kills the Python script that's stuck in `waitpid` _without_ cleanup running, and without killing the `./configure` etc process. Thanks to the new process group, the orchestrator process is now the only process receiving `SIGINT` from the terminal. Therefore, it's modified to send `SIGTERM` to the build sub-processes after it gets `SIGINT`, before `.join()`ing build processes. This commit is also a necessary step for `--fail-fast` to actually be fast: if a tiny package fails in seconds, but Spack is concurrently building LLVM, which will take dozens of minutes, it would be good to call `.terminate()` before `.join()`, which requires a proper `SIGTERM` handler. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index f9335b18689fc7..8bbe93db0ab70b 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -27,6 +27,7 @@ import re import selectors import shutil +import signal import sys import tempfile import termios @@ -360,6 +361,22 @@ def worker_function( if spec.external: return + # Start a new session, so our SIGTERM handler can kill all child processes. + os.setsid() + + def handle_sigterm(signum, frame): + # This SIGTERM handler forwards the signal to child processes, and + # then resets the handler to default. It does not raise an exception, + # because the assumption is we're stuck in waitpid, and we want to + # let child processes finish with SIGTERM before we run the cleanup + # code in finally blocks and __exit__ functions and exit. If we exit + # too early, the child process may still write to the prefix or stage. + signal.signal(signal.SIGTERM, signal.SIG_IGN) + os.killpg(0, signal.SIGTERM) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + + signal.signal(signal.SIGTERM, handle_sigterm) + os.environ["MAKEFLAGS"] = makeflags spack.store.STORE = store spack.config.CONFIG = config @@ -1410,6 +1427,8 @@ def _installer(self) -> None: self.build_status.update() except KeyboardInterrupt: # Cleanup running builds. + for child in self.running_builds.values(): + child.proc.terminate() for child in self.running_builds.values(): child.proc.join() raise From ce4dc2c4ad567f400ea45e5bfda3bcf550d581fd Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 19 Feb 2026 18:20:58 +0100 Subject: [PATCH 071/337] Fix an issue when inheriting a package without modifying the builder (#51942) * Fix an issue when inheriting a package without modifying the builder fixes #51917 The issue is fixed by traversing the MRO of the package and getting the first builder found from the package modules being traversed. Signed-off-by: Massimiliano Culpo * Improve documentation on multiple build systems Signed-off-by: Massimiliano Culpo --------- Signed-off-by: Massimiliano Culpo --- lib/spack/docs/packaging_guide_advanced.rst | 151 ++++++++++-------- lib/spack/docs/packaging_guide_creation.rst | 2 + lib/spack/spack/builder.py | 9 +- lib/spack/spack/test/builder.py | 18 +++ .../inheritance_only_package/package.py | 12 ++ 5 files changed, 123 insertions(+), 69 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builder_test/packages/inheritance_only_package/package.py diff --git a/lib/spack/docs/packaging_guide_advanced.rst b/lib/spack/docs/packaging_guide_advanced.rst index 26a2bc322c6222..73d5ca49218d3d 100644 --- a/lib/spack/docs/packaging_guide_advanced.rst +++ b/lib/spack/docs/packaging_guide_advanced.rst @@ -27,99 +27,102 @@ This section of the packaging guide covers a few advanced topics. Multiple build systems ---------------------- -It is not uncommon for a package to use different build systems across different versions or platforms. -For instance, a project might migrate from Autotools to CMake, or use a different build system on Windows than on UNIX. +Packages may use different build systems over time or across platforms. Spack is designed to handle this seamlessly within a single ``package.py`` file. -While Spack uses one package class per recipe, it can manage multiple build systems by associating different *builder* classes with the package. -This design makes supporting multiple build systems straightforward and maintainable. -The following changes are needed to support multiple build systems in a package: +Let's assume we work with ``curl`` and that the package is built using Autotools so far: -1. The package class should derive from *multiple base classes*, such as ``CMakePackage`` and ``AutotoolsPackage``. -2. The ``build_system`` directive is used to declare the available build systems and specify the default one. -3. The :doc:`build instructions ` are specified in *separate builder classes*. +.. code-block:: python + + from spack_repo.builtin.build_systems.autotools import AutotoolsPackage + + + class Curl(AutotoolsPackage): + + depends_on("zlib-api") + + def configure_args(self): + return [f"--with-zlib={self.spec['zlib-api'].prefix}"] + +To add CMake as a further build system we need to: -Here is a simple example of a package that supports both CMake and Autotools: +1. Add another base to the ``Curl`` package class (in our case ``cmake.CMakePackage``), +2. Explicitly declare which build systems are supported using the ``build_system`` directive, +3. Move the :doc:`build instructions ` in *separate builder classes*. .. code-block:: python - from spack.package import * - from spack_repo.builtin.build_systems import cmake, autotools + from spack_repo.builtin.build_systems import autotools, cmake - class Example(cmake.CMakePackage, autotools.AutotoolsPackage): - variant("my_feature", default=True) - build_system("cmake", "autotools", default="cmake") + class Curl(cmake.CMakePackage, autotools.AutotoolsPackage): + build_system("autotools", "cmake", default="cmake") - class CMakeBuilder(cmake.CMakeBuilder): - def cmake_args(self): - return [self.define_from_variant("MY_FEATURE", "my_feature")] + depends_on("zlib-api") class AutotoolsBuilder(autotools.AutotoolsBuilder): def configure_args(self): - return self.with_or_without("my-feature", variant="my_feature") + return [f"--with-zlib={self.spec['zlib-api'].prefix}"] -When defining a package like this, Spack automatically makes the ``build_system`` **variant** available, which can be used to pick the desired build system at install time. -For example -.. code-block:: spec + class CMakeBuilder(cmake.CMakeBuilder): + def cmake_args(self): + return [self.define_from_variant("USE_NGHTTP2", "nghttp2")] - $ spack install example +feature build_system=cmake +In general, with multiple build systems there is a clear split between the :doc:`package metadata ` and the :doc:`build instructions `: -makes Spack pick the ``CMakeBuilder`` class and runs ``cmake -DMY_FEATURE:BOOL=ON``. +1. The directives such as ``depends_on``, ``variant``, ``patch`` go into the package class +2. The build phase functions like ``configure``, ``build`` and ``install``, and helper functions such as ``cmake_args`` or ``configure_args`` go into the builder classes -Similarly +When ``curl`` is concretized, we can select its build system using the ``build_system`` variant, which is available for every package: .. code-block:: spec - $ spack install example +feature build_system=autotools + $ spack install curl build_system=cmake -will pick the ``AutotoolsBuilder`` class and runs ``./configure --with-my-feature``. +Override "phases" of a build system +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -With multiple build systems, we have a clear split between the :doc:`package metadata ` and the :doc:`build instructions `. -The directives such as ``depends_on``, ``variant``, ``patch`` go into the package class, whereas build phase functions like ``configure``, ``build`` and ``install``, and helper functions such as ``cmake_args`` or ``configure_args`` go into the builder classes. +Sometimes package recipes need to override entire :ref:`phases ` of a build system. +Let's assume this happens for ``cp2k``: -.. note:: - - The signature of certain methods changes when moving from a single build system to multiple build systems. +.. code-block:: python - Suppose you add support for CMake in the following Autotools package: + from spack.package import * + from spack_repo.builtin.build_systems import autotools - .. code-block:: python - from spack.package import * - from spack_repo.builtin.build_systems import autotools + class Cp2k(autotools.AutotoolsPackage): + def install(self, spec: Spec, prefix: str) -> None: + # ...existing code... + pass +If we want to add CMake as another build system we need to remember that the signature of phases changes when moving from the ``Package`` to the ``Builder`` class: - class Example(autotools.AutotoolsPackage): - def install(self, spec: Spec, prefix: str) -> None: - # ...existing code... - pass - - Then you should move the install method to the appropriate builder class, and change its signature: +.. code-block:: python - .. code-block:: python + from spack.package import * + from spack_repo.builtin.build_systems import autotools, cmake - from spack.package import * - from spack_repo.builtin.build_systems import autotools, cmake + class Cp2k(autotools.AutotoolsPackage, cmake.CMakePackage): + build_system("autotools", "cmake", default="cmake") - class Example(autotools.AutotoolsPackage, cmake.CMakePackage): - build_system("autotools", "cmake", default="cmake") + class AutotoolsBuilder(autotools.AutotoolsBuilder): + def install(self, pkg: Cp2k, spec: Spec, prefix: str) -> None: + # ...existing code... + pass - class AutotoolsBuilder(autotools.AutotoolsBuilder): - def install(self, pkg: Example, spec: Spec, prefix: str) -> None: - # ...existing code... - pass +The ``install`` method now takes the ``Package`` instance as the first argument, since ``self`` refers to the builder class. - Notice that the install method now takes the package instance as the first argument. - This is because ``self`` refers to the builder class, not the package class. +Add dependencies conditional on a build system +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Build dependencies typically depend on the choice of the build system. -An effective way to handle this is to use a ``with when("build_system=...")`` block to specify dependencies that are only relevant for a specific build system. +Many build dependencies are conditional on which build system is chosen. +An effective way to handle this is to use a ``with when("build_system=...")`` block to specify dependencies that are only relevant for a specific build system: .. code-block:: python @@ -127,7 +130,7 @@ An effective way to handle this is to use a ``with when("build_system=...")`` bl from spack_repo.builtin.build_systems import cmake, autotools - class Example(cmake.CMakePackage, autotools.AutotoolsPackage): + class Cp2k(cmake.CMakePackage, autotools.AutotoolsPackage): build_system("cmake", "autotools", default="cmake") @@ -145,10 +148,10 @@ An effective way to handle this is to use a ``with when("build_system=...")`` bl depends_on("perl", type="build") depends_on("pkgconfig", type="build") -In the previous example, users could pick the desired build system at install time by specifying the ``build_system`` variant. -Much more commonly, packages transition from one build system to another from one version to the next. -That is, a package might use Autotools in version ``0.63`` and CMake in version ``0.64``. -In such cases we have to use the ``build_system`` directive to indicate when which build system can be used: +Transition from one build system to another +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Packages that transition from one build system to another can be modeled using :ref:`conditional variant values `: .. code-block:: python @@ -156,7 +159,7 @@ In such cases we have to use the ``build_system`` directive to indicate when whi from spack_repo.builtin.build_systems import cmake, autotools - class Example(cmake.CMakePackage, autotools.AutotoolsPackage): + class Cp2k(cmake.CMakePackage, autotools.AutotoolsPackage): build_system( conditional("cmake", when="@0.64:"), @@ -166,16 +169,32 @@ In such cases we have to use the ``build_system`` directive to indicate when whi In the example, the directive imposes a change from ``Autotools`` to ``CMake`` going from ``v0.63`` to ``v0.64``. -We have seen how users can run ``spack install example build_system=cmake`` to pick the desired build system. -The same can be done in ``depends_on`` statements, which has certain use cases. -A notable example is when a CMake package *needs* a CMake config file for its dependency, which is only generated when the dependency is built with CMake (and not Autotools). -In that case, you can *force* the choice of the build system of the dependency: +Inherit from a package with multiple build systems +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Customizing a package supporting multiple build systems is straightforward. +If we need to only customize the metadata, we can just define the derived package class. + +For instance, let's assume we want to add a new version to the ``silo`` package: + +.. code-block:: python + + from spack_repo.builtin.packages.silo.package import Silo as BuiltinSilo + + class Silo(BuiltinSilo): + # Version not in builtin.silo + version("special_version") + +If we don't define any builder, Spack will reuse the custom builder from ``builtin.silo`` by default. +If we need to customize the builder too, we just have to inherit from it, like any other Python class: .. code-block:: python - class Dependent(CMakePackage): + from spack_repo.builtin.packages.silo.package import CMakeBuilder as SiloCMakeBuilder - depends_on("example build_system=cmake") + class CMakeBuilder(SiloCMakeBuilder): + def cmake_args(self): + return [self.define_from_variant("USE_NGHTTP2", "nghttp2")] .. _make-package-findable: diff --git a/lib/spack/docs/packaging_guide_creation.rst b/lib/spack/docs/packaging_guide_creation.rst index 4d1b86792a95f5..b2556b85db8299 100644 --- a/lib/spack/docs/packaging_guide_creation.rst +++ b/lib/spack/docs/packaging_guide_creation.rst @@ -1533,6 +1533,8 @@ In this case, examples of valid options are ``process_managers=auto``, ``process Both validator functions return a :py:class:`~spack.variant.DisjointSetsOfValues` object, which defines chaining methods to further customize the behavior of the variant. +.. _variant-conditional-values: + Conditional Possible Values ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/lib/spack/spack/builder.py b/lib/spack/spack/builder.py index 1a6bcc6feeb70c..35a3564be4209f 100644 --- a/lib/spack/spack/builder.py +++ b/lib/spack/spack/builder.py @@ -70,9 +70,12 @@ def __call__(self, spec, prefix): def get_builder_class(pkg, name: str) -> Optional[Type["Builder"]]: """Return the builder class if a package module defines it.""" - cls = getattr(pkg.module, name, None) - if cls and spack.repo.is_package_module(cls.__module__): - return cls + for current_cls in type(pkg).__mro__: + if not hasattr(current_cls, "module"): + continue + maybe_builder = getattr(current_cls.module, name, None) + if maybe_builder and spack.repo.is_package_module(maybe_builder.__module__): + return maybe_builder return None diff --git a/lib/spack/spack/test/builder.py b/lib/spack/spack/test/builder.py index b38eb85505da5c..ebce27a9ba610a 100644 --- a/lib/spack/spack/test/builder.py +++ b/lib/spack/spack/test/builder.py @@ -215,3 +215,21 @@ class TestBuilder(spack.builder.Builder): assert attributes == ("foo", "bar") long_methods = spack.builder.package_long_methods(TestBuilder) assert long_methods == ("baz", "fee") + + +@pytest.mark.regression("51917") +@pytest.mark.usefixtures("builder_test_repository", "config") +def test_builder_when_inheriting_just_package(working_env): + """Tests that if we inherit a package from another package that has a builder defined, + but we don't need to modify the builder ourselves, we'll get the builder of the base + package class. + """ + base_spec = spack.concretize.concretize_one("callbacks") + derived_spec = spack.concretize.concretize_one("inheritance-only-package") + + base_builder = spack.builder.create(base_spec.package) + derived_builder = spack.builder.create(derived_spec.package) + + # The derived class doesn't redefine a builder, so we should + # get the builder of the base class. + assert type(base_builder) is type(derived_builder) diff --git a/var/spack/test_repos/spack_repo/builder_test/packages/inheritance_only_package/package.py b/var/spack/test_repos/spack_repo/builder_test/packages/inheritance_only_package/package.py new file mode 100644 index 00000000000000..b04dc67026df33 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builder_test/packages/inheritance_only_package/package.py @@ -0,0 +1,12 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack_repo.builder_test.packages.callbacks import package as callbacks + + +class InheritanceOnlyPackage(callbacks.Callbacks): + """Package used to verify that inheritance among packages works as expected, + when there is no override of the builder class. + """ + + pass From 89eadc10fd1ec2606a55b09094cd6b84200cd24b Mon Sep 17 00:00:00 2001 From: Maxim Zhulin Date: Thu, 19 Feb 2026 12:23:43 -0500 Subject: [PATCH 072/337] add debug msg for concretizer start (#51959) Signed-off-by: map0te --- lib/spack/spack/solver/asp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 3293aaaa45cfdd..371e09eaa6150a 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -1144,6 +1144,8 @@ def solve( result, concretization_stats = self._conc_cache.fetch(cache_key) timer.stop("cache-check") + tty.debug("Starting concretizer") + # run the solver and store the result, if it wasn't cached already if not result: problem_repr = "\n".join(problem) From ed936ffd27e9b4dfe7d085c41f6c2d6b73838f95 Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Thu, 19 Feb 2026 14:15:48 -0800 Subject: [PATCH 073/337] Concretizer: sanity checks should not be applied to concrete specs (#51964) Concrete specs are added to the solve to represent the rest of an environment when iteratively concretizing with unify:true or unify:when_possible. These concrete specs should not be pre-checked for invalid deps or invalid/deprecated versions because the concretizer is not changing those specs. --------- Signed-off-by: Gregory Becker --- lib/spack/spack/solver/asp.py | 7 +++++-- lib/spack/spack/test/concretization/core.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 371e09eaa6150a..33b1b0f2e69a6a 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -3060,8 +3060,11 @@ def setup( # once we've done a full traversal and know possible versions, check that the # requested solve is at least consistent. - self.impossible_dependencies_check(specs) - self.input_spec_version_check(specs, allow_deprecated) + # do not check dependency and version availability for already concrete specs + # as they come from reusable specs + abstract_specs = [s for s in specs if not s.concrete] + self.impossible_dependencies_check(abstract_specs) + self.input_spec_version_check(abstract_specs, allow_deprecated) return self.gen diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 158b6d819ff580..d93f31b5aba7c7 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4826,6 +4826,23 @@ def _mock_libc(self): assert mpileaks.satisfies("%c=gcc@12") +def test_concrete_specs_skip_prechecks(mock_packages): + """Test that concrete specs are not checked for unknown versions and dependencies.""" + + specs = [spack.spec.Spec("zlib"), spack.spec.Spec("deprecated-versions@=1.1.0")] + + with pytest.raises(spack.solver.asp.DeprecatedVersionError): + spack.solver.asp.SpackSolverSetup().setup(specs) + + with spack.config.override("config:deprecated", True): + concrete_spec = spack.concretize.concretize_one(specs[1]) + + # Try again with the same version but a concrete spec + specs[1] = concrete_spec + + spack.solver.asp.SpackSolverSetup().setup(specs) + + @pytest.mark.regression("51683") def test_activating_variant_for_conditional_language_dependency(default_mock_concretization): """Tests that a dependency on a conditional language can be concretized, and that the solver From a078884a1627dc484078cd3d1e1777c96793a31f Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Thu, 19 Feb 2026 23:06:05 -0800 Subject: [PATCH 074/337] CHANGELOG.md: update for v1.0.3 (#51973) Signed-off-by: Gregory Becker --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0b68bc5f7718..128829a828ac05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,35 @@ See the [2025.11.0 release](https://github.com/spack/spack-packages/releases/tag/v2025.11.0) of [spack-packages](https://github.com/spack/spack-packages/) for more details. +# v1.0.3 (2026-02-02) + +## Bug fixes + +* Concretizer bugfixes: + * solver: remove a special case for provider weighting #51347 + * solver: improve timeout handling and add Ctrl-C interrupt safety #51341 + * solver: simplify interrupt/timeout logic #51349 +* Repo management bugfixes: + * repo.py: support rhel 7 #51617 + * repo.py: fix checking out commits #51695 + * git: pull_checkout_branch RHEL7 git 1.8.3.1 fix #51779 + * git: fix locking issue in pull_checkout_branch #51854 + * spack repo remove: allow removing from unspecified scope #51563 +* build_environment.py: Prevent deadlock on install process join #51429 +* Fix typo in untrack_env #51554 +* audit.py: fix re.sub(..., N) positional count arg #51735 + +## Enhancements + +* Support Macos Tahoe (#51373, #51394, #51479) +* Support for Python 3.14, except for t-strings (#51686, #51687, #51688, #51697, #51663) +* spack info: show conditional dependencies and licenses; allow filtering #51137 +* Spack fetch less likely to fail due to AI download protections #51496 +* config: relax concurrent_packages to minimum 0 #51840 + * This avoids forward-incompatibility with Spack v1.2 +* Documentation improvements (#51315, #51640) + + # v1.0.2 (2025-09-11) ## Bug Fixes From 8f44ca4319a4dadd853c71cc7bb52aeeb32654f1 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 20 Feb 2026 08:06:46 +0100 Subject: [PATCH 075/337] new_installer.py: implement --fail-fast (#51968) Implement `spack install --fail-fast` for the new installer. The idea is to send SIGTERM to all build processes after first failure, but to stay in the event loop, because it takes a little bit before the subprocesses exit. We only record the first failure (and not build failures caused by Spack itself sending SIGTERM). --- lib/spack/spack/new_installer.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 8bbe93db0ab70b..6a0e9d69aac61b 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1238,9 +1238,7 @@ def __init__( ) -> None: assert install_package or install_deps, "Must install package, dependencies or both" - if fail_fast: - raise NotImplementedError("Fail-fast installs are not implemented") - elif fake: + if fake: raise NotImplementedError("Fake installs are not implemented") elif install_source: raise NotImplementedError("Installing sources is not implemented") @@ -1260,6 +1258,7 @@ def __init__( #: Set of DAG hashes to overwrite (if already installed) self.overwrite: Set[str] = set(overwrite) if overwrite else set() self.keep_prefix = keep_prefix + self.fail_fast = fail_fast # Buffer for incoming, partially received state data from child processes self.state_buffers: Dict[int, str] = {} @@ -1382,10 +1381,20 @@ def _installer(self) -> None: if build.proc.exitcode == 0: to_insert_in_database.append(build) self.build_status.update_state(build.spec.dag_hash(), "finished") - else: + elif not self.fail_fast or not failures: + # In fail-fast mode, only record the first failure. Subsequent failures may + # be a consequence of us terminating other builds, and should not be + # reported as failures in the UI. failures.append(build.spec) self.build_status.update_state(build.spec.dag_hash(), "failed") + if failures and self.fail_fast: + # Terminate other builds to actually fail fast. We continue in the event loop + # waiting for child processes to finish, which may take a little while. + for child in self.running_builds.values(): + child.proc.terminate() + self.pending_builds.clear() + if stdin_ready: try: char = sys.stdin.read(1) From 0178f8998af38a1304656f3fc0d80627ef80302a Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 20 Feb 2026 13:43:37 +0100 Subject: [PATCH 076/337] shell completion: fix spack compilers (#51976) Signed-off-by: Harmen Stoppels --- lib/spack/spack/cmd/compiler.py | 7 +++++-- share/spack/bash/spack-completion.bash | 2 +- share/spack/fish/spack-completion.fish | 2 +- share/spack/spack-completion.bash | 2 +- share/spack/spack-completion.fish | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/cmd/compiler.py b/lib/spack/spack/cmd/compiler.py index abe59a95cc589a..d195b3212d9b94 100644 --- a/lib/spack/spack/cmd/compiler.py +++ b/lib/spack/spack/cmd/compiler.py @@ -180,11 +180,14 @@ def compiler_info(args): def compiler_list(args): compilers = _all_available_compilers(scope=args.scope, remote=args.remote) + if not sys.stdout.isatty(): + for c in sorted(compilers): # type: ignore + print(c.format("{name}@{version}")) + return + # If there are no compilers in any scope, and we're outputting to a tty, give a # hint to the user. if len(compilers) == 0: - if not sys.stdout.isatty(): - return msg = "No compilers available" if args.scope is None: msg += ". Run `spack compiler find` to autodetect compilers" diff --git a/share/spack/bash/spack-completion.bash b/share/spack/bash/spack-completion.bash index e524565f41de0e..5fc7f554c20549 100755 --- a/share/spack/bash/spack-completion.bash +++ b/share/spack/bash/spack-completion.bash @@ -195,7 +195,7 @@ _installed_packages() { _installed_compilers() { if [[ -z "${SPACK_INSTALLED_COMPILERS:-}" ]] then - SPACK_INSTALLED_COMPILERS="$(spack compilers | egrep -v "^(-|=)")" + SPACK_INSTALLED_COMPILERS="$(spack compilers)" fi SPACK_COMPREPLY="$SPACK_INSTALLED_COMPILERS" } diff --git a/share/spack/fish/spack-completion.fish b/share/spack/fish/spack-completion.fish index b389e1c114ba0a..85e86c0a79256e 100644 --- a/share/spack/fish/spack-completion.fish +++ b/share/spack/fish/spack-completion.fish @@ -193,7 +193,7 @@ function __fish_spack_gpg_keys end function __fish_spack_installed_compilers - spack compilers | grep -v '^[=-]\|^$' + spack compilers end function __fish_spack_installed_packages diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index ecfb9b01c23244..67a2c10fe50baf 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -195,7 +195,7 @@ _installed_packages() { _installed_compilers() { if [[ -z "${SPACK_INSTALLED_COMPILERS:-}" ]] then - SPACK_INSTALLED_COMPILERS="$(spack compilers | egrep -v "^(-|=)")" + SPACK_INSTALLED_COMPILERS="$(spack compilers)" fi SPACK_COMPREPLY="$SPACK_INSTALLED_COMPILERS" } diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index c740edfa425e10..b9e6d79fb4fad9 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -193,7 +193,7 @@ function __fish_spack_gpg_keys end function __fish_spack_installed_compilers - spack compilers | grep -v '^[=-]\|^$' + spack compilers end function __fish_spack_installed_packages From 124b9686d153a7e6d6eb9eed7fb318d7fc65272f Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:04:22 -0800 Subject: [PATCH 077/337] environments: rename include concrete properties and vars (from #50207) (#51899) * `included_concrete_envs` -> `included_concrete_env_root_dirs * `included_concrete_name` (and equivalent constants as keys) -> `lockfile_include_key` * test/cmd/env.py: Remove unnecessary extra imports * schema/env.py: refactor include_concrete and flag deprecation plan Signed-off-by: tldahlgren --- lib/spack/spack/cmd/env.py | 22 +++++----- lib/spack/spack/cmd/find.py | 2 +- lib/spack/spack/environment/__init__.py | 2 + lib/spack/spack/environment/environment.py | 51 +++++++++++----------- lib/spack/spack/schema/env.py | 26 ++++++----- lib/spack/spack/solver/reuse.py | 4 +- lib/spack/spack/test/cmd/env.py | 45 ++++++++++--------- 7 files changed, 78 insertions(+), 74 deletions(-) diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py index 3d1e74765e94eb..aba222de4fd2a4 100644 --- a/lib/spack/spack/cmd/env.py +++ b/lib/spack/spack/cmd/env.py @@ -142,15 +142,15 @@ def _env_create( """Create a new environment, with an optional yaml description. Arguments: - name_or_path (str): name of the environment to create, or path to it - init_file (str or file): optional initialization file -- can be - a JSON lockfile (*.lock, *.json), YAML manifest file, or env dir - dir (bool): if True, create an environment in a directory instead - of a named environment - keep_relative (bool): if True, develop paths are copied verbatim into - the new environment file, otherwise they may be made absolute if the - new environment is in a different location - include_concrete (list): list of the included concrete environments + name_or_path: name of the environment to create, or path to it + init_file: optional initialization file -- can be a JSON lockfile + (*.lock, *.json), YAML manifest file, or env dir + dir: if True, create an environment in a directory instead of a named + environment + keep_relative: if True, develop paths are copied verbatim into the new + environment file, otherwise they may be made absolute if the new + environment is in a different location + include_concrete: list of the included concrete environments """ if not dir: env = ev.create( @@ -559,8 +559,8 @@ def _env_untrack_or_remove( if env.name == remove_env.name: continue - # check if an environment is included un another - if remove_env.path in env.included_concrete_envs: + # check if an environment is included in another + if remove_env.path in env.included_concrete_env_root_dirs: msg = f"Environment '{remove_env.name}' is used by environment '{env.name}'" if force: tty.warn(msg) diff --git a/lib/spack/spack/cmd/find.py b/lib/spack/spack/cmd/find.py index 6f9bbe515a298b..8ec43681bce011 100644 --- a/lib/spack/spack/cmd/find.py +++ b/lib/spack/spack/cmd/find.py @@ -307,7 +307,7 @@ def root_decorator(spec, string): print() - if env.included_concrete_envs: + if env.included_concrete_env_root_dirs: tty.msg("Included specs") # Root specs cannot be displayed with prefixes, since those are not diff --git a/lib/spack/spack/environment/__init__.py b/lib/spack/spack/environment/__init__.py index deb05370c35ef3..e86da8c52ab57e 100644 --- a/lib/spack/spack/environment/__init__.py +++ b/lib/spack/spack/environment/__init__.py @@ -572,6 +572,7 @@ installed_specs, is_env_dir, is_latest_format, + lockfile_include_key, lockfile_name, manifest_file, manifest_name, @@ -610,6 +611,7 @@ "installed_specs", "is_env_dir", "is_latest_format", + "lockfile_include_key", "lockfile_name", "manifest_file", "manifest_name", diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 6518781885a02d..81677bd732cf3f 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -165,8 +165,9 @@ def default_manifest_yaml(): default_view_name = "default" # Default behavior to link all packages into views (vs. only root packages) default_view_link = "all" -# The name for any included concrete specs -included_concrete_name = "include_concrete" + +# The key for any concrete specs included in a lockfile. +lockfile_include_key = "include_concrete" def installed_specs(): @@ -341,7 +342,7 @@ def create( string, it specifies the path to the view keep_relative: if True, develop paths are copied verbatim into the new environment file, otherwise they are made absolute - include_concrete: list of concrete environment names/paths to be included + include_concrete: concrete environment names/paths to be included """ environment_dir = environment_dir_from_name(name, exists_ok=False) return create_in_dir( @@ -1019,8 +1020,8 @@ def __init__(self, manifest_dir: Union[str, pathlib.Path]) -> None: #: Repository for this environment (memoized) self._repo = None - #: Environment paths for concrete (lockfile) included environments - self.included_concrete_envs: List[str] = [] + #: Environment root dirs for concrete (lockfile) included environments + self.included_concrete_env_root_dirs: List[str] = [] #: First-level included concretized spec data from/to the lockfile. self.included_concrete_spec_data: Dict[str, Dict[str, List[str]]] = {} #: User specs from included environments from the last concretization @@ -1134,19 +1135,19 @@ def add_view(name, values): def _process_concrete_includes(self): """Extract and load into memory included concrete spec data.""" - _included_concrete_envs = self.manifest[TOP_LEVEL_KEY].get(included_concrete_name, []) + _included_concrete_envs = self.manifest[TOP_LEVEL_KEY].get(lockfile_include_key, []) # Expand config and environment variables - self.included_concrete_envs = [ + self.included_concrete_env_root_dirs = [ spack.util.path.canonicalize_path(_env) for _env in _included_concrete_envs ] - if self.included_concrete_envs: + if self.included_concrete_env_root_dirs: if os.path.exists(self.lock_path): with open(self.lock_path, encoding="utf-8") as f: data = self._read_lockfile(f) - if included_concrete_name in data: - self.included_concrete_spec_data = data[included_concrete_name] + if lockfile_include_key in data: + self.included_concrete_spec_data = data[lockfile_include_key] else: self.include_concrete_envs() @@ -1194,7 +1195,7 @@ def included_user_specs(self) -> SpecList: """Included concrete user (or root) specs from last concretization.""" spec_list = SpecList() - if not self.included_concrete_envs: + if not self.included_concrete_env_root_dirs: return spec_list def add_root_specs(included_concrete_specs): @@ -1203,8 +1204,8 @@ def add_root_specs(included_concrete_specs): for root_list in info["roots"]: spec_list.add(root_list["spec"]) - if "include_concrete" in info: - add_root_specs(info["include_concrete"]) + if lockfile_include_key in info: + add_root_specs(info[lockfile_include_key]) add_root_specs(self.included_concrete_spec_data) return spec_list @@ -1280,7 +1281,7 @@ def include_concrete_envs(self): concrete_hash_seen = set() self.included_concrete_spec_data = {} - for env_path in self.included_concrete_envs: + for env_path in self.included_concrete_env_root_dirs: # Check that environment exists if not is_env_dir(env_path): raise SpackEnvironmentError(f"Unable to find env at {env_path}") @@ -1305,7 +1306,7 @@ def include_concrete_envs(self): # Copy transitive include data transitive = env.included_concrete_spec_data if transitive: - self.included_concrete_spec_data[env_path]["include_concrete"] = transitive + self.included_concrete_spec_data[env_path][lockfile_include_key] = transitive self.unify_specs() self.write() @@ -2090,8 +2091,8 @@ def _to_lockfile_dict(self): "concrete_specs": concrete_specs, } - if self.included_concrete_envs: - data[included_concrete_name] = self.included_concrete_spec_data + if self.included_concrete_env_root_dirs: + data[lockfile_include_key] = self.included_concrete_spec_data return data @@ -2129,8 +2130,8 @@ def add_specs(name, info, specs_by_hash): if "concrete_specs" in info: specs_by_hash.update(info["concrete_specs"]) - if included_concrete_name in info: - for included_name, included_info in info[included_concrete_name].items(): + if lockfile_include_key in info: + for included_name, included_info in info[lockfile_include_key].items(): if included_name not in self.included_concretized_order: self.included_concretized_order[included_name] = [] self.included_concretized_user_specs[included_name] = [] @@ -2156,8 +2157,8 @@ def _read_lockfile_dict(self, d): json_specs_by_hash = d["concrete_specs"] included_json_specs_by_hash = {} - if included_concrete_name in d: - for env_name, env_info in d[included_concrete_name].items(): + if lockfile_include_key in d: + for env_name, env_info in d[lockfile_include_key].items(): included_json_specs_by_hash.update( self.set_included_concretized_user_specs( env_name, env_info, included_json_specs_by_hash @@ -2266,7 +2267,7 @@ def write(self, regenerate: bool = True) -> None: regenerate: regenerate views and run post-write hooks as well as writing if True. """ self.manifest_uptodate_or_warn() - if self.specs_by_hash or self.included_concrete_envs: + if self.specs_by_hash or self.included_concrete_env_root_dirs: self.ensure_env_directory_exists(dot_env=True) self.update_environment_repository() self.manifest.flush() @@ -2407,7 +2408,7 @@ def _prepare_environment_for_concretization(self, *, force: bool): self.env.sync_concretized_specs() # If a combined env, check updated spec is in the linked envs - if self.env.included_concrete_envs: + if self.env.included_concrete_env_root_dirs: self.env.include_concrete_envs() def _partition_user_specs(self) -> Tuple[List[spack.spec.Spec], List[spack.spec.Spec]]: @@ -2892,10 +2893,10 @@ def set_include_concrete(self, include_concrete: List[str]) -> None: Args: include_concrete: list of already existing concrete environments to include """ - self.configuration[included_concrete_name] = [] + self.configuration[lockfile_include_key] = [] for env_path in include_concrete: - self.configuration[included_concrete_name].append(env_path) + self.configuration[lockfile_include_key].append(env_path) self.changed = True diff --git a/lib/spack/spack/schema/env.py b/lib/spack/spack/schema/env.py index 1283862178009b..ea232e1018857c 100644 --- a/lib/spack/spack/schema/env.py +++ b/lib/spack/spack/schema/env.py @@ -16,6 +16,15 @@ #: Top level key in a manifest file TOP_LEVEL_KEY = "spack" +include_concrete = { + "type": "array", + "default": [], + "description": "List of paths to other environments. Includes concrete specs " + "from their spack.lock files without modifying the source environments. Useful " + "for phased deployments where you want to build on existing concrete specs.", + "items": {"type": "string"}, +} + properties: Dict[str, Any] = { "spack": { "type": "object", @@ -28,14 +37,7 @@ **spack.schema.merged.properties, # extra environment schema properties "specs": spec_list_schema, - "include_concrete": { - "type": "array", - "default": [], - "description": "List of paths to other environments. Includes concrete specs " - "from their spack.lock files without modifying the source environments. Useful " - "for phased deployments where you want to build on existing concrete specs.", - "items": {"type": "string"}, - }, + "include_concrete": include_concrete, }, } } @@ -49,14 +51,14 @@ } -def update(data): - """Update the data in place to remove deprecated properties. +def update(data: Dict[str, Any]) -> bool: + """Update the spack.yaml data in place to remove deprecated properties. Args: - data (dict): dictionary to be updated + data: dictionary to be updated Returns: - True if data was changed, False otherwise + ``True`` if data was changed, ``False`` otherwise """ # There are not currently any deprecated attributes in this section # that have not been removed diff --git a/lib/spack/spack/solver/reuse.py b/lib/spack/spack/solver/reuse.py index 49898f17699f0e..da55228f62a54e 100644 --- a/lib/spack/spack/solver/reuse.py +++ b/lib/spack/spack/solver/reuse.py @@ -200,7 +200,7 @@ def _specs_from_environment(env): def _specs_from_environment_included_concrete(env, included_concrete): """Return only concrete specs from the environment included from the included_concrete""" if env: - assert included_concrete in env.included_concrete_envs + assert included_concrete in env.included_concrete_env_root_dirs return [concrete for concrete in env.included_specs_by_hash[included_concrete].values()] else: return [] @@ -293,7 +293,7 @@ def __init__( if source["type"] == "environment" and "path" in source: env_dir = spack.environment.as_env_dir(source["path"]) active_env = spack.environment.active_environment() - if active_env and env_dir in active_env.included_concrete_envs: + if active_env and env_dir in active_env.included_concrete_env_root_dirs: # If the environment is included as a concrete environment, use the # local copy of specs in the active environment. # note: included concrete environments are only updated at concretization diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 7f72131118c575..af8b0ce7871e8c 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -18,8 +18,6 @@ import spack.config import spack.environment as ev import spack.environment.depfile as depfile -import spack.environment.environment -import spack.environment.shell import spack.error import spack.llnl.util.filesystem as fs import spack.llnl.util.link_tree @@ -622,7 +620,7 @@ def test_activate_adds_transitive_run_deps_to_path(install_mockery, mock_fetch, install("--add", "--fake", "depends-on-run-env") env_variables = {} - spack.environment.shell.activate(e).apply_modifications(env_variables) + ev.shell.activate(e).apply_modifications(env_variables) assert env_variables["DEPENDENCY_ENV_VAR"] == "1" @@ -2065,8 +2063,8 @@ def test_env_include_concrete_env_yaml(env_name): combined = ev.read("combined_env") combined_yaml = combined.manifest["spack"] - assert "include_concrete" in combined_yaml - assert test.path in combined_yaml["include_concrete"] + assert ev.lockfile_include_key in combined_yaml + assert test.path in combined_yaml[ev.lockfile_include_key] @pytest.mark.regression("45766") @@ -2101,8 +2099,8 @@ def test_env_multiple_include_concrete_envs(): combined_yaml = combined.manifest["spack"] - assert test1.path in combined_yaml["include_concrete"][0] - assert test2.path in combined_yaml["include_concrete"][1] + assert test1.path in combined_yaml[ev.lockfile_include_key][0] + assert test2.path in combined_yaml[ev.lockfile_include_key][1] # No local specs in the combined env assert not combined_yaml["specs"] @@ -2113,17 +2111,17 @@ def test_env_include_concrete_envs_lockfile(): combined_yaml = combined.manifest["spack"] - assert "include_concrete" in combined_yaml - assert test1.path in combined_yaml["include_concrete"] + assert ev.lockfile_include_key in combined_yaml + assert test1.path in combined_yaml[ev.lockfile_include_key] with open(combined.lock_path, encoding="utf-8") as f: lockfile_as_dict = combined._read_lockfile(f) assert set( - entry["hash"] for entry in lockfile_as_dict["include_concrete"][test1.path]["roots"] + entry["hash"] for entry in lockfile_as_dict[ev.lockfile_include_key][test1.path]["roots"] ) == set(test1.specs_by_hash) assert set( - entry["hash"] for entry in lockfile_as_dict["include_concrete"][test2.path]["roots"] + entry["hash"] for entry in lockfile_as_dict[ev.lockfile_include_key][test2.path]["roots"] ) == set(test2.specs_by_hash) @@ -2140,13 +2138,13 @@ def test_env_include_concrete_add_env(): new_env.write() # add new env to combined - combined.included_concrete_envs.append(new_env.path) + combined.included_concrete_env_root_dirs.append(new_env.path) # assert thing haven't changed yet with open(combined.lock_path, encoding="utf-8") as f: lockfile_as_dict = combined._read_lockfile(f) - assert new_env.path not in lockfile_as_dict["include_concrete"].keys() + assert new_env.path not in lockfile_as_dict[ev.lockfile_include_key].keys() # concretize combined env with new env combined.concretize() @@ -2156,20 +2154,20 @@ def test_env_include_concrete_add_env(): with open(combined.lock_path, encoding="utf-8") as f: lockfile_as_dict = combined._read_lockfile(f) - assert new_env.path in lockfile_as_dict["include_concrete"].keys() + assert new_env.path in lockfile_as_dict[ev.lockfile_include_key].keys() def test_env_include_concrete_remove_env(): test1, test2, combined = setup_combined_multiple_env() # remove test2 from combined - combined.included_concrete_envs = [test1.path] + combined.included_concrete_env_root_dirs = [test1.path] # assert test2 is still in combined's lockfile with open(combined.lock_path, encoding="utf-8") as f: lockfile_as_dict = combined._read_lockfile(f) - assert test2.path in lockfile_as_dict["include_concrete"].keys() + assert test2.path in lockfile_as_dict[ev.lockfile_include_key].keys() # reconcretize combined combined.concretize() @@ -2179,7 +2177,7 @@ def test_env_include_concrete_remove_env(): with open(combined.lock_path, encoding="utf-8") as f: lockfile_as_dict = combined._read_lockfile(f) - assert test2.path not in lockfile_as_dict["include_concrete"].keys() + assert test2.path not in lockfile_as_dict[ev.lockfile_include_key].keys() def configure_reuse(reuse_mode, combined_env) -> Optional[ev.Environment]: @@ -2352,8 +2350,11 @@ def test_concretize_nested_include_concrete_envs(): with open(test3.lock_path, encoding="utf-8") as f: lockfile_as_dict = test3._read_lockfile(f) - assert test2.path in lockfile_as_dict["include_concrete"] - assert test1.path in lockfile_as_dict["include_concrete"][test2.path]["include_concrete"] + assert test2.path in lockfile_as_dict[ev.lockfile_include_key] + assert ( + test1.path + in lockfile_as_dict[ev.lockfile_include_key][test2.path][ev.lockfile_include_key] + ) assert Spec("zlib") in test3.included_concretized_user_specs[test1.path] @@ -2405,7 +2406,7 @@ def test_concretize_nested_included_concrete(): def included_included_spec(path1, path2): included_path1 = test4.included_concrete_spec_data[path1] - included_path2 = included_path1["include_concrete"][path2] + included_path2 = included_path1[ev.lockfile_include_key][path2] return included_path2["roots"][0]["spec"] included_test2_test1 = included_included_spec(test2.path, test1.path) @@ -3528,9 +3529,7 @@ def test_lockfile_not_deleted_on_write_error(tmp_path: pathlib.Path, monkeypatch def _write_helper_raise(self): raise RuntimeError("some error") - monkeypatch.setattr( - spack.environment.environment.EnvironmentManifestFile, "flush", _write_helper_raise - ) + monkeypatch.setattr(ev.environment.EnvironmentManifestFile, "flush", _write_helper_raise) with ev.Environment(str(tmp_path)) as e: e.concretize(force=True) with pytest.raises(RuntimeError): From 77cfb4e8d84963a625f3d1598e6436e046960210 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 08:40:25 +0100 Subject: [PATCH 078/337] build(deps): bump pylint in /.github/workflows/requirements/style (#51979) Bumps [pylint](https://github.com/pylint-dev/pylint) from 4.0.4 to 4.0.5. - [Release notes](https://github.com/pylint-dev/pylint/releases) - [Commits](https://github.com/pylint-dev/pylint/compare/v4.0.4...v4.0.5) --- updated-dependencies: - dependency-name: pylint dependency-version: 4.0.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/requirements/style/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index afd9066accadf0..102c1461816be3 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -5,7 +5,7 @@ isort==7.0.0 mypy==1.19.1 types-six==1.17.0.20251009 vermin==1.8.0 -pylint==4.0.4 +pylint==4.0.5 docutils==0.22.4 ruamel.yaml==0.19.1 slotscheck==0.19.1 From 558c7eb2716ec0ec208ae34bad19f9683c934f51 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 23 Feb 2026 09:00:06 +0100 Subject: [PATCH 079/337] .gitignore: CLAUDE.md for now (#51983) Signed-off-by: Harmen Stoppels --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 276119d7131bb5..33acfae49a1a69 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ spack-db.* *.in.log *.out.log +CLAUDE.md # Configuration: Ignore everything in /etc/spack, # except defaults and site scopes that ship with spack From eb954371fb8fb323f113e25e0abdcf462e2e7834 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Mon, 23 Feb 2026 18:31:10 +0100 Subject: [PATCH 080/337] solver: simplify and improve encoding of variants (#51988) Once we know variant_definition/3 the computation of node_has_variant/3 is deterministic (it's always the highest VariantID that is possible). Also, decide internally in terms of attr("variant_value", ...), and derive attr("variant_selected", ...) only for reconstruction purposes. This should make the decision chain slightly shorter. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 37 +++++++++++----------------- 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index b36dca1abc45cc..09e0ad71933201 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -378,7 +378,6 @@ error(10, "Commit '{0}' must match package.py value '{1}' for '{2}@={3}'", Vsha, % A "condition_set(PackageNode, _)" is the set of nodes on which PackageNode can require / impose conditions condition_set(PackageNode, PackageNode) :- attr("node", PackageNode). -condition_set(PackageNode, PackageNode) :- provider(PackageNode, VirtualNode). condition_set(PackageNode, VirtualNode) :- provider(PackageNode, VirtualNode). condition_set(PackageNode, DependencyNode) :- condition_set(PackageNode, PackageNode), depends_on(PackageNode, DependencyNode). condition_set(ID, VirtualNode) :- condition_set(ID, PackageNode), provider(PackageNode, VirtualNode). @@ -1323,17 +1322,11 @@ variant_definition(node(NodeID, Package), Name, VariantID) :- % If there are any definitions for a variant on a node, the variant is "defined". variant_defined(PackageNode, Name) :- variant_definition(PackageNode, Name, _). -% We must select one definition for each defined variant on a node. -1 { - node_has_variant(PackageNode, Name, VariantID) : variant_definition(PackageNode, Name, VariantID) -} 1 :- - variant_defined(PackageNode, Name). - % Solver must pick the variant definition with the highest id. When conditions hold % for two or more variant definitions, this prefers the last one defined. -:- node_has_variant(node(NodeID, Package), Name, SelectedVariantID), - variant_definition(node(NodeID, Package), Name, VariantID), - VariantID > SelectedVariantID. +node_has_variant(PackageNode, Name, SelectedVariantID) :- + SelectedVariantID = #max { VariantID : variant_definition(PackageNode, Name, VariantID) }, + variant_defined(PackageNode, Name). % B: Associating applicable package rules with nodes @@ -1403,26 +1396,25 @@ variant_single_value(node(NodeID, Package), VariantName) :- % C: Determining variant values on each node % if a variant is sticky, but not set, its value is the default value -attr("variant_selected", node(ID, Package), Variant, Value, VariantType, VariantID) :- +attr("variant_value", node(ID, Package), Variant, Value) :- node_has_variant(node(ID, Package), Variant, VariantID), variant_default_value(node(ID, Package), Variant, Value), pkg_fact(Package, variant_sticky(VariantID)), - variant_type(VariantID, VariantType), not attr("variant_set", node(ID, Package), Variant), build(node(ID, Package)). % we can choose variant values from all the possible values for the node 1 { - attr("variant_selected", PackageNode, Variant, Value, VariantType, VariantID) - : variant_possible_value(PackageNode, Variant, Value) + attr("variant_value", PackageNode, Variant, Value) : variant_possible_value(PackageNode, Variant, Value) } :- - node_has_variant(PackageNode, Variant, VariantID), - variant_type(VariantID, VariantType), + node_has_variant(PackageNode, Variant, _), build(PackageNode). -% variant_selected is only needed for reconstruction on the python side, so we can ignore it here -attr("variant_value", PackageNode, Variant, Value) :- - attr("variant_selected", PackageNode, Variant, Value, VariantType, VariantID). +% variant_selected is only needed for reconstruction on the python side +attr("variant_selected", PackageNode, Variant, Value, VariantType, VariantID) :- + attr("variant_value", PackageNode, Variant, Value), + node_has_variant(PackageNode, Variant, VariantID), + variant_type(VariantID, VariantType). % a variant cannot be set if it is not a variant on the package error(100, "Cannot set variant '{0}' for package '{1}' because the variant condition cannot be satisfied for the given spec", Variant, Package) @@ -1572,10 +1564,9 @@ propagate(ChildNode, PropagatedAttribute, edge_types(DepType1, DepType2)) :- %---- % If a variant is propagated, and can be accepted, set its value -attr("variant_selected", PackageNode, Variant, Value, VariantType, VariantID) :- +attr("variant_value", PackageNode, Variant, Value) :- propagate(PackageNode, variant_value(Variant, Value, _)), - node_has_variant(PackageNode, Variant, VariantID), - variant_type(VariantID, VariantType), + node_has_variant(PackageNode, Variant, _), variant_possible_value(PackageNode, Variant, Value). % If a variant is propagated, we cannot have extraneous values @@ -1585,7 +1576,7 @@ variant_is_propagated(PackageNode, Variant) :- not attr("variant_set", PackageNode, Variant). :- variant_is_propagated(PackageNode, Variant), - attr("variant_selected", PackageNode, Variant, Value, _, _), + attr("variant_value", PackageNode, Variant, Value), not propagate(PackageNode, variant_value(Variant, Value, _)). error(100, "{0} and {1} cannot both propagate variant '{2}' to the shared dependency: {3}", From f8e2f891026a999182c0c10f1420c30d1afdcb1a Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Mon, 23 Feb 2026 18:39:54 +0100 Subject: [PATCH 081/337] solver: fix concretizer hanging on binutils (#51981) The concretizer hangs to solve for: ```console $ spack solve binutils+gold ``` which defines ``` variant("gold", when="@:2.43 +ld") ``` and has `~ld` as a default. The cause seems to be the long chain of decisions that clingo needs to go through to reason about variants. That long chain makes it difficult to learn conflicts, in particular when a conditional variant implies that a non-default value of another variant is taken. Here we short-circuit that decision and make minimization easier by saying that setting a variant to a value that implies another one also sets the other value. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 09e0ad71933201..b0eff29dd15ae3 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1484,6 +1484,17 @@ error(100, "{0} variant '{1}' cannot have values '{2}' and '{3}' as they come fr :- attr("variant_set", node(ID, Package), Variant, Value), not attr("variant_value", node(ID, Package), Variant, Value). +% In a case with `variant("foo", when="+bar")` and a user request for +foo or ~foo, +% force +bar to be set too. This gives no penalty if `+bar` is not the default value, and +% optimizes a long chain of deductions that may cause clingo to hang. +attr("variant_set", node(ID, Package), AnotherVariant, AnotherValue) + :- attr("variant_set", node(ID, Package), Variant, Value), + attr("variant_selected", node(ID, Package), Variant, _, _, VariantID), + pkg_fact(Package, variant_condition(Variant, VariantID, ConditionID)), + pkg_fact(Package, condition_trigger(ConditionID, TriggerID)), + condition_requirement(TriggerID,"variant_value",Package, AnotherVariant, AnotherValue), + build(node(ID, Package)). + % A default variant value that is not used, makes sense only for multi valued variants variant_default_not_used(node(ID, Package), Variant, Value) :- variant_default_value(node(ID, Package), Variant, Value), From 4c8c56d75ca0cc812677bec441eb341851bc90ed Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 23 Feb 2026 19:01:20 +0100 Subject: [PATCH 082/337] new_installer.py: warn when jobserver tokens are not released (#51986) When Spack is the creator of the jobserver, it should verify that at the end of execution all tokens were returned. This helps troubleshooting builds where parallelism is limited over time due to packages failing to return jobserver tokens. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 27 ++++++++++++++++++++++----- lib/spack/spack/test/jobserver.py | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 6a0e9d69aac61b..8fb0c783479b1f 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -35,6 +35,7 @@ import time import traceback import tty +import warnings from gzip import GzipFile from multiprocessing import Pipe, Process from multiprocessing.connection import Connection @@ -568,6 +569,26 @@ def release(self) -> None: self.tokens_acquired -= 1 def close(self) -> None: + if self.created and self.num_jobs > 1: + if self.tokens_acquired != 0: + # It's a non-fatal internal error to close the jobserver with acquired tokens. + warnings.warn("Spack failed to release jobserver tokens", stacklevel=2) + else: + # Verify that all build processes released the tokens they acquired. + total = self.num_jobs - 1 + drained = self.acquire(total) + if drained != total: + n = total - drained + warnings.warn( + f"{n} jobserver {'token was' if n == 1 else 'tokens were'} not released " + "by the build processes. This can indicate that the build ran with " + "limited parallelism.", + stacklevel=2, + ) + + self.r_conn.close() + self.w_conn.close() + # Remove the FIFO if we created it. if self.created and self.fifo_path: try: @@ -578,11 +599,6 @@ def close(self) -> None: os.rmdir(os.path.dirname(self.fifo_path)) except OSError: pass - # TODO: implement a sanity check here: - # 1. did we release all tokens we acquired? - # 2. if we created the jobserver, did the children return all tokens? - self.r_conn.close() - self.w_conn.close() def start_build( @@ -1439,6 +1455,7 @@ def _installer(self) -> None: for child in self.running_builds.values(): child.proc.terminate() for child in self.running_builds.values(): + jobserver.release() child.proc.join() raise finally: diff --git a/lib/spack/spack/test/jobserver.py b/lib/spack/spack/test/jobserver.py index 9dff1066ec998c..d7764c4ae9baab 100644 --- a/lib/spack/spack/test/jobserver.py +++ b/lib/spack/spack/test/jobserver.py @@ -275,3 +275,22 @@ def test_connection_objects_exist(self): assert js.w_conn is not None and js.w_conn.fileno() == js.w finally: js.close() + + def test_close_warns_when_spack_holds_tokens(self): + """Should warn when Spack closes the jobserver while still holding acquired tokens.""" + js = JobServer(4) + js.acquire(1) # Spack acquires a token without releasing it + with pytest.warns(UserWarning, match="Spack failed to release jobserver tokens"): + js.close() + + def test_close_warns_when_subprocess_holds_tokens(self): + """Should warn when a subprocess acquired a token but never released it.""" + js1 = JobServer(4) + os.read(js1.r, 1) # A subprocess acquires a token without releasing it + with pytest.warns(UserWarning, match="1 jobserver token was not released"): + js1.close() + + js2 = JobServer(4) + os.read(js2.r, 2) # A subprocess acquires two tokens without releasing them + with pytest.warns(UserWarning, match="2 jobserver tokens were not released"): + js2.close() From 241b251d61d489e78b04c6e7e6e544b5645c1836 Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Mon, 23 Feb 2026 11:43:29 -0800 Subject: [PATCH 083/337] includes overrides: do not remove hardcoded scopes (#51745) * ConfigScope: add field 'included' for whether this scope is included from another Signed-off-by: Gregory Becker * spack.config: improve semantics for include:: 1. `include::` should not remove the Spack scope, nor any other hard-coded scope, but it should remove the `include` section from those scopes 2. `include::` should not remove directly included scopes, but should remove transitively included scopes Signed-off-by: Gregory Becker * tests/config.py: update include:: test for new semantics Signed-off-by: Gregory Becker * tests: make config fixtures use includes like real spack Signed-off-by: Gregory Becker * bugfix: incorrect scope order in mirror rm Signed-off-by: Gregory Becker * repo remove: same bugfix Signed-off-by: Gregory Becker * debug print Signed-off-by: Gregory Becker * Revert "debug print" This reverts commit d870361e338cceb5b1c6a741fe2b0c82d0aacbc2. Signed-off-by: Gregory Becker --------- Signed-off-by: Gregory Becker --- lib/spack/spack/cmd/mirror.py | 2 +- lib/spack/spack/cmd/repo.py | 2 +- lib/spack/spack/config.py | 50 +++++++++++++++++++++++++----- lib/spack/spack/test/cmd/config.py | 10 +++--- lib/spack/spack/test/cmd/mirror.py | 2 +- lib/spack/spack/test/config.py | 44 +++++++++++++++++++++++++- lib/spack/spack/test/conftest.py | 45 +++++++++++++++++++++------ 7 files changed, 129 insertions(+), 26 deletions(-) diff --git a/lib/spack/spack/cmd/mirror.py b/lib/spack/spack/cmd/mirror.py index 13c9ec8659697f..0bdd2c9addc977 100644 --- a/lib/spack/spack/cmd/mirror.py +++ b/lib/spack/spack/cmd/mirror.py @@ -364,7 +364,7 @@ def mirror_add(args): def mirror_remove(args): """remove a mirror by name""" name = args.name - scopes = [args.scope] if args.scope else list(spack.config.CONFIG.scopes.keys()) + scopes = [args.scope] if args.scope else reversed(list(spack.config.CONFIG.scopes.keys())) removed = False for scope in scopes: diff --git a/lib/spack/spack/cmd/repo.py b/lib/spack/spack/cmd/repo.py index 6525de9951a8f4..2248352c8039af 100644 --- a/lib/spack/spack/cmd/repo.py +++ b/lib/spack/spack/cmd/repo.py @@ -258,7 +258,7 @@ def repo_add(args): def repo_remove(args): """remove a repository from Spack's configuration""" - scopes = [args.scope] if args.scope else list(spack.config.CONFIG.scopes.keys()) + scopes = [args.scope] if args.scope else reversed(list(spack.config.CONFIG.scopes.keys())) found_and_removed = False for scope in scopes: found_and_removed |= _remove_repo(args.namespace_or_path, scope) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 13b2ada527ada6..64578813eee9aa 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -142,11 +142,12 @@ def _include_cache_location(): class ConfigScope: - def __init__(self, name: str) -> None: + def __init__(self, name: str, included: bool = False) -> None: self.name = name self.writable = False self.sections = syaml.syaml_dict() self.prefer_modify = False + self.included = included #: names of any included scopes self._included_scopes: Optional[List["ConfigScope"]] = None @@ -218,9 +219,15 @@ class DirectoryConfigScope(ConfigScope): """Config scope backed by a directory containing one file per section.""" def __init__( - self, name: str, path: str, *, writable: bool = True, prefer_modify: bool = True + self, + name: str, + path: str, + *, + writable: bool = True, + prefer_modify: bool = True, + included: bool = False, ) -> None: - super().__init__(name) + super().__init__(name, included) self.path = path self.writable = writable self.prefer_modify = prefer_modify @@ -281,6 +288,7 @@ def __init__( yaml_path: Optional[List[str]] = None, writable: bool = True, prefer_modify: bool = True, + included: bool = False, ) -> None: """Similar to ``ConfigScope`` but can be embedded in another schema. @@ -299,7 +307,7 @@ def __init__( config: install_tree: $spack/opt/spack """ - super().__init__(name) + super().__init__(name, included) self._raw_data: Optional[YamlConfigDict] = None self.schema = schema self.path = path @@ -753,24 +761,43 @@ def deepcopy_as_builtin( self.get_config(section, scope=scope), line_info=line_info ) - def _filter_overridden(self, scopes: List[ConfigScope]): + def _filter_overridden(self, scopes: List[ConfigScope], includes: bool = False): """Filter out overridden scopes. NOTE: this does not yet handle diamonds or nested `include::` in lists. It is sufficient for include::[] in an env, which allows isolation. + + The ``includes`` option controls whether to return all active scopes (``includes=False``) + or all scopes whose includes have not been overridden (``includes=True``). """ # find last override in scopes i = next((i for i, s in reversed(list(enumerate(scopes))) if s.override_include()), -1) if i < 0: return scopes # no overrides - keep = scopes[i].transitive_includes() + keep = _set(s.name for s in scopes[i:]) keep |= _set(s.name for s in self.scopes.priority_values(ConfigScopePriority.DEFAULTS)) - keep |= _set(s.name for s in scopes[i:]) + + if not includes: + # For all sections except for the include section: + # non-included scopes are still active, as are scopes included + # from the overriding scope + # Transitive scopes from the overriding scope are not included + keep |= _set([s.name for s in scopes[i].included_scopes]) + keep |= _set([s.name for s in scopes if not s.included]) # return scopes to keep, with order preserved return [s for s in scopes if s.name in keep] + @property + def active_include_section_scopes(self) -> List[ConfigScope]: + """Return a list of all scopes whose includes have not been overridden by include::. + + This is different from the active scopes because the ``spack`` scope can be active + while its includes are overwritten, as can the transitive includes from the overriding + scope.""" + return self._filter_overridden([s for s in self.scopes.values()], includes=True) + @property def active_scopes(self) -> List[ConfigScope]: """Return a list of scopes that have not been overridden by include::.""" @@ -804,8 +831,12 @@ def _get_config_memoized( merged_section: Dict[str, Any] = syaml.syaml_dict() updated_scopes = [] for config_scope in scopes: + if section == "include" and config_scope not in self.active_include_section_scopes: + continue + # read potentially cached data from the scope. data = config_scope.get_section(section) + if data and section == "include": # Include overrides are handled by `_filter_overridden` above. Any remaining # includes at this point are *not* actually overridden -- they're scopes with @@ -1053,6 +1084,7 @@ def _scope( config_path, spack.schema.merged.schema, prefer_modify=self.prefer_modify, + included=True, ) if ext and not is_dir: @@ -1064,7 +1096,9 @@ def _scope( # directories are treated as regular ConfigScopes # assign by "default" tty.debug(f"Creating DirectoryConfigScope {config_name} for '{config_path}'") - return DirectoryConfigScope(config_name, config_path, prefer_modify=self.prefer_modify) + return DirectoryConfigScope( + config_name, config_path, prefer_modify=self.prefer_modify, included=True + ) def _valid_parent_scope(self, parent_scope: ConfigScope) -> bool: """Validates that a parent scope is a valid configuration object""" diff --git a/lib/spack/spack/test/cmd/config.py b/lib/spack/spack/test/cmd/config.py index 8fab44786724d9..468371fa383e11 100644 --- a/lib/spack/spack/test/cmd/config.py +++ b/lib/spack/spack/test/cmd/config.py @@ -67,7 +67,7 @@ def test_config_scopes(path, types, mutable_mock_env_path): assert "command_line" in output assert "_builtin" in output if types: - if not any(i in ("all", "path") for i in types): + if not any(i in ("all", "path", "include") for i in types): assert "site" not in output if not any(i in ("all", "env", "include", "path") for i in types): assert not output or all(":" not in x for x in output) @@ -133,21 +133,21 @@ def test_blame_override(mutable_config): # includes are present when section is specified output = config("blame", "include").strip() include_path = re.escape(os.path.join(mutable_config.scopes["site"].path, "include.yaml")) - assert re.search(rf"include:\n{include_path}:\d+\s+\- path: base", output) + assert re.search(rf"{include_path}:\d+\s+\- path: base", output) # includes are also present when section is NOT specified output = config("blame").strip() - assert re.search(rf"include:\n{include_path}:\d+\s+\- path: base", output) + assert re.search(rf"{include_path}:\d+\s+\- path: base", output) mutable_config.push_scope(spack.config.InternalConfigScope("override", {"include:": []})) # site includes are not present when overridden output = config("blame", "include").strip() - assert not re.search(rf"include:\n{include_path}:\d+\s+\- path: base", output) + assert not re.search(rf"{include_path}:\d+\s+\- path: base", output) assert "include: []" in output output = config("blame").strip() - assert not re.search(rf"include:\n{include_path}:\d+\s+\- path: base", output) + assert not re.search(rf"{include_path}:\d+\s+\- path: base", output) assert "include: []" in output diff --git a/lib/spack/spack/test/cmd/mirror.py b/lib/spack/spack/test/cmd/mirror.py index a6dd5605cb34d6..9e0518ec9f5c05 100644 --- a/lib/spack/spack/test/cmd/mirror.py +++ b/lib/spack/spack/test/cmd/mirror.py @@ -302,7 +302,7 @@ def test_mirror_remove_by_scope(mutable_config, tmp_path: pathlib.Path): assert "mock" in system_output # Confirm that when the scope is not specified, it is removed from top scope - mirror("add", "--scope=site", "mock", str(tmp_path / "mockrepo")) + mirror("add", "--scope=site", "mock", str(tmp_path / "mock_mirror")) mirror("remove", "mock") site_output = mirror("list", "--scope=site") system_output = mirror("list", "--scope=system") diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index 2a70f12ae5456b..c077b86f4f89db 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -1265,7 +1265,7 @@ def mock_include_scope(tmp_path): @pytest.fixture def include_config_factory(mock_include_scope): def make_config(): - cfg = spack.config.create() + cfg = spack.config.Configuration() cfg.push_scope( spack.config.DirectoryConfigScope("defaults", str(mock_include_scope / "defaults")), priority=ConfigScopePriority.DEFAULTS, @@ -1390,6 +1390,8 @@ def test_override_included_config(working_env, tmp_path, include_config_factory) include_yaml = override_scope / "include.yaml" subdir = override_scope / "subdir" subdir.mkdir() + anotherdir = override_scope / "anotherdir" + anotherdir.mkdir() with include_yaml.open("w", encoding="utf-8") as f: f.write( @@ -1402,20 +1404,40 @@ def test_override_included_config(working_env, tmp_path, include_config_factory) ) ) + with (subdir / "include.yaml").open("w", encoding="utf-8") as f: + f.write( + textwrap.dedent( + """\ + include: + - name: "anotherdir" + path: "../anotherdir" + """ + ) + ) + # check the mock config is correct cfg = include_config_factory() assert "defaults" in cfg.scopes + assert "tmp_path" in cfg.scopes assert "test1" in cfg.scopes assert "test2" in cfg.scopes assert "test3" in cfg.scopes active_names = [s.name for s in cfg.active_scopes] assert "defaults" in active_names + assert "tmp_path" in active_names assert "test1" in active_names assert "test2" in active_names assert "test3" in active_names + includes = str(cfg.get("include")) + assert "subdir" not in includes + assert "anotherdir" not in includes + assert "test1" in includes + assert "test2" in includes + assert "test3" in includes + # push a scope that overrides everything under it but includes a subdir. # its included subdir should be active, but scopes *not* included by the overriding # scope should not. @@ -1425,34 +1447,54 @@ def test_override_included_config(working_env, tmp_path, include_config_factory) ) assert "defaults" in cfg.scopes + assert "tmp_path" in cfg.scopes assert "test1" in cfg.scopes assert "test2" in cfg.scopes assert "test3" in cfg.scopes assert "override" in cfg.scopes assert "subdir" in cfg.scopes + assert "anotherdir" in cfg.scopes active_names = [s.name for s in cfg.active_scopes] assert "defaults" in active_names + assert "tmp_path" in active_names assert "test1" not in active_names assert "test2" not in active_names assert "test3" not in active_names assert "override" in active_names assert "subdir" in active_names + assert "anotherdir" not in active_names + + includes = str(cfg.get("include")) + assert "subdir" in includes + assert "anotherdir" not in includes + assert "test1" not in includes + assert "test2" not in includes + assert "test3" not in includes # remove the override and ensure everything is back to normal cfg.remove_scope("override") assert "defaults" in cfg.scopes + assert "tmp_path" in cfg.scopes assert "test1" in cfg.scopes assert "test2" in cfg.scopes assert "test3" in cfg.scopes active_names = [s.name for s in cfg.active_scopes] assert "defaults" in active_names + assert "tmp_path" in active_names assert "test1" in active_names assert "test2" in active_names assert "test3" in active_names + includes = str(cfg.get("include")) + assert "subdir" not in includes + assert "anotherdir" not in includes + assert "test1" in includes + assert "test2" in includes + assert "test3" in includes + def test_user_cache_path_is_overridable(working_env): p = "/some/path" diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index f3578695746e31..c5ef19420b092b 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -18,6 +18,7 @@ import stat import sys import tempfile +import textwrap import xml.etree.ElementTree from pathlib import Path from typing import Callable, List, Optional, Tuple @@ -936,6 +937,40 @@ def configuration_dir(tmp_path_factory: pytest.TempPathFactory, linux_os): modules = tmp_path / "site" / "modules.yaml" modules_template = test_config / "modules.yaml" modules.write_text(modules_template.read_text().format(tcl_root, lmod_root)) + + for scope in ("spack", "user", "site", "system"): + scope_path = tmp_path / scope + scope_path.mkdir(exist_ok=True) + + include = tmp_path / "spack" / "include.yaml" + # Need to use relative include paths here so it works for mutable_config fixture too + with include.open("w", encoding="utf-8") as f: + f.write( + textwrap.dedent( + """ + include: + # user configuration scope + - name: "user" + path_override_env_var: SPACK_USER_CONFIG_PATH + path: ../user + optional: true + prefer_modify: true + when: '"SPACK_DISABLE_LOCAL_CONFIG" not in env' + + # site configuration scope + - name: "site" + path: ../site + optional: true + + # system configuration scope + - name: "system" + path_override_env_var: SPACK_SYSTEM_CONFIG_PATH + path: ../system + optional: true + when: '"SPACK_DISABLE_LOCAL_CONFIG" not in env' + """ + ) + ) yield tmp_path @@ -948,15 +983,7 @@ def _create_mock_configuration_scopes(configuration_dir): ), ( ConfigScopePriority.CONFIG_FILES, - spack.config.DirectoryConfigScope("site", str(configuration_dir / "site")), - ), - ( - ConfigScopePriority.CONFIG_FILES, - spack.config.DirectoryConfigScope("system", str(configuration_dir / "system")), - ), - ( - ConfigScopePriority.CONFIG_FILES, - spack.config.DirectoryConfigScope("user", str(configuration_dir / "user")), + spack.config.DirectoryConfigScope("spack", str(configuration_dir / "spack")), ), (ConfigScopePriority.COMMAND_LINE, spack.config.InternalConfigScope("command_line")), ] From 7402192625f567ea3041d1b66bdf88177e033d2b Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Mon, 23 Feb 2026 15:14:21 -0800 Subject: [PATCH 084/337] update changelog for v1.0.4 (#51994) * update changelog for v1.0.4 Signed-off-by: Gregory Becker --------- Signed-off-by: Gregory Becker --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 128829a828ac05..7bd047d1685dcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,7 +207,7 @@ See the [2025.11.0 release](https://github.com/spack/spack-packages/releases/tag/v2025.11.0) of [spack-packages](https://github.com/spack/spack-packages/) for more details. -# v1.0.3 (2026-02-02) +# v1.0.4 (2026-02-23) ## Bug fixes @@ -236,6 +236,11 @@ See the [2025.11.0 release](https://github.com/spack/spack-packages/releases/tag * Documentation improvements (#51315, #51640) +# v1.0.3 (2026-02-20) + +Skipped due to a failure in the release process. + + # v1.0.2 (2025-09-11) ## Bug Fixes From 94e35f5181d1693438e8d6abd716598d2882fb3b Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 23 Feb 2026 20:18:30 -0600 Subject: [PATCH 085/337] Change package templates to use default_args for build/run deps (#51940) --- lib/spack/spack/cmd/create.py | 44 +++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/lib/spack/spack/cmd/create.py b/lib/spack/spack/cmd/create.py index 65f675de0a416b..997d72958cb7a8 100644 --- a/lib/spack/spack/cmd/create.py +++ b/lib/spack/spack/cmd/create.py @@ -173,10 +173,11 @@ class AutoreconfPackageTemplate(PackageTemplate): ) dependencies = """\ - depends_on("autoconf", type="build") - depends_on("automake", type="build") - depends_on("libtool", type="build") - depends_on("m4", type="build") + with default_args(type="build"): + depends_on("autoconf") + depends_on("automake") + depends_on("libtool") + depends_on("m4") # FIXME: Add additional dependencies if required. # depends_on("foo")""" @@ -316,7 +317,8 @@ class BazelPackageTemplate(PackageTemplate): dependencies = """\ # FIXME: Add additional dependencies if required. - depends_on("bazel", type="build")""" + with default_args(type="build"): + depends_on("bazel")""" body_def = """\ def install(self, spec, prefix): @@ -339,7 +341,8 @@ class RacketPackageTemplate(PackageTemplate): # FIXME: Add dependencies if required. Only add the racket dependency # if you need specific versions. A generic racket dependency is # added implicity by the RacketPackage class. - # depends_on("racket@8.3:", type=("build", "run"))""" + # with default_args(type=("build", "run")): + # depends_on("racket@8.3:")""" body_def = """\ # FIXME: specify the name of the package, @@ -378,13 +381,15 @@ class PythonPackageTemplate(PackageTemplate): # FIXME: Add a build backend, usually defined in pyproject.toml. If no such file # exists, use setuptools. - # depends_on("py-setuptools", type="build") - # depends_on("py-hatchling", type="build") - # depends_on("py-flit-core", type="build") - # depends_on("py-poetry-core", type="build") + # with default_args(type="build"): + # depends_on("py-setuptools") + # depends_on("py-hatchling") + # depends_on("py-flit-core") + # depends_on("py-poetry-core") # FIXME: Add additional dependencies if required. - # depends_on("py-foo", type=("build", "run"))""" + # with default_args(type=("build", "run")): + # depends_on("py-foo")""" body_def = """\ def config_settings(self, spec, prefix): @@ -458,7 +463,8 @@ class RPackageTemplate(PackageTemplate): dependencies = """\ # FIXME: Add dependencies if required. - # depends_on("r-foo", type=("build", "run"))""" + # with default_args(type=("build", "run")): + # depends_on("r-foo")""" body_def = """\ def configure_args(self): @@ -499,7 +505,8 @@ class PerlmakePackageTemplate(PackageTemplate): dependencies = """\ # FIXME: Add dependencies if required: - # depends_on("perl-foo", type=("build", "run"))""" + # with default_args(type=("build", "run")): + # depends_on("perl-foo")""" body_def = """\ def configure_args(self): @@ -526,7 +533,8 @@ class PerlbuildPackageTemplate(PerlmakePackageTemplate): depends_on("perl-module-build", type="build") # FIXME: Add additional dependencies if required: - # depends_on("perl-foo", type=("build", "run"))""" + # with default_args(type=("build", "run")): + # depends_on("perl-foo")""" class OctavePackageTemplate(PackageTemplate): @@ -539,7 +547,8 @@ class OctavePackageTemplate(PackageTemplate): extends("octave") # FIXME: Add additional dependencies if required. - # depends_on("octave-foo", type=("build", "run"))""" + # with default_args(type=("build", "run")): + # depends_on("octave-foo")""" def __init__(self, name, url, versions, languages: List[str]): # If the user provided `--name octave-splines`, don't rename it @@ -562,8 +571,9 @@ class RubyPackageTemplate(PackageTemplate): # FIXME: Add dependencies if required. Only add the ruby dependency # if you need specific versions. A generic ruby dependency is # added implicity by the RubyPackage class. - # depends_on("ruby@X.Y.Z:", type=("build", "run")) - # depends_on("ruby-foo", type=("build", "run"))""" + # with default_args(type=("build", "run")): + # depends_on("ruby@X.Y.Z:") + # depends_on("ruby-foo")""" body_def = """\ def build(self, spec, prefix): From 34eba77df2992389b4a36ed521d035433179a52d Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Mon, 23 Feb 2026 23:37:00 -0800 Subject: [PATCH 086/337] bootstrap status: fix reporting of gpg, remove otool (#51952) The issue was in bootstrap/status.py where the _buildcache_requirements() function was incorrectly using _required_system_executable instead of _required_executable for gpg. Also remove otool, which is not a required dependency. Signed-off-by: Gregory Becker --- lib/spack/spack/bootstrap/status.py | 22 ++++++----- lib/spack/spack/test/bootstrap.py | 57 ++++++++++++++++++++++++++++- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/lib/spack/spack/bootstrap/status.py b/lib/spack/spack/bootstrap/status.py index 1626fe8115832d..8a0210ead1d223 100644 --- a/lib/spack/spack/bootstrap/status.py +++ b/lib/spack/spack/bootstrap/status.py @@ -3,13 +3,13 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Query the status of bootstrapping on this machine""" import sys -from typing import Dict, List, Optional, Sequence, Tuple, Union +from typing import List, Optional, Sequence, Tuple, Union import spack.util.executable from ._common import _executables_in_store, _python_import, _try_import_from_store from .config import ensure_bootstrap_configuration -from .core import clingo_root_spec, patchelf_root_spec +from .core import clingo_root_spec, gnupg_root_spec, patchelf_root_spec from .environment import ( BootstrapEnvironment, black_root_spec, @@ -85,15 +85,17 @@ def _core_requirements() -> List[RequiredResponseType]: def _buildcache_requirements() -> List[RequiredResponseType]: - _buildcache_exes: Dict[ExecutablesType, str] = { - ("gpg2", "gpg"): _missing("gpg2", "required to sign/verify buildcaches", False) - } - if sys.platform == "darwin": - _buildcache_exes["otool"] = _missing("otool", "required to relocate binaries") - - # Executables that are not bootstrapped yet - result = [_required_system_executable(exe, msg) for exe, msg in _buildcache_exes.items()] + # Add bootstrappable executables (these can be in PATH or bootstrapped) + # GPG/GPG2 - used for signing and verifying buildcaches + result = [ + _required_executable( + ("gpg2", "gpg"), + gnupg_root_spec(), + _missing("gpg2", "required to sign/verify buildcaches", False), + ) + ] + # Patchelf - only needed on Linux, used for binary relocation if sys.platform == "linux": result.append( _required_executable( diff --git a/lib/spack/spack/test/bootstrap.py b/lib/spack/spack/test/bootstrap.py index 2a9744a397e0dd..d36ca7472a3444 100644 --- a/lib/spack/spack/test/bootstrap.py +++ b/lib/spack/spack/test/bootstrap.py @@ -10,10 +10,12 @@ import spack.bootstrap.clingo import spack.bootstrap.config import spack.bootstrap.core +import spack.bootstrap.status import spack.compilers.config import spack.config import spack.environment import spack.store +import spack.util.executable import spack.util.path from .conftest import _true @@ -206,8 +208,6 @@ def test_nested_use_of_context_manager(mutable_config): def test_status_function_find_files( mutable_config, mock_executable, tmp_path: pathlib.Path, monkeypatch, expected_missing ): - import spack.bootstrap.status - if not expected_missing: mock_executable("foo", "echo Hello WWorld!") @@ -222,6 +222,59 @@ def test_status_function_find_files( assert missing is expected_missing +@pytest.mark.parametrize( + "gpg_in_path,gpg_in_store,expected_missing", + [ + (True, False, False), # gpg exists in PATH + (False, True, False), # gpg exists in bootstrap store + (False, False, True), # gpg is missing + ], +) +def test_gpg_status_check( + mutable_config, + mock_executable, + tmp_path: pathlib.Path, + monkeypatch, + gpg_in_path, + gpg_in_store, + expected_missing, +): + """Test that gpg/gpg2 status is detected whether it's in PATH or in the bootstrap store.""" + # Set up mock PATH with or without gpg + path_dir = tmp_path / "bin" + path_dir.mkdir(exist_ok=True) + monkeypatch.setenv("PATH", str(path_dir)) + + if gpg_in_path: + mock_executable("gpg2", "echo GPG 2.3.4") + + # Mock the bootstrap store function + def mock_executables_in_store(exes, query_spec, query_info=None): + if not gpg_in_store: + return False + + # Simulate found gpg in bootstrap store + if query_info is not None: + query_info["spec"] = "gnupg@2.5.12" + query_info["command"] = spack.util.executable.Executable("gpg") + return True + + monkeypatch.setattr(spack.bootstrap.status, "_executables_in_store", mock_executables_in_store) + + # Call only the buildcache requirements function directly to isolate the test + requirements = spack.bootstrap.status._buildcache_requirements() + + # Find the gpg entry by examining the calls made to set up requirements + # We know the first entry in requirements is the gpg entry because of how + # _buildcache_requirements is structured: + # Make sure we're not out of bounds + assert len(requirements) >= 1, "No gpg requirement found" + + # Check that the gpg requirement matches our expectations + gpg_req = requirements[0] + assert gpg_req[0] is not expected_missing + + @pytest.mark.regression("31042") def test_source_is_disabled(mutable_config): # Get the configuration dictionary of the current bootstrapping source From 229aca2bf13658b5529bd7de817e8f057c9bc680 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 24 Feb 2026 18:20:54 +0100 Subject: [PATCH 087/337] new_installer.py: implement spack install -pN (#51641) In the new installer, `-j` is a global number of jobs shared by concurrent package builds. The `-p` flag was so far not implemented, and parallelism was effectively only limited by `-j`. Sometimes though, it's useful to limit the number of concurrent package builds when disk space in the stage dir is limited, or concurrent fetching is undesirable. With this change, package concurrency can be limited with `-pN`, while keeping the CPU load constant with `-jN`. The defaults in config.yaml have changes: ```yaml config: concurrent_packages: 0 ``` The value 0 means "default", which for the old installer means no package parallelism, and for the new installer means no limit on package parallelism. Signed-off-by: Harmen Stoppels --- etc/spack/defaults/base/config.yaml | 32 ++++++++++----------------- lib/spack/spack/installer.py | 7 ++++-- lib/spack/spack/new_installer.py | 28 ++++++++++++++++------- lib/spack/spack/test/new_installer.py | 30 ++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 31 deletions(-) diff --git a/etc/spack/defaults/base/config.yaml b/etc/spack/defaults/base/config.yaml index 43936bb7ec1e7f..05052f3de0ed59 100644 --- a/etc/spack/defaults/base/config.yaml +++ b/etc/spack/defaults/base/config.yaml @@ -150,28 +150,20 @@ config: # If set to 'urllib', Spack will use python built-in libs to fetch url_fetch_method: urllib - # The maximum number of jobs to use for the build system (e.g. `make`), when - # the -j flag is not given on the command line. Defaults to 16 when not set. - # Note that the maximum number of jobs is limited by the number of cores - # available, taking thread affinity into account when supported. - # For instance: - # - With `build_jobs: 16` and 4 cores available `spack install` will run `make -j4` - # - With `build_jobs: 16` and 32 cores available `spack install` will run `make -j16` - # - With `build_jobs: 2` and 4 cores available `spack install -j6` will run `make -j6` + # The maximum number of jobs to use for the build. When using the old + # installer, this is the number of jobs per package. In the new installer, + # this is the global maximum number of jobs across all packages. When fewer + # cores are available, Spack will use fewer jobs. The `-j` command line + # argument overrides this option. build_jobs: 16 - # The maximum number of concurrent package builds a single Spack instance will run, - # when the `-p` / `--concurrent-packages` flag is not given on the command line. - # Defaults to 1 when not set. - # Generally, big builds like LLVM are going to perform better with a higher -j, and - # builds with lots of dependencies may build better with higher -p. It is currently - # up to the user to balance between -j (build_jobs) and -p (concurrent_packages) - # on `spack install`, but this will be less of an issue in the future when we - # introduce support for the gmake job server and allow more dynamic parallelism. - # This, like `build_jobs`, is also limited by available cores. - # Note: This option has no effect on windows, as parallel builds are disabled on - # windows due to a lack of filesystem locks. - concurrent_packages: 1 + # The maximum number of concurrent package builds a single Spack process + # will perform. The default value of 0 means no package parallelism when using + # the old installer, and unlimited package parallelism (other than the limit + # set by build_jobs) when using the new installer. Setting this to 1 will + # disable package parallelism in both installers. This option is ignored on + # Windows. + concurrent_packages: 0 # Which installer to use: "old" or "new". The new installer is experimental. installer: old diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index 876c76219ee89d..7b6181379a2ad2 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -1528,8 +1528,11 @@ def __init__( explicit = {pkg.spec.dag_hash() for pkg in packages} if explicit else set() if concurrent_packages is None: - concurrent_packages = int(spack.config.get("config:concurrent_packages", default=1)) - self.concurrent_packages = max(1, concurrent_packages) + concurrent_packages = spack.config.get("config:concurrent_packages", default=1) + # The value 0 means no concurrency in the old installer. + if concurrent_packages == 0: + concurrent_packages = 1 + self.concurrent_packages = concurrent_packages install_args = { "dependencies_policy": dependencies_policy, diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 8fb0c783479b1f..bae93bf7ab5406 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1264,7 +1264,6 @@ def __init__( raise NotImplementedError("Stopping before an install phase is not implemented") elif tests is not False: raise NotImplementedError("Tests during install are not implemented") - # verbose and concurrent_packages are not worth erroring out for specs = [pkg.spec for pkg in packages] @@ -1318,6 +1317,15 @@ def __init__( self.running_builds: Dict[int, ChildInfo] = {} self.build_status = BuildStatus(len(self.build_graph.nodes)) self.jobs = spack.config.determine_number_of_jobs(parallel=True) + if concurrent_packages is None: + concurrent_packages_config = spack.config.get("config:concurrent_packages", 0) + # The value 0 in config means no limit (other than self.jobs) + if concurrent_packages_config == 0: + self.capacity = sys.maxsize + else: + self.capacity = concurrent_packages_config + else: + self.capacity = concurrent_packages self.reports: Dict[str, spack.report.RequestRecord] = {} def install(self) -> None: @@ -1361,10 +1369,11 @@ def _installer(self) -> None: self._start(selector, jobserver) while self.pending_builds or self.running_builds or to_insert_in_database: - # Only monitor the jobserver if we have pending builds. - if self.pending_builds and jobserver.r not in selector.get_map(): + # Only monitor the jobserver if we have pending builds and capacity. + can_schedule_more = self.pending_builds and self.capacity > 0 + if can_schedule_more and jobserver.r not in selector.get_map(): selector.register(jobserver.r, selectors.EVENT_READ, "jobserver") - elif not self.pending_builds and jobserver.r in selector.get_map(): + elif not can_schedule_more and jobserver.r in selector.get_map(): selector.unregister(jobserver.r) jobserver_token_available = False @@ -1392,6 +1401,7 @@ def _installer(self) -> None: for pid in finished_pids: build = self.running_builds.pop(pid) + self.capacity += 1 jobserver.release() build.cleanup(selector) if build.proc.exitcode == 0: @@ -1442,10 +1452,11 @@ def _installer(self) -> None: self._start(selector, jobserver) # For the rest we try to obtain tokens from the jobserver. - if self.pending_builds and jobserver_token_available: - # Then we try to schedule as many jobs as we can acquire tokens for. - max_new_jobs = len(self.pending_builds) - for _ in range(jobserver.acquire(max_new_jobs)): + if self.pending_builds and self.capacity > 0 and jobserver_token_available: + # Schedule as many jobs as we can acquire tokens for. + max_new_jobs = min(len(self.pending_builds), self.capacity) + num_acquired = jobserver.acquire(max_new_jobs) + for _ in range(num_acquired): self._start(selector, jobserver) # Finally update the UI @@ -1492,6 +1503,7 @@ def _save_to_db(self, to_insert_in_database: List[ChildInfo]) -> bool: db.lock.release_write(db._write) def _start(self, selector: selectors.BaseSelector, jobserver: JobServer) -> None: + self.capacity -= 1 dag_hash = self.pending_builds.pop() explicit = dag_hash in self.explicit spec = self.build_graph.nodes[dag_hash] diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index fa649b00e5d1c4..4f84f6891858c1 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -12,7 +12,8 @@ pytest.skip("No Windows support", allow_module_level=True) import spack.error -from spack.new_installer import OVERWRITE_GARBAGE_SUFFIX, PrefixPivoter +import spack.spec +from spack.new_installer import OVERWRITE_GARBAGE_SUFFIX, PackageInstaller, PrefixPivoter @pytest.fixture @@ -199,3 +200,30 @@ def test_garbage_move_failure_leaves_backup( assert (existing_prefix / "partial_file").exists() # Backup directory, failed prefix, and empty garbage directory should exist assert len(list(tmp_path.iterdir())) == 3 + + +class TestPackageInstallerConstructor: + """Tests for PackageInstaller constructor, especially capacity initialization.""" + + def test_capacity_explicit_concurrent_packages(self, temporary_store, mock_packages): + """Test that capacity is set correctly when concurrent_packages is explicitly provided.""" + spec = spack.spec.Spec("trivial-install-test-package") + spec._mark_concrete() + assert PackageInstaller([spec.package], concurrent_packages=5).capacity == 5 + assert PackageInstaller([spec.package], concurrent_packages=1).capacity == 1 + + def test_capacity_from_config_default_one( + self, temporary_store, mock_packages, mutable_config + ): + """Test that config value of 0 is treated as unlimited.""" + mutable_config.set("config:concurrent_packages", 0) + spec = spack.spec.Spec("trivial-install-test-package") + spec._mark_concrete() + assert PackageInstaller([spec.package]).capacity == sys.maxsize + + def test_capacity_from_config_non_zero(self, temporary_store, mock_packages, mutable_config): + """Test that non-0 config values are used as-is.""" + mutable_config.set("config:concurrent_packages", 1) + spec = spack.spec.Spec("trivial-install-test-package") + spec._mark_concrete() + assert PackageInstaller([spec.package]).capacity == 1 From 9508d531c714ddc3a0cf735663951ceed0a6bc79 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 24 Feb 2026 18:56:07 +0100 Subject: [PATCH 088/337] package_base.py: PackageViewMixin.spec typehint (#52002) Otherwise `ty` resolves `PackageBase.spec` to `Unknown | Spec` Signed-off-by: Harmen Stoppels --- lib/spack/spack/package_base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/spack/spack/package_base.py b/lib/spack/spack/package_base.py index b1a935cd49010d..e5fc56c000da4a 100644 --- a/lib/spack/spack/package_base.py +++ b/lib/spack/spack/package_base.py @@ -338,6 +338,8 @@ class PackageViewMixin: overriding these functions. """ + spec: spack.spec.Spec + def view_source(self): """The source root directory that will be added to the view: files are added such that their path relative to the view destination matches From cf48a8f3fe73d5aa830b13bfef638c3a67c07974 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 24 Feb 2026 20:37:47 +0100 Subject: [PATCH 089/337] solver: choose `virtual_on_edge` and derive `depends_on` (#52003) * solver: choose attr("virtual_on_edge") Signed-off-by: Massimiliano Culpo * solver: simplify virtual_edge_needed Signed-off-by: Massimiliano Culpo --------- Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index b0eff29dd15ae3..d552375dec21d8 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -76,7 +76,7 @@ attr("namespace", node(ID, Package), Namespace) % Rules on "unification sets", i.e. on sets of nodes allowing a single configuration of any given package unify(SetID, PackageName) :- unification_set(SetID, node(_, PackageName)). -error(1, "Cannot have multiple nodes for {0} in the same unification set {1}", PackageName, SetID) +error(100000, "Cannot have multiple nodes for {0} in the same unification set {1}", PackageName, SetID) :- 2 { unification_set(SetID, node(_, PackageName)) }, unify(SetID, PackageName). unification_set("root", PackageNode) :- attr("root", PackageNode). @@ -876,9 +876,8 @@ edge_needed(ParentNode, node(X, Child)) :- attr("dependency_holds", ParentNode, Child, _). virtual_edge_needed(ParentNode, ChildNode, node(X, Virtual)) :- - depends_on(ParentNode, ChildNode), build(ParentNode), - node_depends_on_virtual(ParentNode, Virtual), + attr("virtual_on_edge", ParentNode, ChildNode, Virtual), provider(ChildNode, node(X, Virtual)). virtual_edge_needed(ParentNode, ChildNode, node(X, Virtual)) :- @@ -979,13 +978,12 @@ node_depends_on_virtual(PackageNode, Virtual, Type) node_depends_on_virtual(PackageNode, Virtual) :- node_depends_on_virtual(PackageNode, Virtual, Type). -1 { attr("depends_on", PackageNode, ProviderNode, Type) : provider(ProviderNode, node(VirtualID, Virtual)) } 1 +1 { attr("virtual_on_edge", PackageNode, ProviderNode, Virtual) : provider(ProviderNode, node(VirtualID, Virtual)) } 1 :- node_depends_on_virtual(PackageNode, Virtual, Type). -attr("virtual_on_edge", PackageNode, ProviderNode, Virtual) - :- attr("dependency_holds", PackageNode, Virtual, Type), - attr("depends_on", PackageNode, ProviderNode, Type), - provider(ProviderNode, node(_, Virtual)). +attr("depends_on", PackageNode, ProviderNode, Type) + :- attr("virtual_on_edge", PackageNode, ProviderNode, Virtual), + node_depends_on_virtual(PackageNode, Virtual, Type). % If a virtual node is in the answer set, it must be either a virtual root, % or used somewhere From 34ea965a24341ec4a831fe69c3609e4393d7d665 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 24 Feb 2026 20:45:39 +0100 Subject: [PATCH 090/337] spack-completion.bash: completion after --foo bar (#51974) Fixes an issue that has bothered me for years. If you do ``` spack buildcache push --base-image foo -- ``` you get nothing, because Spack looks for a completion function ``` _spack_buildcache_push_foo ``` cause it doesn't know that `foo` is a value. This fixes that by keeping track of the last function that exists and use that for tab completion instead, which in this case is ``` _spack_buildcache_push ``` Signed-off-by: Harmen Stoppels --- share/spack/bash/spack-completion.bash | 42 ++++++++++++-------------- share/spack/spack-completion.bash | 42 ++++++++++++-------------- 2 files changed, 38 insertions(+), 46 deletions(-) diff --git a/share/spack/bash/spack-completion.bash b/share/spack/bash/spack-completion.bash index 5fc7f554c20549..aa5072ed43013a 100755 --- a/share/spack/bash/spack-completion.bash +++ b/share/spack/bash/spack-completion.bash @@ -70,28 +70,30 @@ _bash_completion_spack() { # In all following examples, let the cursor be denoted by brackets, i.e. [] # For our purposes, flags should not affect tab completion. For instance, - # `spack install []` and `spack -d install --jobs 8 []` should both give the same - # possible completions. Therefore, we need to ignore any flags in COMP_WORDS. + # `spack install []` and `spack -d install --jobs 8 []` should both give the + # same possible completions. Therefore, we need to ignore any flags in + # COMP_WORDS. We do this by navigating the subcommand tree level-by-level: a + # non-flag word is only kept if a completion function exists for the + # resulting path. local -a COMP_WORDS_NO_FLAGS - local index=0 + COMP_WORDS_NO_FLAGS=("spack") + local subfunction="_spack" + local index=1 while [[ "$index" -lt "$COMP_CWORD" ]] do - if [[ "${COMP_WORDS[$index]}" == [a-z]* ]] + local word="${COMP_WORDS[$index]}" + if [[ "$word" != -* ]] then - COMP_WORDS_NO_FLAGS+=("${COMP_WORDS[$index]}") + local candidate="${subfunction}_${word//-/_}" + if declare -f "$candidate" > /dev/null 2>&1 + then + COMP_WORDS_NO_FLAGS+=("$word") + subfunction="$candidate" + fi fi - let index++ + ((index++)) done - # Options will be listed by a subfunction named after non-flag arguments. - # For example, `spack -d install []` will call _spack_install - # and `spack compiler add []` will call _spack_compiler_add - local subfunction=$(IFS='_'; echo "_${COMP_WORDS_NO_FLAGS[*]}") - - # Translate dashes to underscores, as dashes are not permitted in - # compatibility mode. See https://github.com/spack/spack/pull/4079 - subfunction=${subfunction//-/_} - # However, the word containing the current cursor position needs to be # added regardless of whether or not it is a flag. This allows us to # complete something like `spack install --keep-st[]` @@ -144,14 +146,8 @@ _bash_completion_spack() { # Uncomment this line to enable logging #_test_vars >> temp - # Make sure function exists before calling it - local rgx #this dance is necessary to cover bash and zsh regex - rgx="$subfunction.*function.* " - if [[ "$(LC_ALL=C type $subfunction 2>&1)" =~ $rgx ]] - then - $subfunction - COMPREPLY=($(_compgen_w "$SPACK_COMPREPLY" "$cur")) - fi + $subfunction + COMPREPLY=($(_compgen_w "$SPACK_COMPREPLY" "$cur")) # if every completion is an alias for the same thing, just return that thing. _spack_compress_aliases diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 67a2c10fe50baf..e4b33c20705564 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -70,28 +70,30 @@ _bash_completion_spack() { # In all following examples, let the cursor be denoted by brackets, i.e. [] # For our purposes, flags should not affect tab completion. For instance, - # `spack install []` and `spack -d install --jobs 8 []` should both give the same - # possible completions. Therefore, we need to ignore any flags in COMP_WORDS. + # `spack install []` and `spack -d install --jobs 8 []` should both give the + # same possible completions. Therefore, we need to ignore any flags in + # COMP_WORDS. We do this by navigating the subcommand tree level-by-level: a + # non-flag word is only kept if a completion function exists for the + # resulting path. local -a COMP_WORDS_NO_FLAGS - local index=0 + COMP_WORDS_NO_FLAGS=("spack") + local subfunction="_spack" + local index=1 while [[ "$index" -lt "$COMP_CWORD" ]] do - if [[ "${COMP_WORDS[$index]}" == [a-z]* ]] + local word="${COMP_WORDS[$index]}" + if [[ "$word" != -* ]] then - COMP_WORDS_NO_FLAGS+=("${COMP_WORDS[$index]}") + local candidate="${subfunction}_${word//-/_}" + if declare -f "$candidate" > /dev/null 2>&1 + then + COMP_WORDS_NO_FLAGS+=("$word") + subfunction="$candidate" + fi fi - let index++ + ((index++)) done - # Options will be listed by a subfunction named after non-flag arguments. - # For example, `spack -d install []` will call _spack_install - # and `spack compiler add []` will call _spack_compiler_add - local subfunction=$(IFS='_'; echo "_${COMP_WORDS_NO_FLAGS[*]}") - - # Translate dashes to underscores, as dashes are not permitted in - # compatibility mode. See https://github.com/spack/spack/pull/4079 - subfunction=${subfunction//-/_} - # However, the word containing the current cursor position needs to be # added regardless of whether or not it is a flag. This allows us to # complete something like `spack install --keep-st[]` @@ -144,14 +146,8 @@ _bash_completion_spack() { # Uncomment this line to enable logging #_test_vars >> temp - # Make sure function exists before calling it - local rgx #this dance is necessary to cover bash and zsh regex - rgx="$subfunction.*function.* " - if [[ "$(LC_ALL=C type $subfunction 2>&1)" =~ $rgx ]] - then - $subfunction - COMPREPLY=($(_compgen_w "$SPACK_COMPREPLY" "$cur")) - fi + $subfunction + COMPREPLY=($(_compgen_w "$SPACK_COMPREPLY" "$cur")) # if every completion is an alias for the same thing, just return that thing. _spack_compress_aliases From db75cddc2aa9285a563ac71afef69db416303f86 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 24 Feb 2026 21:05:03 +0100 Subject: [PATCH 091/337] new_installer.py: parse build log on failure (#52000) The failure message referenced s.package.log_path, a symlink that may not exist if the build fails before the stage is created. Now the parent creates the log file via tempfile.mkstemp, passes the path to the child, and the failure message always references a real, existing file. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 44 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index bae93bf7ab5406..05debb91811a58 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -87,7 +87,16 @@ class ChildInfo: """Information about a child process.""" - __slots__ = ("proc", "spec", "output_r_conn", "state_r_conn", "control_w_conn", "explicit") + __slots__ = ( + "proc", + "spec", + "output_r_conn", + "state_r_conn", + "control_w_conn", + "explicit", + "prefix_lock", + "log_path", + ) def __init__( self, @@ -97,6 +106,7 @@ def __init__( state_r_conn: Connection, control_w_conn: Connection, explicit: bool = False, + log_path: str = "", ) -> None: self.proc = proc self.spec = spec @@ -104,6 +114,7 @@ def __init__( self.state_r_conn = state_r_conn self.control_w_conn = control_w_conn self.explicit = explicit + self.log_path = log_path def cleanup(self, selector: selectors.BaseSelector) -> None: """Unregister and close file descriptors, and join the child process.""" @@ -331,6 +342,7 @@ def worker_function( js2: Optional[Connection], store: spack.store.Store, config: spack.config.Configuration, + log_path: str = "", ): """ Function run in the build child process. Installs the specified spec, sending state updates @@ -383,12 +395,8 @@ def handle_sigterm(signum, frame): spack.config.CONFIG = config spack.paths.set_working_dir() - # Create a log file in the root of the stage dir. - log_fd, log_path = tempfile.mkstemp( - prefix=f"spack-stage-{spec.name}-{spec.dag_hash()}-", - suffix=".log", - dir=spack.stage.get_stage_root(), - ) + # Open the log file created by the parent process. + log_fd = os.open(log_path, os.O_WRONLY | os.O_TRUNC) tee = Tee(echo_control, parent, log_fd) # Use closedfd=false because of the connection objects. Use line buffering. @@ -627,6 +635,13 @@ def start_build( makeflags = jobserver.makeflags(gmake) fifo = "--jobserver-auth=fifo:" in makeflags + log_fd, log_path = tempfile.mkstemp( + prefix=f"spack-stage-{spec.name}-{spec.dag_hash()}-", + suffix=".log", + dir=spack.stage.get_stage_root(), + ) + os.close(log_fd) # child will open it + proc = Process( target=worker_function, args=( @@ -649,6 +664,7 @@ def start_build( None if fifo else jobserver.w_conn, spack.store.STORE, spack.config.CONFIG, + log_path, ), ) proc.start() @@ -662,7 +678,7 @@ def start_build( os.set_blocking(output_r_conn.fileno(), False) os.set_blocking(state_r_conn.fileno(), False) - return ChildInfo(proc, spec, output_r_conn, state_r_conn, control_w_conn, explicit) + return ChildInfo(proc, spec, output_r_conn, state_r_conn, control_w_conn, explicit, log_path) def get_jobserver_config(makeflags: Optional[str] = None) -> Optional[Union[str, Tuple[int, int]]]: @@ -1315,6 +1331,7 @@ def __init__( self.explicit = explicit self.running_builds: Dict[int, ChildInfo] = {} + self.log_paths: Dict[str, str] = {} self.build_status = BuildStatus(len(self.build_graph.nodes)) self.jobs = spack.config.determine_number_of_jobs(parallel=True) if concurrent_packages is None: @@ -1482,7 +1499,15 @@ def _installer(self) -> None: jobserver.close() if failures: - lines = [f"{s}: {s.package.log_path}" for s in failures] + for s in failures: + log_path = self.log_paths.get(s.dag_hash()) + if log_path and os.path.exists(log_path): + out = io.StringIO() + spack.build_environment.write_log_summary(out, f"{s} build", log_path) + summary = out.getvalue() + if summary: + sys.stderr.write(summary) + lines = [f"{s}: {self.log_paths[s.dag_hash()]}" for s in failures] raise spack.error.InstallError( "The following packages failed to install:\n" + "\n".join(lines) ) @@ -1527,6 +1552,7 @@ def _start(self, selector: selectors.BaseSelector, jobserver: JobServer) -> None skip_patch=self.skip_patch, jobserver=jobserver, ) + self.log_paths[dag_hash] = child_info.log_path pid = child_info.proc.pid assert type(pid) is int self.running_builds[pid] = child_info From c8d02b284e645bb43da880a0b081ad23541ffdc1 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 25 Feb 2026 07:53:36 +0100 Subject: [PATCH 092/337] solver: turn a choice rule into an integrity constraint (#52001) It probably doesn't matter much for performance, but the dimension is reduced like: O(Package x Virtual) -> O(Virtual) Also, integrity constraint should help the USC strategy to learn clauses faster. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index d552375dec21d8..d919946b7311fe 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -977,6 +977,7 @@ node_depends_on_virtual(PackageNode, Virtual, Type) virtual(Virtual). node_depends_on_virtual(PackageNode, Virtual) :- node_depends_on_virtual(PackageNode, Virtual, Type). +virtual_is_needed(Virtual) :- node_depends_on_virtual(PackageNode, Virtual). 1 { attr("virtual_on_edge", PackageNode, ProviderNode, Virtual) : provider(ProviderNode, node(VirtualID, Virtual)) } 1 :- node_depends_on_virtual(PackageNode, Virtual, Type). @@ -1001,9 +1002,8 @@ attr("virtual_on_incoming_edges", ProviderNode, Virtual) attr("root", ProviderNode), provider(ProviderNode, node(min_dupe_id, Virtual)). -% dependencies on virtuals also imply that the virtual is a virtual node -1 { attr("virtual_node", node(0..X-1, Virtual)) : max_dupes(Virtual, X) } - :- node_depends_on_virtual(PackageNode, Virtual). +% If a virtual is needed on an edge, at least one virtual node must exist +:- virtual_is_needed(Virtual), not 1 { attr("virtual_node", node(0..X-1, Virtual)) : max_dupes(Virtual, X) }. % If there's a virtual node, we must select one and only one provider. % The provider must be selected among the possible providers. From ed39087cc3db5c6525dc626260e17a37428bd3f3 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 25 Feb 2026 08:58:29 +0100 Subject: [PATCH 093/337] new_installer.py: small log file improvements (#51998) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 05debb91811a58..f694f4d36fb0e5 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -105,16 +105,16 @@ def __init__( output_r_conn: Connection, state_r_conn: Connection, control_w_conn: Connection, + log_path: str, explicit: bool = False, - log_path: str = "", ) -> None: self.proc = proc self.spec = spec self.output_r_conn = output_r_conn self.state_r_conn = state_r_conn self.control_w_conn = control_w_conn - self.explicit = explicit self.log_path = log_path + self.explicit = explicit def cleanup(self, selector: selectors.BaseSelector) -> None: """Unregister and close file descriptors, and join the child process.""" @@ -342,7 +342,7 @@ def worker_function( js2: Optional[Connection], store: spack.store.Store, config: spack.config.Configuration, - log_path: str = "", + log_path: str, ): """ Function run in the build child process. Installs the specified spec, sending state updates @@ -368,6 +368,7 @@ def worker_function( js2: Connection for old style jobserver write fd (if any). Unused, just to inherit fd. store: global store instance from parent config: global config instance from parent + log_path: Path to the log file to write build output to """ # TODO: don't start a build for external packages @@ -396,7 +397,7 @@ def handle_sigterm(signum, frame): spack.paths.set_working_dir() # Open the log file created by the parent process. - log_fd = os.open(log_path, os.O_WRONLY | os.O_TRUNC) + log_fd = os.open(log_path, os.O_WRONLY | os.O_TRUNC, 0o644) tee = Tee(echo_control, parent, log_fd) # Use closedfd=false because of the connection objects. Use line buffering. @@ -636,7 +637,7 @@ def start_build( fifo = "--jobserver-auth=fifo:" in makeflags log_fd, log_path = tempfile.mkstemp( - prefix=f"spack-stage-{spec.name}-{spec.dag_hash()}-", + prefix=f"spack-stage-{spec.name}-{spec.version}-{spec.dag_hash()}-", suffix=".log", dir=spack.stage.get_stage_root(), ) @@ -678,7 +679,7 @@ def start_build( os.set_blocking(output_r_conn.fileno(), False) os.set_blocking(state_r_conn.fileno(), False) - return ChildInfo(proc, spec, output_r_conn, state_r_conn, control_w_conn, explicit, log_path) + return ChildInfo(proc, spec, output_r_conn, state_r_conn, control_w_conn, log_path, explicit) def get_jobserver_config(makeflags: Optional[str] = None) -> Optional[Union[str, Tuple[int, int]]]: From 23461b833cb42386190c4e3382662c6e4aed6f39 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 25 Feb 2026 18:48:55 +0100 Subject: [PATCH 094/337] new_installer.py: delayed database write (#51951) This makes the installer a bit snappier in the UI, and reduces the latency for short-running installs (e.g. python packages and installs from build cache). The idea is to save installed specs to the database only if in the last 5 seconds no other specs finished to build. Builds of parents are now started before the child is stored in the database. This reduces latency significantly; previously builds of parents were only started when the database was updated (a few hundred milliseconds later). For multi-spack-process parallel builds, this will mean that the Spack process continues to hold a write lock after the spec has been installed, until it's persisted to the database. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 50 +++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index f694f4d36fb0e5..b56357408b5ac2 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -74,6 +74,9 @@ #: How long to display finished packages before graying them out CLEANUP_TIMEOUT = 2.0 +#: How often to flush completed builds to the database +DATABASE_WRITE_INTERVAL = 5.0 + #: Size of the output buffer for child processes OUTPUT_BUFFER_SIZE = 4096 @@ -1378,7 +1381,10 @@ def _installer(self) -> None: spack.store.STORE.db.lock.path ) - to_insert_in_database: List[ChildInfo] = [] + # Finished builds that have not yet been written to the database. + finished_builds: List[ChildInfo] = [] + next_database_write = 0.0 + failures: List[spack.spec.Spec] = [] try: @@ -1386,7 +1392,7 @@ def _installer(self) -> None: if self.pending_builds and not self.running_builds: self._start(selector, jobserver) - while self.pending_builds or self.running_builds or to_insert_in_database: + while self.pending_builds or self.running_builds or finished_builds: # Only monitor the jobserver if we have pending builds and capacity. can_schedule_more = self.pending_builds and self.capacity > 0 if can_schedule_more and jobserver.r not in selector.get_map(): @@ -1417,13 +1423,19 @@ def _installer(self) -> None: elif data == "stdin": stdin_ready = True + current_time = time.monotonic() for pid in finished_pids: build = self.running_builds.pop(pid) self.capacity += 1 jobserver.release() build.cleanup(selector) if build.proc.exitcode == 0: - to_insert_in_database.append(build) + # Add successful builds for database insertion (after a short delay) + finished_builds.append(build) + self.build_graph.enqueue_parents( + build.spec.dag_hash(), self.pending_builds + ) + next_database_write = current_time + DATABASE_WRITE_INTERVAL self.build_status.update_state(build.spec.dag_hash(), "finished") elif not self.fail_fast or not failures: # In fail-fast mode, only record the first failure. Subsequent failures may @@ -1456,14 +1468,19 @@ def _installer(self) -> None: elif char == "p" or char == "N": self.build_status.next(-1) - # Flush installed packages to the database and enqueue any parents that are now - # ready. - if to_insert_in_database and self._save_to_db(to_insert_in_database): - for entry in to_insert_in_database: - self.build_graph.enqueue_parents( - entry.spec.dag_hash(), self.pending_builds - ) - to_insert_in_database.clear() + # Insert into the database if we have any finished builds, and either the delay + # interval has passed, or we're done with all builds. The database save is not + # guaranteed; it fails if another process holds the lock. We'll try again next + # iteration of the event loop in that case. + if ( + finished_builds + and ( + current_time >= next_database_write + or not (self.pending_builds or self.running_builds) + ) + and self._save_to_db(finished_builds) + ): + finished_builds.clear() # Again, the first job should start immediately and does not require a token. if self.pending_builds and not self.running_builds: @@ -1488,6 +1505,11 @@ def _installer(self) -> None: child.proc.join() raise finally: + # Make sure to write any successful builds to the database before exiting + with spack.store.STORE.db.write_transaction(): + for build in finished_builds: + spack.store.STORE.db._add(build.spec, explicit=build.explicit) + # Restore terminal settings if old_stdin_settings: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_stdin_settings) @@ -1513,7 +1535,7 @@ def _installer(self) -> None: "The following packages failed to install:\n" + "\n".join(lines) ) - def _save_to_db(self, to_insert_in_database: List[ChildInfo]) -> bool: + def _save_to_db(self, finished_builds: List[ChildInfo]) -> bool: db = spack.store.STORE.db try: # Only try to get the lock once (non-blocking). If it fails, try it next time. @@ -1522,8 +1544,8 @@ def _save_to_db(self, to_insert_in_database: List[ChildInfo]) -> bool: except spack.util.lock.LockTimeoutError: return False try: - for entry in to_insert_in_database: - db._add(entry.spec, explicit=entry.explicit) + for build in finished_builds: + db._add(build.spec, explicit=build.explicit) return True finally: db.lock.release_write(db._write) From 1345bf392d26ea8923d041fdc05e0507ffe067a1 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:12:40 -0800 Subject: [PATCH 095/337] Include parent scopes: consistent validation, add unit test (#51966) * Include parent scopes: tweak validation, add unit test Signed-off-by: tldahlgren --- lib/spack/spack/config.py | 14 ++++++-------- lib/spack/spack/test/config.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 64578813eee9aa..0f2175b19a08db 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -1042,9 +1042,8 @@ def _scope( Raises: ValueError: the required configuration path does not exist """ - assert self._valid_parent_scope( - parent_scope - ), "Optional includes must have valid parent_scope object" + # Ensure the parent scope is valid + self._validate_parent_scope(parent_scope) # use specified name if there is one config_name = self.name @@ -1100,15 +1099,14 @@ def _scope( config_name, config_path, prefer_modify=self.prefer_modify, included=True ) - def _valid_parent_scope(self, parent_scope: ConfigScope) -> bool: + def _validate_parent_scope(self, parent_scope: ConfigScope): """Validates that a parent scope is a valid configuration object""" # enforced by type checking but those can always be # type: ignore'd assert isinstance( parent_scope, ConfigScope - ), f"Optional include must have valid parent scope,\ - of type ConfigScope; Type:{type(parent_scope)} is not valid." - # naive check that parent scope name isn't empty or just whitespace - return bool(re.sub(r"\s", "", parent_scope.name)) + ), f"Includes must be within a configuration scope (ConfigScope), not {type(parent_scope)}" + + assert parent_scope.name.strip(), "Parent scope of an include must have a name" def evaluate_condition(self) -> bool: # circular dependencies diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index c077b86f4f89db..6fcf690f743ac0 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -2009,3 +2009,21 @@ def test_config_scope_empty_write(tmp_path: pathlib.Path): config_scope = spack.config.DirectoryConfigScope("test", str(tmp_path)) assert config_scope.get_section("include") is None + + +def test_include_bad_parent_scope(tmp_path: pathlib.Path): + """Test parent scope validation.""" + path = tmp_path / "config.yaml" + path.touch() + entry = {"path": str(path)} + include = spack.config.included_path(entry) + + # Confirm require a ConfigScope parent + with pytest.raises(AssertionError, match="configuration scope"): + _ = include.scopes("_builtin") # type: ignore + + # Confirm require a named parent scope + for name in ["", " "]: + parent_scope = spack.config.InternalConfigScope(name, spack.config.CONFIG_DEFAULTS) + with pytest.raises(AssertionError, match="must have a name"): + _ = include.scopes(parent_scope) From 2e5c7c0b7ca01e643572d6efe20e5a5c5d123010 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 26 Feb 2026 08:18:19 +0100 Subject: [PATCH 096/337] tests: isolate binary index cache in some tests (#52007) Signed-off-by: Harmen Stoppels --- lib/spack/spack/test/binary_distribution.py | 27 ++++++++++++++++----- lib/spack/spack/test/cmd/ci.py | 7 +++++- lib/spack/spack/test/conftest.py | 7 ++++-- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/lib/spack/spack/test/binary_distribution.py b/lib/spack/spack/test/binary_distribution.py index d677a53c35bf08..46812ec6821540 100644 --- a/lib/spack/spack/test/binary_distribution.py +++ b/lib/spack/spack/test/binary_distribution.py @@ -273,8 +273,11 @@ def test_use_bin_index(monkeypatch, tmp_path: pathlib.Path, mutable_config): """Check use of binary cache index: perform an operation that instantiates it, and a second operation that reconstructs it. """ + index_cache_root = str(tmp_path / "index_cache") monkeypatch.setattr( - spack.binary_distribution, "BINARY_INDEX", spack.binary_distribution.BinaryCacheIndex() + spack.binary_distribution, + "BINARY_INDEX", + spack.binary_distribution.BinaryCacheIndex(index_cache_root), ) # Create a mirror, configure us to point at it, install a spec, and @@ -289,7 +292,9 @@ def test_use_bin_index(monkeypatch, tmp_path: pathlib.Path, mutable_config): # Now the test buildcache_cmd("list", "-al") - spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex() + spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex( + index_cache_root + ) cache_list = buildcache_cmd("list", "-al") assert "libdwarf" in cache_list @@ -301,8 +306,11 @@ def test_use_bin_index_active_env_with_view( """Check use of binary cache index: perform an operation that instantiates it, and a second operation that reconstructs it. """ + index_cache_root = str(tmp_path / "index_cache") monkeypatch.setattr( - spack.binary_distribution, "BINARY_INDEX", spack.binary_distribution.BinaryCacheIndex() + spack.binary_distribution, + "BINARY_INDEX", + spack.binary_distribution.BinaryCacheIndex(index_cache_root), ) # Create a mirror, configure us to point at it, install a spec, and @@ -321,7 +329,9 @@ def test_use_bin_index_active_env_with_view( # Now the test buildcache_cmd("list", "-al") - spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex() + spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex( + index_cache_root + ) cache_list = buildcache_cmd("list", "-al") assert "libdwarf" in cache_list @@ -333,8 +343,11 @@ def test_use_bin_index_with_view( """Check use of binary cache index: perform an operation that instantiates it, and a second operation that reconstructs it. """ + index_cache_root = str(tmp_path / "index_cache") monkeypatch.setattr( - spack.binary_distribution, "BINARY_INDEX", spack.binary_distribution.BinaryCacheIndex() + spack.binary_distribution, + "BINARY_INDEX", + spack.binary_distribution.BinaryCacheIndex(index_cache_root), ) # Create a mirror, configure us to point at it, install a spec, and @@ -354,7 +367,9 @@ def test_use_bin_index_with_view( # Now the test buildcache_cmd("list", "-al") - spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex() + spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex( + index_cache_root + ) cache_list = buildcache_cmd("list", "-al") assert "libdwarf" in cache_list diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index 5255aa838d72de..cba5469f4fcb5c 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -1047,7 +1047,12 @@ def test_ci_generate_override_runner_attrs( def test_ci_rebuild_index( - tmp_path: pathlib.Path, working_env, mutable_mock_env_path, install_mockery, mock_fetch + tmp_path: pathlib.Path, + working_env, + mutable_mock_env_path, + install_mockery, + mock_fetch, + mock_binary_index, ): scratch = tmp_path / "working_dir" mirror_dir = scratch / "mirror" diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index c5ef19420b092b..b09a73984bcba1 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -1410,18 +1410,21 @@ def module_configuration(monkeypatch, request, mutable_config): @pytest.fixture() -def mock_gnupghome(monkeypatch): +def mock_gnupghome(monkeypatch, tmp_path): # GNU PGP can't handle paths longer than 108 characters (wtf!@#$) so we # have to make our own tmp_path with a shorter name than pytest's. # This comes up because tmp paths on macOS are already long-ish, and # pytest makes them longer. + short_name_tmpdir = tempfile.mkdtemp() + # Redirect bootstrap root before gpg.init() so each xdist worker writes + # bootstrap config to its own isolated directory. + monkeypatch.setattr(spack.paths, "default_user_bootstrap_path", str(tmp_path / "bootstrap")) try: spack.util.gpg.init() except spack.util.gpg.SpackGPGError: if not spack.util.gpg.GPG: pytest.skip("This test requires gpg") - short_name_tmpdir = tempfile.mkdtemp() with spack.util.gpg.gnupghome_override(short_name_tmpdir): yield short_name_tmpdir From bdfad3cad492b31c285110b65f80c3c0bd33f3a0 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 26 Feb 2026 08:45:26 +0100 Subject: [PATCH 097/337] environment: allow group of specs with dependencies (#51891) Currently, there are scenarios and use cases that are difficult to express with a single `spack.yaml` file. For instance: 1. Start with an old system compiler to bootstrap a new toolchain, and then software on top of that 2. Deploy a highly heterogeneous stack (different compilers, different options for the same set of specs) while ensuring a fine control over dependencies These use cases are usually dealt with an iterative approach or multiple environments. Both methods are sub-optimal and error prone. In this commit we solve these kind of issues by allowing named group of specs, as shown in #49097. Each named group of specs: - Can have dependencies on other groups. If that happens, the dependency groups are concretized before the current group, and their roots specs are always available for reuse in the current concretization. - Can override configuration scopes with details that matter only for the current group, since the override is then used _only_ for the concretization of the current group Signed-off-by: Massimiliano Culpo --- lib/spack/docs/conf.py | 1 + lib/spack/docs/environments.rst | 113 ++++ lib/spack/spack/ci/__init__.py | 8 +- lib/spack/spack/cmd/find.py | 2 +- lib/spack/spack/concretize.py | 58 +- lib/spack/spack/enums.py | 2 + lib/spack/spack/environment/__init__.py | 51 +- lib/spack/spack/environment/environment.py | 586 ++++++++++++++---- lib/spack/spack/filesystem_view.py | 6 +- lib/spack/spack/llnl/string.py | 6 +- lib/spack/spack/schema/env.py | 51 +- lib/spack/spack/schema/spec_list.py | 19 +- lib/spack/spack/schema/view.py | 15 +- lib/spack/spack/solver/asp.py | 11 +- lib/spack/spack/solver/reuse.py | 210 ++----- lib/spack/spack/spec_filter.py | 48 ++ lib/spack/spack/test/cmd/concretize.py | 16 +- lib/spack/spack/test/cmd/develop.py | 4 +- lib/spack/spack/test/cmd/env.py | 55 ++ .../test/concretization/compiler_runtimes.py | 4 +- lib/spack/spack/test/concretization/core.py | 15 +- .../spack/test/concretization/requirements.py | 6 +- lib/spack/spack/test/env.py | 342 +++++++++- 23 files changed, 1308 insertions(+), 321 deletions(-) create mode 100644 lib/spack/spack/spec_filter.py diff --git a/lib/spack/docs/conf.py b/lib/spack/docs/conf.py index 97724413a500c7..0c1546b10c5088 100644 --- a/lib/spack/docs/conf.py +++ b/lib/spack/docs/conf.py @@ -366,6 +366,7 @@ def setup(sphinx): ("py:class", "spack.traverse.EdgeAndDepth"), ("py:class", "spack.vendor.archspec.cpu.microarchitecture.Microarchitecture"), ("py:class", "spack.vendor.jinja2.Environment"), + ("py:class", "SpecFiltersFactory"), # TypeVar that is not handled correctly ("py:class", "spack.llnl.util.lang.ClassPropertyType"), ("py:class", "spack.llnl.util.lang.K"), diff --git a/lib/spack/docs/environments.rst b/lib/spack/docs/environments.rst index fbf2db10db12d0..fc1683fdab918c 100644 --- a/lib/spack/docs/environments.rst +++ b/lib/spack/docs/environments.rst @@ -925,6 +925,83 @@ For example, the following environment has three root packages: ``gcc@8.1.0``, ` This allows for a much-needed reduction in redundancy between packages and constraints. +.. _environment-spec-groups: + +Spec Groups +^^^^^^^^^^^ + +.. versionadded:: 1.2 + +Environments can be organized with named spec groups, enabling you to apply localized configuration overrides and establish concretization dependencies. +This is extremely useful in a couple of common scenarios, as detailed below. + +.. _environment-spec-groups-bootstrapping-compiler: + +Building and using a compiler in a single environment +""""""""""""""""""""""""""""""""""""""""""""""""""""" + +A common use case is to build a recent compiler on top of an existing system and then compile a stack of software with it. +For instance, assume we are interested in building ``hdf5`` and ``libtree`` with ``gcc@15.2``. +The following manifest file would do exactly that: + +.. code-block:: yaml + + spack: + specs: + - group: compiler + specs: + - gcc@15.2 + + - group: apps + needs: [compiler] + specs: + - hdf5 %gcc@15.2 + - libtree %gcc@15.2 + +The ``group:`` attribute allows to name a group of specs, which are then listed under the ``specs:`` attribute in the same object. +The simplest example is the ``compiler`` group composed of just the ``gcc@15.2`` spec. + +To express dependencies among groups of specs the ``needs:`` attribute is used, which is a list of names corresponding to the groups we depend on. +The way this works is that group dependencies are always concretized *before* the current group, and their specs are *always* available for reuse when the current group is concretized. + +.. _environment-spec-groups-configuring-groups: + +Configuring a group of specs +"""""""""""""""""""""""""""" + +Another common scenario is the deployment of different configurations (e.g. CUDA enabled vs. +ROCm enabled) of the same set of software. +As an example, assume we want to install ``gromacs`` and ``quantum-espresso`` for both ``target=x86_64_v3`` and ``target=x86_64_v4``. +That can be done with the following manifest file: + +.. code-block:: yaml + + spack: + - group: apps-x86_64_v3 + specs: + - gromacs + - quantum-espresso + override: + packages: + all: + prefer: + - target=x86_64_v3 + + - group: apps-x86_64_v4 + specs: + - gromacs + - quantum-espresso + override: + packages: + all: + prefer: + - target=x86_64_v4 + +The ``override:`` attribute allows us to override the configuration for a single group of specs. +The overridden part is always added as the *topmost* scope when the current group is concretized. +This ensures the override always takes precedence over other sources of configuration. + + Modifying Environment Variables ------------------------------- @@ -1086,6 +1163,42 @@ Given the example above, the spec ``zlib@1.2.8`` will be linked into ``/my/view/ If the keyword ``all`` does not appear in the projections configuration file, any spec that does not satisfy any entry in the file will be linked into the root of the view as in a single-prefix view. Any entries that appear below the keyword ``all`` in the projections configuration file will not be used, as all specs will use the projection under ``all`` before reaching those entries. +Group of Specs +"""""""""""""" + +Views can also be applied to a selected list of :ref:`spec groups `. +This can be done by specifying the ``group:`` attribute in the view configuration. +For instance, with the following manifest: + +.. code-block:: yaml + + spack: + concretizer: + unify: true + + packages: + all: + require: + - target=x86_64_v4 + + specs: + - group: compiler + specs: + - gcc@15.2 + + - group: apps + needs: [compiler] + specs: + - hdf5~mpi %gcc@15.2 + - libtree %gcc@15.2 + + view: + apps: + root: ./views/apps + group: apps + +The view will only contain entries from the ``apps`` group, and will not include specs from the ``compiler`` group. + Activating environment views ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/lib/spack/spack/ci/__init__.py b/lib/spack/spack/ci/__init__.py index 8906e9aa6d966c..e6bd5583d0121f 100644 --- a/lib/spack/spack/ci/__init__.py +++ b/lib/spack/spack/ci/__init__.py @@ -495,13 +495,7 @@ def generate_pipeline(env: ev.Environment, args) -> None: rebuild_everything = not options.prune_up_to_date and not options.prune_untouched # Build a pipeline from the specs in the concrete environment - pipeline = PipelineDag( - [ - concrete - for abstract, concrete in env.concretized_specs() - if abstract in env.spec_lists["specs"] - ] - ) + pipeline = PipelineDag([env.specs_by_hash[x.hash] for x in env.concretized_roots]) # Optionally add various pruning filters pruning_filters = [] diff --git a/lib/spack/spack/cmd/find.py b/lib/spack/spack/cmd/find.py index 8ec43681bce011..aa2973513793be 100644 --- a/lib/spack/spack/cmd/find.py +++ b/lib/spack/spack/cmd/find.py @@ -335,7 +335,7 @@ def _find_query(args, env): if args.show_configured_externals: packages_with_externals = external_config_with_implicit_externals(spack.config.CONFIG) completion_mode = spack.config.CONFIG.get("concretizer:externals:completion") - results = spack.solver.reuse.SpecFilter.from_packages_yaml( + results = spack.solver.reuse.spec_filter_from_packages_yaml( external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, include=[], diff --git a/lib/spack/spack/concretize.py b/lib/spack/spack/concretize.py index efa477f69f03d4..afceeca1d8d68f 100644 --- a/lib/spack/spack/concretize.py +++ b/lib/spack/spack/concretize.py @@ -5,7 +5,7 @@ import importlib import sys import time -from typing import Dict, Iterable, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Sequence, Tuple, Union import spack.compilers import spack.compilers.config @@ -20,9 +20,15 @@ SpecPair = Tuple[Spec, Spec] TestsType = Union[bool, Iterable[str]] +if TYPE_CHECKING: + from spack.solver.reuse import SpecFiltersFactory + def _concretize_specs_together( - abstract_specs: Sequence[Spec], tests: TestsType = False + abstract_specs: Sequence[Spec], + *, + tests: TestsType = False, + factory: Optional["SpecFiltersFactory"] = None, ) -> List[Spec]: """Given a number of specs as input, tries to concretize them together. @@ -30,16 +36,22 @@ def _concretize_specs_together( abstract_specs: abstract specs to be concretized tests: list of package names for which to consider tests dependencies. If True, all nodes will have test dependencies. If False, test dependencies will be disregarded. + factory: optional factory to produce a list of specs to be reused """ from spack.solver.asp import Solver allow_deprecated = spack.config.get("config:deprecated", False) - result = Solver().solve(abstract_specs, tests=tests, allow_deprecated=allow_deprecated) + result = Solver(specs_factory=factory).solve( + abstract_specs, tests=tests, allow_deprecated=allow_deprecated + ) return [s.copy() for s in result.specs] def concretize_together( - spec_list: Sequence[SpecPairInput], tests: TestsType = False + spec_list: Sequence[SpecPairInput], + *, + tests: TestsType = False, + factory: Optional["SpecFiltersFactory"] = None, ) -> List[SpecPair]: """Given a number of specs as input, tries to concretize them together. @@ -48,15 +60,19 @@ def concretize_together( already concrete spec or None if not yet concretized tests: list of package names for which to consider tests dependencies. If True, all nodes will have test dependencies. If False, test dependencies will be disregarded. + factory: optional factory to produce a list of specs to be reused """ to_concretize = [concrete if concrete else abstract for abstract, concrete in spec_list] abstract_specs = [abstract for abstract, _ in spec_list] - concrete_specs = _concretize_specs_together(to_concretize, tests=tests) + concrete_specs = _concretize_specs_together(to_concretize, tests=tests, factory=factory) return list(zip(abstract_specs, concrete_specs)) def concretize_together_when_possible( - spec_list: Sequence[SpecPairInput], tests: TestsType = False + spec_list: Sequence[SpecPairInput], + *, + tests: TestsType = False, + factory: Optional["SpecFiltersFactory"] = None, ) -> List[SpecPair]: """Given a number of specs as input, tries to concretize them together to the extent possible. @@ -68,6 +84,7 @@ def concretize_together_when_possible( already concrete spec or None if not yet concretized tests: list of package names for which to consider tests dependencies. If True, all nodes will have test dependencies. If False, test dependencies will be disregarded. + factory: optional factory to produce a list of specs to be reused """ from spack.solver.asp import Solver @@ -80,7 +97,7 @@ def concretize_together_when_possible( allow_deprecated = spack.config.get("config:deprecated", False) j = 0 start = time.monotonic() - for result in Solver().solve_in_rounds( + for result in Solver(specs_factory=factory).solve_in_rounds( to_concretize, tests=tests, allow_deprecated=allow_deprecated ): now = time.monotonic() @@ -105,7 +122,10 @@ def concretize_together_when_possible( def concretize_separately( - spec_list: Sequence[SpecPairInput], tests: TestsType = False + spec_list: Sequence[SpecPairInput], + *, + tests: TestsType = False, + factory: Optional["SpecFiltersFactory"] = None, ) -> List[SpecPair]: """Concretizes the input specs separately from each other. @@ -114,6 +134,7 @@ def concretize_separately( already concrete spec or None if not yet concretized tests: list of package names for which to consider tests dependencies. If True, all nodes will have test dependencies. If False, test dependencies will be disregarded. + factory: optional factory to produce a list of specs to be reused """ from spack.bootstrap import ( ensure_bootstrap_configuration, @@ -123,7 +144,7 @@ def concretize_separately( to_concretize = [abstract for abstract, concrete in spec_list if not concrete] args = [ - (i, str(abstract), tests) + (i, str(abstract), tests, factory) for i, abstract in enumerate(to_concretize) if not abstract.concrete ] @@ -188,15 +209,22 @@ def concretize_separately( ] -def _concretize_task(packed_arguments: Tuple[int, str, TestsType]) -> Tuple[int, Spec, float]: - index, spec_str, tests = packed_arguments +def _concretize_task( + packed_arguments: Tuple[int, str, TestsType, Optional["SpecFiltersFactory"]], +) -> Tuple[int, Spec, float]: + index, spec_str, tests, factory = packed_arguments with tty.SuppressOutput(msg_enabled=False): start = time.time() - spec = concretize_one(Spec(spec_str), tests=tests) + spec = concretize_one(Spec(spec_str), tests=tests, factory=factory) return index, spec, time.time() - start -def concretize_one(spec: Union[str, Spec], tests: TestsType = False) -> Spec: +def concretize_one( + spec: Union[str, Spec], + *, + tests: TestsType = False, + factory: Optional["SpecFiltersFactory"] = None, +) -> Spec: """Return a concretized copy of the given spec. Args: @@ -219,7 +247,9 @@ def concretize_one(spec: Union[str, Spec], tests: TestsType = False) -> Spec: ) allow_deprecated = spack.config.get("config:deprecated", False) - result = Solver().solve([spec], tests=tests, allow_deprecated=allow_deprecated) + result = Solver(specs_factory=factory).solve( + [spec], tests=tests, allow_deprecated=allow_deprecated + ) # take the best answer opt, i, answer = min(result.answers) diff --git a/lib/spack/spack/enums.py b/lib/spack/spack/enums.py index ded48034637c69..2e06d012f324a7 100644 --- a/lib/spack/spack/enums.py +++ b/lib/spack/spack/enums.py @@ -22,6 +22,8 @@ class ConfigScopePriority(enum.IntEnum): ENVIRONMENT = 2 CUSTOM = 3 COMMAND_LINE = 4 + # Topmost scope reserved for internal use + ENVIRONMENT_SPEC_GROUPS = 5 class PropagationPolicy(enum.Enum): diff --git a/lib/spack/spack/environment/__init__.py b/lib/spack/spack/environment/__init__.py index e86da8c52ab57e..61c65224e35788 100644 --- a/lib/spack/spack/environment/__init__.py +++ b/lib/spack/spack/environment/__init__.py @@ -54,6 +54,7 @@ - ``v4`` - ``v5`` - ``v6`` + - ``v7`` * - ``v0.12:0.14`` - ✅ - @@ -61,6 +62,7 @@ - - - + - * - ``v0.15:0.16`` - ✅ - ✅ @@ -68,6 +70,7 @@ - - - + - * - ``v0.17`` - ✅ - ✅ @@ -75,6 +78,7 @@ - - - + - * - ``v0.18:`` - ✅ - ✅ @@ -82,6 +86,7 @@ - ✅ - - + - * - ``v0.22:v0.23`` - ✅ - ✅ @@ -89,7 +94,17 @@ - ✅ - ✅ - - * - ``v1.0:`` + - + * - ``v1.0:1.1`` + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + - ✅ + - + * - ``v1.2:`` + - ✅ - ✅ - ✅ - ✅ @@ -543,6 +558,40 @@ }, } } + +Version 7 +--------- + +Version 7 adds the additional attribute ``group`` to ``roots``. + +As part of Spack v1.2 each environment can define multiple groups of specs, and fine-tune their +concretization separately. This attribute is needed to associate each root spec with the +corresponding group. + +.. code-block:: json + + { + "_meta": { + "file-type": "spack-lockfile", + "lockfile-version": 7, + "specfile-version": 5 + }, + "spack": { + "version": "1.2.0.dev0", + "type": "git", + "commit": "94b055476f874f424f20e3c0f33b0f22de29220a" + }, + "roots": [ + { + "hash": "o72mlpqvb5xijyqg4iyubpnvd5bfcomb", + "spec": "hdf5", + "group": "default" + } + ], + "concrete_specs": { + } + } + """ from .environment import ( diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 81677bd732cf3f..85ae2b9518034b 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -12,10 +12,22 @@ import shutil import stat import warnings -from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple, Union +from collections.abc import KeysView +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Mapping, + Optional, + Sequence, + Set, + Tuple, + Union, +) import spack -import spack.concretize import spack.config import spack.deptypes as dt import spack.error @@ -39,17 +51,20 @@ import spack.util.spack_yaml as syaml import spack.variant as vt from spack import traverse +from spack.enums import ConfigScopePriority from spack.llnl.util.filesystem import copy_tree, islink, readlink, symlink from spack.llnl.util.lang import stable_partition from spack.llnl.util.link_tree import ConflictingSpecsError from spack.schema.env import TOP_LEVEL_KEY from spack.spec import Spec +from spack.spec_filter import SpecFilter from spack.util.path import substitute_path_variables -from ..enums import ConfigScopePriority from .list import SpecList, SpecListError, SpecListParser -SpecPair = spack.concretize.SpecPair +SpecPair = Tuple[Spec, Spec] + +DEFAULT_USER_SPEC_GROUP = "default" #: environment variable used to indicate the active environment spack_env_var = "SPACK_ENV" @@ -145,7 +160,7 @@ def default_manifest_yaml(): valid_environment_name_re = rf"^\w[{sep_re}\w-]*$" #: version of the lockfile format. Must increase monotonically. -lockfile_format_version = 6 +CURRENT_LOCKFILE_VERSION = 7 READER_CLS = { @@ -155,12 +170,13 @@ def default_manifest_yaml(): 4: spack.spec.SpecfileV3, 5: spack.spec.SpecfileV4, 6: spack.spec.SpecfileV5, + 7: spack.spec.SpecfileV5, } # Magic names # The name of the standalone spec list in the manifest yaml -user_speclist_name = "specs" +USER_SPECS_KEY = "specs" # The name of the default view (the view loaded on env.activate) default_view_name = "default" # Default behavior to link all packages into views (vs. only root packages) @@ -676,35 +692,40 @@ def _error_on_nonempty_view_dir(new_root): class ViewDescriptor: def __init__( self, - base_path, - root, - projections={}, - select=[], - exclude=[], - link=default_view_link, - link_type="symlink", - ): + base_path: str, + root: str, + *, + projections: Optional[Dict[str, str]] = None, + select: Optional[List[str]] = None, + exclude: Optional[List[str]] = None, + link: str = default_view_link, + link_type: fsv.LinkType = "symlink", + groups: Optional[Union[str, List[str]]] = None, + ) -> None: self.base = base_path self.raw_root = root self.root = spack.util.path.canonicalize_path(root, default_wd=base_path) - self.projections = projections - self.select = select - self.exclude = exclude - self.link_type = fsv.canonicalize_link_type(link_type) + self.projections = projections or {} + self.select = select or [] + self.exclude = exclude or [] + self.link_type: fsv.LinkType = fsv.canonicalize_link_type(link_type) self.link = link + if isinstance(groups, str): + groups = [groups] + self.groups: Optional[List[str]] = groups - def select_fn(self, spec): + def select_fn(self, spec: Spec) -> bool: return any(spec.satisfies(s) for s in self.select) - def exclude_fn(self, spec): + def exclude_fn(self, spec: Spec) -> bool: return not any(spec.satisfies(e) for e in self.exclude) - def update_root(self, new_path): + def update_root(self, new_path: str) -> None: self.raw_root = new_path self.root = spack.util.path.canonicalize_path(new_path, default_wd=self.base) - def __eq__(self, other): - return all( + def __eq__(self, other: object) -> bool: + return isinstance(other, ViewDescriptor) and all( [ self.root == other.root, self.projections == other.projections, @@ -730,19 +751,20 @@ def to_dict(self): return ret @staticmethod - def from_dict(base_path, d): + def from_dict(base_path: str, d) -> "ViewDescriptor": return ViewDescriptor( base_path, d["root"], - d.get("projections", {}), - d.get("select", []), - d.get("exclude", []), - d.get("link", default_view_link), - d.get("link_type", "symlink"), + projections=d.get("projections", {}), + select=d.get("select", []), + exclude=d.get("exclude", []), + link=d.get("link", default_view_link), + link_type=d.get("link_type", "symlink"), + groups=d.get("group", None), ) @property - def _current_root(self): + def _current_root(self) -> Optional[str]: if not islink(self.root): return None @@ -841,7 +863,12 @@ def specs_for_view(self, concrete_roots: List[Spec]) -> List[Spec]: return self._exclude_duplicate_runtimes(result) - def regenerate(self, concrete_roots: List[Spec]) -> None: + def regenerate(self, env: "Environment") -> None: + if self.groups is None: + concrete_roots = env.concrete_roots() + else: + concrete_roots = [c for g in self.groups for _, c in env.concretized_specs_by(group=g)] + specs = self.specs_for_view(concrete_roots) # To ensure there are no conflicts with packages being installed @@ -968,12 +995,15 @@ def env_subdir_path(manifest_dir: Union[str, pathlib.Path]) -> str: class ConcretizedRootInfo: """Data on root specs that have been concretized""" - __slots__ = ("root", "hash", "new") + __slots__ = ("root", "hash", "new", "group") - def __init__(self, *, root_spec: spack.spec.Spec, root_hash: str, new: bool = False): + def __init__( + self, *, root_spec: spack.spec.Spec, root_hash: str, new: bool = False, group: str + ): self.root = root_spec self.hash = root_hash self.new = new + self.group = group def __str__(self): return f"{self.root} -> {self.hash} [new={self.new}]" @@ -984,10 +1014,11 @@ def __eq__(self, other: object) -> bool: and self.root == other.root and self.hash == other.hash and self.new == other.new + and self.group == other.group ) def __hash__(self) -> int: - return hash((self.root, self.hash, self.new)) + return hash((self.root, self.hash, self.new, self.group)) class Environment: @@ -1045,15 +1076,30 @@ def _load_manifest_file(self): with self.manifest.use_config(): self._read() - @property - def unify(self): - if self._unify is None: - self._unify = spack.config.get("concretizer:unify", False) - return self._unify + @contextlib.contextmanager + def config_override_for_group(self, *, group: str): + key = self.manifest._ensure_group_exists(group=group) + internal_scope = self.manifest.config_override(group=key) + if internal_scope is None: + # No internal scope + tty.debug( + f"[{__name__}] No configuration override necessary for the '{group}' group " + f"in the environment at {self.manifest_path}" + ) + yield + return - @unify.setter - def unify(self, value): - self._unify = value + try: + tty.debug( + f"[{__name__}] Overriding the configuration for the '{group}' group defined " + f"in {self.manifest_path} before concretization" + ) + spack.config.CONFIG.push_scope( + internal_scope, priority=ConfigScopePriority.ENVIRONMENT_SPEC_GROUPS + ) + yield + finally: + spack.config.CONFIG.remove_scope(internal_scope.name) def __getstate__(self): state = self.__dict__.copy() @@ -1165,16 +1211,26 @@ def _sync_speclists(self): data=spack.config.CONFIG.get("definitions", []) ) ) + for group in self.manifest.groups(): + tty.debug(f"[{__name__}]: Synchronizing user specs from the '{group}' group", level=2) + key = self._user_specs_key(group=group) + self.spec_lists[key] = self._spec_lists_parser.parse_user_specs( + name=key, yaml_list=self.manifest.user_specs(group=group) + ) - env_configuration = self.manifest[TOP_LEVEL_KEY] - spec_list = env_configuration.get(user_speclist_name, []) - self.spec_lists[user_speclist_name] = self._spec_lists_parser.parse_user_specs( - name=user_speclist_name, yaml_list=spec_list - ) + def _user_specs_key(self, *, group: Optional[str] = None) -> str: + if group is None or group == DEFAULT_USER_SPEC_GROUP: + return USER_SPECS_KEY + return f"{USER_SPECS_KEY}:{group}" @property - def user_specs(self): - return self.spec_lists[user_speclist_name] + def user_specs(self) -> SpecList: + return self.user_specs_by(group=DEFAULT_USER_SPEC_GROUP) + + def user_specs_by(self, *, group: Optional[str]) -> SpecList: + """Returns a dictionary of user specs keyed by their group.""" + key = self._user_specs_key(group=group) + return self.spec_lists[key] @property def dev_specs(self): @@ -1259,7 +1315,7 @@ def repos_path(self): return os.path.join(self.env_subdir_path, "repos") @property - def view_path_default(self): + def view_path_default(self) -> str: # default path for environment views return os.path.join(self.env_subdir_path, "view") @@ -1315,7 +1371,7 @@ def destroy(self): """Remove this environment from Spack entirely.""" shutil.rmtree(self.path) - def add(self, user_spec, list_name=user_speclist_name) -> bool: + def add(self, user_spec, list_name=USER_SPECS_KEY) -> bool: """Add a single user_spec (non-concretized) to the Environment Returns: @@ -1328,7 +1384,7 @@ def add(self, user_spec, list_name=user_speclist_name) -> bool: if list_name not in self.spec_lists: raise SpackEnvironmentError(f"No list {list_name} exists in environment {self.name}") - if list_name == user_speclist_name: + if list_name == USER_SPECS_KEY: if spec.anonymous: raise SpackEnvironmentError("cannot add anonymous specs to an environment") elif not spack.repo.PATH.exists(spec.name) and not spec.abstract_hash: @@ -1340,7 +1396,7 @@ def add(self, user_spec, list_name=user_speclist_name) -> bool: existing = str(spec) in list_to_change.yaml_list if not existing: list_to_change.add(spec) - if list_name == user_speclist_name: + if list_name == USER_SPECS_KEY: self.manifest.add_user_spec(str(user_spec)) else: self.manifest.add_definition(str(user_spec), list_name=list_name) @@ -1351,7 +1407,7 @@ def add(self, user_spec, list_name=user_speclist_name) -> bool: def change_existing_spec( self, change_spec: Spec, - list_name: str = user_speclist_name, + list_name: str = USER_SPECS_KEY, match_spec: Optional[Spec] = None, allow_changing_multiple_specs=False, ): @@ -1392,7 +1448,7 @@ def change_existing_spec( for idx, spec in matches: override_spec = Spec.override(spec, change_spec) - if list_name == user_speclist_name: + if list_name == USER_SPECS_KEY: self.manifest.override_user_spec(str(override_spec), idx=idx) else: self.manifest.override_definition( @@ -1400,7 +1456,7 @@ def change_existing_spec( ) self._sync_speclists() - def remove(self, query_spec, list_name=user_speclist_name, force=False): + def remove(self, query_spec, list_name=USER_SPECS_KEY, force=False): """Remove specs from an environment that match a query_spec""" err_msg_header = ( f"Cannot remove '{query_spec}' from '{list_name}' definition " @@ -1437,7 +1493,7 @@ def remove(self, query_spec, list_name=user_speclist_name, force=False): msg += " It will be removed from the concrete specs." tty.warn(msg) else: - if list_name == user_speclist_name: + if list_name == USER_SPECS_KEY: self.manifest.remove_user_spec(str(spec)) else: self.manifest.remove_definition(str(spec), list_name=list_name) @@ -1563,9 +1619,21 @@ def concretize( def sync_concretized_specs(self) -> None: """Removes concrete specs that no longer correlate to a user spec""" - to_deconcretize = [x.root for x in self.concretized_roots if x.root not in self.user_specs] - for spec in to_deconcretize: - self.deconcretize_by_user_spec(spec) + if not self.concretized_roots: + return + + to_deconcretize, user_specs = [], self._all_user_specs_with_group() + for x in self.concretized_roots: + if (x.group, x.root) not in user_specs: + to_deconcretize.append(x) + for x in to_deconcretize: + self.deconcretize_by_user_spec(x.root, group=x.group) + + def _all_user_specs_with_group(self) -> Set[Tuple[str, Spec]]: + result = set() + for group in self.manifest.groups(): + result.update([(group, x) for x in self.user_specs_by(group=group)]) + return result def clear_concretized_specs(self) -> None: """Clears the currently concretized specs""" @@ -1577,15 +1645,20 @@ def deconcretize_by_hash(self, dag_hash: str) -> None: self.concretized_roots = [x for x in self.concretized_roots if x.hash != dag_hash] self._maybe_remove_dag_hash(dag_hash) - def deconcretize_by_user_spec(self, spec: spack.spec.Spec) -> None: - """Remove a user spec from the environment concretization + def deconcretize_by_user_spec( + self, spec: spack.spec.Spec, *, group: Optional[str] = None + ) -> None: + """Removes a user spec from the environment concretization Arguments: spec: user spec to deconcretize + group: group of the spec to remove. If not specified, the spec is removed from + the default group """ + group = group or DEFAULT_USER_SPEC_GROUP # spec has to be a root of the environment - self.concretized_roots, discarded = stable_partition( - self.concretized_roots, lambda x: x.root != spec + discarded, self.concretized_roots = stable_partition( + self.concretized_roots, lambda x: x.group == group and x.root == spec ) assert ( len({x.hash for x in discarded}) == 1 @@ -1661,6 +1734,7 @@ def update_default_view(self, path_or_bool: Union[str, bool]) -> None: if default_view_name in self.views: self.default_view.update_root(view_path) else: + assert isinstance(view_path, str), f"expected str for 'view_path', but got {view_path}" self.views[default_view_name] = ViewDescriptor(self.path, view_path) self.manifest.set_default_view(self._default_view_as_yaml()) @@ -1684,7 +1758,7 @@ def regenerate_views(self): return for view in self.views.values(): - view.regenerate(self.concrete_roots()) + view.regenerate(self) def check_views(self): """Checks if the environments default view can be activated.""" @@ -1761,7 +1835,12 @@ def rm_view_from_env( return env_mod def add_concrete_spec( - self, spec: spack.spec.Spec, concrete: spack.spec.Spec, *, new: bool = True + self, + spec: spack.spec.Spec, + concrete: spack.spec.Spec, + *, + new: bool = True, + group: Optional[str] = None, ): """Called when a new concretized spec is added to the environment. @@ -1774,7 +1853,10 @@ def add_concrete_spec( """ assert concrete.concrete h = concrete.dag_hash() - self.concretized_roots.append(ConcretizedRootInfo(root_spec=spec, root_hash=h, new=new)) + group = group or DEFAULT_USER_SPEC_GROUP + self.concretized_roots.append( + ConcretizedRootInfo(root_spec=spec, root_hash=h, new=new, group=group) + ) self.specs_by_hash[h] = concrete def _dev_specs_that_need_overwrite(self): @@ -1911,22 +1993,38 @@ def concretized_specs(self): for x in self.concretized_roots: yield x.root, self.specs_by_hash[x.hash] + yield from self.concretized_specs_from_all_included_environments() + + def concretized_specs_from_all_included_environments(self): seen = {(x.root, x.hash) for x in self.concretized_roots} for included_env in self.included_concretized_user_specs: - for s, h in zip( - self.included_concretized_user_specs[included_env], - self.included_concretized_order[included_env], - ): - if (s, h) in seen: - continue - seen.add((s, h)) - yield s, self.included_specs_by_hash[included_env][h] + yield from self.concretized_specs_from_included_environment(included_env, _seen=seen) + + def concretized_specs_from_included_environment( + self, included_env: str, *, _seen: Optional[Set[Tuple[spack.spec.Spec, str]]] = None + ): + _seen = set() if _seen is None else _seen + for s, h in zip( + self.included_concretized_user_specs[included_env], + self.included_concretized_order[included_env], + ): + if (s, h) in _seen: + continue + _seen.add((s, h)) + yield s, self.included_specs_by_hash[included_env][h] def concrete_roots(self): """Same as concretized_specs, except it returns the list of concrete roots *without* associated user spec""" return [root for _, root in self.concretized_specs()] + def concretized_specs_by(self, *, group: str) -> Iterable[Tuple[Spec, Spec]]: + """Generates all the (abstract, concrete) spec pairs for a given group""" + for x in self.concretized_roots: + if x.group != group: + continue + yield x.root, self.specs_by_hash[x.hash] + def get_by_hash(self, dag_hash: str) -> List[Spec]: # If it's not a partial hash prefix we can early exit early_exit = len(dag_hash) == 32 @@ -2060,10 +2158,21 @@ def _concrete_specs_dict(self): return concrete_specs def _concrete_roots_dict(self): - return [{"hash": x.hash, "spec": str(x.root)} for x in self.concretized_roots] + if not self.has_groups(): + return [{"hash": x.hash, "spec": str(x.root)} for x in self.concretized_roots] + + return [ + {"hash": x.hash, "spec": str(x.root), "group": x.group} for x in self.concretized_roots + ] + + def has_groups(self) -> bool: + groups = self.manifest.groups() + # True if groups != {DEFAULT_USER_SPEC_GROUP} + return len(groups) != 1 or DEFAULT_USER_SPEC_GROUP not in groups def _to_lockfile_dict(self): """Create a dictionary to store a lockfile for this environment.""" + lockfile_version = CURRENT_LOCKFILE_VERSION if self.has_groups() else 6 concrete_specs = self._concrete_specs_dict() root_specs = self._concrete_roots_dict() @@ -2080,7 +2189,7 @@ def _to_lockfile_dict(self): # metadata about the format "_meta": { "file-type": "spack-lockfile", - "lockfile-version": lockfile_format_version, + "lockfile-version": lockfile_version, "specfile-version": spack.spec.SPECFILE_FORMAT_VERSION, }, # spack version information @@ -2148,9 +2257,16 @@ def _read_lockfile_dict(self, d): self.included_concretized_order = {} roots = d["roots"] + default_user_specs_group = DEFAULT_USER_SPEC_GROUP self.concretized_roots = [ - ConcretizedRootInfo(root_spec=Spec(r["spec"]), root_hash=r["hash"], new=False) + # Lockfile versions < 7 don't have the "group" attribute + ConcretizedRootInfo( + root_spec=Spec(r["spec"]), + root_hash=r["hash"], + new=False, + group=r.get("group", default_user_specs_group), + ) for r in roots ] @@ -2173,7 +2289,7 @@ def _read_lockfile_dict(self, d): f"Spack {spack.__version__} cannot read the lockfile '{self.lock_path}', using " f"the v{current_lockfile_format} format." ) - if lockfile_format_version < current_lockfile_format: + if CURRENT_LOCKFILE_VERSION < current_lockfile_format: msg += " You need to use a newer Spack version." raise SpackEnvironmentError(msg) @@ -2369,6 +2485,99 @@ def _is_uninstalled(spec): return not spec.installed or (spec.satisfies("dev_path=*") or spec.satisfies("^dev_path=*")) +class ReusableSpecsFactory: + """Creates a list of SpecFilters to generate the reusable specs for the environment""" + + def __init__(self, *, env: Environment, group: str): + self.env = env + self.group = group + + @staticmethod + def _const(specs: List[Spec]) -> Callable[[], List[Spec]]: + """Returns a zero-argument callable that always returns the given list.""" + return lambda: specs + + def __call__( + self, is_usable: Callable[[Spec], bool], configuration: spack.config.Configuration + ) -> List[SpecFilter]: + result = [] + # Specs from group dependencies _must_ be reused, regardless of configuration + dependencies = self.env.manifest.needs(group=self.group) + necessary_specs = [] + for d in dependencies: + necessary_specs.extend([x for _, x in self.env.concretized_specs_by(group=d)]) + + # Specs from groups listed as dependencies + if necessary_specs: + necessary_specs = list( + traverse.traverse_nodes(necessary_specs, deptype=("link", "run")) + ) + result.append( + SpecFilter( + self._const(necessary_specs), include=[], exclude=[], is_usable=is_usable + ) + ) + + # Included environments and _this_ group, instead, are subject to configuration + concretizer_yaml = configuration.get_config("concretizer") + reuse_yaml = concretizer_yaml.get("reuse", False) + + # With no reuse don't account for previously concretized specs in _this_ group + if reuse_yaml is False: + return result + + this_group_specs = [x for _, x in self.env.concretized_specs_by(group=self.group)] + included_specs = [ + x for _, x in self.env.concretized_specs_from_all_included_environments() + ] + additional_specs = list(traverse.traverse_nodes(this_group_specs + included_specs)) + if not isinstance(reuse_yaml, Mapping): + result.append( + SpecFilter( + self._const(additional_specs), include=[], exclude=[], is_usable=is_usable + ) + ) + return result + + # Here we know we have a complex reuse configuration + default_include = reuse_yaml.get("include", []) + default_exclude = reuse_yaml.get("exclude", []) + for source in reuse_yaml.get("from", []): + # We just need to take care of the environment-related parts + if source["type"] != "environment": + continue + + include = source.get("include", default_include) + exclude = source.get("exclude", default_exclude) + if "path" not in source: + result.append( + SpecFilter( + self._const(additional_specs), + include=include, + exclude=exclude, + is_usable=is_usable, + ) + ) + continue + + env_dir = as_env_dir(source["path"]) + if env_dir in self.env.included_concrete_env_root_dirs: + spec_pairs_from_included_envs = [ + x for _, x in self.env.concretized_specs_from_included_environment(env_dir) + ] + included_specs = list(traverse.traverse_nodes(spec_pairs_from_included_envs)) + result.append( + SpecFilter( + self._const(included_specs), + include=include, + exclude=exclude, + is_usable=is_usable, + ) + ) + + return result + + class EnvironmentConcretizer: def __init__(self, env: Environment): self.env = env @@ -2378,27 +2587,51 @@ def concretize( ) -> List[SpecPair]: if force is None: force = spack.config.get("concretizer:force") + self._prepare_environment_for_concretization(force=force) + + result = [] + # Sort so that the ordering is deterministic, and "default" specs are first + for current_group in self._order_groups(): + with self.env.config_override_for_group(group=current_group): + partial_result = self._concretize_single_group(group=current_group, tests=tests) + result.extend(partial_result) + # Unify the specs objects, so we get correct references to all parents + if result: + self.env.unify_specs() + return result + + def _concretize_single_group( + self, *, group: str, tests: Union[bool, Sequence[str]] + ) -> List[SpecPair]: # Exit early if the set of concretized specs is the set of user specs - self._prepare_environment_for_concretization(force=force) - new_user_specs, kept_user_specs = self._partition_user_specs() + new_user_specs, kept_user_specs = self._partition_user_specs(group=group) if not new_user_specs: return [] # Pick the right concretization strategy - unify = self.env.unify + if group != DEFAULT_USER_SPEC_GROUP: + tty.msg(f"Concretizing the '{group}' group of specs") + unify = spack.config.CONFIG.get_config("concretizer").get("unify", False) + factory = ReusableSpecsFactory(env=self.env, group=group) if unify == "when_possible": - return self._concretize_together_where_possible( - new_user_specs, kept_user_specs, tests=tests + partial_result = self._concretize_together_where_possible( + new_user_specs, kept_user_specs, tests=tests, group=group, factory=factory ) - if unify is True: - return self._concretize_together(new_user_specs, kept_user_specs, tests=tests) + elif unify is True: + partial_result = self._concretize_together( + new_user_specs, kept_user_specs, tests=tests, group=group, factory=factory + ) - if unify is False: - return self._concretize_separately(new_user_specs, kept_user_specs, tests=tests) + elif unify is False: + partial_result = self._concretize_separately( + new_user_specs, kept_user_specs, tests=tests, group=group, factory=factory + ) + else: + raise SpackEnvironmentError(f"concretization strategy not implemented [{unify}]") - raise SpackEnvironmentError(f"concretization strategy not implemented [{unify}]") + return partial_result def _prepare_environment_for_concretization(self, *, force: bool): """Reset the environment concrete state and ensure consistency with user specs.""" @@ -2411,17 +2644,53 @@ def _prepare_environment_for_concretization(self, *, force: bool): if self.env.included_concrete_env_root_dirs: self.env.include_concrete_envs() - def _partition_user_specs(self) -> Tuple[List[spack.spec.Spec], List[spack.spec.Spec]]: + def _partition_user_specs( + self, *, group: str + ) -> Tuple[List[spack.spec.Spec], List[spack.spec.Spec]]: """Splits the users specs in the list of the ones to be computed, and the list of the ones to retain. """ - concretized_user_specs = {x.root for x in self.env.concretized_roots} + concretized_user_specs = {x.root for x in self.env.concretized_roots if x.group == group} kept_user_specs, new_user_specs = stable_partition( - self.env.user_specs, lambda x: x in concretized_user_specs + self.env.user_specs_by(group=group), lambda x: x in concretized_user_specs ) kept_user_specs += self.env.included_user_specs return new_user_specs, kept_user_specs + def _order_groups(self) -> List[str]: + done, result = {DEFAULT_USER_SPEC_GROUP}, [DEFAULT_USER_SPEC_GROUP] + all_groups = self.env.manifest.groups() + remaining = all_groups - {DEFAULT_USER_SPEC_GROUP} + + # Validate upfront that all 'needs' references point to defined groups + for group in remaining: + for dep in self.env.manifest.needs(group=group): + if dep not in all_groups: + raise SpackEnvironmentConfigError( + f"group '{group}' needs '{dep}', but '{dep}' is not a defined group", + self.env.manifest.manifest_file, + ) + + while remaining: + # Check we have groups that are "ready" + ready = [] + for current in remaining: + deps = self.env.manifest.needs(group=current) + if all(d in done for d in deps): + ready.append(current) + + # Check we can progress — if nothing is ready, there is a cycle + if not ready: + raise SpackEnvironmentConfigError( + f"cyclic dependency detected among groups: {', '.join(sorted(remaining))}", + self.env.manifest.manifest_file, + ) + + result.extend(ready) + done.update(ready) + remaining.difference_update(ready) + return result + def _user_spec_pairs( self, user_specs_to_compute: List[Spec], user_specs_to_keep: List[Spec] ) -> List[SpecPair]: @@ -2433,28 +2702,46 @@ def _user_spec_pairs( return specs_to_concretize def _concretize_together_where_possible( - self, to_compute: List[Spec], to_keep: List[Spec], *, tests: Union[bool, Sequence] = False + self, + to_compute: List[Spec], + to_keep: List[Spec], + *, + group: Optional[str] = None, + tests: Union[bool, Sequence] = False, + factory: ReusableSpecsFactory, ) -> List[SpecPair]: + import spack.concretize + specs_to_concretize = self._user_spec_pairs(to_compute, to_keep) result = spack.concretize.concretize_together_when_possible( - specs_to_concretize, tests=tests + specs_to_concretize, tests=tests, factory=factory ) result = [x for x in result if x[0] in to_compute] for abstract, concrete in result: - self.env.add_concrete_spec(abstract, concrete, new=True) + self.env.add_concrete_spec(abstract, concrete, new=True, group=group) return result def _concretize_together( - self, to_compute: List[Spec], to_keep: List[Spec], *, tests: Union[bool, Sequence] = False + self, + to_compute: List[Spec], + to_keep: List[Spec], + *, + group: Optional[str] = None, + tests: Union[bool, Sequence] = False, + factory: ReusableSpecsFactory, ) -> List[SpecPair]: + import spack.concretize + to_concretize = self._user_spec_pairs(to_compute, to_keep) try: - concrete_pairs = spack.concretize.concretize_together(to_concretize, tests=tests) + concrete_pairs = spack.concretize.concretize_together( + to_concretize, tests=tests, factory=factory + ) except spack.error.UnsatisfiableSpecError as e: # "Enhance" the error message for multiple root specs, suggest a less strict # form of concretization. - if len(self.env.user_specs) > 1: + if len(self.env.user_specs_by(group=group)) > 1: e.message += ". " if to_keep: e.message += ( @@ -2470,23 +2757,29 @@ def _concretize_together( # Return the portion of the return value that is new result = concrete_pairs[: len(to_compute)] for abstract, concrete in result: - self.env.add_concrete_spec(abstract, concrete, new=True) + self.env.add_concrete_spec(abstract, concrete, new=True, group=group) return result def _concretize_separately( - self, to_compute: List[Spec], to_keep: List[Spec], *, tests: Union[bool, Sequence] = False + self, + to_compute: List[Spec], + to_keep: List[Spec], + *, + group: Optional[str] = None, + tests: Union[bool, Sequence] = False, + factory: ReusableSpecsFactory, ) -> List[SpecPair]: - """Concretization strategy that concretizes separately one - user spec after the other. - """ + """Concretization strategy that concretizes separately one user spec after the other""" + import spack.concretize + to_concretize = [(x, None) for x in to_compute] - concrete_pairs = spack.concretize.concretize_separately(to_concretize, tests=tests) + concrete_pairs = spack.concretize.concretize_separately( + to_concretize, tests=tests, factory=factory + ) for abstract, concrete in concrete_pairs: - self.env.add_concrete_spec(abstract, concrete, new=True) + self.env.add_concrete_spec(abstract, concrete, new=True, group=group) - # Unify the specs objects, so we get correct references to all parents - self.env.unify_specs() return concrete_pairs @@ -2817,8 +3110,47 @@ def __init__(self, manifest_dir: Union[pathlib.Path, str], name: Optional[str] = with self.manifest_file.open(encoding="utf-8") as f: self.yaml_content = _read_yaml(f) + # Maps groups to their dependencies + self._groups: Dict[str, Tuple[str, ...]] = {DEFAULT_USER_SPEC_GROUP: tuple()} + # Raw YAML definitions of the user specs for each group + self._user_specs: Dict[str, List] = {DEFAULT_USER_SPEC_GROUP: []} + # Configuration overrides for each group + self._config_override: Dict[str, Any] = {DEFAULT_USER_SPEC_GROUP: None} + self._init_user_specs() + self.changed = False + def _init_user_specs(self): + specs_yaml = self.configuration.get(USER_SPECS_KEY, []) + for item in specs_yaml: + if isinstance(item, str): + self._user_specs[DEFAULT_USER_SPEC_GROUP].append(item) + elif isinstance(item, dict): + group = item.get("group", DEFAULT_USER_SPEC_GROUP) + + # Error if a group is defined more than once + if group != DEFAULT_USER_SPEC_GROUP and group in self._groups: + raise SpackEnvironmentConfigError( + f"group '{group}' defined more than once", self.manifest_file + ) + + # Add an entry for the user specs and store group dependencies + if group not in self._user_specs: + self._user_specs[group] = [] + self._groups[group] = tuple(item.get("needs", ())) + self._config_override[group] = item.get("override", None) + + if "matrix" in item: + # Short form if the group is composed of only one matrix + self._user_specs[group].append({"matrix": item["matrix"]}) + elif "specs" in item: + self._user_specs[group].extend(item["specs"]) + + def _clear_user_specs(self) -> None: + self._user_specs = {DEFAULT_USER_SPEC_GROUP: []} + self._groups = {DEFAULT_USER_SPEC_GROUP: tuple()} + self._config_override = {DEFAULT_USER_SPEC_GROUP: None} + def _all_matches(self, user_spec: str) -> List[str]: """Maps the input string to the first equivalent user spec in the manifest, and returns it. @@ -2839,17 +3171,46 @@ def _all_matches(self, user_spec: str) -> List[str]: return result + def user_specs(self, *, group: Optional[str] = None) -> List: + group = self._ensure_group_exists(group) + return self._user_specs[group] + + def config_override( + self, *, group: Optional[str] = None + ) -> Optional[spack.config.InternalConfigScope]: + group = self._ensure_group_exists(group) + data = self._config_override[group] + if data is None: + return None + return spack.config.InternalConfigScope(f"env:groups:{group}", data) + + def groups(self) -> KeysView: + """Returns the list of groups defined in the manifest""" + return self._groups.keys() + + def needs(self, *, group: Optional[str] = None) -> Tuple[str, ...]: + """Returns the dependencies of a group of user specs.""" + group = self._ensure_group_exists(group) + return self._groups[group] + + def _ensure_group_exists(self, group: Optional[str]) -> str: + group = DEFAULT_USER_SPEC_GROUP if group is None else group + if group not in self._groups: + raise ValueError(f"user specs group '{group}' not found in {self.manifest_file}") + return group + def add_user_spec(self, user_spec: str) -> None: - """Appends the user spec passed as input to the list of root specs. + """Appends the user spec passed as input to the default list of root specs. Args: user_spec: user spec to be appended """ self.configuration.setdefault("specs", []).append(user_spec) + self._user_specs[DEFAULT_USER_SPEC_GROUP].append(user_spec) self.changed = True def remove_user_spec(self, user_spec: str) -> None: - """Removes the user spec passed as input from the list of root specs + """Removes the user spec passed as input from the default list of root specs Args: user_spec: user spec to be removed @@ -2860,6 +3221,7 @@ def remove_user_spec(self, user_spec: str) -> None: try: for key in self._all_matches(user_spec): self.configuration["specs"].remove(key) + self._user_specs[DEFAULT_USER_SPEC_GROUP].remove(key) except ValueError as e: msg = f"cannot remove {user_spec} from {self}, no such spec exists" raise SpackEnvironmentError(msg) from e @@ -2868,6 +3230,7 @@ def remove_user_spec(self, user_spec: str) -> None: def clear(self) -> None: """Clear all user specs from the list of root specs""" self.configuration["specs"] = [] + self._clear_user_specs() self.changed = True def override_user_spec(self, user_spec: str, idx: int) -> None: @@ -2882,6 +3245,8 @@ def override_user_spec(self, user_spec: str, idx: int) -> None: """ try: self.configuration["specs"][idx] = user_spec + self._clear_user_specs() + self._init_user_specs() except ValueError as e: msg = f"cannot override {user_spec} from {self}" raise SpackEnvironmentError(msg) from e @@ -3113,8 +3478,7 @@ class SpackEnvironmentConfigError(SpackEnvironmentError): """Class for Spack environment-specific configuration errors.""" def __init__(self, msg, filename): - self.filename = filename - super().__init__(msg) + super().__init__(f"{msg} in {filename}") class SpackEnvironmentDevelopError(SpackEnvironmentError): diff --git a/lib/spack/spack/filesystem_view.py b/lib/spack/spack/filesystem_view.py index d331f65d79b2ca..72aa02a713cd09 100644 --- a/lib/spack/spack/filesystem_view.py +++ b/lib/spack/spack/filesystem_view.py @@ -104,9 +104,11 @@ def view_copy( #: Type alias for link types LinkType = Literal["hardlink", "hard", "copy", "relocate", "add", "symlink", "soft"] +CanonicalLinkType = Literal["hardlink", "copy", "symlink"] + #: supported string values for `link_type` in an env, mapped to canonical values -_LINK_TYPES = { +_LINK_TYPES: Dict[LinkType, CanonicalLinkType] = { "hardlink": "hardlink", "hard": "hardlink", "copy": "copy", @@ -119,7 +121,7 @@ def view_copy( _VALID_LINK_TYPES = sorted(set(_LINK_TYPES.values())) -def canonicalize_link_type(link_type: LinkType) -> str: +def canonicalize_link_type(link_type: LinkType) -> CanonicalLinkType: """Return canonical""" canonical = _LINK_TYPES.get(link_type) if not canonical: diff --git a/lib/spack/spack/llnl/string.py b/lib/spack/spack/llnl/string.py index 2f15051bf9634a..17bf5d72741b4d 100644 --- a/lib/spack/spack/llnl/string.py +++ b/lib/spack/spack/llnl/string.py @@ -4,10 +4,10 @@ """String manipulation functions that do not have other dependencies than Python standard library """ -from typing import List, Optional +from typing import List, Optional, Sequence -def comma_list(sequence: List[str], article: str = "") -> str: +def comma_list(sequence: Sequence[str], article: str = "") -> str: if type(sequence) is not list: sequence = list(sequence) @@ -26,7 +26,7 @@ def comma_list(sequence: List[str], article: str = "") -> str: return out -def comma_or(sequence: List[str]) -> str: +def comma_or(sequence: Sequence[str]) -> str: """Return a string with all the elements of the input joined by comma, but the last one (which is joined by ``"or"``). """ diff --git a/lib/spack/spack/schema/env.py b/lib/spack/spack/schema/env.py index ea232e1018857c..85c449e86d26f0 100644 --- a/lib/spack/spack/schema/env.py +++ b/lib/spack/spack/schema/env.py @@ -7,11 +7,12 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/env.py :lines: 19- """ + from typing import Any, Dict import spack.schema.merged -from .spec_list import spec_list_schema +from .spec_list import spec_list_properties, spec_list_schema #: Top level key in a manifest file TOP_LEVEL_KEY = "spack" @@ -25,6 +26,22 @@ "items": {"type": "string"}, } +group_name_and_deps = { + "group": {"type": "string", "description": "Name for this group of specs"}, + "needs": { + "type": "array", + "description": "Groups of specs that are needed by this group", + "items": {"type": "string"}, + }, + "override": { + "type": "object", + "description": "Top-most configuration scope for this group of specs", + "additionalProperties": False, + "properties": {**spack.schema.merged.properties}, + }, +} + + properties: Dict[str, Any] = { "spack": { "type": "object", @@ -36,7 +53,37 @@ # merged configuration scope schemas **spack.schema.merged.properties, # extra environment schema properties - "specs": spec_list_schema, + "specs": { + "type": "array", + "description": "List of specs to include in the environment, " + "supporting both simple specs and matrix configurations", + "default": [], + "items": { + "anyOf": [ + { + "type": "object", + "description": "Matrix configuration for generating multiple specs" + " from combinations of constraints", + "additionalProperties": False, + "properties": {**spec_list_properties}, + }, + {"type": "string", "description": "Simple spec string"}, + {"type": "null"}, + { + "type": "object", + "description": "User spec group with a single matrix", + "additionalProperties": False, + "properties": {**spec_list_properties, **group_name_and_deps}, + }, + { + "type": "object", + "description": "User spec group with multiple matrices", + "additionalProperties": False, + "properties": {**group_name_and_deps, "specs": spec_list_schema}, + }, + ] + }, + }, "include_concrete": include_concrete, }, } diff --git a/lib/spack/spack/schema/spec_list.py b/lib/spack/spack/schema/spec_list.py index 3b77b9672244b8..2af176c6ebf1db 100644 --- a/lib/spack/spack/schema/spec_list.py +++ b/lib/spack/spack/schema/spec_list.py @@ -11,6 +11,15 @@ }, } +spec_list_properties = { + "matrix": matrix_schema, + "exclude": { + "type": "array", + "description": "List of specific spec combinations to exclude from the " "matrix", + "items": {"type": "string"}, + }, +} + spec_list_schema = { "type": "array", "description": "List of specs to include in the environment, supporting both simple specs and " @@ -23,15 +32,7 @@ "description": "Matrix configuration for generating multiple specs from " "combinations of constraints", "additionalProperties": False, - "properties": { - "matrix": matrix_schema, - "exclude": { - "type": "array", - "description": "List of specific spec combinations to exclude from the " - "matrix", - "items": {"type": "string"}, - }, - }, + "properties": {**spec_list_properties}, }, {"type": "string", "description": "Simple spec string"}, {"type": "null"}, diff --git a/lib/spack/spack/schema/view.py b/lib/spack/spack/schema/view.py index d809a1cc439cb3..3d86e4611e8196 100644 --- a/lib/spack/spack/schema/view.py +++ b/lib/spack/spack/schema/view.py @@ -36,7 +36,20 @@ "properties": { "root": { "type": "string", - "description": "Root directory path where the view will be " "created", + "description": "Root directory path where the view will be created", + }, + "group": { + "oneOf": [ + { + "type": "array", + "items": {"type": "string"}, + "description": "Groups of specs to include in the view", + }, + { + "type": "string", + "description": "Groups of specs to include in the view", + }, + ] }, "link": { "enum": ["roots", "all", "run"], diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 33b1b0f2e69a6a..bdec4276fabf4d 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -43,7 +43,6 @@ import spack.concretize import spack.config import spack.deptypes as dt -import spack.environment as ev import spack.error import spack.llnl.util.lang import spack.llnl.util.tty as tty @@ -81,7 +80,7 @@ ) from .input_analysis import create_counter, create_graph_analyzer from .requirements import RequirementKind, RequirementOrigin, RequirementParser, RequirementRule -from .reuse import ReusableSpecsSelector, create_external_parser +from .reuse import ReusableSpecsSelector, SpecFiltersFactory, create_external_parser from .runtimes import RuntimePropertyRecorder, all_libcs, external_config_with_implicit_externals from .versions import Provenance @@ -2904,6 +2903,9 @@ def setup( Return: A ProblemInstanceBuilder populated with facts and rules for an ASP solve. """ + # TODO: remove this local import and get rid of dependency on globals + import spack.environment as ev + reuse = reuse or [] if packages_with_externals is None: packages_with_externals = external_config_with_implicit_externals(spack.config.CONFIG) @@ -3673,6 +3675,8 @@ def splice_at_hash( self._splices.setdefault(parent_spec, []).append(splice) def build_specs(self, function_tuples: List[FunctionTupleT]) -> List[spack.spec.Spec]: + # TODO: remove this local import and get rid of dependency on globals + import spack.environment as ev attr_key = { # hash attributes are handled first, since they imply entire concrete specs @@ -3891,7 +3895,7 @@ class Solver: and passes the setup method to the driver, as well. """ - def __init__(self): + def __init__(self, *, specs_factory: Optional[SpecFiltersFactory] = None): # Compute possible compilers first, so we see them as externals _ = spack.compilers.config.all_compilers(init_config=True) @@ -3904,6 +3908,7 @@ def __init__(self): self.selector = ReusableSpecsSelector( configuration=spack.config.CONFIG, external_parser=create_external_parser(self.packages_with_externals, completion_mode), + factory=specs_factory, packages_with_externals=self.packages_with_externals, ) diff --git a/lib/spack/spack/solver/reuse.py b/lib/spack/spack/solver/reuse.py index da55228f62a54e..ab18b16e1c9b44 100644 --- a/lib/spack/spack/solver/reuse.py +++ b/lib/spack/spack/solver/reuse.py @@ -3,11 +3,11 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import enum import functools -from typing import Any, Callable, List, Mapping +import typing +from typing import Any, Callable, List, Mapping, Optional import spack.binary_distribution import spack.config -import spack.environment import spack.llnl.path import spack.repo import spack.spec @@ -19,104 +19,52 @@ complete_variants_and_architecture, extract_dicts_from_configuration, ) +from spack.spec_filter import SpecFilter from .runtimes import all_libcs +if typing.TYPE_CHECKING: + import spack.environment -class SpecFilter: - """Given a method to produce a list of specs, this class can filter them according to - different criteria. - """ - def __init__( - self, - factory: Callable[[], List[spack.spec.Spec]], - is_usable: Callable[[spack.spec.Spec], bool], - include: List[str], - exclude: List[str], - ) -> None: - """ - Args: - factory: factory to produce a list of specs - is_usable: predicate that takes a spec in input and returns False if the spec - should not be considered for this filter, True otherwise. - include: if present, a "good" spec must match at least one entry in the list - exclude: if present, a "good" spec must not match any entry in the list - """ - self.factory = factory - self.is_usable = is_usable - self.include = include - self.exclude = exclude - - def is_selected(self, s: spack.spec.Spec) -> bool: - if not self.is_usable(s): - return False - - if self.include and not any(s.satisfies(c) for c in self.include): - return False - - if self.exclude and any(s.satisfies(c) for c in self.exclude): - return False - - return True - - def selected_specs(self) -> List[spack.spec.Spec]: - return [s for s in self.factory() if self.is_selected(s)] - - @staticmethod - def from_store(configuration, *, packages_with_externals, include, exclude) -> "SpecFilter": - """Constructs a filter that takes the specs from the current store.""" - is_reusable = functools.partial( - _is_reusable, packages_with_externals=packages_with_externals, local=True - ) - factory = functools.partial(_specs_from_store, configuration=configuration) - return SpecFilter(factory=factory, is_usable=is_reusable, include=include, exclude=exclude) - - @staticmethod - def from_buildcache(*, packages_with_externals, include, exclude) -> "SpecFilter": - """Constructs a filter that takes the specs from the configured buildcaches.""" - is_reusable = functools.partial( - _is_reusable, packages_with_externals=packages_with_externals, local=False - ) - return SpecFilter( - factory=_specs_from_mirror, is_usable=is_reusable, include=include, exclude=exclude - ) +def spec_filter_from_store( + configuration, *, packages_with_externals, include, exclude +) -> SpecFilter: + """Constructs a filter that takes the specs from the current store.""" + is_reusable = functools.partial( + _is_reusable, packages_with_externals=packages_with_externals, local=True + ) + factory = functools.partial(_specs_from_store, configuration=configuration) + return SpecFilter(factory=factory, is_usable=is_reusable, include=include, exclude=exclude) - @staticmethod - def from_environment(*, packages_with_externals, include, exclude, env) -> "SpecFilter": - is_reusable = functools.partial( - _is_reusable, packages_with_externals=packages_with_externals, local=True - ) - factory = functools.partial(_specs_from_environment, env=env) - return SpecFilter(factory=factory, is_usable=is_reusable, include=include, exclude=exclude) - @staticmethod - def from_environment_included_concrete( - *, - packages_with_externals, - include: List[str], - exclude: List[str], - env: spack.environment.Environment, - included_concrete: str, - ) -> "SpecFilter": - is_reusable = functools.partial( - _is_reusable, packages_with_externals=packages_with_externals, local=True - ) - factory = functools.partial( - _specs_from_environment_included_concrete, env=env, included_concrete=included_concrete - ) - return SpecFilter(factory=factory, is_usable=is_reusable, include=include, exclude=exclude) - - @staticmethod - def from_packages_yaml( - *, external_parser: ExternalSpecsParser, packages_with_externals, include, exclude - ) -> "SpecFilter": - is_reusable = functools.partial( - _is_reusable, packages_with_externals=packages_with_externals, local=True - ) - return SpecFilter( - external_parser.all_specs, is_usable=is_reusable, include=include, exclude=exclude - ) +def spec_filter_from_buildcache(*, packages_with_externals, include, exclude) -> SpecFilter: + """Constructs a filter that takes the specs from the configured buildcaches.""" + is_reusable = functools.partial( + _is_reusable, packages_with_externals=packages_with_externals, local=False + ) + return SpecFilter( + factory=_specs_from_mirror, is_usable=is_reusable, include=include, exclude=exclude + ) + + +def spec_filter_from_environment(*, packages_with_externals, include, exclude, env) -> SpecFilter: + is_reusable = functools.partial( + _is_reusable, packages_with_externals=packages_with_externals, local=True + ) + factory = functools.partial(_specs_from_environment, env=env) + return SpecFilter(factory=factory, is_usable=is_reusable, include=include, exclude=exclude) + + +def spec_filter_from_packages_yaml( + *, external_parser: ExternalSpecsParser, packages_with_externals, include, exclude +) -> SpecFilter: + is_reusable = functools.partial( + _is_reusable, packages_with_externals=packages_with_externals, local=True + ) + return SpecFilter( + external_parser.all_specs, is_usable=is_reusable, include=include, exclude=exclude + ) def _has_runtime_dependencies(spec: spack.spec.Spec) -> bool: @@ -197,15 +145,6 @@ def _specs_from_environment(env): return [] -def _specs_from_environment_included_concrete(env, included_concrete): - """Return only concrete specs from the environment included from the included_concrete""" - if env: - assert included_concrete in env.included_concrete_env_root_dirs - return [concrete for concrete in env.included_specs_by_hash[included_concrete].values()] - else: - return [] - - class ReuseStrategy(enum.Enum): ROOTS = enum.auto() DEPENDENCIES = enum.auto() @@ -228,24 +167,40 @@ def create_external_parser( return ExternalSpecsParser(external_dicts, complete_node=complete_fn) +SpecFiltersFactory = Callable[ + [Callable[[spack.spec.Spec], bool], spack.config.Configuration], List[SpecFilter] +] + + class ReusableSpecsSelector: """Selects specs that can be reused during concretization.""" def __init__( self, + *, configuration: spack.config.Configuration, external_parser: ExternalSpecsParser, packages_with_externals: Any, + factory: Optional[SpecFiltersFactory] = None, ) -> None: + # Local import to break circular dependencies + import spack.environment + self.configuration = configuration self.store = spack.store.create(configuration) self.reuse_strategy = ReuseStrategy.ROOTS - reuse_yaml = self.configuration.get("concretizer:reuse", False) + self.reuse_sources = [] + if factory is not None: + is_reusable = functools.partial( + _is_reusable, packages_with_externals=packages_with_externals, local=True + ) + self.reuse_sources.extend(factory(is_reusable, configuration)) + if not isinstance(reuse_yaml, Mapping): self.reuse_sources.append( - SpecFilter.from_packages_yaml( + spec_filter_from_packages_yaml( external_parser=external_parser, packages_with_externals=packages_with_externals, include=[], @@ -260,21 +215,15 @@ def __init__( self.reuse_strategy = ReuseStrategy.DEPENDENCIES self.reuse_sources.extend( [ - SpecFilter.from_store( + spec_filter_from_store( configuration=self.configuration, packages_with_externals=packages_with_externals, include=[], exclude=[], ), - SpecFilter.from_buildcache( + spec_filter_from_buildcache( packages_with_externals=packages_with_externals, include=[], exclude=[] ), - SpecFilter.from_environment( - packages_with_externals=packages_with_externals, - include=[], - exclude=[], - env=spack.environment.active_environment(), # with all concrete includes - ), ] ) else: @@ -293,45 +242,20 @@ def __init__( if source["type"] == "environment" and "path" in source: env_dir = spack.environment.as_env_dir(source["path"]) active_env = spack.environment.active_environment() - if active_env and env_dir in active_env.included_concrete_env_root_dirs: - # If the environment is included as a concrete environment, use the - # local copy of specs in the active environment. - # note: included concrete environments are only updated at concretization - # time, and reuse needs to match the included specs. - self.reuse_sources.append( - SpecFilter.from_environment_included_concrete( - packages_with_externals=packages_with_externals, - include=include, - exclude=exclude, - env=active_env, - included_concrete=env_dir, - ) - ) - else: + if not active_env or env_dir not in active_env.included_concrete_env_root_dirs: # If the environment is not included as a concrete environment, use the # current specs from its lockfile. self.reuse_sources.append( - SpecFilter.from_environment( + spec_filter_from_environment( packages_with_externals=packages_with_externals, include=include, exclude=exclude, env=spack.environment.environment_from_name_or_dir(env_dir), ) ) - elif source["type"] == "environment": - # reusing from the current environment implicitly reuses from all of the - # included concrete environments - self.reuse_sources.append( - SpecFilter.from_environment( - packages_with_externals=packages_with_externals, - include=include, - exclude=exclude, - env=spack.environment.active_environment(), - ) - ) elif source["type"] == "local": self.reuse_sources.append( - SpecFilter.from_store( + spec_filter_from_store( self.configuration, packages_with_externals=packages_with_externals, include=include, @@ -340,7 +264,7 @@ def __init__( ) elif source["type"] == "buildcache": self.reuse_sources.append( - SpecFilter.from_buildcache( + spec_filter_from_buildcache( packages_with_externals=packages_with_externals, include=include, exclude=exclude, @@ -352,7 +276,7 @@ def __init__( # Since libcs are implicit externals, we need to implicitly include them include = include + sorted(all_libcs()) # type: ignore[type-var] self.reuse_sources.append( - SpecFilter.from_packages_yaml( + spec_filter_from_packages_yaml( external_parser=external_parser, packages_with_externals=packages_with_externals, include=include, @@ -363,7 +287,7 @@ def __init__( # If "external" is not specified, we assume that all externals have to be included if not has_external_source: self.reuse_sources.append( - SpecFilter.from_packages_yaml( + spec_filter_from_packages_yaml( external_parser=external_parser, packages_with_externals=packages_with_externals, include=[], diff --git a/lib/spack/spack/spec_filter.py b/lib/spack/spack/spec_filter.py new file mode 100644 index 00000000000000..039df55bb473ea --- /dev/null +++ b/lib/spack/spack/spec_filter.py @@ -0,0 +1,48 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from typing import Callable, List + +import spack.spec + + +class SpecFilter: + """Given a method to produce a list of specs, this class can filter them according to + different criteria. + """ + + def __init__( + self, + factory: Callable[[], List[spack.spec.Spec]], + is_usable: Callable[[spack.spec.Spec], bool], + include: List[str], + exclude: List[str], + ) -> None: + """ + Args: + factory: factory to produce a list of specs + is_usable: predicate that takes a spec in input and returns False if the spec + should not be considered for this filter, True otherwise. + include: if present, a spec must match at least one entry in the list, + to be in the output + exclude: if present, a spec must not match any entry in the list to be in the output + """ + self.factory = factory + self.is_usable = is_usable + self.include = include + self.exclude = exclude + + def is_selected(self, s: spack.spec.Spec) -> bool: + if not self.is_usable(s): + return False + + if self.include and not any(s.satisfies(c) for c in self.include): + return False + + if self.exclude and any(s.satisfies(c) for c in self.exclude): + return False + + return True + + def selected_specs(self) -> List[spack.spec.Spec]: + return [s for s in self.factory() if self.is_selected(s)] diff --git a/lib/spack/spack/test/cmd/concretize.py b/lib/spack/spack/test/cmd/concretize.py index f4b61aa67801c5..cc154c7435312c 100644 --- a/lib/spack/spack/test/cmd/concretize.py +++ b/lib/spack/spack/test/cmd/concretize.py @@ -20,36 +20,40 @@ @pytest.mark.parametrize("unify", unification_strategies) -def test_concretize_all_test_dependencies(unify, mutable_mock_env_path): +def test_concretize_all_test_dependencies(unify, mutable_config, mutable_mock_env_path): """Check all test dependencies are concretized.""" env("create", "test") with ev.read("test") as e: - e.unify = unify + mutable_config.set("concretizer:unify", unify) add("depb") concretize("--test", "all") assert e.matching_spec("test-dependency") @pytest.mark.parametrize("unify", unification_strategies) -def test_concretize_root_test_dependencies_not_recursive(unify, mutable_mock_env_path): +def test_concretize_root_test_dependencies_not_recursive( + unify, mutable_config, mutable_mock_env_path +): """Check that test dependencies are not concretized recursively.""" env("create", "test") with ev.read("test") as e: - e.unify = unify + mutable_config.set("concretizer:unify", unify) add("depb") concretize("--test", "root") assert e.matching_spec("test-dependency") is None @pytest.mark.parametrize("unify", unification_strategies) -def test_concretize_root_test_dependencies_are_concretized(unify, mutable_mock_env_path): +def test_concretize_root_test_dependencies_are_concretized( + unify, mutable_config, mutable_mock_env_path +): """Check that root test dependencies are concretized.""" env("create", "test") with ev.read("test") as e: - e.unify = unify + mutable_config.set("concretizer:unify", unify) add("pkg-a") add("pkg-b") concretize("--test", "root") diff --git a/lib/spack/spack/test/cmd/develop.py b/lib/spack/spack/test/cmd/develop.py index b029ca38b1abd3..b0146b6e99d0a4 100644 --- a/lib/spack/spack/test/cmd/develop.py +++ b/lib/spack/spack/test/cmd/develop.py @@ -330,14 +330,14 @@ def test_recursive(mutable_mock_env_path, install_mockery, mock_fetch): def test_develop_fails_with_multiple_concrete_versions( - mutable_mock_env_path, install_mockery, mock_fetch + mutable_mock_env_path, install_mockery, mock_fetch, mutable_config ): env("create", "test") with ev.read("test") as e: add("indirect-mpich@1.0") add("indirect-mpich@0.9") - e.unify = False + mutable_config.set("concretizer:unify", False) e.concretize() with pytest.raises(SpackError) as develop_error: diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index af8b0ce7871e8c..7990895c3cee81 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -4684,3 +4684,58 @@ def test_concretized_specs_and_include_concrete(mutable_config): concretized_specs = list(e.concretized_specs()) assert list(dedupe(spec_pairs + included_pairs)) == concretized_specs assert len(concretized_specs) == 5 + + +def test_view_can_select_group_of_specs(installed_environment, tmp_path: pathlib.Path): + """Tests that we can select groups of specs in a view and exclude other groups""" + view_dir = tmp_path / "view" + with installed_environment( + f"""\ +spack: + specs: + - group: apps1 + specs: + - mpileaks + - group: apps2 + specs: + - cmake + - group: apps3 + specs: + - pkg-a + view: + default: + root: {view_dir} + group: [apps1, apps2] +""" + ) as test: + for item in test.concretized_roots: + # Assertions are based on the behavior of the "--fake" install + bin_file = pathlib.Path(test.default_view.view()._root) / "bin" / item.root.name + assert not bin_file.exists() if item.group == "apps3" else bin_file.exists() + + +def test_view_can_select_group_of_specs_using_string( + installed_environment, tmp_path: pathlib.Path +): + """Tests that we can select groups of specs in a view and exclude other groups""" + view_dir = tmp_path / "view" + with installed_environment( + f"""\ +spack: + specs: + - group: apps1 + specs: + - mpileaks + - group: apps2 + specs: + - cmake + view: + default: + root: {view_dir} + group: apps1 +""" + ) as test: + for item in test.concretized_roots: + # Assertions are based on the behavior of the "--fake" install + bin_file = pathlib.Path(test.default_view.view()._root) / "bin" / item.root.name + assert not bin_file.exists() if item.group == "apps2" else bin_file.exists() diff --git a/lib/spack/spack/test/concretization/compiler_runtimes.py b/lib/spack/spack/test/concretization/compiler_runtimes.py index 8343494ea18b14..35f46795ffa344 100644 --- a/lib/spack/spack/test/concretization/compiler_runtimes.py +++ b/lib/spack/spack/test/concretization/compiler_runtimes.py @@ -16,7 +16,7 @@ import spack.solver.asp import spack.spec from spack.environment.environment import ViewDescriptor -from spack.solver.reuse import SpecFilter, create_external_parser +from spack.solver.reuse import create_external_parser, spec_filter_from_packages_yaml from spack.solver.runtimes import external_config_with_implicit_externals from spack.version import Version @@ -25,7 +25,7 @@ def _concretize_with_reuse(*, root_str, reused_str, config): reused_spec = spack.concretize.concretize_one(reused_str) packages_with_externals = external_config_with_implicit_externals(config) completion_mode = config.get("concretizer:externals:completion") - external_specs = SpecFilter.from_packages_yaml( + external_specs = spec_filter_from_packages_yaml( external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, include=[], diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index d93f31b5aba7c7..1b6f561942534e 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -37,6 +37,7 @@ import spack.solver.reuse import spack.solver.runtimes import spack.spec +import spack.spec_filter import spack.util.file_cache import spack.util.hash import spack.util.spack_yaml as syaml @@ -44,7 +45,7 @@ from spack.externals import ExternalDependencyError from spack.installer import PackageInstaller from spack.solver.asp import Result -from spack.solver.reuse import SpecFilter, create_external_parser +from spack.solver.reuse import create_external_parser, spec_filter_from_packages_yaml from spack.solver.runtimes import external_config_with_implicit_externals from spack.spec import Spec from spack.test.conftest import RepoBuilder @@ -2010,7 +2011,7 @@ def test_version_weight_and_provenance(self, mutable_config): packages_with_externals = external_config_with_implicit_externals(mutable_config) completion_mode = mutable_config.get("concretizer:externals:completion") - external_specs = SpecFilter.from_packages_yaml( + external_specs = spec_filter_from_packages_yaml( external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, include=[], @@ -2044,7 +2045,7 @@ def test_variant_penalty(self, mutable_config): """Test package preferences during concretization.""" packages_with_externals = external_config_with_implicit_externals(mutable_config) completion_mode = mutable_config.get("concretizer:externals:completion") - external_specs = SpecFilter.from_packages_yaml( + external_specs = spec_filter_from_packages_yaml( external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, include=[], @@ -2400,7 +2401,7 @@ def test_result_specs_is_not_empty(self, mutable_config, specs): specs = [Spec(s) for s in specs] packages_with_externals = external_config_with_implicit_externals(mutable_config) completion_mode = mutable_config.get("concretizer:externals:completion") - external_specs = SpecFilter.from_packages_yaml( + external_specs = spec_filter_from_packages_yaml( external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, include=[], @@ -3277,7 +3278,7 @@ def test_filtering_reused_specs( ) completion_mode = mutable_config.get("concretizer:externals:completion") selector = spack.solver.asp.ReusableSpecsSelector( - mutable_config, + configuration=mutable_config, external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, ) @@ -3318,7 +3319,7 @@ def test_selecting_reused_sources(reuse_yaml, expected_length, mutable_config): ) completion_mode = mutable_config.get("concretizer:externals:completion") selector = spack.solver.asp.ReusableSpecsSelector( - mutable_config, + configuration=mutable_config, external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, ) @@ -3341,7 +3342,7 @@ def test_selecting_reused_sources(reuse_yaml, expected_length, mutable_config): def test_spec_filters(specs, include, exclude, expected): specs = [Spec(x) for x in specs] expected = [Spec(x) for x in expected] - f = spack.solver.reuse.SpecFilter( + f = spack.spec_filter.SpecFilter( factory=lambda: specs, is_usable=lambda x: True, include=include, exclude=exclude ) assert f.selected_specs() == expected diff --git a/lib/spack/spack/test/concretization/requirements.py b/lib/spack/spack/test/concretization/requirements.py index 76e4d5a242dff7..fe9afb2c851060 100644 --- a/lib/spack/spack/test/concretization/requirements.py +++ b/lib/spack/spack/test/concretization/requirements.py @@ -20,7 +20,7 @@ import spack.version from spack.installer import PackageInstaller from spack.solver.asp import InternalConcretizerError, UnsatisfiableSpecError -from spack.solver.reuse import SpecFilter, create_external_parser +from spack.solver.reuse import create_external_parser, spec_filter_from_packages_yaml from spack.solver.runtimes import external_config_with_implicit_externals from spack.spec import Spec from spack.util.url import path_to_file_url @@ -1323,7 +1323,7 @@ def test_requirements_on_compilers_and_reuse( root_specs = [Spec(input_spec)] packages_with_externals = external_config_with_implicit_externals(mutable_config) completion_mode = mutable_config.get("concretizer:externals:completion") - external_specs = SpecFilter.from_packages_yaml( + external_specs = spec_filter_from_packages_yaml( external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, include=[], @@ -1513,7 +1513,7 @@ def test_language_preferences_and_reuse( reused_nodes = list(initial_mpileaks.traverse()) packages_with_externals = external_config_with_implicit_externals(mutable_config) completion_mode = mutable_config.get("concretizer:externals:completion") - external_specs = SpecFilter.from_packages_yaml( + external_specs = spec_filter_from_packages_yaml( external_parser=create_external_parser(packages_with_externals, completion_mode), packages_with_externals=packages_with_externals, include=[], diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index e635496826b13c..d1c0d3b0a48235 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Test environment internals without CLI""" + import filecmp import os import pathlib @@ -15,12 +16,15 @@ import spack.platforms import spack.solver.asp import spack.spec +from spack.enums import ConfigScopePriority +from spack.environment import SpackEnvironmentConfigError from spack.environment.environment import ( EnvironmentManifestFile, SpackEnvironmentViewError, _error_on_nonempty_view_dir, ) from spack.environment.list import UndefinedReferenceError +from spack.traverse import traverse_nodes pytestmark = [ pytest.mark.not_on_windows("Envs are not supported on windows"), @@ -552,9 +556,8 @@ def test_environment_concretizer_scheme_used( ) mutable_config.set("concretizer:unify", unify_in_lower_scope) assert mutable_config.get("concretizer:unify") == unify_in_lower_scope - with ev.Environment(manifest.parent) as e: + with ev.Environment(manifest.parent): assert mutable_config.get("concretizer:unify") == unify_in_spack_yaml - assert e.unify == unify_in_spack_yaml @pytest.mark.parametrize("unify_in_config", [True, False, "when_possible"]) @@ -572,8 +575,8 @@ def test_environment_config_scheme_used(tmp_path: pathlib.Path, unify_in_config) ) with spack.config.override("concretizer:unify", unify_in_config): - with ev.Environment(manifest.parent) as e: - assert e.unify == unify_in_config + with ev.Environment(manifest.parent): + assert spack.config.CONFIG.get("concretizer:unify") == unify_in_config @pytest.mark.parametrize( @@ -1646,3 +1649,334 @@ def test_installed_specs_disregards_deprecation(tmp_path, mutable_config): if node.satisfies("%c"): assert node.satisfies("%c=gcc@12"), node.tree() assert not node.satisfies("%c=gcc@7"), node.tree() + + +@pytest.fixture() +def create_temporary_manifest(tmp_path): + manifest_path = tmp_path / "spack.yaml" + + def _create(spack_yaml: str): + manifest_path.write_text(spack_yaml) + return EnvironmentManifestFile(tmp_path) + + return _create + + +@pytest.mark.usefixtures("mutable_config") +class TestEnvironmentGroups: + """Tests for the environment "groups" feature""" + + def test_manifest_and_groups(self, create_temporary_manifest): + """Tests a basic case of reading groups from a manifest file""" + manifest = create_temporary_manifest( + """ + spack: + specs: + - mpileaks + - group: compiler + matrix: + - [gcc@14] + - group: apps + needs: [compiler] + specs: + - matrix: + - [mpileaks] + - ["%gcc@14"] + - mpich + - libelf + """ + ) + # Check manifest properties + assert set(manifest.groups()) == {"default", "compiler", "apps"} + + assert manifest.user_specs(group="default") == manifest.user_specs() + assert manifest.user_specs() == ["mpileaks", "libelf"] + assert manifest.user_specs(group="compiler") == [{"matrix": [["gcc@14"]]}] + assert manifest.user_specs(group="apps") == [ + {"matrix": [["mpileaks"], ["%gcc@14"]]}, + "mpich", + ] + + assert manifest.needs(group="default") == () + assert manifest.needs(group="compiler") == () + assert manifest.needs(group="apps") == ("compiler",) + + # Check user specs within the environment + e = ev.Environment(manifest.manifest_dir) + assert e.user_specs.specs == [spack.spec.Spec("mpileaks"), spack.spec.Spec("libelf")] + + compiler_specs = e.user_specs_by(group="compiler") + assert compiler_specs.name == "specs:compiler" + assert compiler_specs.specs == [spack.spec.Spec("gcc@14")] + + apps_specs = e.user_specs_by(group="apps") + assert apps_specs.name == "specs:apps" + assert apps_specs.specs == [spack.spec.Spec("mpileaks %gcc@14"), spack.spec.Spec("mpich")] + + def test_cannot_define_group_twice(self, create_temporary_manifest): + """Tests that defining the same group twice raises an error""" + with pytest.raises(SpackEnvironmentConfigError, match="defined more than once"): + create_temporary_manifest( + """ + spack: + specs: + - group: compiler + matrix: + - [gcc@14] + - group: compiler + matrix: + - [llvm@20] +""" + ) + + def test_matrix_can_be_expanded_in_groups(self, create_temporary_manifest): + """Tests that definitions can be expanded also for matrix groups""" + manifest = create_temporary_manifest( + """ +spack: + definitions: + - compilers: ["%gcc", "%clang"] + - desired_specs: ["mpileaks@2.1"] + specs: + - group: apps + specs: + - matrix: + - [$desired_specs] + - [$compilers] + - mpich +""" + ) + e = ev.Environment(manifest.manifest_dir) + assert e.user_specs.specs == [] + assert e.user_specs_by(group="apps").specs == [ + spack.spec.Spec("mpileaks@2.1 %gcc"), + spack.spec.Spec("mpileaks@2.1 %clang"), + spack.spec.Spec("mpich"), + ] + + def test_environment_without_groups_use_lockfile_v6(self, create_temporary_manifest): + manifest = create_temporary_manifest( + """ +spack: + specs: + - mpileaks + - pkg-a +""" + ) + with ev.Environment(manifest.manifest_dir) as e: + e.concretize() + lockfile_data = e._to_lockfile_dict() + assert lockfile_data["_meta"]["lockfile-version"] == 6 + assert all("group" not in x for x in lockfile_data["roots"]) + + def test_independent_groups_concretization(self, create_temporary_manifest): + """Tests that groups of specs without dependencies among them can be concretized + correctly + """ + manifest = create_temporary_manifest( + """ + spack: + specs: + - mpileaks + - group: compiler + matrix: + - [gcc@14] + - libelf + """ + ) + + with ev.Environment(manifest.manifest_dir) as e: + e.concretize() + roots = e.concrete_roots() + assert len(roots) == 3 + + default_specs = list(e.concretized_specs_by(group="default")) + assert len(default_specs) == 2 + + compiler_specs = list(e.concretized_specs_by(group="compiler")) + assert len(compiler_specs) == 1 + + def test_independent_group_dont_reuse(self, create_temporary_manifest): + """Tests that there is no cross-groups reuse among groups of specs without dependencies.""" + manifest = create_temporary_manifest( + """ + spack: + specs: + - mpileaks@2.2 + - group: app + matrix: + - [mpileaks] + """ + ) + + with ev.Environment(manifest.manifest_dir) as e: + e.concretize() + + _, default_mpileaks = list(e.concretized_specs_by(group="default"))[0] + assert default_mpileaks.satisfies("@2.2") + + _, app_mpileaks = list(e.concretized_specs_by(group="app"))[0] + assert app_mpileaks.satisfies("@2.3") + + def test_relying_on_a_dependency_group(self, create_temporary_manifest): + """Tests that a group of specs that would not concretize without a dependency group + works correctly. + """ + manifest = create_temporary_manifest( + """ + spack: + specs: + - group: app + matrix: + - [mpileaks] + - ["%c,cxx=gcc@14"] + """ + ) + + # We have no gcc@14 configured, so this will raise an error + with ev.Environment(manifest.manifest_dir) as e: + with pytest.raises(spack.solver.asp.UnsatisfiableSpecError): + e.concretize() + + manifest = create_temporary_manifest( + """ + spack: + specs: + - group: compiler + specs: + - gcc@14 + - group: mpileaks + needs: [compiler] + matrix: + - [mpileaks] + - ["%c,cxx=gcc@14"] + """ + ) + + # In this case gcc@14 is taken from the "needed" group + with ev.Environment(manifest.manifest_dir) as e: + e.concretize() + + _, gcc = next(iter(e.concretized_specs_by(group="compiler"))) + assert gcc.satisfies("gcc@14") + _, mpileaks = next(iter(e.concretized_specs_by(group="mpileaks"))) + assert mpileaks["c"].dag_hash() == gcc.dag_hash() + + def test_manifest_can_contain_config_override(self, mutable_config, create_temporary_manifest): + manifest = create_temporary_manifest( + """ + spack: + concretizer: + unify: False + specs: + - group: compiler + override: + concretizer: + unify: True + """ + ) + + with ev.Environment(manifest.manifest_dir) as e: + assert mutable_config.get_config("concretizer")["unify"] is False + + # Assert the internal scope works when used manually + override = manifest.config_override(group="compiler") + mutable_config.push_scope( + override, priority=ConfigScopePriority.ENVIRONMENT_SPEC_GROUPS + ) + assert mutable_config.get_config("concretizer")["unify"] is True + mutable_config.remove_scope(override.name) + assert mutable_config.get_config("concretizer")["unify"] is False + + # Assert the context manager works too + with e.config_override_for_group(group="compiler"): + assert mutable_config.get_config("concretizer")["unify"] is True + assert mutable_config.get_config("concretizer")["unify"] is False + + def test_overriding_concretization_properties_per_group(self, create_temporary_manifest): + manifest = create_temporary_manifest( + """ + spack: + concretizer: + unify: True + specs: + - group: compiler + specs: + - gcc@14 + - group: scalapacks + needs: [compiler] + matrix: + - [netlib-scalapack] + - ["%mpi=mpich", "%mpi=mpich2"] + - ["%lapack=openblas-with-lapack", "%lapack=netlib-lapack"] + override: + concretizer: + unify: False + packages: + c: + prefer: [gcc@14] + cxx: + prefer: [gcc@14] + fortran: + prefer: [gcc@14] + """ + ) + + with ev.Environment(manifest.manifest_dir) as e: + e.concretize() + + assert len(list(e.concretized_specs_by(group="compiler"))) == 1 + + gcc = next(x for _, x in e.concretized_specs_by(group="compiler")) + assert gcc.satisfies("gcc@14") and not gcc.external + assert gcc.satisfies("%c,cxx,fortran=gcc") + gcc_hash = gcc.dag_hash() + + assert len(list(e.concretized_specs_by(group="scalapacks"))) == 4 + scalapacks = [x for _, x in e.concretized_specs_by(group="scalapacks")] + for node in traverse_nodes(scalapacks, deptype=("link", "run")): + assert node.satisfies(f"%[when=c]c=gcc/{gcc_hash}") + assert node.satisfies(f"%[when=cxx]cxx=gcc/{gcc_hash}") + assert node.satisfies(f"%[when=fortran]fortran=gcc/{gcc_hash}") + + def test_missing_needs_group_gives_clear_error(self, create_temporary_manifest): + """Tests that referencing a non-existent group in 'needs' gives a clear error message + that includes the name of the blocked group and the missing dependency. + """ + manifest = create_temporary_manifest( + """ +spack: + specs: + - group: apps + needs: [nonexistent] + specs: + - mpileaks +""" + ) + with ev.Environment(manifest.manifest_dir) as e: + with pytest.raises( + ev.SpackEnvironmentConfigError, match=r"but 'nonexistent' is not a defined group" + ): + e.concretize() + + def test_cyclic_group_dependencies_give_clear_error(self, create_temporary_manifest): + """Tests that cyclic group dependencies give a clear error message that mentions + the groups involved in the cycle. + """ + manifest = create_temporary_manifest( + """ +spack: + specs: + - group: alpha + needs: [beta] + specs: + - mpileaks + - group: beta + needs: [alpha] + specs: + - zlib +""" + ) + with ev.Environment(manifest.manifest_dir) as e: + with pytest.raises(ev.SpackEnvironmentConfigError, match=r"among groups: alpha, beta"): + e.concretize() From 18ea99c92cc4c5b49405c068f0fb3e24399de19a Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 26 Feb 2026 11:01:28 +0100 Subject: [PATCH 098/337] lock.py: reduce syscalls to a minimum (#51996) * Lookup open file handles by (ino, dev) key * Drop `pid` from the key: when forking, fds are inherited and all cached file handles are valid; when forkserver/spawn, the cache is empty. In neither case `pid` matters. * Do not close a file handle when the last lock is released * Use EAFP pattern: * directly open lock file in read/write mode * if it fails, open in read mode * if it fails, try to make the relevant dirs and open in read/write This makes the number of syscalls minimal in the most common use cases: 1. the parent dirs and lock files already exists 2. the lock is repeatedly locked and unlocked In repeated lock calls, the happy path is just a stat call before the fcntl syscall. If the lock file has been unlinked from the dir and re-created by a different process, we invalidate our cache and re-open the new lock file: this is guaranteed because we maintain an open fd, meaning that the new lock file must have a different inode. --- lib/spack/spack/llnl/util/lock.py | 437 ++++++++++++------------- lib/spack/spack/new_installer.py | 8 - lib/spack/spack/test/llnl/util/lock.py | 35 +- 3 files changed, 225 insertions(+), 255 deletions(-) diff --git a/lib/spack/spack/llnl/util/lock.py b/lib/spack/spack/llnl/util/lock.py index 63abafa6ece8aa..7424dcbf145d30 100644 --- a/lib/spack/spack/llnl/util/lock.py +++ b/lib/spack/spack/llnl/util/lock.py @@ -9,7 +9,7 @@ import time from datetime import datetime from types import TracebackType -from typing import IO, Any, Callable, ContextManager, Dict, Generator, Optional, Tuple, Type, Union +from typing import IO, Callable, ContextManager, Dict, Generator, Optional, Tuple, Type, Union from spack.llnl.util import lang, tty @@ -35,6 +35,7 @@ ReleaseFnType = Optional[Callable[[], bool]] +DevIno = Tuple[int, int] # (st_dev, st_ino) from os.stat_result def true_fn() -> bool: @@ -43,119 +44,92 @@ def true_fn() -> bool: class OpenFile: - """Record for keeping track of open lockfiles (with reference counting). + """Record for keeping track of open lockfiles (with reference counting).""" - There's really only one ``OpenFile`` per inode, per process, but we record the - filehandle here as it's the thing we end up using in python code. You can get - the file descriptor from the file handle if needed -- or we could make this track - file descriptors as well in the future. - """ + __slots__ = ("fh", "key", "refs") - def __init__(self, fh: IO) -> None: + def __init__(self, fh: IO[bytes], key: DevIno): self.fh = fh + self.key = key # (dev, ino) self.refs = 0 class OpenFileTracker: - """Track open lockfiles, to minimize number of open file descriptors. - - The ``fcntl`` locks that Spack uses are associated with an inode and a process. - This is convenient, because if a process exits, it releases its locks. - Unfortunately, this also means that if you close a file, *all* locks associated - with that file's inode are released, regardless of whether the process has any - other open file descriptors on it. - - Because of this, we need to track open lock files so that we only close them when - a process no longer needs them. We do this by tracking each lockfile by its - inode and process id. This has several nice properties: - - 1. Tracking by pid ensures that, if we fork, we don't inadvertently track the parent - process's lockfiles. ``fcntl`` locks are not inherited across forks, so we'll - just track new lockfiles in the child. - 2. Tracking by inode ensures that references are counted per inode, and that we don't - inadvertently close a file whose inode still has open locks. - 3. Tracking by both pid and inode ensures that we only open lockfiles the minimum - number of times necessary for the locks we have. - - Note: as mentioned elsewhere, these locks aren't thread safe -- they're designed to - work in Python and assume the GIL. - """ + """Track open lockfiles by inode, to minimize the number of open file descriptors. - def __init__(self) -> None: - """Create a new ``OpenFileTracker``.""" - self._descriptors: Dict[Any, OpenFile] = {} + ``fcntl`` locks are associated with an inode. If a process closes *any* file descriptor for an + inode, all fcntl locks the process holds on that inode are released, even if other descriptors + for the same inode are still open. - def get_fh(self, path: str) -> IO: - """Get a filehandle for a lockfile. + To avoid accidentally dropping locks we keep at most one open file descriptor per inode and + reference-count it. The descriptor is only closed when the reference count reaches zero (i.e. + no ``Lock`` in this process still needs it). - This routine will open writable files for read/write even if you're asking - for a shared (read-only) lock. This is so that we can upgrade to an exclusive - (write) lock later if requested. + Descriptors are *not* released on unlock; they are kept alive across lock/unlock cycles so that + the next lock operation can skip re-opening the file. ``Lock._ensure_valid_handle`` + re-validates the on-disk inode before each lock operation and drops a stale descriptor when + the file was deleted and replaced. + """ - Arguments: - path: path to lock file we want a filehandle for - """ - # Open writable files as rb+ so we can upgrade to write later - os_mode, fh_mode = (os.O_RDWR | os.O_CREAT), "rb+" + def __init__(self): + self._descriptors: Dict[DevIno, OpenFile] = {} - pid = os.getpid() - open_file = None # OpenFile object, if there is one - stat = None # stat result for the lockfile, if it exists + def get_ref_for_inode(self, key: DevIno) -> Optional[OpenFile]: + """Fast lookup: do we already have this inode open?""" + return self._descriptors.get(key) + def create_and_track(self, path: str) -> OpenFile: + """Slow path: Open file, handle directory creation, track it.""" + # Open the file and create it if it doesn't exist (incl. directories). try: - # see whether we've seen this inode/pid before - stat = os.stat(path) - key = (stat.st_dev, stat.st_ino, pid) - open_file = self._descriptors.get(key) - + try: + fd = os.open(path, os.O_RDWR | os.O_CREAT) + mode = "rb+" + except PermissionError: + fd = os.open(path, os.O_RDONLY) + mode = "rb" except OSError as e: - if e.errno != errno.ENOENT: # only handle file not found + if e.errno != errno.ENOENT: raise - - # path does not exist -- fail if we won't be able to create it - parent = os.path.dirname(path) or "." - if not os.access(parent, os.W_OK): + # Directory missing, create and retry + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT) + except OSError: raise CantCreateLockError(path) - - # if there was no already open file, we'll need to open one - if not open_file: - if stat and not os.access(path, os.W_OK): - # we know path exists but not if it's writable. If it's read-only, - # only open the file for reading (and fail if we're trying to get - # an exclusive (write) lock on it) - os_mode, fh_mode = os.O_RDONLY, "rb" - - fd = os.open(path, os_mode) - fh = os.fdopen(fd, fh_mode) - open_file = OpenFile(fh) - - # if we just created the file, we'll need to get its inode here - if not stat: - stat = os.fstat(fd) - key = (stat.st_dev, stat.st_ino, pid) - - self._descriptors[key] = open_file - - open_file.refs += 1 - return open_file.fh - - def release_by_stat(self, stat): - key = (stat.st_dev, stat.st_ino, os.getpid()) - open_file = self._descriptors.get(key) - assert open_file, "Attempted to close non-existing inode: %s" % stat.st_ino - + mode = "rb+" + + # Get file identifier (device, inode) for tracking. + stat = os.fstat(fd) + key = (stat.st_dev, stat.st_ino) + + # Did we open a file we already track, e.g. a symlink to existing tracker file. + if key in self._descriptors: + os.close(fd) + existing = self._descriptors[key] + existing.refs += 1 + return existing + + # Track the new file. + fh = os.fdopen(fd, mode) + obj = OpenFile(fh, key) + obj.refs += 1 + self._descriptors[key] = obj + return obj + + def release(self, open_file: OpenFile): + """Decrement the reference count and close the file handle when it reaches zero.""" open_file.refs -= 1 - if not open_file.refs: - del self._descriptors[key] + if open_file.refs <= 0: + if self._descriptors.get(open_file.key) is open_file: + del self._descriptors[open_file.key] open_file.fh.close() - def release_by_fh(self, fh): - self.release_by_stat(os.fstat(fh.fileno())) - def purge(self): - for key in list(self._descriptors.keys()): - self._descriptors[key].fh.close() - del self._descriptors[key] + """Close all tracked file descriptors and clear the cache.""" + for open_file in self._descriptors.values(): + open_file.fh.close() + self._descriptors.clear() #: Open file descriptors for locks in this process. Used to prevent one process @@ -198,16 +172,14 @@ def is_valid(op: int) -> bool: class Lock: """This is an implementation of a filesystem lock using Python's lockf. - In Python, ``lockf`` actually calls ``fcntl``, so this should work with - any filesystem implementation that supports locking through the fcntl - calls. This includes distributed filesystems like Lustre (when flock - is enabled) and recent NFS versions. + In Python, ``lockf`` actually calls ``fcntl``, so this should work with any filesystem + implementation that supports locking through the fcntl calls. This includes distributed + filesystems like Lustre (when flock is enabled) and recent NFS versions. - Note that this is for managing contention over resources *between* - processes and not for managing contention between threads in a process: the - functions of this object are not thread-safe. A process also must not - maintain multiple locks on the same file (or, more specifically, on - overlapping byte ranges in the same file). + Note that this is for managing contention over resources *between* processes and not for + managing contention between threads in a process: the functions of this object are not + thread-safe. A process also must not maintain multiple locks on the same file (or, more + specifically, on overlapping byte ranges in the same file). """ def __init__( @@ -222,29 +194,29 @@ def __init__( ) -> None: """Construct a new lock on the file at ``path``. - By default, the lock applies to the whole file. Optionally, - caller can specify a byte range beginning ``start`` bytes from - the start of the file and extending ``length`` bytes from there. + By default, the lock applies to the whole file. Optionally, caller can specify a byte + range beginning ``start`` bytes from the start of the file and extending ``length`` bytes + from there. - This exposes a subset of fcntl locking functionality. It does - not currently expose the ``whence`` parameter -- ``whence`` is - always ``os.SEEK_SET`` and ``start`` is always evaluated from the - beginning of the file. + This exposes a subset of fcntl locking functionality. It does not currently expose the + ``whence`` parameter -- ``whence`` is always ``os.SEEK_SET`` and ``start`` is always + evaluated from the beginning of the file. Args: path: path to the lock start: optional byte offset at which the lock starts length: optional number of bytes to lock - default_timeout: seconds to wait for lock attempts, - where None means to wait indefinitely + default_timeout: seconds to wait for lock attempts, where None means to wait + indefinitely debug: debug mode specific to locking - desc: optional debug message lock description, which is - helpful for distinguishing between different Spack locks. + desc: optional debug message lock description, which is helpful for distinguishing + between different Spack locks. """ self.path = path - self._file: Optional[IO[bytes]] = None self._reads = 0 self._writes = 0 + self._file_ref: Optional[OpenFile] = None + self._cached_key: Optional[DevIno] = None # byte range parameters self._start = start @@ -267,20 +239,68 @@ def __init__( self.host: Optional[str] = None self.old_host: Optional[str] = None + def _ensure_valid_handle(self) -> IO[bytes]: + """Return a valid file handle for the lock file, opening or re-opening as needed. + + On the happy path this costs a single ``os.stat`` syscall: if the inode on disk matches + ``_cached_key``, the already-open file handle is returned immediately. + + If the inode changed (the lock file was deleted and replaced by another process), the stale + reference is released and a fresh one is obtained. If the file does not exist yet it is + created (along with any missing parent directories). + """ + try: + # Check what is currently on disk. This is the only syscall in the happy path. + stat_res = os.stat(self.path) + current_key = (stat_res.st_dev, stat_res.st_ino) + + # Double-check that our cache corresponds the file on disk. + if self._file_ref and not self._file_ref.fh.closed: + if self._cached_key == current_key: + return self._file_ref.fh + + # Stale path: file was deleted and replaced on disk. + FILE_TRACKER.release(self._file_ref) + self._file_ref = None + + # Get reference to the verified inode from the tracker if it exist, or a new one. + existing_ref = FILE_TRACKER.get_ref_for_inode(current_key) + if existing_ref: + self._file_ref = existing_ref + self._file_ref.refs += 1 + else: + # We don't have it tracked, so we need to open and track it ourselves. + self._file_ref = FILE_TRACKER.create_and_track(self.path) + except OSError as e: + # Re-raise all errors except for "file not found". + if e.errno != errno.ENOENT: + raise + + # File was not found, so remove it from our cache. + if self._file_ref: + FILE_TRACKER.release(self._file_ref) + self._file_ref = None + + self._file_ref = FILE_TRACKER.create_and_track(self.path) + + # Update our local cache of what we hold + self._cached_key = self._file_ref.key + + return self._file_ref.fh + @staticmethod def _poll_interval_generator( _wait_times: Optional[Tuple[float, float, float]] = None, ) -> Generator[float, None, None]: - """This implements a backoff scheme for polling a contended resource - by suggesting a succession of wait times between polls. + """This implements a backoff scheme for polling a contended resource by suggesting a + succession of wait times between polls. - It suggests a poll interval of .1s until 2 seconds have passed, - then a poll interval of .2s until 10 seconds have passed, and finally - (for all requests after 10s) suggests a poll interval of .5s. + It suggests a poll interval of .1s until 2 seconds have passed, then a poll interval of + .2s until 10 seconds have passed, and finally (for all requests after 10s) suggests a poll + interval of .5s. - This doesn't actually track elapsed time, it estimates the waiting - time as though the caller always waits for the full length of time - suggested by this function. + This doesn't actually track elapsed time, it estimates the waiting time as though the + caller always waits for the full length of time suggested by this function. """ num_requests = 0 stage1, stage2, stage3 = _wait_times or (1e-1, 2e-1, 5e-1) @@ -295,27 +315,39 @@ def _poll_interval_generator( def __repr__(self) -> str: """Formal representation of the lock.""" - rep = "{0}(".format(self.__class__.__name__) + rep = f"{self.__class__.__name__}(" for attr, value in self.__dict__.items(): - rep += "{0}={1}, ".format(attr, value.__repr__()) - return "{0})".format(rep.strip(", ")) + rep += f"{attr}={value.__repr__()}, " + return f"{rep.strip(', ')})" def __str__(self) -> str: """Readable string (with key fields) of the lock.""" - location = "{0}[{1}:{2}]".format(self.path, self._start, self._length) - timeout = "timeout={0}".format(self.default_timeout) - activity = "#reads={0}, #writes={1}".format(self._reads, self._writes) - return "({0}, {1}, {2})".format(location, timeout, activity) + location = f"{self.path}[{self._start}:{self._length}]" + timeout = f"timeout={self.default_timeout}" + activity = f"#reads={self._reads}, #writes={self._writes}" + return f"({location}, {timeout}, {activity})" + + def __getstate__(self): + """Don't include file handles or counts in pickled state.""" + state = self.__dict__.copy() + del state["_file_ref"] + del state["_reads"] + del state["_writes"] + return state + + def __setstate__(self, state): + self.__dict__.update(state) + self._file_ref = None + self._reads = 0 + self._writes = 0 def _lock(self, op: int, timeout: Optional[float] = None) -> Tuple[float, int]: """This takes a lock using POSIX locks (``fcntl.lockf``). - The lock is implemented as a spin lock using a nonblocking call - to ``lockf()``. + The lock is implemented as a spin lock using a nonblocking call to ``lockf()``. - If the lock times out, it raises a ``LockError``. If the lock is - successfully acquired, the total wait time and the number of attempts - is returned. + If the lock times out, it raises a ``LockError``. If the lock is successfully acquired, the + total wait time and the number of attempts is returned. """ assert LockType.is_valid(op) op_str = LockType.to_str(op) @@ -323,12 +355,9 @@ def _lock(self, op: int, timeout: Optional[float] = None) -> Tuple[float, int]: self._log_acquiring("{0} LOCK".format(op_str)) timeout = timeout or self.default_timeout - # Create file and parent directories if they don't exist. - if self._file is None: - self._ensure_parent_directory() - self._file = FILE_TRACKER.get_fh(self.path) + fh = self._ensure_valid_handle() - if LockType.to_module(op) == fcntl.LOCK_EX and self._file.mode == "rb": + if LockType.to_module(op) == fcntl.LOCK_EX and fh.mode == "rb": # Attempt to upgrade to write lock w/a read-only file. # If the file were writable, we'd have opened it rb+ raise LockROFileError(self.path) @@ -360,17 +389,12 @@ def _poll_lock(self, op: int) -> bool: """Attempt to acquire the lock in a non-blocking manner. Return whether the locking attempt succeeds """ - assert self._file is not None, "cannot poll a lock without the file being set" + assert self._file_ref is not None, "cannot poll a lock without the file being set" + fh = self._file_ref.fh.fileno() module_op = LockType.to_module(op) try: # Try to get the lock (will raise if not available.) - fcntl.lockf( - self._file.fileno(), - module_op | fcntl.LOCK_NB, - self._length, - self._start, - os.SEEK_SET, - ) + fcntl.lockf(fh, module_op | fcntl.LOCK_NB, self._length, self._start, os.SEEK_SET) # help for debugging distributed locking if self.debug: @@ -395,22 +419,14 @@ def _poll_lock(self, op: int) -> bool: return False - def _ensure_parent_directory(self) -> str: - parent = os.path.dirname(self.path) - - # relative paths to lockfiles in the current directory have no parent - if not parent: - return "." - os.makedirs(parent, exist_ok=True) - return parent - def _read_log_debug_data(self) -> None: """Read PID and host data out of the file if it is there.""" - assert self._file is not None, "cannot read debug log without the file being set" + assert self._file_ref is not None, "cannot read debug log without the file being set" self.old_pid = self.pid self.old_host = self.host - line = self._file.read() + self._file_ref.fh.seek(0) + line = self._file_ref.fh.read() if line: pid, host = line.decode("utf-8").strip().split(",") _, _, pid = pid.rpartition("=") @@ -419,7 +435,7 @@ def _read_log_debug_data(self) -> None: def _write_log_debug_data(self) -> None: """Write PID and host data to the file, recording old values.""" - assert self._file is not None, "cannot write debug log without the file being set" + assert self._file_ref is not None, "cannot write debug log without the file being set" self.old_pid = self.pid self.old_host = self.host @@ -427,36 +443,33 @@ def _write_log_debug_data(self) -> None: self.host = socket.gethostname() # write pid, host to disk to sync over FS - self._file.seek(0) - self._file.write(f"pid={self.pid},host={self.host}".encode("utf-8")) - self._file.truncate() - self._file.flush() - os.fsync(self._file.fileno()) + self._file_ref.fh.seek(0) + self._file_ref.fh.write(f"pid={self.pid},host={self.host}".encode("utf-8")) + self._file_ref.fh.truncate() + self._file_ref.fh.flush() + os.fsync(self._file_ref.fh.fileno()) def _unlock(self) -> None: """Releases a lock using POSIX locks (``fcntl.lockf``) - Releases the lock regardless of mode. Note that read locks may - be masquerading as write locks, but this removes either. - + Releases the lock regardless of mode. Note that read locks may be masquerading as write + locks, but this removes either. """ - assert self._file is not None, "cannot unlock without the file being set" - fcntl.lockf(self._file.fileno(), fcntl.LOCK_UN, self._length, self._start, os.SEEK_SET) - FILE_TRACKER.release_by_fh(self._file) - self._file = None + assert self._file_ref is not None, "cannot unlock without the file being set" + fcntl.lockf( + self._file_ref.fh.fileno(), fcntl.LOCK_UN, self._length, self._start, os.SEEK_SET + ) self._reads = 0 self._writes = 0 def acquire_read(self, timeout: Optional[float] = None) -> bool: """Acquires a recursive, shared lock for reading. - Read and write locks can be acquired and released in arbitrary - order, but the POSIX lock is held until all local read and - write locks are released. - - Returns True if it is the first acquire and actually acquires - the POSIX lock, False if it is a nested transaction. + Read and write locks can be acquired and released in arbitrary order, but the POSIX lock is + held until all local read and write locks are released. + Returns True if it is the first acquire and actually acquires the POSIX lock, False if it + is a nested transaction. """ timeout = timeout or self.default_timeout @@ -475,13 +488,11 @@ def acquire_read(self, timeout: Optional[float] = None) -> bool: def acquire_write(self, timeout: Optional[float] = None) -> bool: """Acquires a recursive, exclusive lock for writing. - Read and write locks can be acquired and released in arbitrary - order, but the POSIX lock is held until all local read and - write locks are released. - - Returns True if it is the first acquire and actually acquires - the POSIX lock, False if it is a nested transaction. + Read and write locks can be acquired and released in arbitrary order, but the POSIX lock + is held until all local read and write locks are released. + Returns True if it is the first acquire and actually acquires the POSIX lock, False if it + is a nested transaction. """ timeout = timeout or self.default_timeout @@ -503,11 +514,7 @@ def acquire_write(self, timeout: Optional[float] = None) -> bool: return False def is_write_locked(self) -> bool: - """Check if the file is write locked - - Return: - (bool): ``True`` if the path is write locked, otherwise, ``False`` - """ + """Returns ``True`` if the path is write locked, otherwise, ``False``""" try: self.acquire_read() @@ -520,8 +527,7 @@ def is_write_locked(self) -> bool: return False def downgrade_write_to_read(self, timeout: Optional[float] = None) -> None: - """ - Downgrade from an exclusive write lock to a shared read. + """Downgrade from an exclusive write lock to a shared read. Raises: LockDowngradeError: if this is an attempt at a nested transaction @@ -539,8 +545,7 @@ def downgrade_write_to_read(self, timeout: Optional[float] = None) -> None: raise LockDowngradeError(self.path) def upgrade_read_to_write(self, timeout: Optional[float] = None) -> None: - """ - Attempts to upgrade from a shared read lock to an exclusive write. + """Attempts to upgrade from a shared read lock to an exclusive write. Raises: LockUpgradeError: if this is an attempt at a nested transaction @@ -561,19 +566,17 @@ def release_read(self, release_fn: ReleaseFnType = None) -> bool: """Releases a read lock. Arguments: - release_fn (typing.Callable): function to call *before* the last recursive - lock (read or write) is released. - - If the last recursive lock will be released, then this will call - release_fn and return its result (if provided), or return True - (if release_fn was not provided). + release_fn: function to call *before* the last recursive lock (read or write) is + released. - Otherwise, we are still nested inside some other lock, so do not - call the release_fn and, return False. + If the last recursive lock will be released, then this will call release_fn and return its + result (if provided), or return True (if release_fn was not provided). - Does limited correctness checking: if a read lock is released - when none are held, this will raise an assertion error. + Otherwise, we are still nested inside some other lock, so do not call the release_fn and, + return False. + Does limited correctness checking: if a read lock is released when none are held, this + will raise an assertion error. """ assert self._reads > 0 @@ -597,18 +600,15 @@ def release_write(self, release_fn: ReleaseFnType = None) -> bool: """Releases a write lock. Arguments: - release_fn (typing.Callable): function to call before the last recursive - write is released. + release_fn: function to call before the last recursive write is released. - If the last recursive *write* lock will be released, then this - will call release_fn and return its result (if provided), or - return True (if release_fn was not provided). Otherwise, we are - still nested inside some other write lock, so do not call the - release_fn, and return False. - - Does limited correctness checking: if a read lock is released - when none are held, this will raise an assertion error. + If the last recursive *write* lock will be released, then this will call release_fn and + return its result (if provided), or return True (if release_fn was not provided). + Otherwise, we are still nested inside some other write lock, so do not call the release_fn, + and return False. + Does limited correctness checking: if a read lock is released when none are held, this + will raise an assertion error. """ assert self._writes > 0 release_fn = release_fn or true_fn @@ -704,17 +704,14 @@ class LockTransaction: timeout: number of seconds to set for the timeout when acquiring the lock (default no timeout) - If the ``acquire_fn`` returns a value, it is used as the return value for - ``__enter__``, allowing it to be passed as the ``as`` argument of a - ``with`` statement. + If the ``acquire_fn`` returns a value, it is used as the return value for ``__enter__``, + allowing it to be passed as the ``as`` argument of a ``with`` statement. - If ``acquire_fn`` returns a context manager, *its* ``__enter__`` function - will be called after the lock is acquired, and its ``__exit__`` function - will be called before ``release_fn`` in ``__exit__``, allowing you to - nest a context manager inside this one. + If ``acquire_fn`` returns a context manager, *its* ``__enter__`` function will be called after + the lock is acquired, and its ``__exit__`` function will be called before ``release_fn`` in + ``__exit__``, allowing you to nest a context manager inside this one. Timeout for lock is customizable. - """ def __init__( diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index b56357408b5ac2..7c4a67ed93478a 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -51,7 +51,6 @@ import spack.deptypes as dt import spack.error import spack.hooks -import spack.llnl.util.lock import spack.llnl.util.tty import spack.paths import spack.report @@ -1374,13 +1373,6 @@ def _installer(self) -> None: else: old_stdin_settings = None - # Setup the database write lock. TODO: clean this up - if isinstance(spack.store.STORE.db.lock, spack.util.lock.Lock): - spack.store.STORE.db.lock._ensure_parent_directory() - spack.store.STORE.db.lock._file = spack.llnl.util.lock.FILE_TRACKER.get_fh( - spack.store.STORE.db.lock.path - ) - # Finished builds that have not yet been written to the database. finished_builds: List[ChildInfo] = [] next_database_write = 0.0 diff --git a/lib/spack/spack/test/llnl/util/lock.py b/lib/spack/spack/test/llnl/util/lock.py index 26262132100e26..577a73ade2832a 100644 --- a/lib/spack/spack/test/llnl/util/lock.py +++ b/lib/spack/spack/test/llnl/util/lock.py @@ -4,23 +4,9 @@ """These tests ensure that our lock works correctly. -This can be run in two ways. +Run with pytest:: -First, it can be run as a node-local test, with a typical invocation like -this:: - - spack test lock - -You can *also* run it as an MPI program, which allows you to test locks -across nodes. So, e.g., you can run the test like this:: - - mpirun -n 7 spack test lock - -And it will test locking correctness among MPI processes. Ideally, you -want the MPI processes to span across multiple nodes, so, e.g., for Slurm -you might do this:: - - srun -N 7 -n 7 -m cyclic spack test lock + pytest lib/spack/spack/test/llnl/util/lock.py You can use this to test whether your shared filesystem properly supports POSIX reader-writer locking with byte ranges through fcntl. @@ -36,9 +22,7 @@ Add names and paths for your preferred filesystem mounts to test on them; the tests are parametrized to run on all the filesystems listed in this -dict. Note that 'tmp' will be skipped for MPI testing, as it is often a -node-local filesystem, and multi-node tests will fail if the locks aren't -actually on a shared filesystem. +dict. """ import collections @@ -645,22 +629,22 @@ def test_upgrade_read_to_write(private_lock_path): lock.acquire_read() assert lock._reads == 1 assert lock._writes == 0 - assert lock._file.mode == "rb+" + assert lock._file_ref.fh.mode == "rb+" lock.acquire_write() assert lock._reads == 1 assert lock._writes == 1 - assert lock._file.mode == "rb+" + assert lock._file_ref.fh.mode == "rb+" lock.release_write() assert lock._reads == 1 assert lock._writes == 0 - assert lock._file.mode == "rb+" + assert lock._file_ref.fh.mode == "rb+" lock.release_read() assert lock._reads == 0 assert lock._writes == 0 - assert lock._file is None + assert not lock._file_ref.fh.closed # recycle the file handle for next lock @pytest.mark.skipif(getuid() == 0, reason="user is root") @@ -678,15 +662,12 @@ def test_upgrade_read_to_write_fails_with_readonly_file(private_lock_path): lock.acquire_read() assert lock._reads == 1 assert lock._writes == 0 - assert lock._file.mode == "rb" + assert lock._file_ref.fh.mode == "rb" # upgrade to write here with pytest.raises(lk.LockROFileError): lock.acquire_write() - # TODO: lk.FILE_TRACKER does not release private_lock_path - lk.FILE_TRACKER.release_by_stat(os.stat(private_lock_path)) - class ComplexAcquireAndRelease: def __init__(self, lock_path): From 06f3ac473591a77e6a623838fc2b164b7c06023b Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 26 Feb 2026 16:44:13 +0100 Subject: [PATCH 099/337] spack find: display group of specs in environment (#52009) This commit adds support for group of specs to `spack find`. Includes a unit test to avoid regressions. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/cmd/find.py | 57 ++++++++++++++++++-------------- lib/spack/spack/test/cmd/find.py | 53 +++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/lib/spack/spack/cmd/find.py b/lib/spack/spack/cmd/find.py index aa2973513793be..9f30fc57b92937 100644 --- a/lib/spack/spack/cmd/find.py +++ b/lib/spack/spack/cmd/find.py @@ -17,6 +17,7 @@ import spack.spec import spack.store from spack.cmd.common import arguments +from spack.llnl.util.tty.color import colorize from spack.solver.reuse import create_external_parser from spack.solver.runtimes import external_config_with_implicit_externals @@ -256,18 +257,12 @@ def display_env(env, args, decorator, results): In an environment, ``spack find`` outputs a preliminary section showing the root specs of the environment (this is in addition to the section listing out specs matching the query parameters). - """ - tty.msg("In environment %s" % env.name) - - num_roots = len(env.user_specs) or "No" - tty.msg(f"{num_roots} root specs") + total_roots = sum(len(env.user_specs_by(group=g)) for g in env.manifest.groups()) + root_spec_str = f"{total_roots or 'no'} root {'spec' if total_roots == 1 else 'specs'}" + tty.msg(f"In environment {env.name} ({root_spec_str})") - concretized_user_specs = [x.root for x in env.concretized_roots] - concrete_specs = { - root: concrete_root - for root, concrete_root in zip(concretized_user_specs, env.concrete_roots()) - } + concrete_specs = {x.root: env.specs_by_hash[x.hash] for x in env.concretized_roots} def root_decorator(spec, string): """Decorate root specs with their install status if needed""" @@ -290,22 +285,34 @@ def root_decorator(spec, string): return f"{status} {string}" with spack.store.STORE.db.read_transaction(): - cmd.display_specs( - env.user_specs, - args, - # these are overrides of CLI args - paths=False, - long=False, - very_long=False, - # these enforce details in the root specs to show what the user asked for - namespaces=True, - show_flags=True, - decorator=root_decorator, - variants=True, - specfile_format=args.specfile_format, - ) + for group in env.manifest.groups(): + group_specs = env.user_specs_by(group=group) + if not group_specs: + continue + + if env.has_groups(): + header = ( + f"{spack.spec.ARCHITECTURE_COLOR}{{root specs}} / " + f"{spack.spec.COMPILER_COLOR}{{{group}}}" + ) + tty.hline(colorize(header), char="-") - print() + cmd.display_specs( + group_specs, + args, + # these are overrides of CLI args + paths=False, + long=False, + very_long=False, + # these enforce details in the root specs to show what the user asked for + groups=False, + namespaces=True, + show_flags=True, + decorator=root_decorator, + variants=True, + specfile_format=args.specfile_format, + ) + print() if env.included_concrete_env_root_dirs: tty.msg("Included specs") diff --git a/lib/spack/spack/test/cmd/find.py b/lib/spack/spack/test/cmd/find.py index aa5ac1ee676e91..56b673b0322cb8 100644 --- a/lib/spack/spack/test/cmd/find.py +++ b/lib/spack/spack/test/cmd/find.py @@ -380,7 +380,7 @@ def test_find_specs_include_concrete_env( with ev.read("combined_env"): output = find() - assert "No root specs" in output + assert "no root specs" in output assert "Included specs" in output assert "mpileaks" in output assert "libelf" in output @@ -417,7 +417,7 @@ def test_find_specs_nested_include_concrete_env( with ev.read("test3"): output = find() - assert "No root specs" in output + assert "no root specs" in output assert "Included specs" in output assert "mpileaks" in output assert "libelf" in output @@ -536,3 +536,52 @@ def test_find_based_on_commit_sha(mock_git_version_info, monkeypatch): install("--fake", f"git-test-commit commit={commits[0]}") output = find(f"commit={commits[0]}") assert "git-test-commit" in output + + +@pytest.mark.usefixtures("mock_packages") +@pytest.mark.parametrize( + "spack_yaml,expected,not_expected", + [ + ( + """ +spack: + specs: + - mpileaks + - group: extras + specs: + - libelf +""", + [ + "2 root specs", + # Group names + "extras", + "default", + # root specs + "mpileaks", + "libelf", + ], + [], + ), + ( + """ +spack: + specs: + - group: tools + specs: + - libelf +""", + ["1 root spec", "tools", "libelf"], + ["1 root specs", "default"], + ), + ], +) +def test_find_env_with_groups(spack_yaml, expected, not_expected, tmp_path: pathlib.Path): + """Tests that the output of spack find contains expected matches when using an + environment with groups. + """ + (tmp_path / "spack.yaml").write_text(spack_yaml) + with ev.Environment(tmp_path): + output = find() + + assert all(x in output for x in expected) + assert all(x not in output for x in not_expected) From d78301c3714e67a6a1811923dc011748c14f2924 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Thu, 26 Feb 2026 23:13:02 -0800 Subject: [PATCH 100/337] config.py: fix invalid scope error message (#52010) Signed-off-by: tldahlgren --- lib/spack/spack/config.py | 3 ++- lib/spack/spack/test/config.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 0f2175b19a08db..7c8a517addd5e2 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -659,7 +659,8 @@ def _validate_scope(self, scope: Optional[str]) -> ConfigScope: else: raise ValueError( - f"Invalid config scope: '{scope}'. Must be one of {self.scopes.keys()}" + f"Invalid config scope: '{scope}'. Must be one of " + f"{[k for k in self.scopes.keys()]}" ) def get_config_filename(self, scope: str, section: str) -> str: diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index 6fcf690f743ac0..4e0f2d5f69c54e 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -2027,3 +2027,9 @@ def test_include_bad_parent_scope(tmp_path: pathlib.Path): parent_scope = spack.config.InternalConfigScope(name, spack.config.CONFIG_DEFAULTS) with pytest.raises(AssertionError, match="must have a name"): _ = include.scopes(parent_scope) + + +def test_config_invalid_scope(mock_low_high_config): + err = "Must be one of \['low', 'high'\]" # noqa: W605 + with pytest.raises(ValueError, match=err): + spack.config.CONFIG.get_config_filename("noscope", "nosection") From 5d258c7e3fef31b8733d276ede825662552e70ca Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 27 Feb 2026 11:10:27 +0100 Subject: [PATCH 101/337] new_installer.py: improve non-TTY output format (#52005) Show [+]/[x]/[e]/[ ] indicators and print the install prefix for finished builds, matching the TTY display format. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 11 ++++++++++- lib/spack/spack/test/installer_tui.py | 3 ++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 7c4a67ed93478a..38b73e9340f294 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -931,8 +931,17 @@ def update_state(self, build_id: str, state: str) -> None: # For non-TTY output, print state changes immediately without colors if not self.is_tty: + if build_info.external: + indicator = "[e]" + elif state == "finished": + indicator = "[+]" + elif state == "failed": + indicator = "[x]" + else: + indicator = "[ ]" + suffix = build_info.prefix if state == "finished" else state self.stdout.write( - f"{build_info.hash} {build_info.name}@{build_info.version}: {state}\n" + f"{indicator} {build_info.hash} {build_info.name}@{build_info.version} {suffix}\n" ) self.stdout.flush() diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 55f39eb3ddac3d..27ebd02f2c3901 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -198,9 +198,10 @@ def test_non_tty_output(self): status.update_state(build_id, "finished") output = fake_stdout.getvalue() + assert "[+]" in output assert "mypackage" in output assert "1.0" in output - assert "finished" in output + assert "/fake/prefix/mypackage" in output # prefix is shown for finished builds # Non-TTY output should not contain ANSI escape codes assert "\033[" not in output From b99e4b7e3dc02b37aae7861d9082e2190e2d1048 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 27 Feb 2026 12:07:16 +0100 Subject: [PATCH 102/337] new_installer.py: always pivot prefix, decouple from `--overwrite` (#51960) Previously the `--overwrite` flag was passed on all the way to the build process, where it was used to decide whether to error if the install prefix exists prior to the build. That would lead to hard to overcome install failures if an initial build didn't clear its prefix on failure (which was the case due to `SIGTERM` exiting the interpreter immediately, see #51967) With this change, `--overwrite` is only used to determine what specs will get rebuild. The build process always does an "overwrite install" in the sense that if a prefix exists, it's moved out of the way. * On success and generally with `--keep-prefix`, the old prefix is removed. * Otherwise, on failure, the old prefix is moved back in place. Also fix the weird alias `pathlib as pathlb`, probably an artifact from auto-import in my editor at some point in time. --- lib/spack/spack/new_installer.py | 46 ++++------ lib/spack/spack/test/new_installer.py | 117 +++++++++++++++----------- 2 files changed, 84 insertions(+), 79 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 38b73e9340f294..dfbd831b5ea9ed 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -243,22 +243,19 @@ def install_from_buildcache( class PrefixPivoter: - """Manages the installation prefix during overwrite installations.""" + """Manages the installation prefix of a build.""" - def __init__(self, prefix: str, overwrite: bool, keep_prefix: bool = False) -> None: + def __init__(self, prefix: str, keep_prefix: bool = False) -> None: """Initialize the prefix pivoter. Args: prefix: The installation prefix path - overwrite: Whether to allow overwriting an existing prefix - keep_prefix: Whether to keep a failed installation prefix (when not overwriting) + keep_prefix: Whether to keep a failed installation prefix """ self.prefix = prefix - #: Whether to allow installation when the prefix exists - self.overwrite = overwrite #: Whether to keep a failed installation prefix self.keep_prefix = keep_prefix - #: Temporary location for the original prefix during overwrite + #: Temporary location for the original prefix self.tmp_prefix: Optional[str] = None self.parent = os.path.dirname(prefix) @@ -266,9 +263,7 @@ def __enter__(self) -> "PrefixPivoter": """Enter the context: move existing prefix to temporary location if needed.""" if not self._lexists(self.prefix): return self - if not self.overwrite: - raise spack.error.InstallError(f"Install prefix {self.prefix} already exists") - # Move the existing prefix to a temporary location + # Move the existing prefix to a temporary location so the build starts fresh self.tmp_prefix = self._mkdtemp( dir=self.parent, prefix=".", suffix=OVERWRITE_BACKUP_SUFFIX ) @@ -280,36 +275,32 @@ def __exit__( ) -> None: """Exit the context: cleanup on success, restore on failure.""" if exc_type is None: - # Success: remove the backup in case of overwrite + # Success: remove the backup if self.tmp_prefix is not None: self._rmtree_ignore_errors(self.tmp_prefix) return # Failure handling: - # Priority 1: If we're overwriting, always restore the original prefix - # Priority 2: If keep_prefix is False, remove the failed installation - - if self.overwrite and self.tmp_prefix is not None: - # Overwrite case: restore the original prefix if it existed - # The highest priority is to restore the original prefix, so we try to: - # rename prefix -> garbage: move failed dir out of the way - # rename tmp_prefix -> prefix: restore original prefix - # remove garbage (this is allowed to fail) + if self.keep_prefix: + # Leave the failed prefix in place, discard the backup + if self.tmp_prefix is not None: + self._rmtree_ignore_errors(self.tmp_prefix) + elif self.tmp_prefix is not None: + # There was a pre-existing prefix: pivot back to it and discard the failed build garbage = self._mkdtemp(dir=self.parent, prefix=".", suffix=OVERWRITE_GARBAGE_SUFFIX) try: self._rename(self.prefix, garbage) has_failed_prefix = True - except FileNotFoundError: # prefix dir does not exist, so we don't have to delete it. + except FileNotFoundError: # build never created the prefix dir has_failed_prefix = False self._rename(self.tmp_prefix, self.prefix) if has_failed_prefix: self._rmtree_ignore_errors(garbage) - elif not self.keep_prefix and self._lexists(self.prefix): - # Not overwriting, keep_prefix is False: remove the failed installation + elif self._lexists(self.prefix): + # No backup, just remove the failed installation garbage = self._mkdtemp(dir=self.parent, prefix=".", suffix=OVERWRITE_GARBAGE_SUFFIX) self._rename(self.prefix, garbage) self._rmtree_ignore_errors(garbage) - # else: keep_prefix is True, leave the failed prefix in place def _lexists(self, path: str) -> bool: return os.path.lexists(path) @@ -333,7 +324,6 @@ def worker_function( dirty: bool, keep_stage: bool, restage: bool, - overwrite: bool, keep_prefix: bool, skip_patch: bool, state: Connection, @@ -359,7 +349,6 @@ def worker_function( dirty: Whether to preserve user environment in the build environment keep_stage: Whether to keep the build stage after installation restage: Whether to restage the source before building - overwrite: Whether to overwrite the existing install prefix keep_prefix: Whether to keep a failed installation prefix skip_patch: Whether to skip the patch phase state: Connection to send state updates to @@ -407,7 +396,7 @@ def handle_sigterm(signum, frame): exit_code = 0 try: - with PrefixPivoter(spec.prefix, overwrite, keep_prefix): + with PrefixPivoter(spec.prefix, keep_prefix): _install( spec, explicit, @@ -621,7 +610,6 @@ def start_build( dirty: bool, keep_stage: bool, restage: bool, - overwrite: bool, keep_prefix: bool, skip_patch: bool, jobserver: JobServer, @@ -656,7 +644,6 @@ def start_build( dirty, keep_stage, restage, - overwrite, keep_prefix, skip_patch, state_w_conn, @@ -1571,7 +1558,6 @@ def _start(self, selector: selectors.BaseSelector, jobserver: JobServer) -> None # keep_stage/restage logic taken from installer.py keep_stage=self.keep_stage or is_develop, restage=self.restage and not is_develop, - overwrite=dag_hash in self.overwrite, keep_prefix=self.keep_prefix, skip_patch=self.skip_patch, jobserver=jobserver, diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index 4f84f6891858c1..3af72f575a9aee 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for the new_installer.py module""" -import pathlib as pathlb +import pathlib import sys import pytest @@ -11,13 +11,12 @@ if sys.platform == "win32": pytest.skip("No Windows support", allow_module_level=True) -import spack.error import spack.spec from spack.new_installer import OVERWRITE_GARBAGE_SUFFIX, PackageInstaller, PrefixPivoter @pytest.fixture -def existing_prefix(tmp_path: pathlb.Path) -> pathlb.Path: +def existing_prefix(tmp_path: pathlib.Path) -> pathlib.Path: """Creates a standard existing prefix with content.""" prefix = tmp_path / "existing_prefix" prefix.mkdir() @@ -28,28 +27,22 @@ def existing_prefix(tmp_path: pathlb.Path) -> pathlb.Path: class TestPrefixPivoter: """Tests for the PrefixPivoter class.""" - def test_no_existing_prefix(self, tmp_path: pathlb.Path): + def test_no_existing_prefix(self, tmp_path: pathlib.Path): """Test installation when prefix doesn't exist yet.""" prefix = tmp_path / "new_prefix" - with PrefixPivoter(str(prefix), overwrite=False): + with PrefixPivoter(str(prefix)): prefix.mkdir() (prefix / "installed_file").write_text("content") assert prefix.exists() assert (prefix / "installed_file").read_text() == "content" - def test_existing_prefix_no_overwrite_raises(self, existing_prefix: pathlb.Path): - """Test that existing prefix raises error when overwrite=False.""" - with pytest.raises(spack.error.InstallError, match="already exists"): - with PrefixPivoter(str(existing_prefix), overwrite=False): - pass - - def test_overwrite_success_cleans_up_old_prefix( - self, tmp_path: pathlb.Path, existing_prefix: pathlb.Path + def test_existing_prefix_success_cleans_up_old_prefix( + self, tmp_path: pathlib.Path, existing_prefix: pathlib.Path ): - """Test that overwrite=True moves old prefix and cleans it up on success.""" - with PrefixPivoter(str(existing_prefix), overwrite=True): + """Test that an existing prefix is moved aside, and cleaned up on success.""" + with PrefixPivoter(str(existing_prefix)): assert not existing_prefix.exists() existing_prefix.mkdir() (existing_prefix / "new_file").write_text("new content") @@ -60,15 +53,12 @@ def test_overwrite_success_cleans_up_old_prefix( # Only the existing_prefix directory should remain assert len(list(tmp_path.iterdir())) == 1 - def test_overwrite_failure_restores_original_prefix( - self, tmp_path: pathlb.Path, existing_prefix: pathlb.Path + def test_existing_prefix_failure_restores_original_prefix( + self, tmp_path: pathlib.Path, existing_prefix: pathlib.Path ): - """Test that original prefix is restored when installation fails. - - Note: keep_prefix=True is passed but should be ignored since overwrite=True - takes precedence.""" + """Test that the original prefix is restored when installation fails.""" with pytest.raises(RuntimeError, match="simulated failure"): - with PrefixPivoter(str(existing_prefix), overwrite=True, keep_prefix=True): + with PrefixPivoter(str(existing_prefix), keep_prefix=False): existing_prefix.mkdir() (existing_prefix / "partial_file").write_text("partial") raise RuntimeError("simulated failure") @@ -76,22 +66,24 @@ def test_overwrite_failure_restores_original_prefix( assert existing_prefix.exists() assert (existing_prefix / "old_file").read_text() == "old content" assert not (existing_prefix / "partial_file").exists() - # Only the existing_prefix directory should remain + # Only the original prefix should remain assert len(list(tmp_path.iterdir())) == 1 - def test_overwrite_failure_no_partial_prefix_created(self, existing_prefix: pathlb.Path): - """Test restoration when failure occurs before any prefix is created.""" + def test_existing_prefix_failure_no_partial_prefix_created( + self, existing_prefix: pathlib.Path + ): + """Test restoration when failure occurs before the build creates the prefix dir.""" with pytest.raises(RuntimeError, match="early failure"): - with PrefixPivoter(str(existing_prefix), overwrite=True): + with PrefixPivoter(str(existing_prefix)): raise RuntimeError("early failure") assert existing_prefix.exists() assert (existing_prefix / "old_file").read_text() == "old content" - def test_overwrite_true_no_existing_prefix(self, tmp_path: pathlb.Path): - """Test that overwrite=True works fine when prefix doesn't exist.""" + def test_no_existing_prefix_success(self, tmp_path: pathlib.Path): + """Test that a fresh install with no pre-existing prefix works fine.""" prefix = tmp_path / "new_prefix" - with PrefixPivoter(str(prefix), overwrite=True): + with PrefixPivoter(str(prefix)): prefix.mkdir() (prefix / "installed_file").write_text("content") @@ -99,34 +91,64 @@ def test_overwrite_true_no_existing_prefix(self, tmp_path: pathlb.Path): # Only the new_prefix directory should remain assert len(list(tmp_path.iterdir())) == 1 - def test_keep_prefix_true_leaves_failed_install(self, tmp_path: pathlb.Path): - """Test that keep_prefix=True preserves the failed installation.""" + def test_keep_prefix_true_with_existing_prefix_keeps_failed_install( + self, tmp_path: pathlib.Path, existing_prefix: pathlib.Path + ): + """Test that keep_prefix=True keeps the failed install and discards the backup.""" + with pytest.raises(RuntimeError, match="simulated failure"): + with PrefixPivoter(str(existing_prefix), keep_prefix=True): + existing_prefix.mkdir() + (existing_prefix / "partial_file").write_text("partial content") + raise RuntimeError("simulated failure") + + # The failed prefix should be kept (not the original) + assert existing_prefix.exists() + assert (existing_prefix / "partial_file").exists() + assert not (existing_prefix / "old_file").exists() + # Backup should have been removed + assert len(list(tmp_path.iterdir())) == 1 + + def test_keep_prefix_false_removes_failed_install(self, tmp_path: pathlib.Path): + """Test that keep_prefix=False removes the failed installation (no pre-existing prefix).""" prefix = tmp_path / "new_prefix" with pytest.raises(RuntimeError, match="simulated failure"): - with PrefixPivoter(str(prefix), overwrite=False, keep_prefix=True): + with PrefixPivoter(str(prefix), keep_prefix=False): prefix.mkdir() (prefix / "partial_file").write_text("partial content") raise RuntimeError("simulated failure") - # Failed prefix should still exist + # Failed prefix should be removed + assert not prefix.exists() + # Nothing should remain + assert len(list(tmp_path.iterdir())) == 0 + + def test_keep_prefix_true_no_existing_prefix(self, tmp_path: pathlib.Path): + """Test failure with keep_prefix=True when no prefix existed beforehand.""" + prefix = tmp_path / "new_prefix" + + with pytest.raises(RuntimeError, match="simulated failure"): + with PrefixPivoter(str(prefix), keep_prefix=True): + prefix.mkdir() + (prefix / "partial_file").write_text("partial content") + raise RuntimeError("simulated failure") + + # The failed prefix should be kept assert prefix.exists() assert (prefix / "partial_file").exists() - assert (prefix / "partial_file").read_text() == "partial content" - # Only the failed prefix should remain + # No backup should exist assert len(list(tmp_path.iterdir())) == 1 - def test_keep_prefix_false_removes_failed_install(self, tmp_path: pathlb.Path): - """Test that keep_prefix=False removes the failed installation.""" + def test_failure_no_prefix_created(self, tmp_path: pathlib.Path): + """Test failure when the prefix directory was never created.""" prefix = tmp_path / "new_prefix" with pytest.raises(RuntimeError, match="simulated failure"): - with PrefixPivoter(str(prefix), overwrite=False, keep_prefix=False): - prefix.mkdir() - (prefix / "partial_file").write_text("partial content") + with PrefixPivoter(str(prefix), keep_prefix=False): + # Do NOT create the prefix directory raise RuntimeError("simulated failure") - # Failed prefix should be removed + # Prefix should not exist assert not prefix.exists() # Nothing should remain assert len(list(tmp_path.iterdir())) == 0 @@ -138,12 +160,11 @@ class FailingPrefixPivoter(PrefixPivoter): def __init__( self, prefix: str, - overwrite: bool, keep_prefix: bool = False, fail_on_restore: bool = False, fail_on_move_garbage: bool = False, ): - super().__init__(prefix, overwrite, keep_prefix) + super().__init__(prefix, keep_prefix) self.fail_on_restore = fail_on_restore self.fail_on_move_garbage = fail_on_move_garbage self.restore_rename_count = 0 @@ -168,10 +189,10 @@ class TestPrefixPivoterFailureRecovery: """Tests for edge cases and failure recovery in PrefixPivoter.""" def test_restore_failure_leaves_backup( - self, tmp_path: pathlb.Path, existing_prefix: pathlb.Path + self, tmp_path: pathlib.Path, existing_prefix: pathlib.Path ): """Test that if restoration fails, the backup is not deleted.""" - pivoter = FailingPrefixPivoter(str(existing_prefix), overwrite=True, fail_on_restore=True) + pivoter = FailingPrefixPivoter(str(existing_prefix), fail_on_restore=True) with pytest.raises(OSError, match="Simulated rename failure during restore"): with pivoter: @@ -184,12 +205,10 @@ def test_restore_failure_leaves_backup( assert len(list(tmp_path.iterdir())) == 2 def test_garbage_move_failure_leaves_backup( - self, tmp_path: pathlb.Path, existing_prefix: pathlb.Path + self, tmp_path: pathlib.Path, existing_prefix: pathlib.Path ): """Test that if moving the failed install to garbage fails, the backup is preserved.""" - pivoter = FailingPrefixPivoter( - str(existing_prefix), overwrite=True, fail_on_move_garbage=True - ) + pivoter = FailingPrefixPivoter(str(existing_prefix), fail_on_move_garbage=True) with pytest.raises(OSError, match="Simulated rename failure moving to garbage"): with pivoter: From b67fc8162421055c4c08b80a9da7f230481f0a44 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 27 Feb 2026 13:42:26 +0100 Subject: [PATCH 103/337] new_installer.py: initial multi-process support (#51985) Allow multi-process concurrency, additionally to per-process package parallelism. Basic idea is as follows. If there are pending builds in this Spack process: * Take a read lock on the database (if this fails, re-enter event loop) * Take a prefix write lock on the to-be-installed spec (if this fails, try the next pending build) * If the spec is installed in the meantime: drop the prefix write lock, remove the pending build, enqueue its parents, continue * If the spec is not installed, acquire a jobserver token if needed (if this fails, re-enter the event loop) * If all succeeds, schedule the build, try to schedule more. * Finally release the read lock on the db. If it's not possible to obtain any write lock on the prefix lock, inform the event loop that it shouldn't wake up on available jobserver tokens. This is a measure to avoid a busy wait: locally there are jobserver tokens available, but all pending builds are claimed by another process. What this commit does not yet do: * Take read locks on build dependencies and their link/run deps. So, it does not guard against concurrent uninstalls of (dependencies of) build deps. * Avoid scheduling builds of packages that failed to install in another Spack process. --- lib/spack/spack/new_installer.py | 230 ++++++++++++++++++++------ lib/spack/spack/test/new_installer.py | 211 ++++++++++++++++++++++- 2 files changed, 394 insertions(+), 47 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index dfbd831b5ea9ed..0fe30ccdbcc11c 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -117,6 +117,7 @@ def __init__( self.control_w_conn = control_w_conn self.log_path = log_path self.explicit = explicit + self.prefix_lock: Optional[spack.util.lock.Lock] = None def cleanup(self, selector: selectors.BaseSelector) -> None: """Unregister and close file descriptors, and join the child process.""" @@ -762,7 +763,9 @@ class BuildInfo: "control_w_conn", ) - def __init__(self, spec: spack.spec.Spec, explicit: bool, control_w_conn: Connection) -> None: + def __init__( + self, spec: spack.spec.Spec, explicit: bool, control_w_conn: Optional[Connection] + ) -> None: self.state: str = "starting" self.explicit: bool = explicit self.version: str = str(spec.version) @@ -808,7 +811,9 @@ def __init__( self.get_time = get_time self.is_tty = is_tty if is_tty is not None else self.stdout.isatty() - def add_build(self, spec: spack.spec.Spec, explicit: bool, control_w_conn: Connection) -> None: + def add_build( + self, spec: spack.spec.Spec, explicit: bool, control_w_conn: Optional[Connection] = None + ) -> None: """Add a new build to the display and mark the display as dirty.""" self.builds[spec.dag_hash()] = BuildInfo(spec, explicit, control_w_conn) self.dirty = True @@ -824,7 +829,9 @@ def toggle(self) -> None: self.overview_mode = True self.dirty = True try: - os.write(self.builds[self.tracked_build_id].control_w_conn.fileno(), b"0") + conn = self.builds[self.tracked_build_id].control_w_conn + if conn is not None: + os.write(conn.fileno(), b"0") except (KeyError, OSError): pass self.tracked_build_id = "" @@ -885,7 +892,9 @@ def next(self, direction: int = 1) -> None: # Stop following the previous and start following the new build. if self.tracked_build_id: try: - os.write(self.builds[self.tracked_build_id].control_w_conn.fileno(), b"0") + conn = self.builds[self.tracked_build_id].control_w_conn + if conn is not None: + os.write(conn.fileno(), b"0") except (KeyError, OSError): pass @@ -897,7 +906,9 @@ def next(self, direction: int = 1) -> None: ) self.stdout.flush() try: - os.write(new_build.control_w_conn.fileno(), b"1") + conn = new_build.control_w_conn + if conn is not None: + os.write(conn.fileno(), b"1") except (KeyError, OSError): pass @@ -1239,6 +1250,101 @@ def enqueue_parents(self, dag_hash: str, pending_builds: List[str]) -> None: pending_builds.append(parent) +def schedule_builds( + pending: List[str], + build_graph: BuildGraph, + db: spack.database.Database, + prefix_locker: spack.database.SpecLocker, + overwrite: Set[str], + capacity: int, + needs_jobserver_token: bool, + jobserver: JobServer, +) -> Tuple[bool, List[Tuple[str, spack.util.lock.Lock]], List[Tuple[str, spack.spec.Spec]]]: + """Try to schedule as many pending builds as possible. + + For each pending spec, attempts to acquire a non-blocking per-spec write lock. Under both the + DB read lock and the prefix write lock, checks whether another process has already installed + the spec. If so, captures it as newly_installed (caller enqueues parents) and releases the + lock. Otherwise, acquires a jobserver token if needed and adds the (dag_hash, lock) pair to + to_start (caller launches the build). + + Args: + pending: List of dag hashes pending installation; modified in-place. + build_graph: The build dependency graph; used for node lookup and parent enqueueing. + db: Package database; used for read lock and installed-status queries. + prefix_locker: Per-spec write locker. + overwrite: Set of dag hashes to overwrite even if already installed. + capacity: Maximum number of new builds to add to to_start in this call. + needs_jobserver_token: True if a jobserver token is required for the first new build. + jobserver: Jobserver for acquiring tokens. + + Returns: + A (blocked, to_start, newly_installed) tuple where ``blocked`` is True if any pending + builds were blocked on locks; ``to_start`` contains ``(dag_hash, lock)`` pairs where the + write lock is held and the caller must start the build and eventually release the lock; + and ``newly_installed`` contains ``(dag_hash, spec)`` pairs found already installed by + another process for which the caller must update the UI and enqueue parents. + """ + to_start: List[Tuple[str, spack.util.lock.Lock]] = [] + newly_installed: List[Tuple[str, spack.spec.Spec]] = [] + blocked = True + + # Acquire the DB read lock non-blocking; hold it throughout the loop so the in-memory snapshot + # stays consistent while we acquire per-spec prefix locks. + try: + db.lock.acquire_read(timeout=1e-9) + except spack.util.lock.LockTimeoutError: + return blocked, to_start, newly_installed + + try: + db._read() # refresh in-memory snapshot under the read lock + + idx = 0 + while capacity and idx < len(pending): + dag_hash = pending[idx] + spec = build_graph.nodes[dag_hash] + lock = prefix_locker.lock(spec) + + try: + lock.acquire_write(timeout=1e-9) + blocked = False + except spack.util.lock.LockTimeoutError: + # another process is building this spec; try the next one + idx += 1 + continue + + # Check installed status under the DB read lock and prefix write lock. + upstream, record = db.query_by_spec_hash(dag_hash) + + # Don't schedule builds for specs from upstream databases. + assert not ( + upstream and record and not record.installed + ), f"Cannot install {spec}: it is uninstalled in an upstream database." + + # If the spec is already installed by another process, capture it and enqueue parents. + if dag_hash not in overwrite and record and record.installed: + lock.release_write() + del pending[idx] + newly_installed.append((dag_hash, spec)) + build_graph.enqueue_parents(dag_hash, pending) + continue + + # Acquire a jobserver token if needed. The first (implicit) job needs no token. + if needs_jobserver_token and not jobserver.acquire(1): + lock.release_write() + break # no tokens available right now; stop scheduling + + del pending[idx] + to_start.append((dag_hash, lock)) + capacity -= 1 + needs_jobserver_token = True # all subsequent jobs need a token + + finally: + db.lock.release_read() + + return blocked, to_start, newly_installed + + class PackageInstaller: def __init__( @@ -1280,6 +1386,8 @@ def __init__( elif tests is not False: raise NotImplementedError("Tests during install are not implemented") + self.db = spack.store.STORE.db + specs = [pkg.spec for pkg in packages] self.root_policy: InstallPolicy = root_policy @@ -1301,7 +1409,7 @@ def __init__( include_build_deps, install_package, install_deps, - spack.store.STORE.db, + self.db, self.overwrite, ) @@ -1345,17 +1453,7 @@ def __init__( self.reports: Dict[str, spack.report.RequestRecord] = {} def install(self) -> None: - # This installer has not implemented the per-spec exclusive locks during installation. - # Instead, take an exclusive lock on the entire range to avoid that other Spack install - # process start installing the same specs. - lock = spack.util.lock.Lock( - str(spack.store.STORE.prefix_locker.lock_path), desc="prefix lock" - ) - lock.acquire_write() - try: - self._installer() - finally: - lock.release_write() + self._installer() def _installer(self) -> None: jobserver = JobServer(self.jobs) @@ -1376,19 +1474,18 @@ def _installer(self) -> None: failures: List[spack.spec.Spec] = [] try: - # Start the first job immediately, as it does not require a jobserver token. - if self.pending_builds and not self.running_builds: - self._start(selector, jobserver) + # Try to schedule builds immediately. The first job does not require a token. + blocked = self._schedule_builds(selector, jobserver) while self.pending_builds or self.running_builds or finished_builds: - # Only monitor the jobserver if we have pending builds and capacity. - can_schedule_more = self.pending_builds and self.capacity > 0 + # Monitor the jobserver when we have pending builds, capacity, and at least one + # spec is not locked by another process. + can_schedule_more = self.pending_builds and self.capacity and not blocked if can_schedule_more and jobserver.r not in selector.get_map(): selector.register(jobserver.r, selectors.EVENT_READ, "jobserver") elif not can_schedule_more and jobserver.r in selector.get_map(): selector.unregister(jobserver.r) - jobserver_token_available = False stdin_ready = False events = selector.select(timeout=SPINNER_INTERVAL) @@ -1406,8 +1503,6 @@ def _installer(self) -> None: self._handle_child_state(key.fd, child_info, selector) elif data.name == "sentinel": finished_pids.append(data.pid) - elif data == "jobserver": - jobserver_token_available = True elif data == "stdin": stdin_ready = True @@ -1470,17 +1565,9 @@ def _installer(self) -> None: ): finished_builds.clear() - # Again, the first job should start immediately and does not require a token. - if self.pending_builds and not self.running_builds: - self._start(selector, jobserver) - - # For the rest we try to obtain tokens from the jobserver. - if self.pending_builds and self.capacity > 0 and jobserver_token_available: - # Schedule as many jobs as we can acquire tokens for. - max_new_jobs = min(len(self.pending_builds), self.capacity) - num_acquired = jobserver.acquire(max_new_jobs) - for _ in range(num_acquired): - self._start(selector, jobserver) + # Try to schedule more builds, acquiring per-spec locks and jobserver tokens. + if self.capacity and self.pending_builds: + blocked = self._schedule_builds(selector, jobserver) # Finally update the UI self.build_status.update() @@ -1494,9 +1581,15 @@ def _installer(self) -> None: raise finally: # Make sure to write any successful builds to the database before exiting - with spack.store.STORE.db.write_transaction(): + with self.db.write_transaction(): for build in finished_builds: - spack.store.STORE.db._add(build.spec, explicit=build.explicit) + self.db._add(build.spec, explicit=build.explicit) + + # Release any prefix write locks that were not yet released via _save_to_db + for build in finished_builds: + if build.prefix_lock is not None: + build.prefix_lock.release_write() + build.prefix_lock = None # Restore terminal settings if old_stdin_settings: @@ -1524,23 +1617,67 @@ def _installer(self) -> None: ) def _save_to_db(self, finished_builds: List[ChildInfo]) -> bool: - db = spack.store.STORE.db try: # Only try to get the lock once (non-blocking). If it fails, try it next time. - if db.lock.acquire_write(timeout=1e-9): - db._read() + if self.db.lock.acquire_write(timeout=1e-9): + self.db._read() except spack.util.lock.LockTimeoutError: return False try: for build in finished_builds: - db._add(build.spec, explicit=build.explicit) - return True + self.db._add(build.spec, explicit=build.explicit) finally: - db.lock.release_write(db._write) + self.db.lock.release_write(self.db._write) + + # DB has been written and flushed; release per-spec prefix write locks so other processes + # can see the specs are now installed and acquire their own locks. + for build in finished_builds: + if build.prefix_lock is not None: + build.prefix_lock.release_write() + build.prefix_lock = None + + return True + + def _schedule_builds(self, selector: selectors.BaseSelector, jobserver: JobServer) -> bool: + """Try to schedule as many pending builds as possible. + + Delegates to the module-level schedule_builds() function and then performs the + side-effects that require the selector and running-build state: updating build_status for + specs that were found already installed, and launching new builds via _start(). + + Preconditions: self.capacity > 0 and self.pending_builds is not empty. - def _start(self, selector: selectors.BaseSelector, jobserver: JobServer) -> None: + Returns True if we had capacity to schedule, but were blocked by locks held by other + processes. In that case we should not monitor the jobserver for new tokens, since we'd end + up in a busy wait loop until the locks are released. + """ + blocked, to_start, newly_installed = schedule_builds( + pending=self.pending_builds, + build_graph=self.build_graph, + db=self.db, + prefix_locker=spack.store.STORE.prefix_locker, + overwrite=self.overwrite, + capacity=self.capacity, + needs_jobserver_token=bool(self.running_builds), + jobserver=jobserver, + ) + # Specs installed by another process. + for dag_hash, spec in newly_installed: + self.build_status.add_build(spec, explicit=dag_hash in self.explicit) + self.build_status.update_state(dag_hash, "finished") + # Specs we can start building ourselves. + for dag_hash, lock in to_start: + self._start(selector, jobserver, dag_hash, lock) + return blocked + + def _start( + self, + selector: selectors.BaseSelector, + jobserver: JobServer, + dag_hash: str, + prefix_lock: spack.util.lock.Lock, + ) -> None: self.capacity -= 1 - dag_hash = self.pending_builds.pop() explicit = dag_hash in self.explicit spec = self.build_graph.nodes[dag_hash] is_develop = spec.is_develop @@ -1563,6 +1700,7 @@ def _start(self, selector: selectors.BaseSelector, jobserver: JobServer) -> None jobserver=jobserver, ) self.log_paths[dag_hash] = child_info.log_path + child_info.prefix_lock = prefix_lock pid = child_info.proc.pid assert type(pid) is int self.running_builds[pid] = child_info diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index 3af72f575a9aee..b1caecb32d9772 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -12,7 +12,14 @@ pytest.skip("No Windows support", allow_module_level=True) import spack.spec -from spack.new_installer import OVERWRITE_GARBAGE_SUFFIX, PackageInstaller, PrefixPivoter +import spack.util.lock +from spack.new_installer import ( + OVERWRITE_GARBAGE_SUFFIX, + JobServer, + PackageInstaller, + PrefixPivoter, + schedule_builds, +) @pytest.fixture @@ -246,3 +253,205 @@ def test_capacity_from_config_non_zero(self, temporary_store, mock_packages, mut spec = spack.spec.Spec("trivial-install-test-package") spec._mark_concrete() assert PackageInstaller([spec.package]).capacity == 1 + + +class _FakeBuildGraph: + """Minimal stand-in for BuildGraph in schedule_builds unit tests. + + Provides the two interface points that schedule_builds calls: + - .nodes (dict: dag_hash -> Spec) + - .enqueue_parents(dag_hash, pending_builds) + """ + + def __init__(self, specs): + self.nodes = {spec.dag_hash(): spec for spec in specs} + + def enqueue_parents(self, dag_hash, pending_builds): + """Remove dag_hash from nodes; no parents in these simple unit tests.""" + self.nodes.pop(dag_hash, None) + + +class TestScheduleBuilds: + """Unit tests for the module-level schedule_builds() function.""" + + def _make_spec(self, name): + """Return a minimal concrete spec suitable for locking and DB queries.""" + spec = spack.spec.Spec(name) + spec._mark_concrete() + return spec + + def _mark_installed(self, spec, store): + """Create the install directory structure and register the spec in the DB as installed.""" + store.layout.create_install_directory(spec) + store.db.add(spec, explicit=True) + + def test_not_installed_no_running_starts_build(self, temporary_store, mock_packages): + """A fresh spec with no running builds is added to to_start.""" + spec = self._make_spec("trivial-install-test-package") + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + capacity=1, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert not blocked + assert len(to_start) == 1 + assert to_start[0][0] == spec.dag_hash() + assert not newly_installed + assert not pending # removed from the pending list + finally: + for _, lock in to_start: + lock.release_write() + jobserver.close() + + def test_already_installed_yields_newly_installed(self, temporary_store, mock_packages): + """A spec already in the DB is returned in newly_installed, not in to_start.""" + spec = self._make_spec("trivial-install-test-package") + self._mark_installed(spec, temporary_store) + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + capacity=1, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert not blocked + assert not to_start + assert len(newly_installed) == 1 + assert newly_installed[0][0] == spec.dag_hash() + assert not pending # removed from the pending list + finally: + jobserver.close() + + def test_no_jobserver_token_returns_empty(self, temporary_store, mock_packages): + """When has_running_builds=True and no token is available, nothing is started.""" + spec = self._make_spec("trivial-install-test-package") + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + # num_jobs=1 writes 0 tokens to the FIFO. Only the implicit token exists. + jobserver = JobServer(num_jobs=1) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + capacity=2, + needs_jobserver_token=True, + jobserver=jobserver, + ) + assert not blocked + assert not to_start + assert not newly_installed + assert len(pending) == 1 + finally: + jobserver.close() + + def test_all_locked_returns_blocked(self, temporary_store, mock_packages, monkeypatch): + """When all pending specs are locked externally, blocked_on_locks is True.""" + spec = self._make_spec("trivial-install-test-package") + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + # Pre-register the lock in the prefix_locker cache, then patch acquire_write to fail. + lock = temporary_store.prefix_locker.lock(spec) + + def always_timeout(timeout=None): + raise spack.util.lock.LockTimeoutError("write", lock.path, 0, 1) + + monkeypatch.setattr(lock, "acquire_write", always_timeout) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + capacity=2, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert blocked + assert not to_start + assert not newly_installed + assert len(pending) == 1 + finally: + jobserver.close() + + def test_overwrite_installed_spec_is_started(self, temporary_store, mock_packages): + """A spec in the overwrite set is scheduled even when already installed.""" + spec = self._make_spec("trivial-install-test-package") + self._mark_installed(spec, temporary_store) + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite={spec.dag_hash()}, + capacity=1, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert not blocked + assert len(to_start) == 1 + assert to_start[0][0] == spec.dag_hash() + assert not newly_installed + finally: + for _, lock in to_start: + lock.release_write() + jobserver.close() + + def test_mixed_locked_unlocked(self, temporary_store, mock_packages, monkeypatch): + """Only the unlocked spec enters to_start when one spec is externally locked.""" + spec_a = self._make_spec("trivial-install-test-package") + spec_b = self._make_spec("trivial-smoke-test") + pending = [spec_a.dag_hash(), spec_b.dag_hash()] + bg = _FakeBuildGraph([spec_a, spec_b]) + jobserver = JobServer(num_jobs=4) + # Patch spec_a's lock to always time out, simulating an external write lock. + lock_a = temporary_store.prefix_locker.lock(spec_a) + + def always_timeout(timeout=None): + raise spack.util.lock.LockTimeoutError("write", lock_a.path, 0, 1) + + monkeypatch.setattr(lock_a, "acquire_write", always_timeout) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + capacity=2, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert not blocked # spec_b was schedulable + started_hashes = {h for h, _ in to_start} + assert spec_b.dag_hash() in started_hashes + assert spec_a.dag_hash() not in started_hashes + assert not newly_installed + finally: + for _, lock in to_start: + lock.release_write() + jobserver.close() From 28c3b2c8da0bb9cc8a93331f4a10830d3ae328f6 Mon Sep 17 00:00:00 2001 From: Wouter Deconinck Date: Mon, 2 Mar 2026 01:19:31 -0600 Subject: [PATCH 104/337] setup-env.sh: if exe contains qemu, use /proc/$$/comm instead (#41710) Signed-off-by: Wouter Deconinck --- share/spack/setup-env.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/share/spack/setup-env.sh b/share/spack/setup-env.sh index dd71d33420e009..3a4be407ffab48 100644 --- a/share/spack/setup-env.sh +++ b/share/spack/setup-env.sh @@ -232,6 +232,10 @@ _spack_determine_shell() { # If procfs is present this seems a more reliable # way to detect the current shell _sp_exe=$(readlink /proc/$$/exe) + # Qemu emulation has _sp_exe point to the emulator + if [ "${_sp_exe##*qemu*}" != "${_sp_exe}" ]; then + _sp_exe=$(cat /proc/$$/comm) + fi # Shell may contain number, like zsh5 instead of zsh basename ${_sp_exe} | tr -d '0123456789' elif [ -n "${BASH:-}" ]; then From 8206de1c24a7a93cfb89379aa0fbfd1067a5dc5d Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Mon, 2 Mar 2026 09:19:23 -0800 Subject: [PATCH 105/337] spack repo list: machine readable output with --json (#51950) * Add --json option to spack repo list This enhancement adds machine-readable JSON output format to the 'spack repo list' command, similar to other Spack commands like 'find'. The JSON output includes detailed information about each repository: - name: Configuration name - namespace: Repository namespace - path: Path to the repository - api_version: Package API version - status: Repository status (installed, uninitialized, or error) - error: Error message if the repository has issues Added tests to verify the functionality and updated documentation. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Gregory Becker * style Signed-off-by: Gregory Becker * completions Signed-off-by: Gregory Becker * refactor test for pythonic clarity Signed-off-by: Gregory Becker --------- Signed-off-by: Gregory Becker Co-authored-by: Claude Opus 4.6 --- lib/spack/spack/cmd/repo.py | 75 +++++++++++++++++++++++++----- lib/spack/spack/test/cmd/repo.py | 76 +++++++++++++++++++++++++++++++ share/spack/spack-completion.bash | 4 +- share/spack/spack-completion.fish | 8 +++- 4 files changed, 148 insertions(+), 15 deletions(-) diff --git a/lib/spack/spack/cmd/repo.py b/lib/spack/spack/cmd/repo.py index 2248352c8039af..50ca50874227fe 100644 --- a/lib/spack/spack/cmd/repo.py +++ b/lib/spack/spack/cmd/repo.py @@ -5,6 +5,7 @@ import argparse import os import shlex +import sys import tempfile from typing import Any, Dict, Generator, List, Optional, Tuple, Union @@ -16,6 +17,7 @@ import spack.util.executable import spack.util.git import spack.util.path +import spack.util.spack_json as sjson import spack.util.spack_yaml from spack.cmd.common import arguments from spack.error import SpackError @@ -58,6 +60,9 @@ def setup_parser(subparser: argparse.ArgumentParser): output_group.add_argument( "--namespaces", action="store_true", help="show repository namespaces only" ) + output_group.add_argument( + "--json", action="store_true", help="output repositories as machine-readable json records" + ) # Add add_parser = sp.add_parser("add", help=repo_add.__doc__) @@ -302,7 +307,19 @@ def _remove_repo(namespace_or_path, scope): def repo_list(args): - """show registered repositories and their namespaces""" + """show registered repositories and their namespaces + + List all package repositories known to Spack. Repositories + can be local directories or remote git repositories. + + The output can be filtered by: + --scope= to list repositories from a specific scope + + The output format can be controlled using one of: + --names to show only configuration names + --namespaces to show only repository namespaces + --json to output repositories as machine-readable json records + """ descriptors = spack.repo.RepoDescriptors.from_config( lock=spack.repo.package_repository_lock(), config=spack.config.CONFIG, scope=args.scope ) @@ -320,25 +337,61 @@ def repo_list(args): print(maybe_repo.namespace) return - # Default table format: collect all repository information for aligned output + # Collect all repository information repo_info = [] for name, path, maybe_repo in _iter_repos_from_descriptors(descriptors): if isinstance(maybe_repo, spack.repo.Repo): - repo_info.append( - ("@g{[+]}", maybe_repo.namespace, maybe_repo.package_api_str, maybe_repo.root) - ) + status = "installed" + namespace = maybe_repo.namespace + api = maybe_repo.package_api_str + repo_path = maybe_repo.root elif maybe_repo is None: # Uninitialized Git-based repo case - repo_info.append(("@K{ - }", name, "", path)) + status = "uninitialized" + namespace = name + api = "" + repo_path = path else: # Exception/error case - repo_info.append(("@r{[-]}", name, "", f"{path}: {maybe_repo}")) + status = "error" + namespace = name + api = "" + repo_path = path + + # Add the repo info to our list + repo_info.append( + { + "name": name, + "namespace": namespace, + "path": repo_path, + "api_version": api, + "status": status, + "error": str(maybe_repo) if isinstance(maybe_repo, Exception) else None, + } + ) + + # Output in JSON format if requested + if args.json: + sjson.dump(repo_info, sys.stdout) + return + + # Default table format with aligned output + formatted_repo_info = [] + for repo in repo_info: + if repo["status"] == "installed": + status = "@g{[+]}" + elif repo["status"] == "uninitialized": + status = "@K{ - }" + else: # error + status = "@r{[-]}" + + formatted_repo_info.append((status, repo["namespace"], repo["api_version"], repo["path"])) - if repo_info: - max_namespace_width = max(len(namespace) for _, namespace, _, _ in repo_info) + 3 - max_api_width = max(len(api) for _, _, api, _ in repo_info) + 3 + if formatted_repo_info: + max_namespace_width = max(len(namespace) for _, namespace, _, _ in formatted_repo_info) + 3 + max_api_width = max(len(api) for _, _, api, _ in formatted_repo_info) + 3 # Print aligned output - for status, namespace, api, path in repo_info: + for status, namespace, api, path in formatted_repo_info: cpath = color.cescape(path) color.cprint( f"{status} {namespace:<{max_namespace_width}} {api:<{max_api_width}} {cpath}" diff --git a/lib/spack/spack/test/cmd/repo.py b/lib/spack/spack/test/cmd/repo.py index 1aa874dca66ec6..4f564b1cea1b57 100644 --- a/lib/spack/spack/test/cmd/repo.py +++ b/lib/spack/spack/test/cmd/repo.py @@ -813,6 +813,82 @@ def test_repo_list_format_flags( assert config_names_lines == ["monorepo", "uninitialized", "misconfigured"] +def test_repo_list_json_output(mutable_config: spack.config.Configuration, tmp_path: pathlib.Path): + """Test the --json flag for repo list command. + + This test verifies that: + 1. The --json flag produces valid JSON output + 2. The output contains the expected repository information + 3. Different repository types (installed, uninitialized, error) + are correctly represented + """ + import json + + # Fake a git monorepo with two package repositories + monorepo_path = tmp_path / "monorepo" + (monorepo_path / ".git").mkdir(parents=True) + repo("create", str(monorepo_path), "repo_one") + repo("create", str(monorepo_path), "repo_two") + + # Configure repositories in Spack + test_repos = { + # git repo that provides two package repositories + "monorepo": { + "git": "https://example.com/monorepo.git", + "destination": str(monorepo_path), + "paths": ["spack_repo/repo_one", "spack_repo/repo_two"], + }, + # git repo that is not yet cloned + "uninitialized": { + "git": "https://example.com/uninitialized.git", + "destination": str(tmp_path / "uninitialized"), + }, + # invalid local repository + "misconfigured": str(tmp_path / "misconfigured"), + } + mutable_config.set("repos", test_repos, scope="site") + + # Get and parse JSON output + json_output = repo("list", "--json") + repo_data = json.loads(json_output) + + # Verify we got a list of repositories + assert isinstance(repo_data, list), "Expected JSON output to be a list" + + # Index repositories by namespace for easier validation + repos_by_namespace = {} + for item in repo_data: + # Check all required fields are present + required_fields = ["name", "namespace", "path", "api_version", "status", "error"] + for field in required_fields: + assert field in item, f"Repository missing required field: {field}" + + # Store by namespace for later validation + repos_by_namespace[item["namespace"]] = item + + # Verify installed repositories (repo_one and repo_two) + for namespace in ["repo_one", "repo_two"]: + assert namespace in repos_by_namespace, f"Missing repository: {namespace}" + repo_info = repos_by_namespace[namespace] + assert repo_info["name"] == "monorepo", f"Incorrect name for {namespace}" + assert repo_info["status"] == "installed", f"Incorrect status for {namespace}" + assert repo_info["error"] is None, f"Unexpected error for {namespace}" + assert repo_info["api_version"], f"Missing API version for {namespace}" + + # Verify uninitialized repository + assert "uninitialized" in repos_by_namespace, "Missing uninitialized repository" + uninit_repo = repos_by_namespace["uninitialized"] + assert uninit_repo["name"] == "uninitialized", "Incorrect name for uninitialized repo" + assert uninit_repo["status"] == "uninitialized", "Incorrect status for uninitialized repo" + + # Verify misconfigured repository + assert "misconfigured" in repos_by_namespace, "Missing misconfigured repository" + misc_repo = repos_by_namespace["misconfigured"] + assert misc_repo["name"] == "misconfigured", "Incorrect name for misconfigured repo" + assert misc_repo["status"] == "error", "Incorrect status for misconfigured repo" + assert misc_repo["error"] is not None, "Missing error message for misconfigured repo" + + @pytest.mark.parametrize( "repo_name,flags", [ diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index e4b33c20705564..46d655020a0028 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -1816,11 +1816,11 @@ _spack_repo_create() { } _spack_repo_list() { - SPACK_COMPREPLY="-h --help --scope --names --namespaces" + SPACK_COMPREPLY="-h --help --scope --names --namespaces --json" } _spack_repo_ls() { - SPACK_COMPREPLY="-h --help --scope --names --namespaces" + SPACK_COMPREPLY="-h --help --scope --names --namespaces --json" } _spack_repo_add() { diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index b9e6d79fb4fad9..703c82b69fd73c 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -2850,7 +2850,7 @@ complete -c spack -n '__fish_spack_using_command repo create' -s d -l subdirecto complete -c spack -n '__fish_spack_using_command repo create' -s d -l subdirectory -r -d 'subdirectory to store packages in the repository' # spack repo list -set -g __fish_spack_optspecs_spack_repo_list h/help scope= names namespaces +set -g __fish_spack_optspecs_spack_repo_list h/help scope= names namespaces json complete -c spack -n '__fish_spack_using_command repo list' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command repo list' -s h -l help -d 'show this help message and exit' complete -c spack -n '__fish_spack_using_command repo list' -l scope -r -f -a '_builtin defaults:base defaults system site user spack command_line' @@ -2859,9 +2859,11 @@ complete -c spack -n '__fish_spack_using_command repo list' -l names -f -a names complete -c spack -n '__fish_spack_using_command repo list' -l names -d 'show configuration names only' complete -c spack -n '__fish_spack_using_command repo list' -l namespaces -f -a namespaces complete -c spack -n '__fish_spack_using_command repo list' -l namespaces -d 'show repository namespaces only' +complete -c spack -n '__fish_spack_using_command repo list' -l json -f -a json +complete -c spack -n '__fish_spack_using_command repo list' -l json -d 'output repositories as machine-readable json records' # spack repo ls -set -g __fish_spack_optspecs_spack_repo_ls h/help scope= names namespaces +set -g __fish_spack_optspecs_spack_repo_ls h/help scope= names namespaces json complete -c spack -n '__fish_spack_using_command repo ls' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command repo ls' -s h -l help -d 'show this help message and exit' complete -c spack -n '__fish_spack_using_command repo ls' -l scope -r -f -a '_builtin defaults:base defaults system site user spack command_line' @@ -2870,6 +2872,8 @@ complete -c spack -n '__fish_spack_using_command repo ls' -l names -f -a names complete -c spack -n '__fish_spack_using_command repo ls' -l names -d 'show configuration names only' complete -c spack -n '__fish_spack_using_command repo ls' -l namespaces -f -a namespaces complete -c spack -n '__fish_spack_using_command repo ls' -l namespaces -d 'show repository namespaces only' +complete -c spack -n '__fish_spack_using_command repo ls' -l json -f -a json +complete -c spack -n '__fish_spack_using_command repo ls' -l json -d 'output repositories as machine-readable json records' # spack repo add set -g __fish_spack_optspecs_spack_repo_add h/help name= path= scope= From bf1345e687997a7ef130da0cb157b6ff955c97c9 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 3 Mar 2026 17:27:41 +0100 Subject: [PATCH 106/337] solver: add virtuals to the correct unification sets (#52015) * solver: add virtuals to the correct unification sets fixes #51995 There are cases where in a unified environment one root depends on e.g. llvm as a provider for libllvm, but is compiled with gcc, and another root is instead compiled with llvm. In those cases we have: ``` provider(node(0, gcc), node(0, c)). provider(node(0, llvm), node(1, c)). provider(node(0, llvm), node(0, libllvm)). ``` and we have to add the virtual node that is being provided to the correct unification set. The rule: ``` unification_set(SetID, VirtualNode) :- provider(PackageNode, VirtualNode), unification_set(SetID, PackageNode). ``` is therefore wrong in those cases, and so are other similar simplifying assumptions. In this commit we fix the issue by adding virtuals to the correct unification sets. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 42 +++++++++++--- lib/spack/spack/solver/heuristic.lp | 1 + lib/spack/spack/test/env.py | 58 +++++++++++++++++++ .../builtin_mock/packages/llvm/package.py | 1 + .../builtin_mock/packages/mesa/package.py | 14 +++++ .../builtin_mock/packages/paraview/package.py | 19 ++++++ 6 files changed, 126 insertions(+), 9 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/mesa/package.py create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/paraview/package.py diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index d919946b7311fe..82676874977ecc 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -80,6 +80,9 @@ error(100000, "Cannot have multiple nodes for {0} in the same unification set {1 :- 2 { unification_set(SetID, node(_, PackageName)) }, unify(SetID, PackageName). unification_set("root", PackageNode) :- attr("root", PackageNode). +unification_set("root", PackageNode) :- attr("virtual_root", PackageNode). + +% A package with a dependency that *is not* build belongs to the same unification set as the parent unification_set(SetID, ChildNode) :- attr("depends_on", ParentNode, ChildNode, Type), Type != "build", unification_set(SetID, ParentNode). % A separate unification set can be created if any of the build dependencies can be duplicated @@ -89,15 +92,31 @@ needs_build_unification_set(ParentNode) :- attr("depends_on", ParentNode, _, "bu unification_set(("build", ID), node(X, Child)) :- attr("depends_on", ParentNode, node(X, Child), "build"), build_set_id(ParentNode, ID), - unification_set(_, ParentNode). + needs_build_unification_set(ParentNode). + +% A virtual that is on an edge of non-build type belongs to the same unification set as the parent of the provider +unification_set(SetID, VirtualNode) :- + virtual_on_edge(ParentNode, ProviderNode, VirtualNode, Type), Type != "build", + unification_set(SetID, ParentNode). + +% A virtual that is on a build edge, goes in the build set id of the parent of the provider +unification_set(("build", ID), VirtualNode) :- + virtual_on_edge(ParentNode, ProviderNode, VirtualNode, "build"), + build_set_id(ParentNode, ID), + needs_build_unification_set(ParentNode). + +% Needed for reused dependencies. A reused dependency has its build edges trimmed, so we +% only care about the non-build edges. +unification_set(SetID, node(VirtualID, Virtual)) :- + concrete(ParentNode), + attr("virtual_on_edge", ParentNode, ProviderNode, Virtual), + provider(ProviderNode, node(VirtualID, Virtual)), + unification_set(SetID, ParentNode). % Limit the number of unification sets to a reasonable number to avoid combinatorial explosion #const max_build_unification_sets = 4. 1 { build_set_id(ParentNode, 0..max_build_unification_sets-1) } 1 :- needs_build_unification_set(ParentNode). -unification_set(SetID, VirtualNode) - :- provider(PackageNode, VirtualNode), - unification_set(SetID, PackageNode). % Compute sub-sets of the nodes, if requested. These can be either the nodes connected % to another node by "link" edges, or the nodes connected to another node by "link and @@ -593,7 +612,10 @@ attr(Name, node(X, A1), A2, A3, A4) :- impose(ID, PackageNode), imposed_constrai % Provider set is relevant only for literals, since it's the only place where `^[virtuals=foo] bar` % might appear in the HEAD of a rule -attr("provider_set", node(min_dupe_id, Provider), node(min_dupe_id, Virtual)) +1 { + attr("provider_set", node(0..MaxProvider-1, Provider), node(0..MaxVirtual-1, Virtual)): + max_dupes(Provider, MaxProvider), max_dupes(Virtual, MaxVirtual) +} 1 :- solve_literal(TriggerID), trigger_and_effect(_, TriggerID, EffectID), impose(EffectID, _), @@ -820,6 +842,9 @@ attr("uses_virtual", PackageNode, Virtual) :- attr("node_flag", node(ID, Package), node_flag(FlagType, Flag, _, _)), not imposed_constraint(Hash, "node_flag", Package, node_flag(FlagType, Flag, _, _)). +% we cannot have two nodes with the same hash +:- attr("hash", PackageNode1, Hash), attr("hash", PackageNode2, Hash), PackageNode1 < PackageNode2. + #defined condition/1. #defined subcondition/2. #defined condition_requirement/3. @@ -979,12 +1004,11 @@ node_depends_on_virtual(PackageNode, Virtual, Type) node_depends_on_virtual(PackageNode, Virtual) :- node_depends_on_virtual(PackageNode, Virtual, Type). virtual_is_needed(Virtual) :- node_depends_on_virtual(PackageNode, Virtual). -1 { attr("virtual_on_edge", PackageNode, ProviderNode, Virtual) : provider(ProviderNode, node(VirtualID, Virtual)) } 1 +1 { virtual_on_edge(PackageNode, ProviderNode, node(VirtualID, Virtual), Type) : provider(ProviderNode, node(VirtualID, Virtual)) } 1 :- node_depends_on_virtual(PackageNode, Virtual, Type). -attr("depends_on", PackageNode, ProviderNode, Type) - :- attr("virtual_on_edge", PackageNode, ProviderNode, Virtual), - node_depends_on_virtual(PackageNode, Virtual, Type). +attr("virtual_on_edge", PackageNode, ProviderNode, Virtual) :- virtual_on_edge(PackageNode, ProviderNode, node(_, Virtual), _). +attr("depends_on", PackageNode, ProviderNode, Type) :- virtual_on_edge(PackageNode, ProviderNode, _, Type). % If a virtual node is in the answer set, it must be either a virtual root, % or used somewhere diff --git a/lib/spack/spack/solver/heuristic.lp b/lib/spack/spack/solver/heuristic.lp index 1baed82b7091f6..15dde1f936c48a 100644 --- a/lib/spack/spack/solver/heuristic.lp +++ b/lib/spack/spack/solver/heuristic.lp @@ -13,6 +13,7 @@ #heuristic attr("version", node(PackageID, Package), Version). [80, level] #heuristic attr("variant_value", PackageNode, Variant, Value). [80, level] #heuristic attr("node_target", node(PackageID, Package), Target). [80, level] +#heuristic virtual_on_edge(PackageNode, ProviderNode, Virtual, Type). [80, level] #heuristic attr("virtual_node", node(X, Virtual)). [600, init] #heuristic attr("virtual_node", node(X, Virtual)). [-1, sign] diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index d1c0d3b0a48235..13719105d057e3 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -1980,3 +1980,61 @@ def test_cyclic_group_dependencies_give_clear_error(self, create_temporary_manif with ev.Environment(manifest.manifest_dir) as e: with pytest.raises(ev.SpackEnvironmentConfigError, match=r"among groups: alpha, beta"): e.concretize() + + +@pytest.mark.regression("51995") +def test_mixed_compilers_and_libllvm(tmp_path, config): + """Tests that we divide virtual nodes correctly among unification sets. + + This test concretizes a unified environment where one package uses gcc as a C++ compiler + and depends on llvm as a provider of libllvm, while the other package uses llvm as a C++ + compiler. + """ + spack_yaml = """ +spack: + specs: + - paraview %cxx=llvm + - mesa %cxx=gcc %libllvm=llvm + packages: + c: + prefer: + - gcc + cxx: + prefer: + - gcc + gcc:: + externals: + - spec: gcc@13.2.0 languages:='c,c++,fortran' + prefix: /path + extra_attributes: + compilers: + c: /path/bin/gcc + cxx: /path/bin/g++ + fortran: /path/bin/gfortran + llvm:: + externals: + - spec: llvm@20.1.8+clang+flang+lld+lldb + prefix: /usr + extra_attributes: + compilers: + c: /usr/bin/gcc + cxx: /usr/bin/g++ + fortran: /usr/bin/gfortran + concretizer: + unify: true +""" + manifest = tmp_path / "spack.yaml" + manifest.write_text(spack_yaml) + with ev.Environment(tmp_path) as e: + e.concretize() + + for x in e.concrete_roots(): + if x.name == "mesa": + mesa = x + else: + paraview = x + + assert paraview.satisfies("%cxx=llvm@20") + assert paraview.satisfies(f"%{mesa}") + assert mesa.satisfies("%cxx=gcc %libllvm=llvm") + assert paraview["cxx"].dag_hash() == mesa["libllvm"].dag_hash() diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/llvm/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/llvm/package.py index eb47f5f847c34a..acf6067b6b2034 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/llvm/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/llvm/package.py @@ -33,6 +33,7 @@ class Llvm(Package, CompilerPackage): provides("c", "cxx", when="+clang") provides("fortran", when="+flang") + provides("libllvm") depends_on("c") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/mesa/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/mesa/package.py new file mode 100644 index 00000000000000..fefac7ee208ae9 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/mesa/package.py @@ -0,0 +1,14 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class Mesa(Package): + """Package depending on libllvm (a link-type virtual provided by a compiler)""" + + homepage = "https://www.mesa.com" + + version("2.0.1") + depends_on("libllvm") + depends_on("cxx", type="build") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/paraview/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/paraview/package.py new file mode 100644 index 00000000000000..7b32f31f7f354e --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/paraview/package.py @@ -0,0 +1,19 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class Paraview(Package): + """Package depending on a library, that has a link dependency to libllvm""" + + homepage = "http://www.example.com" + url = "http://www.example.com/c-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + depends_on("mesa") + depends_on("cxx", type="build") From 2bbb9d1d12a3cf3b2553f03cb2b141ba9f244be2 Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Tue, 3 Mar 2026 08:58:26 -0800 Subject: [PATCH 107/337] lmod layout: support non-virtuals in hierarchy (#51824) Several users have requested the ability to have `python` or other non-virtual packages as elements in the lmod hierarchy, to better isolate builds with different versions / features. This PR eliminates the restriction that hierarchy components must be virtual packages or compilers. Includes test and documentation modifications. Signed-off-by: Gregory Becker --- lib/spack/docs/module_file_support.rst | 9 ++-- lib/spack/spack/modules/lmod.py | 34 ++++----------- .../data/modules/lmod/complex_hierarchy.yaml | 1 + .../lmod/non_virtual_in_hierarchy.yaml | 11 ----- lib/spack/spack/test/modules/lmod.py | 42 ++++++++++--------- 5 files changed, 37 insertions(+), 60 deletions(-) delete mode 100644 lib/spack/spack/test/data/modules/lmod/non_virtual_in_hierarchy.yaml diff --git a/lib/spack/docs/module_file_support.rst b/lib/spack/docs/module_file_support.rst index 1ccefb5f6c4a26..dbae34821e2ddf 100644 --- a/lib/spack/docs/module_file_support.rst +++ b/lib/spack/docs/module_file_support.rst @@ -447,7 +447,7 @@ When specifying module names by projection for Lmod modules, we recommend NOT in :class: note When ``lmod`` is activated Spack will generate a set of hierarchical lua module files that are understood by Lmod. - The hierarchy always contains the ``Core`` and ``Compiler`` layers, but can be extended to include any virtual packages present in Spack. + The hierarchy always contains the ``Core`` and ``Compiler`` layers, but can be extended to include any package or virtual package in Spack. A case that could be useful in practice is for instance: .. code-block:: yaml @@ -460,13 +460,14 @@ When specifying module names by projection for Lmod modules, we recommend NOT in core_compilers: - "gcc@4.8" core_specs: - - "python" + - "r" hierarchy: - "mpi" - "lapack" + - "python" - that will generate a hierarchy in which the ``lapack`` and ``mpi`` layer can be switched independently. - This allows a site to build the same libraries or applications against different implementations of ``mpi`` and ``lapack``, and let Lmod switch safely from one to the other. + that will generate a hierarchy in which the ``python``, ``lapack`` and ``mpi`` layer can be switched independently. + This allows a site to build the same libraries or applications against different implementations of ``mpi`` and ``lapack``, and with different versions of those implementations and of ``python``, and let Lmod switch safely from among the resulting installs. All packages built with a compiler in ``core_compilers`` and all packages that satisfy a spec in ``core_specs`` will be put in the ``Core`` hierarchy of the lua modules. diff --git a/lib/spack/spack/modules/lmod.py b/lib/spack/spack/modules/lmod.py index 1cd9bdb0778ff9..7ca7ea47cab94f 100644 --- a/lib/spack/spack/modules/lmod.py +++ b/lib/spack/spack/modules/lmod.py @@ -14,7 +14,6 @@ import spack.error import spack.llnl.util.filesystem as fs import spack.llnl.util.lang as lang -import spack.repo import spack.spec import spack.tengine as tengine import spack.util.environment @@ -158,15 +157,6 @@ def hierarchy_tokens(self): """ tokens = configuration(self.name).get("hierarchy", []) - # Check if all the tokens in the hierarchy are virtual specs. - # If not warn the user and raise an error. - not_virtual = [t for t in tokens if t != "compiler" and not spack.repo.PATH.is_virtual(t)] - if not_virtual: - msg = "Non-virtual specs in 'hierarchy' list for lmod: {0}\n" - msg += "Please check the 'modules.yaml' configuration files" - msg = msg.format(", ".join(not_virtual)) - raise NonVirtualInHierarchyError(msg) - # Append 'compiler' which is always implied tokens.append("compiler") @@ -183,10 +173,7 @@ def requires(self): The ``compiler`` key is always present among the requirements. """ # If it's a core_spec, lie and say it requires a core compiler - if ( - any(self.spec.satisfies(core_spec) for core_spec in self.core_specs) - or self.compiler is None - ): + if any(self.spec.satisfies(core_spec) for core_spec in self.core_specs): return {"compiler": self.core_compilers[0]} hierarchy_filter_list = [] @@ -197,17 +184,18 @@ def requires(self): # Keep track of the requirements that this package has in terms # of virtual packages that participate in the hierarchical structure + requirements = {"compiler": self.compiler or self.core_compilers[0]} - requirements = {"compiler": self.compiler} - # For each virtual dependency in the hierarchy + # For each dependency in the hierarchy for x in self.hierarchy_tokens: # Skip anything filtered for this spec if x in hierarchy_filter_list: continue # If I depend on it - if x in self.spec and not self.spec.package.provides(x): + if x in self.spec and not (self.spec.name == x or self.spec.package.provides(x)): requirements[x] = self.spec[x] # record the actual provider + return requirements @property @@ -230,7 +218,7 @@ def provides(self): # All the other tokens in the hierarchy must be virtual dependencies for x in self.hierarchy_tokens: - if self.spec.package.provides(x): + if self.spec.name == x or self.spec.package.provides(x): provides[x] = self.spec return provides @@ -255,7 +243,9 @@ def missing(self): @property def hidden(self): # Never hide a module that opens a hierarchy - if any(self.spec.package.provides(x) for x in self.hierarchy_tokens): + if any( + self.spec.name == x or self.spec.package.provides(x) for x in self.hierarchy_tokens + ): return False return super().hidden @@ -505,9 +495,3 @@ class CoreCompilersNotFoundError(spack.error.SpackError, KeyError): """Error raised if the key ``core_compilers`` has not been specified in the configuration file. """ - - -class NonVirtualInHierarchyError(spack.error.SpackError, TypeError): - """Error raised if non-virtual specs are used as hierarchy tokens in - the lmod section of ``modules.yaml``. - """ diff --git a/lib/spack/spack/test/data/modules/lmod/complex_hierarchy.yaml b/lib/spack/spack/test/data/modules/lmod/complex_hierarchy.yaml index 91adfd92e63e73..a408d3ee0a238c 100644 --- a/lib/spack/spack/test/data/modules/lmod/complex_hierarchy.yaml +++ b/lib/spack/spack/test/data/modules/lmod/complex_hierarchy.yaml @@ -13,6 +13,7 @@ lmod: - lapack - blas - mpi + - python filter_hierarchy_specs: 'mpileaks@:2.1': [mpi] diff --git a/lib/spack/spack/test/data/modules/lmod/non_virtual_in_hierarchy.yaml b/lib/spack/spack/test/data/modules/lmod/non_virtual_in_hierarchy.yaml deleted file mode 100644 index bf9df440d03843..00000000000000 --- a/lib/spack/spack/test/data/modules/lmod/non_virtual_in_hierarchy.yaml +++ /dev/null @@ -1,11 +0,0 @@ -enable: - - lmod -lmod: - core_compilers: - - 'clang@3.3' - hierarchy: - - mpi - - openblas - - all: - autoload: direct diff --git a/lib/spack/spack/test/modules/lmod.py b/lib/spack/spack/test/modules/lmod.py index ec31f39e651ad1..fb9d42cfc27810 100644 --- a/lib/spack/spack/test/modules/lmod.py +++ b/lib/spack/spack/test/modules/lmod.py @@ -40,12 +40,14 @@ def compiler(request): @pytest.fixture( params=[ - ("mpich@3.0.4", ("mpi",)), - ("mpich@3.0.1", []), - ("openblas@0.2.15", ("blas",)), - ("openblas-with-lapack@0.2.15", ("blas", "lapack")), - ("mpileaks@2.3", ("mpi",)), - ("mpileaks@2.1", []), + ("mpich@3.0.4", ("mpi",), True, False), + ("mpich@3.0.1", [], True, True), + ("openblas@0.2.15", ("blas",), True, False), + ("openblas-with-lapack@0.2.15", ("blas", "lapack"), True, False), + ("mpileaks@2.3", ("mpi",), True, False), + ("mpileaks@2.1", [], True, False), + ("py-extension1@2.0", ("python",), False, True), + ("python@3.8.0", ("python",), False, True), ] ) def provider(request): @@ -69,8 +71,14 @@ def test_layout_for_specs_compiled_with_core_compilers( def test_file_layout(self, compiler, provider, factory, module_configuration): """Tests the layout of files in the hierarchy is the one expected.""" module_configuration("complex_hierarchy") - spec_string, services = provider - module, spec = factory(spec_string + "%" + compiler) + spec_string, services, use_compiler, place_in_core = provider + + # Non-python specs add compiler + factory_string = spec_string + if use_compiler: + factory_string += "%" + compiler + + module, spec = factory(factory_string) layout = module.layout @@ -82,7 +90,8 @@ def test_file_layout(self, compiler, provider, factory, module_configuration): # is transformed to r"Core" if the compiler is listed among core # compilers # Check that specs listed as core_specs are transformed to "Core" - if compiler == "clang@=15.0.0" or spec_string == "mpich@3.0.1": + # Check that specs with no hierarchy components are transformed to "Core" + if "clang@=15.0.0" in factory_string or place_in_core: assert "Core" in layout.available_path_parts else: assert compiler.replace("@=", "/") in layout.available_path_parts @@ -93,9 +102,12 @@ def test_file_layout(self, compiler, provider, factory, module_configuration): service_part = spec_string.replace("@", "/") service_part = "-".join([service_part, layout.spec.dag_hash(length=7)]) - if "mpileaks" in spec_string: + if "mpi" in spec: # It's a user, not a provider, so create the provider string service_part = layout.spec["mpi"].format("{name}/{version}-{hash:7}") + elif "python" in spec: + # It's a user, not a provider, so create the provider string + service_part = layout.spec["python"].format("{name}/{version}-{hash:7}") else: # Only relevant for providers, not users, of virtuals assert service_part in path_parts @@ -310,16 +322,6 @@ def test_no_core_compilers(self, factory, module_configuration): with pytest.raises(spack.modules.lmod.CoreCompilersNotFoundError): module.write() - def test_non_virtual_in_hierarchy(self, factory, module_configuration): - """Ensures that if a non-virtual is in hierarchy, an exception will - be raised. - """ - module_configuration("non_virtual_in_hierarchy") - - module, spec = factory(mpileaks_spec_string) - with pytest.raises(spack.modules.lmod.NonVirtualInHierarchyError): - module.write() - def test_conflicts(self, modulefile_content, module_configuration): """Tests adding conflicts to the module.""" From 8841665e2ea629042ca916f9e995781acc05a246 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 3 Mar 2026 18:31:36 +0100 Subject: [PATCH 108/337] packages.yaml: mark opengl as buildable:false (#52019) The `opengl` package fails at runtime with a message, saying it's a placeholder for external libraries. Make it fail at concretization time instead by default. Signed-off-by: Massimiliano Culpo --- etc/spack/defaults/base/packages.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/etc/spack/defaults/base/packages.yaml b/etc/spack/defaults/base/packages.yaml index f9c1d8f77dbe8d..a91d8dd1bbd91b 100644 --- a/etc/spack/defaults/base/packages.yaml +++ b/etc/spack/defaults/base/packages.yaml @@ -103,6 +103,8 @@ packages: buildable: false musl: buildable: false + opengl: + buildable: false spectrum-mpi: buildable: false xl: From 3e99a4355acfb182409fdfcf3bef2449fad4abdf Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 4 Mar 2026 20:08:48 +0100 Subject: [PATCH 109/337] solver: fix rule for virtuals that are provided together (#52021) * solver: fix rule for virtuals that are provided together fixes #51512 Sometimes a unified environment has roots compiled with different compilers. In those cases the rule to ensure that virtuals that need to be provided together ARE provided together was wrong. In this PR we fix it, and we add a test case to avoid regression. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 27 ++++++++------- lib/spack/spack/test/env.py | 49 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 82676874977ecc..5ed5438e7bb511 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -980,20 +980,19 @@ error(1, Msg) :- % Virtual dependencies %----------------------------------------------------------------------------- -% Enforces all virtuals to be provided, if multiple of them are provided together -error(100, "Package '{0}' needs to provide both '{1}' and '{2}' together, but provides only '{1}'", Package, Virtual1, Virtual2) -:- % This package provides 2 or more virtuals together - condition_holds(ID, node(X, Package)), - pkg_fact(Package, provided_together(ID, SetID, Virtual1)), - pkg_fact(Package, provided_together(ID, SetID, Virtual2)), - Virtual1 != Virtual2, - % One node depends on those virtuals AND on this package - node_depends_on_virtual(ClientNode, Virtual1), - node_depends_on_virtual(ClientNode, Virtual2), - depends_on(ClientNode, node(X, Package)), - % But this package is a provider of only one of them - provider(node(X, Package), node(_, Virtual1)), - not provider(node(X, Package), node(_, Virtual2)). +% Package provides to this client at least one virtual from those that need to be provided together +node_uses_provider_with_constraints(ClientNode, node(X, Package), ID, SetID) :- + condition_holds(ID, node(X, Package)), + pkg_fact(Package, provided_together(ID, SetID, V)), + attr("virtual_on_edge", ClientNode, node(X, Package), V). + +% This error is triggered if the package provides some but not all required virtuals from +% the set that needs to be provided together +error(100, "Package '{0}' needs to also provide '{1}' (provided_together constraint)", Package, Virtual) +:- node_uses_provider_with_constraints(ClientNode, node(X, Package), ID, SetID), + pkg_fact(Package, provided_together(ID, SetID, Virtual)), + node_depends_on_virtual(ClientNode, Virtual), + not attr("virtual_on_edge", ClientNode, node(X, Package), Virtual). % if a package depends on a virtual, it's not external and we have a % provider for that virtual then it depends on the provider diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index 13719105d057e3..dc291055832294 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -2038,3 +2038,52 @@ def test_mixed_compilers_and_libllvm(tmp_path, config): assert paraview.satisfies(f"%{mesa}") assert mesa.satisfies("%cxx=gcc %libllvm=llvm") assert paraview["cxx"].dag_hash() == mesa["libllvm"].dag_hash() + + +@pytest.mark.regression("51512") +def test_unified_environment_with_mixed_compilers_and_fortran(tmp_path, config): + """Tests that we can concretize a unified environment using two C/C++ compilers for the root + specs and GCC for Fortran, where both roots depend on Fortran. + """ + spack_yaml = """ + spack: + specs: + - mpich %c,cxx=llvm + - openblas %c,fortran=gcc + packages: + gcc:: + externals: + - spec: gcc@13.2.0 languages:='c,c++,fortran' + prefix: /path + extra_attributes: + compilers: + c: /path/bin/gcc + cxx: /path/bin/g++ + fortran: /path/bin/gfortran + llvm:: + externals: + - spec: llvm@20.1.8+clang~flang + prefix: /usr + extra_attributes: + compilers: + c: /usr/bin/gcc + cxx: /usr/bin/g++ + fortran: /usr/bin/gfortran + concretizer: + unify: true + """ + manifest = tmp_path / "spack.yaml" + manifest.write_text(spack_yaml) + with ev.Environment(tmp_path) as e: + e.concretize() + + for x in e.concrete_roots(): + if x.name == "mpich": + mpich = x + else: + openblas = x + + assert mpich.satisfies("%c,cxx=llvm") + assert mpich.satisfies("%fortran=gcc") + assert openblas.satisfies("%c,fortran=gcc") + assert mpich["fortran"].dag_hash() == openblas["fortran"].dag_hash() From 9cc94922d01a3f212f1c9ceb39ac79af1b587004 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:31:21 -0800 Subject: [PATCH 110/337] Support named git includes (#51939) This PR adds support for specifying the name of a git include. It also changes paths for SingleFileScopes reported using spack config scopes -p so they do NOT end with the path separator. For example, given include: - name: site git: https://github.com/spack/spack-configs.git branch: main paths: - USC/config the scope names would be: $ spack config scopes -p Scope Path command_line spack $spack/etc/spack/ user $HOME/.spack/ site $HOME/.spack/includes/nncrh7v/USC/config/ system /etc/spack/ defaults $spack/etc/spack/defaults/ defaults:darwin $spack/etc/spack/defaults/darwin/ defaults:base $spack/etc/spack/defaults/base/ _builtin Consequently the default site configuration is overridden. Suppose you only want two of the current five `USC/config` configuration files. If you provide multiple explicit paths, then the name will be prepended to each path entry to ensure uniqueness. Given you only want the config.yaml and packages.yaml files from the repository: include: - name: site git: https://github.com/spack/spack-configs.git branch: main paths: - USC/config/config.yaml - USC/config/packages.yaml then the scope names are: $ spack config scopes -p Scope Path command_line spack $spack/etc/spack/ user $HOME/.spack/ site:config.yaml $HOME/.spack/includes/nncrh7v/USC/config/config.yaml site:packages.yaml $HOME/.spack/includes/nncrh7v/USC/config/packages.yaml site $HOME/github/prs/spack/etc/spack/site/ system /etc/spack/ defaults $spack/etc/spack/defaults/ defaults:darwin $spack/etc/spack/defaults/darwin/ defaults:base $spack/etc/spack/defaults/base/ _builtin Which means those configuration files do NOT override the default site configuration but their contents do have higher precedence. --------- Signed-off-by: tldahlgren --- lib/spack/docs/include_yaml.rst | 49 ++++++++++++++++++++++++------- lib/spack/spack/cmd/config.py | 2 +- lib/spack/spack/config.py | 38 ++++++++++++------------ lib/spack/spack/schema/include.py | 1 + lib/spack/spack/test/config.py | 11 +++++-- 5 files changed, 68 insertions(+), 33 deletions(-) diff --git a/lib/spack/docs/include_yaml.rst b/lib/spack/docs/include_yaml.rst index aa090ef2711f6e..48d179341a6f32 100644 --- a/lib/spack/docs/include_yaml.rst +++ b/lib/spack/docs/include_yaml.rst @@ -13,6 +13,8 @@ Include Settings (include.yaml) =============================== Spack allows you to include configuration files through ``include.yaml``, or in the ``include:`` section in an environment. +You can specify includes using local paths, remote paths, and ``git`` URLs. +Included paths become configuration scopes in Spack and can even be used to override built-in scopes. Local files ~~~~~~~~~~~ @@ -62,28 +64,51 @@ The ``config.yaml`` file would be cached locally to a special include location a ~~~~~~~~~~~~~~~~~~~~~~~~ You can also include configuration files from a ``git`` repository. -The `branch`, `commit`, or `tag` to be checked out is required. +The ``branch``, ``commit``, or ``tag`` to be checked out is required. A list of relative paths in which to find the configuration files is also required. Inclusion of the repository (and its paths) can be optional or conditional. +If you want to control the :ref:`name of the configuration scope `, you can provide a ``name``. For example, suppose we only want to include the ``config.yaml`` and ``packages.yaml`` files from the `spack/spack-configs `_ repository's ``USC/config`` directory when using the ``centos7`` operating system. -We would then configure the ``include.yaml`` file as follows: - -.. code-block:: yaml +And we want the configuration scope name to start ``USC``. +We would then configure the ``include.yaml`` file as follows:: include: - - git: https://github.com/spack/spack-configs + - name: USC + git: https://github.com/spack/spack-configs branch: main when: os == "centos7" paths: - USC/config/config.yaml - USC/config/packages.yaml -If the condition is satisfied, then the ``main`` branch of the repository will be cloned and the settings for the two files integrated into Spack's configuration. +If the condition is satisfied, then the ``main`` branch of the repository will be cloned when the configuration scopes are initially created. +Once cloned, the settings for the two files under the ``USC/config`` directory will be integrated into Spack's configuration. +In this example, the new scopes can be seen by running:: + + $ spack config scopes -p + Scope Path + command_line + spack /Users/username/spack/etc/spack/ + user /Users/username/.spack/ + USC:config.yaml /Users/username/.spack/includes/nncrh7v/USC/config/config.yaml + USC:packages.yaml /Users/username/.spack/includes/nncrh7v/USC/config/packages.yaml + site /Users/username/spack/etc/spack/site/ + system /etc/spack/ + defaults /Users/username/spack/etc/spack/defaults/ + defaults:darwin /Users/username/spack/etc/spack/defaults/darwin/ + defaults:base /Users/username/spack/etc/spack/defaults/base/ + _builtin + +Since there are two unique paths, each results in a separate configuration scope. +If only the ``USC/config`` directory was listed under ``paths``, then there would be only one configuration scope, named ``USC``, and the configuration settings from all of the configuration files within that directory would be integrated. .. versionadded:: 1.1 ``git:``, ``branch:``, ``commit:``, and ``tag:`` attributes. +.. versionadded:: 1.2 + ``name:`` attribute. + Precedence ~~~~~~~~~~ @@ -143,13 +168,15 @@ If not, Spack will instead use the ``path:`` specified in configuration. .. versionadded:: 1.1 The ``path_override_env_var:`` attribute. +.. _named-config-scopes: + Named configuration scopes ~~~~~~~~~~~~~~~~~~~~~~~~~~ -By default, the included scope names are is constructed by appending ``:`` and the included scope's basename to the parent scope name. +By default, the included scope names are constructed by appending ``:`` and the included scope's basename to the parent scope name. For example, Spack's own ``defaults`` scope includes a ``base`` scope and a platform-specific scope:: - > spack config scopes -p + $ spack config scopes -p Scope Path command_line spack /home/username/spack/etc/spack/ @@ -200,8 +227,8 @@ You can see that all three of these scopes are given meaningful names, and all t The ``user`` and ``system`` scopes can also be disabled by setting ``SPACK_DISABLE_LOCAL_CONFIG``. Finally, the ``user`` scope can be overridden with a path in ``SPACK_USER_CONFIG_PATH`` if it is set. -Overriding scopes by name: -^^^^^^^^^^^^^^^^^^^^^^^^^^ +Overriding scopes by name +^^^^^^^^^^^^^^^^^^^^^^^^^ Configuration scopes have unique names. This means that you can use the ``name:`` attribute to *replace* a builtin scope. @@ -230,7 +257,7 @@ The newly included ``user`` scope will *completely* override the builtin ``user` .. warning:: - Using ``name:`` to override the ``defaults`` scope can have *very* unexpected consequences and is not advised. + Overriding the ``defaults`` scope can have **very** unexpected consequences and is not advised. .. versionadded:: 1.1 The ``name:`` attribute. diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index 04bddc186ae1f4..ca366ccc338700 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -292,7 +292,7 @@ def _config_scope_info(args, scope, active, included): result.append( section_path if section_path and os.path.exists(section_path) - else f"{scope.path}{os.sep}" + else f"{scope.path}{'' if os.path.isfile(scope.path) else os.sep}" ) else: result.append(" ") diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 7c8a517addd5e2..b1ed1fcd5d9ce7 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -1043,29 +1043,32 @@ def _scope( Raises: ValueError: the required configuration path does not exist """ + # circular dependencies + import spack.util.path + # Ensure the parent scope is valid self._validate_parent_scope(parent_scope) - # use specified name if there is one - config_name = self.name - if not config_name: - # Try to use the relative path to create the included scope name - parent_path = getattr(parent_scope, "path", None) - if parent_path and str(parent_path) == os.path.commonprefix( - [parent_path, config_path] - ): - included_name = os.path.relpath(config_path, parent_path) - else: - included_name = config_path + # Determine the configuration scope name + config_name = self.name or parent_scope.name + + # But ensure that name is unique if there are multiple paths. + if not self.name or len(getattr(self, "paths", [])) > 1: + parent_path = pathlib.Path(getattr(parent_scope, "path", "")) + real_path = pathlib.Path(spack.util.path.substitute_path_variables(path)) + + try: + included_name = real_path.relative_to(parent_path) + except ValueError: + included_name = real_path if sys.platform == "win32": # Clean windows path for use in config name that looks nicer # ie. The path: C:\\some\\path\\to\\a\\file # becomes C/some/path/to/a/file - included_name = included_name.replace("\\", "/") - included_name = included_name.replace(":", "") + included_name = included_name.as_posix().replace(":", "") - config_name = f"{parent_scope.name}:{included_name}" + config_name = f"{config_name}:{included_name}" _, ext = os.path.splitext(config_path) ext_is_yaml = ext == ".yaml" or ext == ".yml" @@ -1077,7 +1080,6 @@ def _scope( raise ValueError(f"Required path ({path}) does not exist{dest}") if (exists and not is_dir) or ext_is_yaml: - # files are assumed to be SingleFileScopes tty.debug(f"Creating SingleFileScope {config_name} for '{config_path}'") return SingleFileScope( config_name, @@ -1322,9 +1324,9 @@ def scopes(self, parent_scope: ConfigScope) -> List[ConfigScope]: raise spack.error.ConfigError(f"Unable to cache the include: {self}") scopes: List[ConfigScope] = [] - for relative_path in self.paths: - config_path = os.path.join(destination, relative_path) - scope = self._scope(relative_path, config_path, parent_scope) + for path in self.paths: + config_path = os.path.join(destination, path) + scope = self._scope(path, config_path, parent_scope) if scope is not None: scopes.append(scope) diff --git a/lib/spack/spack/schema/include.py b/lib/spack/spack/schema/include.py index 5b2c9dfe9958b9..1cc1fcb4c13e4a 100644 --- a/lib/spack/spack/schema/include.py +++ b/lib/spack/spack/schema/include.py @@ -92,6 +92,7 @@ "description": "List of relative paths within the repository where " "configuration files are located", }, + "name": {"type": "string"}, "when": { "type": "string", "description": "Include this config only when the condition (as " diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index 4e0f2d5f69c54e..7d4493ea06b54b 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -1819,10 +1819,12 @@ def __call__(self, *args, **kwargs) -> str: # type: ignore return "" - paths = ["config.yaml", "packages.yaml"] + # Specifying two relative paths, one explicit, one implicit + paths = ["./config.yaml", "packages.yaml"] entry = { "git": "https://example.com/windows/configs.git", key: value, + "name": "site", "paths": paths, "when": 'platform == "test"', } @@ -1852,9 +1854,12 @@ def _checkout(*args, **kwargs): parent_scope = mock_low_high_config.scopes["low"] scopes = include.scopes(parent_scope) assert scopes and len(scopes) == len(paths) + + base_paths = [os.path.basename(p) for p in paths] for scope in scopes: assert isinstance(scope, spack.config.SingleFileScope) - assert os.path.basename(scope.path) in paths # type: ignore[union-attr] + assert os.path.basename(scope.path) in base_paths # type: ignore[union-attr] + assert scope.name.split(":")[1] in base_paths # Second pass uses the scopes previously built. # Only need to do this for one of the parameters. @@ -2030,6 +2035,6 @@ def test_include_bad_parent_scope(tmp_path: pathlib.Path): def test_config_invalid_scope(mock_low_high_config): - err = "Must be one of \['low', 'high'\]" # noqa: W605 + err = "Must be one of \\['low', 'high'\\]" # noqa: W605 with pytest.raises(ValueError, match=err): spack.config.CONFIG.get_config_filename("noscope", "nosection") From 6a903a934c18ffba17ea71fe5fe31a987c235297 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:32:47 -0800 Subject: [PATCH 111/337] Bugfix/cmd list: return proper path for json and html output (#51914) * Bugfix/cmd list: return proper path for json and html output Signed-off-by: tldahlgren * spack list: improve handling of local, spack, and non-spack package repos. Signed-off-by: tldahlgren * Don't use os.sep for checking the file URL (on windows) Signed-off-by: tldahlgren * test_list_format_non_github_repo: Use 'as_uri' to ensure have a file URI Signed-off-by: tldahlgren * Add test_list_github_url_fails Signed-off-by: tldahlgren --------- Signed-off-by: tldahlgren --- lib/spack/spack/cmd/list.py | 63 +++++++++++++++-- lib/spack/spack/test/cmd/list.py | 113 +++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/cmd/list.py b/lib/spack/spack/cmd/list.py index eaa63f3ebeb02f..fcc57d1580c10f 100644 --- a/lib/spack/spack/cmd/list.py +++ b/lib/spack/spack/cmd/list.py @@ -10,14 +10,17 @@ import re import sys from html import escape -from typing import Type +from typing import Optional, Type import spack.deptypes as dt import spack.llnl.util.tty as tty import spack.package_base import spack.repo +import spack.util.git from spack.cmd.common import arguments +from spack.llnl.util.filesystem import working_dir from spack.llnl.util.tty.colify import colify +from spack.util.url import path_to_file_url from spack.version import VersionList description = "list and search available packages" @@ -140,10 +143,60 @@ def name_only(pkgs, out): tty.msg("%d packages" % len(pkgs)) -def github_url(pkg: Type[spack.package_base.PackageBase]) -> str: - """Link to a package file on github.""" - mod_path = pkg.__module__.replace(".", "/") - return f"https://github.com/spack/spack/blob/develop/var/spack/{mod_path}.py" +def github_url(pkg: Type[spack.package_base.PackageBase]) -> Optional[str]: + """Link to a package file in spack package's github or the path to the file. + + Args: + pkg: package instance + + Returns: URL to the package file on github or the local file path; otherwise, ``None``. + """ + git = None + module_path = f"{pkg.__module__.replace('.', '/')}.py" + for repo in spack.repo.PATH.repos: + if not repo.python_path: + continue + + path = os.path.join(repo.python_path, module_path) + if not os.path.exists(path): + continue + + git = git or spack.util.git.git() + if not git: + tty.debug("Cannot determine package URL for {pkg} without 'git', using path URL") + return path_to_file_url(path) + + tty.debug(f"Checking git for repository path '{path}'") + with working_dir(os.path.dirname(path)): + origin_url = git( + "config", + "--get", + "remote.origin.url", + output=str, + error=os.devnull, + fail_on_error=False, + ) + + if not origin_url: + tty.debug("Cannot determine remote origin url, using path URL") + return path_to_file_url(path) + + # Handle spack repositories cloned with any scheme (e.g., ssh) by + # ignoring the scheme designation. + if any([name in origin_url for name in ["spack.git", "spack-packages.git"]]): + git_repo = (origin_url.split("/")[-1]).replace(".git", "").strip() + prefix = git( + "rev-parse", "--show-prefix", output=str, error=os.devnull, fail_on_error=False + ) + return ( + f"https://github.com/spack/{git_repo}/blob/develop/{prefix.strip()}package.py" + ) + + tty.debug(f"Unrecognized repository for {pkg}, using path URL") + return path_to_file_url(path) + + tty.debug(f"Unable to determine the package repository URL for {pkg}") + return None def rows_for_ncols(elts, ncols): diff --git a/lib/spack/spack/test/cmd/list.py b/lib/spack/spack/test/cmd/list.py index ee45f163a91e5d..d13ea5b3207491 100644 --- a/lib/spack/spack/test/cmd/list.py +++ b/lib/spack/spack/test/cmd/list.py @@ -7,9 +7,12 @@ import pytest +import spack.cmd.list import spack.paths import spack.repo +import spack.util.git from spack.main import SpackCommand +from spack.test.conftest import RepoBuilder pytestmark = [pytest.mark.usefixtures("mock_packages")] @@ -63,6 +66,8 @@ def test_list_format_version_json(): output = list("--format", "version_json") assert '{"name": "zmpi",' in output assert '{"name": "dyninst",' in output + assert "packages/zmpi/package.py" in output + import json json.loads(output) @@ -75,6 +80,83 @@ def test_list_format_html(): assert '
' in output assert "

hdf5" in output + assert "packages/hdf5/package.py" in output + + +@pytest.mark.parametrize( + "url", + [ + "git@github.com:username/spack-packages.git", + "https://github.com/username/spack-packages.git", + "git@github.com:username/spack.git", + "https://github.com/username/spack.git", + ], +) +def test_list_url_schemes(mock_util_executable, url): + """Confirm the command handles supported repository URLs.""" + pkg_name = "hdf5" + + _, _, registered_responses = mock_util_executable + registered_responses["config"] = url + registered_responses["rev-parse"] = f"path/to/builtin/packages/{pkg_name}/" + + output = list("--format", "version_json", pkg_name) + assert f"{registered_responses['rev-parse']}package.py" in output + assert os.path.basename(url).replace(".git", "") in output + + +def test_list_format_local_repo(tmp_path: pathlib.Path): + """Confirm a file path is returned for local repository.""" + pkg_name = "mypkg" + repo_root = tmp_path / "repos" / "spack_repo" / "builtin" + repo_root.mkdir(parents=True) + (repo_root / "repo.yaml").write_text("repo:\n namespace: builtin\n api: v2.2\n") + package_root = repo_root / "packages" / pkg_name + package_root.mkdir(parents=True) + (package_root / "package.py").write_text( + """\ +from spack.package import * + +class Mypkg(Package): + pass +""" + ) + + test_repo = spack.repo.from_path(str(repo_root)) + with spack.repo.use_repositories(test_repo): + # Confirm a path is returned when fail to retrieve the remote origin URL + output = list("--format", "version_json", pkg_name) + assert "github.com" not in output + assert f"packages/{pkg_name}/package.py" in output + + +def test_list_format_non_github_repo(tmp_path: pathlib.Path, mock_util_executable): + """Confirm a file path is returned for a non-github repository.""" + pkg_name = "mypkg" + repo_root = tmp_path / "my" / "project" / "spack_repo" / "builtin" + repo_root.mkdir(parents=True) + (repo_root / "repo.yaml").write_text("repo:\n namespace: builtin\n api: v2.2\n") + package_root = repo_root / "packages" / pkg_name + package_root.mkdir(parents=True) + package_path = package_root / "package.py" + package_path.write_text( + """\ +from spack.package import * + +class Mypkg(Package): + pass +""" + ) + + test_repo = spack.repo.from_path(str(repo_root)) + with spack.repo.use_repositories(test_repo): + # Confirm a path is returned for a non-standard spack repository + _, _, registered_responses = mock_util_executable + registered_responses["config"] = "https://gitlab.com/username/my-packages.git" + registered_responses["rev-parse"] = str(package_root) + os.sep + + output = list("--format", "version_json", pkg_name) + assert package_path.as_uri() in output def test_list_update(tmp_path: pathlib.Path): @@ -140,3 +222,34 @@ def test_list_repos(): assert total_pkgs > mock_pkgs > builder_pkgs assert both_repos == total_pkgs + + +@pytest.mark.usefixtures("config") +def test_list_github_url_fails(repo_builder: RepoBuilder, monkeypatch): + with spack.repo.use_repositories(repo_builder.root): + repo_builder.add_package("pkg-a") + repo = spack.repo.PATH.repos[0] + pkg = repo.get_pkg_class("pkg-a") + + old_path = repo.python_path + try: + # Check that a repository with no python path has no URL + monkeypatch.setattr(repo, "python_path", None) + assert ( + spack.cmd.list.github_url(pkg) is None + ), "Expected no python path means unable to determine the repo URL" + + # Check that a repository path that doesn't exist has no URL + monkeypatch.setattr(repo, "python_path", "/repo/root/does/not/exists") + assert ( + spack.cmd.list.github_url(pkg) is None + ), "Expected bad repo path means unable to determine the repo URL" + finally: + monkeypatch.setattr(repo, "python_path", old_path) + + # Check that missing git results in the file path + monkeypatch.setattr(spack.util.git, "git", lambda: None) + filepath = spack.cmd.list.github_url(pkg) + assert filepath and filepath.startswith( + "file://" + ), "Expected missing 'git' results in a file URI" From e520b875169eaebd8648103cbe8e42ae315a4b86 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 5 Mar 2026 19:32:28 +0100 Subject: [PATCH 112/337] solver: fix issues with the computation of `max_dupes` (#52027) * solver: don't assume max_dupes = 1 for possible link/run deps If a virtual is a possible link/run dependency, don't make assumption on its number of duplicates based on whether it may appear in the link/run closure. That may cause issues if e.g. packages have typos and use: ``` depends_on("c") ``` since the max_dupes for that language will be 1 instead of the configured default of 2. Signed-off-by: Massimiliano Culpo * solver: fix an issue with virtuals when max_dupes > 1 When a virtual is depended on with multiple edge types, we must ensure that the same package gets the same provider on each of the edge types. E.g. it can't happen that a: ``` depends_on("lapack", type=("build","link")) ``` is satisfied by openblas on the link part, and by netlib-lapack on the build part. Signed-off-by: Massimiliano Culpo --------- Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 5 ++++ lib/spack/spack/solver/input_analysis.py | 11 ++----- lib/spack/spack/test/concretization/core.py | 29 ++++++++++++++++++- .../spack/test/data/config/concretizer.yaml | 23 +++++++++++++++ .../packages/pkg_with_c_link_dep/package.py | 16 ++++++++++ 5 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_c_link_dep/package.py diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 5ed5438e7bb511..aa5f9eb102df72 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1006,6 +1006,11 @@ virtual_is_needed(Virtual) :- node_depends_on_virtual(PackageNode, Virtual). 1 { virtual_on_edge(PackageNode, ProviderNode, node(VirtualID, Virtual), Type) : provider(ProviderNode, node(VirtualID, Virtual)) } 1 :- node_depends_on_virtual(PackageNode, Virtual, Type). +% A package that depends on a virtual with type ("build", "link") cannot have two providers +% (one for the "build" and one for the "link") +:- node_depends_on_virtual(PackageNode, Virtual), M = #count { VirtualID : virtual_on_edge(PackageNode, ProviderNode, node(VirtualID, Virtual), _) }, M > 1. + + attr("virtual_on_edge", PackageNode, ProviderNode, Virtual) :- virtual_on_edge(PackageNode, ProviderNode, node(_, Virtual), _). attr("depends_on", PackageNode, ProviderNode, Type) :- virtual_on_edge(PackageNode, ProviderNode, _, Type). diff --git a/lib/spack/spack/solver/input_analysis.py b/lib/spack/spack/solver/input_analysis.py index f12907e07ca528..d8a0e5d148d614 100644 --- a/lib/spack/spack/solver/input_analysis.py +++ b/lib/spack/spack/solver/input_analysis.py @@ -482,7 +482,7 @@ def possible_packages_facts(self, gen, fn): gen.newline() gen.h2("Packages with multiple possible nodes (build-tools)") - default = spack.config.CONFIG.get("concretizer:duplicates:max_dupes:default", 2) + default = spack.config.CONFIG.get("concretizer:duplicates:max_dupes:default", 1) duplicates = spack.config.CONFIG.get("concretizer:duplicates:max_dupes", {}) for package_name in sorted(self.possible_dependencies() & build_tools): max_dupes = duplicates.get(package_name, default) @@ -491,13 +491,8 @@ def possible_packages_facts(self, gen, fn): gen.fact(fn.multiple_unification_sets(package_name)) gen.newline() - gen.h2("Maximum number of nodes (link-run virtuals)") - for package_name in sorted(self._link_run_virtuals): - gen.fact(fn.max_dupes(package_name, 1)) - gen.newline() - - gen.h2("Maximum number of nodes (other virtuals)") - for package_name in sorted(self.possible_virtuals() - self._link_run_virtuals): + gen.h2("Maximum number of nodes (virtuals)") + for package_name in sorted(self.possible_virtuals()): max_dupes = duplicates.get(package_name, default) gen.fact(fn.max_dupes(package_name, max_dupes)) gen.newline() diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 1b6f561942534e..8a2a7f130a63b5 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -34,6 +34,7 @@ import spack.repo import spack.solver.asp import spack.solver.core +import spack.solver.input_analysis import spack.solver.reuse import spack.solver.runtimes import spack.spec @@ -659,11 +660,15 @@ def test_concretize_two_virtuals_with_dual_provider(self): """ spack.concretize.concretize_one("hypre ^openblas-with-lapack") - def test_concretize_two_virtuals_with_dual_provider_and_a_conflict(self): + @pytest.mark.parametrize("max_dupes_default", [1, 2, 3]) + def test_concretize_two_virtuals_with_dual_provider_and_a_conflict( + self, max_dupes_default, mutable_config + ): """Test a package with multiple virtual dependencies and force a provider that provides both, and another conflicting package that provides one. """ + mutable_config.set("concretizer:duplicates:max_dupes:default", max_dupes_default) s = Spec("hypre ^openblas-with-lapack ^netlib-lapack") with pytest.raises(spack.error.SpackError): spack.concretize.concretize_one(s) @@ -4930,3 +4935,25 @@ def test_default_values_used_if_subset_required_by_dependent(mock_packages): a = spack.concretize.concretize_one("multivalue-variant-multi-defaults-dependent") # we still end up using baz, and we don't drop it to avoid an extra dependency. assert a.satisfies("%multivalue-variant-multi-defaults myvariant=bar,baz") + + +def test_virtual_gets_multiple_dupes(mock_packages, config): + """Tests that virtual packages always get multiple dupes, according to what we have in + the configuration files. + """ + specs = [spack.spec.Spec("pkg-with-c-link-dep")] + possible_graph = spack.solver.input_analysis.NoStaticAnalysis( + configuration=spack.config.CONFIG, repo=spack.repo.PATH + ) + counter = spack.solver.input_analysis.MinimalDuplicatesCounter( + specs, tests=False, possible_graph=possible_graph + ) + gen = spack.solver.asp.ProblemInstanceBuilder() + counter.possible_packages_facts(gen, spack.solver.core.fn) + + asp = gen.asp_problem + # "c" is a compiler language virtual and must allow multiple nodes, not be capped at 1 + selected_lines = [line for line in asp if line.startswith('max_dupes("c"')] + assert len(selected_lines) == 1 + max_dupes_c = selected_lines[0] + assert 'max_dupes("c",2).' == max_dupes_c, f"should have max_dupes=2, but got: {max_dupes_c}" diff --git a/lib/spack/spack/test/data/config/concretizer.yaml b/lib/spack/spack/test/data/config/concretizer.yaml index a1a30ff0280bde..382c694c3de5de 100644 --- a/lib/spack/spack/test/data/config/concretizer.yaml +++ b/lib/spack/spack/test/data/config/concretizer.yaml @@ -3,7 +3,30 @@ concretizer: targets: granularity: microarchitectures host_compatible: false + duplicates: strategy: minimal + max_dupes: + default: 1 + # Virtuals + c: 2 + cxx: 2 + fortran: 1 + # Regular packages + cmake: 2 + gmake: 2 + python: 2 + python-venv: 2 + py-cython: 2 + py-flit-core: 2 + py-pip: 2 + py-setuptools: 2 + py-versioneer: 2 + py-wheel: 2 + xcb-proto: 2 + # Compilers + gcc: 2 + llvm: 2 + concretization_cache: enable: false diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_c_link_dep/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_c_link_dep/package.py new file mode 100644 index 00000000000000..3ff6657415eecf --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_c_link_dep/package.py @@ -0,0 +1,16 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class PkgWithCLinkDep(Package): + """Simple package with one optional dependency""" + + homepage = "http://www.example.com" + url = "http://www.example.com/a-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + # This package erroneously declares a build,link dependency on c + depends_on("c") From 341772bbab8673059dfc10913bd3ad5092050c8f Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 6 Mar 2026 00:14:05 +0100 Subject: [PATCH 113/337] variant.py: Sequence -> Iterable (#51859) Fix accidental quadratic complexity issue when iterating DisjointSetsOfValues objects. Signed-off-by: Harmen Stoppels --- lib/spack/spack/variant.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/lib/spack/spack/variant.py b/lib/spack/spack/variant.py index 30a0581e3a5d15..23c8410389bab4 100644 --- a/lib/spack/spack/variant.py +++ b/lib/spack/spack/variant.py @@ -247,10 +247,10 @@ def __str__(self) -> str: ) -def _flatten(values) -> Collection: +def _flatten(values) -> Tuple: """Flatten instances of _ConditionalVariantValues for internal representation""" if isinstance(values, DisjointSetsOfValues): - return values + return tuple(values) flattened: List = [] for item in values: @@ -511,9 +511,7 @@ def __init__(self, name): super().__init__(VariantType.INDICATOR, name, (None,)) -# The class below inherit from Sequence to disguise as a tuple and comply -# with the semantic expected by the 'values' argument of the variant directive -class DisjointSetsOfValues(collections.abc.Sequence): +class DisjointSetsOfValues(collections.abc.Iterable): """Allows combinations from one of many mutually exclusive sets. The value ``('none',)`` is reserved to denote the empty set @@ -597,11 +595,8 @@ def prohibit_empty_set(self): ) return object_without_empty_set - def __getitem__(self, idx): - return tuple(itertools.chain.from_iterable(self.sets))[idx] - - def __len__(self): - return sum(len(x) for x in self.sets) + def __iter__(self): + return itertools.chain.from_iterable(self.sets) @property def validator(self): From 3d24559ee20d51ee8b5af920213a4d2618b1af8c Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Fri, 6 Mar 2026 08:14:13 +0100 Subject: [PATCH 114/337] spack config: add --group option (#52025) This option works only if an environment is active and accounts for group overrides when displaying the configuration. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/cmd/config.py | 28 +++++++++++++++++ lib/spack/spack/test/cmd/config.py | 49 ++++++++++++++++++++++++++++++ share/spack/spack-completion.bash | 4 +-- share/spack/spack-completion.fish | 8 +++-- 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index ca366ccc338700..ec7b1c9dd07102 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -46,6 +46,12 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: choices=spack.config.SECTION_SCHEMAS, ) get_parser.add_argument("--json", action="store_true", help="output configuration as JSON") + get_parser.add_argument( + "--group", + metavar="group", + default=None, + help="show configuration as seen by this environment spec group (requires active env)", + ) blame_parser = sp.add_parser( "blame", help="print configuration annotated with source file:line" @@ -57,6 +63,12 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: metavar="section", choices=spack.config.SECTION_SCHEMAS, ) + blame_parser.add_argument( + "--group", + metavar="group", + default=None, + help="show configuration as seen by this environment spec group (requires active env)", + ) edit_parser = sp.add_parser("edit", help="edit configuration file") edit_parser.add_argument( @@ -183,6 +195,22 @@ def print_configuration(args, *, blame: bool) -> None: if args.scope and args.section is None: tty.die(f"the argument --scope={args.scope} requires specifying a section.") + group = getattr(args, "group", None) + if group is not None: + env = ev.active_environment() + if env is None: + tty.die("the argument --group requires an active environment") + try: + with env.config_override_for_group(group=group): + _print_configuration_helper(args, blame=blame) + except ValueError as e: + tty.die(str(e)) + return + + _print_configuration_helper(args, blame=blame) + + +def _print_configuration_helper(args, *, blame: bool) -> None: yaml = blame or not args.json if args.section is not None: diff --git a/lib/spack/spack/test/cmd/config.py b/lib/spack/spack/test/cmd/config.py index 468371fa383e11..a07f9b027364aa 100644 --- a/lib/spack/spack/test/cmd/config.py +++ b/lib/spack/spack/test/cmd/config.py @@ -718,3 +718,52 @@ def update_config(data): with ev.Environment(str(tmp_path)) as e: assert not e.manifest.yaml_content["spack"]["config"]["ccache"] + + +_GROUP_OVERRIDE_SPACK_YAML = """\ +spack: + specs: + - group: mygroup + specs: + - zlib + override: + packages: + zlib: + version: ['1.2.13'] +""" + + +@pytest.mark.parametrize("cmd_str", ["get", "blame"]) +def test_config_with_group_shows_override_packages(cmd_str, tmp_path, mutable_config): + """Tests that packages should show that group's override packages config, + when the option is given. + """ + (tmp_path / "spack.yaml").write_text(_GROUP_OVERRIDE_SPACK_YAML) + + with ev.Environment(str(tmp_path)): + output = config(cmd_str, "packages") + assert "1.2.13" not in output + if cmd_str == "blame": + assert "env:groups:mygroup" not in output + output = config(cmd_str, "--group=mygroup", "packages") + assert "1.2.13" in output + if cmd_str == "blame": + assert "env:groups:mygroup" in output + + +@pytest.mark.parametrize("cmd_str", ["get", "blame"]) +def test_config_with_group_requires_active_environment(cmd_str, mutable_config): + """Tests that using groups outside an environment should give a clear error.""" + output = config(cmd_str, "--group=mygroup", "packages", fail_on_error=False) + assert config.returncode != 0 + assert "--group requires an active environment" in output + + +@pytest.mark.parametrize("cmd_str", ["get", "blame"]) +def test_config_with_unknown_group_gives_clear_error(cmd_str, tmp_path, mutable_config): + """Tests that using a non-existing group gives a clear error.""" + (tmp_path / "spack.yaml").write_text("spack:\n specs:\n - zlib\n") + with ev.Environment(str(tmp_path)): + output = config(cmd_str, "--group=nonexistent", "packages", fail_on_error=False) + assert config.returncode != 0 + assert "'nonexistent' not found in" in output diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 46d655020a0028..888b1d5a5f0821 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -844,7 +844,7 @@ _spack_config() { _spack_config_get() { if $list_options then - SPACK_COMPREPLY="-h --help --json" + SPACK_COMPREPLY="-h --help --json --group" else _config_sections fi @@ -853,7 +853,7 @@ _spack_config_get() { _spack_config_blame() { if $list_options then - SPACK_COMPREPLY="-h --help" + SPACK_COMPREPLY="-h --help --group" else _config_sections fi diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 703c82b69fd73c..4b2ad9f2730060 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -1264,18 +1264,22 @@ complete -c spack -n '__fish_spack_using_command config' -l scope -r -f -a '_bui complete -c spack -n '__fish_spack_using_command config' -l scope -r -d 'configuration scope to read/modify' # spack config get -set -g __fish_spack_optspecs_spack_config_get h/help json +set -g __fish_spack_optspecs_spack_config_get h/help json group= complete -c spack -n '__fish_spack_using_command_pos 0 config get' -f -a 'bootstrap cdash ci compilers concretizer config definitions develop env_vars include mirrors modules packages repos toolchains upstreams view' complete -c spack -n '__fish_spack_using_command config get' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command config get' -s h -l help -d 'show this help message and exit' complete -c spack -n '__fish_spack_using_command config get' -l json -f -a json complete -c spack -n '__fish_spack_using_command config get' -l json -d 'output configuration as JSON' +complete -c spack -n '__fish_spack_using_command config get' -l group -r -f -a group +complete -c spack -n '__fish_spack_using_command config get' -l group -r -d 'show configuration as seen by this environment spec group (requires active env)' # spack config blame -set -g __fish_spack_optspecs_spack_config_blame h/help +set -g __fish_spack_optspecs_spack_config_blame h/help group= complete -c spack -n '__fish_spack_using_command_pos 0 config blame' -f -a 'bootstrap cdash ci compilers concretizer config definitions develop env_vars include mirrors modules packages repos toolchains upstreams view' complete -c spack -n '__fish_spack_using_command config blame' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command config blame' -s h -l help -d 'show this help message and exit' +complete -c spack -n '__fish_spack_using_command config blame' -l group -r -f -a group +complete -c spack -n '__fish_spack_using_command config blame' -l group -r -d 'show configuration as seen by this environment spec group (requires active env)' # spack config edit set -g __fish_spack_optspecs_spack_config_edit h/help print-file From 0257aeb7925e67691f7cb1d8c806f36f5b0292ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:32:05 +0100 Subject: [PATCH 115/337] build(deps): bump sphinxcontrib-svg2pdfconverter in /lib/spack/docs (#52031) Bumps [sphinxcontrib-svg2pdfconverter](https://github.com/missinglinkelectronics/sphinxcontrib-svg2pdfconverter) from 2.0.0 to 2.1.0. - [Commits](https://github.com/missinglinkelectronics/sphinxcontrib-svg2pdfconverter/compare/v2.0.0...v2.1.0) --- updated-dependencies: - dependency-name: sphinxcontrib-svg2pdfconverter dependency-version: 2.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lib/spack/docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/docs/requirements.txt b/lib/spack/docs/requirements.txt index 4422cce37178ee..3e6dcb275eed5b 100644 --- a/lib/spack/docs/requirements.txt +++ b/lib/spack/docs/requirements.txt @@ -1,6 +1,6 @@ sphinx==9.1.0 sphinxcontrib-programoutput==0.18 -sphinxcontrib-svg2pdfconverter==2.0.0 +sphinxcontrib-svg2pdfconverter==2.1.0 sphinx-copybutton==0.5.2 sphinx-last-updated-by-git==0.3.8 sphinx-sitemap==2.9.0 From 6e8e2417068cfddd9358cf41fea0c6a4948d1dca Mon Sep 17 00:00:00 2001 From: Peter Scheibel Date: Fri, 6 Mar 2026 02:35:29 -0800 Subject: [PATCH 116/337] tests: print autocompletion diff when it needs updating (#52032) Signed-off-by: Peter Scheibel --- lib/spack/spack/test/cmd/commands.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/test/cmd/commands.py b/lib/spack/spack/test/cmd/commands.py index ffd5547795ed82..e68a0809963f0a 100644 --- a/lib/spack/spack/test/cmd/commands.py +++ b/lib/spack/spack/test/cmd/commands.py @@ -297,4 +297,15 @@ def test_updated_completion_scripts(shell, tmp_path: pathlib.Path): commands("--aliases", "--format", shell, "--header", header, "--update", new_script) - assert filecmp.cmp(old_script, new_script), msg + if not filecmp.cmp(old_script, new_script): + # If there is a diff, something is wrong: in that case output what the diff is. + import difflib + + with open(old_script, "r", encoding="utf-8") as f1, open( + new_script, "r", encoding="utf-8" + ) as f2: + l1 = f1.readlines() + l2 = f2.readlines() + diff = difflib.unified_diff(l1, l2, fromfile=old_script, tofile=new_script) + msg += "\nDiff failure:\n\n" + "".join(diff) + raise AssertionError(msg) From c9e77cd2dff815adc6eaa3624655c7d12b571ff9 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 6 Mar 2026 11:43:39 +0100 Subject: [PATCH 117/337] new_installer.py: event-driven terminal resize (#52011) Currently we do an ioctl syscall on every redraw to get the terminal size. Instead use the self-pipe trick to handle `SIGWINCH` in the event loop, so we only query the terminal size if changed. This is only enabled in TTY mode. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 37 +++++++++++++++++++++++++-- lib/spack/spack/test/installer_tui.py | 21 +++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 0fe30ccdbcc11c..3b321f3b27b7a9 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -785,7 +785,7 @@ def __init__( self, total: int, stdout: io.TextIOWrapper = sys.stdout, # type: ignore[assignment] - get_terminal_size: Callable[[], Tuple[int, int]] = os.get_terminal_size, + get_terminal_size: Callable[[], os.terminal_size] = os.get_terminal_size, get_time: Callable[[], float] = time.monotonic, is_tty: Optional[bool] = None, ) -> None: @@ -808,9 +808,16 @@ def __init__( self.stdout = stdout self.get_terminal_size = get_terminal_size + self.terminal_size = os.terminal_size((0, 0)) + self.terminal_size_changed: bool = True self.get_time = get_time self.is_tty = is_tty if is_tty is not None else self.stdout.isatty() + def on_resize(self) -> None: + """Refresh cached terminal size and trigger a redraw.""" + self.terminal_size_changed = True + self.dirty = True + def add_build( self, spec: spack.spec.Spec, explicit: bool, control_w_conn: Optional[Connection] = None ) -> None: @@ -990,7 +997,10 @@ def update(self, finalize: bool = False) -> None: if self.active_area_rows > 0: buffer.write(f"\033[{self.active_area_rows}F") - max_width, max_height = self.get_terminal_size() + if self.terminal_size_changed: + self.terminal_size = self.get_terminal_size() + self.terminal_size_changed = False + max_width, max_height = self.terminal_size self.total_lines = 0 total_finished = len(self.finished_builds) @@ -1458,6 +1468,7 @@ def install(self) -> None: def _installer(self) -> None: jobserver = JobServer(self.jobs) selector = selectors.DefaultSelector() + sigwinch_r = sigwinch_w = -1 # Set stdin to non-blocking for key press detection if sys.stdin.isatty(): @@ -1467,6 +1478,19 @@ def _installer(self) -> None: else: old_stdin_settings = None + if sys.stdout.isatty(): + # Listen to terminal resizing events with self-pipe trick. + sigwinch_r, sigwinch_w = os.pipe() + + def _handle_sigwinch(signum: int, frame: object) -> None: + try: + os.write(sigwinch_w, b"\x00") + except OSError: + pass + + signal.signal(signal.SIGWINCH, _handle_sigwinch) + selector.register(sigwinch_r, selectors.EVENT_READ, "sigwinch") + # Finished builds that have not yet been written to the database. finished_builds: List[ChildInfo] = [] next_database_write = 0.0 @@ -1505,6 +1529,9 @@ def _installer(self) -> None: finished_pids.append(data.pid) elif data == "stdin": stdin_ready = True + elif data == "sigwinch": + os.read(sigwinch_r, 64) # drain the pipe + self.build_status.on_resize() current_time = time.monotonic() for pid in finished_pids: @@ -1595,6 +1622,12 @@ def _installer(self) -> None: if old_stdin_settings: termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_stdin_settings) + if sigwinch_r >= 0: + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + selector.unregister(sigwinch_r) + os.close(sigwinch_r) + os.close(sigwinch_w) + # Clean up resources # Final cleanup of any remaining finished packages before exit self.build_status.overview_mode = True diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 27ebd02f2c3901..78a424ee23bf12 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -99,6 +99,27 @@ def add_mock_builds(status: BuildStatus, count: int) -> List[MockSpec]: class TestBasicStateManagement: """Test basic state management operations""" + def test_on_resize(self): + """Test that on_resize sets terminal_size_changed and update() fetches lazily""" + sizes = [os.terminal_size((80, 24))] + fake_stdout = SimpleTextIOWrapper(tty=True) + status = BuildStatus( + total=0, stdout=fake_stdout, get_terminal_size=lambda: sizes[-1], is_tty=True + ) + # terminal_size_changed is True from __init__; terminal_size is placeholder + assert status.terminal_size_changed is True + + # After on_resize the flag stays set and dirty is True + sizes.append(os.terminal_size((120, 40))) + status.on_resize() + assert status.terminal_size_changed is True + assert status.dirty is True + + # The actual size is fetched lazily on the first update() + status.update() + assert status.terminal_size == os.terminal_size((120, 40)) + assert status.terminal_size_changed is False + def test_add_build(self): """Test that add_build adds builds correctly""" status, _, _ = create_build_status(total=2) From 50271c20b71fccc3a84c9d6d735f419dedd07562 Mon Sep 17 00:00:00 2001 From: Brian Vanderwende Date: Fri, 6 Mar 2026 05:23:12 -0700 Subject: [PATCH 118/337] Support cxx and fortran for Lmod compiler hierarchy (#52018) Signed-off-by: Brian Vanderwende --- lib/spack/spack/modules/lmod.py | 29 ++++++++++--------- lib/spack/spack/test/modules/lmod.py | 10 +++++++ .../single_language_virtual/package.py | 24 +++++++++++++++ 3 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/single_language_virtual/package.py diff --git a/lib/spack/spack/modules/lmod.py b/lib/spack/spack/modules/lmod.py index 7ca7ea47cab94f..f50a5a03d82016 100644 --- a/lib/spack/spack/modules/lmod.py +++ b/lib/spack/spack/modules/lmod.py @@ -103,20 +103,23 @@ def __init__(self, spec: spack.spec.Spec, module_set_name: str, explicit: bool) super().__init__(spec, module_set_name, explicit) candidates = collections.defaultdict(list) + language_virtuals = ("c", "cxx", "fortran") + for node in spec.traverse(deptype=("link", "run")): - candidates["c"].extend(node.dependencies(virtuals=("c",))) - candidates["cxx"].extend(node.dependencies(virtuals=("c",))) - - if candidates["c"]: - self.compiler = candidates["c"][0] - if len(set(candidates["c"])) > 1: - warnings.warn( - f"{spec.short_spec} uses more than one compiler, and might not fit the " - f"LMod hierarchy. Using {self.compiler.short_spec} as the LMod compiler." - ) - - elif not candidates["c"]: - self.compiler = None + for language in language_virtuals: + candidates[language].extend(node.dependencies(virtuals=(language,))) + + self.compiler = None + + for language in language_virtuals: + if candidates[language]: + self.compiler = candidates[language][0] + if len(set(candidates[language])) > 1: + warnings.warn( + f"{spec.short_spec} uses more than one compiler, and might not fit the " + f"LMod hierarchy. Using {self.compiler.short_spec} as the LMod compiler." + ) + break @property def core_compilers(self) -> List[spack.spec.Spec]: diff --git a/lib/spack/spack/test/modules/lmod.py b/lib/spack/spack/test/modules/lmod.py index fb9d42cfc27810..e065c19a130e60 100644 --- a/lib/spack/spack/test/modules/lmod.py +++ b/lib/spack/spack/test/modules/lmod.py @@ -135,6 +135,16 @@ def test_compilers_provided_different_name( assert "compiler" in provides assert provides["compiler"] == spack.spec.Spec("intel-oneapi-compilers@=3.0") + @pytest.mark.parametrize("language", ["c", "cxx", "fortran"]) + def test_compiler_language_virtuals(self, factory, module_configuration, language): + """Tests all compiler virtuals for hierarchical module placement.""" + module_configuration("complex_hierarchy") + module, spec = factory(f"single-language-virtual +{language} %{language}=gcc@=10.2.1") + + requires = module.conf.requires + + assert "gcc@=10.2.1" in requires["compiler"] + def test_simple_case(self, modulefile_content, module_configuration): """Tests the generation of a simple Lua module file.""" diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/single_language_virtual/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/single_language_virtual/package.py new file mode 100644 index 00000000000000..451de0bf489976 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/single_language_virtual/package.py @@ -0,0 +1,24 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class SingleLanguageVirtual(Package): + """Package using a single language virtual for compilation""" + + homepage = "http://www.example.com" + url = "http://www.example.com/foo-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + variant("c", default=False) + variant("cxx", default=False) + variant("fortran", default=False) + + depends_on("c", when="+c") + depends_on("cxx", when="+cxx") + depends_on("fortran", when="+fortran") From 580bc2d8f3fb51e22ab936ecced002a27bfe8791 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 6 Mar 2026 18:00:10 +0100 Subject: [PATCH 119/337] color.py: fix @@ unescaping (#52034) Move @@ -> @ replacement into match_to_ansi's text branch so it applies inside `{...}` blocks; top-level `@@` is already handled by the regex. Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/tty/color.py | 3 +++ lib/spack/spack/test/llnl/util/tty/color.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lib/spack/spack/llnl/util/tty/color.py b/lib/spack/spack/llnl/util/tty/color.py index 290ca8d70c2cbc..539af5ae3a3e1d 100644 --- a/lib/spack/spack/llnl/util/tty/color.py +++ b/lib/spack/spack/llnl/util/tty/color.py @@ -265,6 +265,9 @@ def match_to_ansi(match) -> str: semi = ";" if color_number else "" ansi_code = _escape(f"{styles[style]}{semi}{color_number}", color, enclose, zsh) if text: + # must be here, not in the final return: top-level @@ is already handled by + # the regex, and its @-results could form new @@ pairs. + text = text.replace("@@", "@") return f"{ansi_code}{text}{_escape(0, color, enclose, zsh)}" else: return ansi_code diff --git a/lib/spack/spack/test/llnl/util/tty/color.py b/lib/spack/spack/test/llnl/util/tty/color.py index 14bef046a3edaf..7f752b6365c412 100644 --- a/lib/spack/spack/test/llnl/util/tty/color.py +++ b/lib/spack/spack/test/llnl/util/tty/color.py @@ -8,6 +8,7 @@ import pytest import spack.llnl.util.tty.color as color +from spack.llnl.util.tty.color import cescape, colorize, csub test_text = [ "@r{The quick brown fox jumps over the lazy yellow dog.", @@ -49,3 +50,23 @@ def test_color_wrap(cols, text, indent): # make sure we wrap the same as textwrap assert color.csub(color_wrapped) == wrapped assert plain_cwrapped == wrapped + + +def test_cescape_at_sign_roundtrip(): + """cescape followed by colorize should not double-escape '@' inside color blocks.""" + raw = 'if spec.satisfies("@:25.1"):' + colorized = colorize("@R{%s}" % cescape(raw), color=True) + assert csub(colorized) == raw + + +def test_cescape_multiple_at_signs_roundtrip(): + """Multiple consecutive '@' characters should survive a cescape/colorize roundtrip.""" + raw = "foo @@@@@bar" + colorized = colorize("@R{%s}" % cescape(raw), color=True) + assert csub(colorized) == raw + + +def test_colorize_top_level_consecutive_escaped_ats(): + """Consecutive @@ at the top level (outside braces) must each unescape independently.""" + assert colorize("@@@@", color=False) == "@@" + assert colorize("@@@@@@", color=False) == "@@@" From 7842cb42412b07df1a2fd9d0751b712be0b0635d Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sat, 7 Mar 2026 09:15:05 +0100 Subject: [PATCH 120/337] new_installer.py: read locks on installed deps (#52012) After writing an installed spec to the DB, downgrade the prefix write lock to a read lock instead of releasing it outright. This prevents another process from uninstalling the spec while the current process still depends on it. The read locks are collected in `retained_read_locks` and released on exit. Locks and resources are released best-effort under a single `finally` block after the event loop finishes. --- lib/spack/spack/new_installer.py | 226 ++++++++++++++++++-------- lib/spack/spack/test/new_installer.py | 81 +++++++++ 2 files changed, 242 insertions(+), 65 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 3b321f3b27b7a9..125aad0235caaa 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -39,7 +39,18 @@ from gzip import GzipFile from multiprocessing import Pipe, Process from multiprocessing.connection import Connection -from typing import TYPE_CHECKING, Callable, Dict, Generator, List, Optional, Set, Tuple, Union +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + Generator, + List, + NamedTuple, + Optional, + Set, + Tuple, + Union, +) from spack.vendor.typing_extensions import Literal @@ -1260,6 +1271,19 @@ def enqueue_parents(self, dag_hash: str, pending_builds: List[str]) -> None: pending_builds.append(parent) +class ScheduleResult(NamedTuple): + """Return value of :func:`schedule_builds`.""" + + #: True if any pending builds were blocked on locks held by other processes. + blocked: bool + #: ``(dag_hash, lock)`` pairs where the write lock is held and the caller must start the build + #: and eventually release the lock. + to_start: List[Tuple[str, spack.util.lock.Lock]] + #: ``(dag_hash, spec, lock)`` triples found already installed by another process; the read lock + #: is held and the caller must add it to retained_read_locks. + newly_installed: List[Tuple[str, spack.spec.Spec, spack.util.lock.Lock]] + + def schedule_builds( pending: List[str], build_graph: BuildGraph, @@ -1269,14 +1293,18 @@ def schedule_builds( capacity: int, needs_jobserver_token: bool, jobserver: JobServer, -) -> Tuple[bool, List[Tuple[str, spack.util.lock.Lock]], List[Tuple[str, spack.spec.Spec]]]: +) -> ScheduleResult: """Try to schedule as many pending builds as possible. - For each pending spec, attempts to acquire a non-blocking per-spec write lock. Under both the - DB read lock and the prefix write lock, checks whether another process has already installed - the spec. If so, captures it as newly_installed (caller enqueues parents) and releases the - lock. Otherwise, acquires a jobserver token if needed and adds the (dag_hash, lock) pair to - to_start (caller launches the build). + For each pending spec, attempts to acquire a non-blocking per-spec write lock. If the write + lock times out, a read lock is tried as a fallback: a successful read lock means the first + process finished and downgraded its write lock. If the DB confirms the spec is installed, it + is captured as newly_installed; if the DB says it is not installed, the concurrent process was + likely killed mid-build, and the spec is retried next iteration. Under both the DB read lock + and the prefix lock, checks whether another process has already installed the spec. If so, + captures it as newly_installed (caller enqueues parents) and keeps a read lock on the prefix + to prevent concurrent uninstall. Otherwise, acquires a jobserver token if needed and adds the + (dag_hash, lock) pair to to_start (caller launches the build). Args: pending: List of dag hashes pending installation; modified in-place. @@ -1289,14 +1317,11 @@ def schedule_builds( jobserver: Jobserver for acquiring tokens. Returns: - A (blocked, to_start, newly_installed) tuple where ``blocked`` is True if any pending - builds were blocked on locks; ``to_start`` contains ``(dag_hash, lock)`` pairs where the - write lock is held and the caller must start the build and eventually release the lock; - and ``newly_installed`` contains ``(dag_hash, spec)`` pairs found already installed by - another process for which the caller must update the UI and enqueue parents. + A :class:`ScheduleResult` with ``blocked``, ``to_start``, and ``newly_installed`` + fields; see :class:`ScheduleResult` for field semantics. """ to_start: List[Tuple[str, spack.util.lock.Lock]] = [] - newly_installed: List[Tuple[str, spack.spec.Spec]] = [] + newly_installed: List[Tuple[str, spack.spec.Spec, spack.util.lock.Lock]] = [] blocked = True # Acquire the DB read lock non-blocking; hold it throughout the loop so the in-memory snapshot @@ -1304,7 +1329,7 @@ def schedule_builds( try: db.lock.acquire_read(timeout=1e-9) except spack.util.lock.LockTimeoutError: - return blocked, to_start, newly_installed + return ScheduleResult(blocked, to_start, newly_installed) try: db._read() # refresh in-memory snapshot under the read lock @@ -1318,27 +1343,43 @@ def schedule_builds( try: lock.acquire_write(timeout=1e-9) blocked = False + have_write = True except spack.util.lock.LockTimeoutError: - # another process is building this spec; try the next one - idx += 1 - continue + # Write lock failed: either another process is actively building, or it + # finished and downgraded to a read lock. Try a read lock to find out. + try: + lock.acquire_read(timeout=1e-9) + except spack.util.lock.LockTimeoutError: + idx += 1 + continue # active build in progress; try the next spec + have_write = False - # Check installed status under the DB read lock and prefix write lock. + # Check installed status under the DB read lock and prefix lock. upstream, record = db.query_by_spec_hash(dag_hash) - # Don't schedule builds for specs from upstream databases. - assert not ( - upstream and record and not record.installed - ), f"Cannot install {spec}: it is uninstalled in an upstream database." - - # If the spec is already installed by another process, capture it and enqueue parents. + # If the spec is already installed, treat it as done regardless of lock type. if dag_hash not in overwrite and record and record.installed: - lock.release_write() + if have_write: + lock.downgrade_write_to_read() + # keep the read lock (either downgraded or already a read lock) del pending[idx] - newly_installed.append((dag_hash, spec)) + newly_installed.append((dag_hash, spec, lock)) build_graph.enqueue_parents(dag_hash, pending) continue + if not have_write: + # If have to install but only got a read lock, try it in next iteration of the + # event loop. + lock.release_read() + idx += 1 + continue + + # Write lock acquired: proceed with scheduling. + # Don't schedule builds for specs from upstream databases. + assert not ( + upstream and record and not record.installed + ), f"Cannot install {spec}: it is uninstalled in an upstream database." + # Acquire a jobserver token if needed. The first (implicit) job needs no token. if needs_jobserver_token and not jobserver.acquire(1): lock.release_write() @@ -1352,7 +1393,7 @@ def schedule_builds( finally: db.lock.release_read() - return blocked, to_start, newly_installed + return ScheduleResult(blocked, to_start, newly_installed) class PackageInstaller: @@ -1493,13 +1534,15 @@ def _handle_sigwinch(signum: int, frame: object) -> None: # Finished builds that have not yet been written to the database. finished_builds: List[ChildInfo] = [] + # Prefix read locks retained after DB flush (downgraded from write locks in _save_to_db). + retained_read_locks: List[spack.util.lock.Lock] = [] next_database_write = 0.0 failures: List[spack.spec.Spec] = [] try: # Try to schedule builds immediately. The first job does not require a token. - blocked = self._schedule_builds(selector, jobserver) + blocked = self._schedule_builds(selector, jobserver, retained_read_locks) while self.pending_builds or self.running_builds or finished_builds: # Monitor the jobserver when we have pending builds, capacity, and at least one @@ -1588,52 +1631,91 @@ def _handle_sigwinch(signum: int, frame: object) -> None: current_time >= next_database_write or not (self.pending_builds or self.running_builds) ) - and self._save_to_db(finished_builds) + and self._save_to_db(finished_builds, retained_read_locks) ): finished_builds.clear() # Try to schedule more builds, acquiring per-spec locks and jobserver tokens. if self.capacity and self.pending_builds: - blocked = self._schedule_builds(selector, jobserver) + blocked = self._schedule_builds(selector, jobserver, retained_read_locks) # Finally update the UI self.build_status.update() - except KeyboardInterrupt: - # Cleanup running builds. + finally: + # Flush any not-yet-written successful builds to the DB; save the exception on error + # to be re-raised after best-effort cleanup. + db_exc = None + try: + with self.db.write_transaction(): + for build in finished_builds: + self.db._add(build.spec, explicit=build.explicit) + except Exception as e: + db_exc = e + + # Send SIGTERM to running builds; this is a no-op in the successful case. for child in self.running_builds.values(): - child.proc.terminate() + try: + child.proc.terminate() + except Exception: + pass + + # Release our jobserver token for each terminated build and then join. for child in self.running_builds.values(): - jobserver.release() - child.proc.join() - raise - finally: - # Make sure to write any successful builds to the database before exiting - with self.db.write_transaction(): - for build in finished_builds: - self.db._add(build.spec, explicit=build.explicit) + try: + jobserver.release() + child.proc.join() + except Exception: + pass - # Release any prefix write locks that were not yet released via _save_to_db + # Release all held locks best-effort, so that one failure does not prevent the others + # from being released. + for child in self.running_builds.values(): + try: + if child.prefix_lock is not None: + child.prefix_lock.release_write() + child.prefix_lock = None + except Exception: + pass + for lock in retained_read_locks: + try: + lock.release_read() + except Exception: + pass for build in finished_builds: - if build.prefix_lock is not None: - build.prefix_lock.release_write() - build.prefix_lock = None + try: + if build.prefix_lock is not None: + build.prefix_lock.release_write() + build.prefix_lock = None + except Exception: + pass - # Restore terminal settings + # Terminal related cleanup if old_stdin_settings: - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_stdin_settings) + try: + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_stdin_settings) + except Exception: + pass if sigwinch_r >= 0: - signal.signal(signal.SIGWINCH, signal.SIG_DFL) - selector.unregister(sigwinch_r) - os.close(sigwinch_r) - os.close(sigwinch_w) - - # Clean up resources - # Final cleanup of any remaining finished packages before exit - self.build_status.overview_mode = True - self.build_status.update(finalize=True) - selector.close() - jobserver.close() + try: + signal.signal(signal.SIGWINCH, signal.SIG_DFL) + selector.unregister(sigwinch_r) + os.close(sigwinch_r) + os.close(sigwinch_w) + except Exception: + pass + + try: + self.build_status.overview_mode = True + self.build_status.update(finalize=True) + selector.close() + jobserver.close() + except Exception: + pass + + # Re-raise the DB exception if any. + if db_exc is not None: + raise db_exc if failures: for s in failures: @@ -1649,7 +1731,9 @@ def _handle_sigwinch(signum: int, frame: object) -> None: "The following packages failed to install:\n" + "\n".join(lines) ) - def _save_to_db(self, finished_builds: List[ChildInfo]) -> bool: + def _save_to_db( + self, finished_builds: List[ChildInfo], retained_read_locks: List[spack.util.lock.Lock] + ) -> bool: try: # Only try to get the lock once (non-blocking). If it fails, try it next time. if self.db.lock.acquire_write(timeout=1e-9): @@ -1662,16 +1746,27 @@ def _save_to_db(self, finished_builds: List[ChildInfo]) -> bool: finally: self.db.lock.release_write(self.db._write) - # DB has been written and flushed; release per-spec prefix write locks so other processes - # can see the specs are now installed and acquire their own locks. + # DB has been written and flushed; downgrade per-spec prefix write locks to read locks so + # other processes can see the specs are installed, while preventing concurrent uninstalls. for build in finished_builds: if build.prefix_lock is not None: - build.prefix_lock.release_write() - build.prefix_lock = None + try: + build.prefix_lock.downgrade_write_to_read() + retained_read_locks.append(build.prefix_lock) + except Exception: + build.prefix_lock.release_write() + raise + finally: + build.prefix_lock = None return True - def _schedule_builds(self, selector: selectors.BaseSelector, jobserver: JobServer) -> bool: + def _schedule_builds( + self, + selector: selectors.BaseSelector, + jobserver: JobServer, + retained_read_locks: List[spack.util.lock.Lock], + ) -> bool: """Try to schedule as many pending builds as possible. Delegates to the module-level schedule_builds() function and then performs the @@ -1695,7 +1790,8 @@ def _schedule_builds(self, selector: selectors.BaseSelector, jobserver: JobServe jobserver=jobserver, ) # Specs installed by another process. - for dag_hash, spec in newly_installed: + for dag_hash, spec, lock in newly_installed: + retained_read_locks.append(lock) self.build_status.add_build(spec, explicit=dag_hash in self.explicit) self.build_status.update_state(dag_hash, "finished") # Specs we can start building ourselves. diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index b1caecb32d9772..d298418d7f0e54 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -336,6 +336,8 @@ def test_already_installed_yields_newly_installed(self, temporary_store, mock_pa assert newly_installed[0][0] == spec.dag_hash() assert not pending # removed from the pending list finally: + for _, _, lock in newly_installed: + lock.release_read() jobserver.close() def test_no_jobserver_token_returns_empty(self, temporary_store, mock_packages): @@ -455,3 +457,82 @@ def always_timeout(timeout=None): for _, lock in to_start: lock.release_write() jobserver.close() + + def test_write_locked_read_locked_installed_yields_newly_installed( + self, temporary_store, mock_packages, monkeypatch + ): + """Write lock fails but read lock succeeds and spec is installed: treated as done. + + Simulates the case where another process finished building and downgraded its write lock + to a read lock. The spec should appear in newly_installed. blocked remains True because no + write lock was obtained, preventing the jobserver from firing unnecessarily. + """ + spec = self._make_spec("trivial-install-test-package") + self._mark_installed(spec, temporary_store) + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + lock = temporary_store.prefix_locker.lock(spec) + + def write_timeout(timeout=None): + raise spack.util.lock.LockTimeoutError("write", lock.path, 0, 1) + + monkeypatch.setattr(lock, "acquire_write", write_timeout) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + capacity=2, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert blocked # no write lock was obtained; jobserver should not fire + assert not to_start + assert len(newly_installed) == 1 + dag_hash, installed_spec, lock = newly_installed[0] + assert dag_hash == spec.dag_hash() + assert installed_spec == spec + assert not pending # spec was removed from pending + finally: + for _, _, lock in newly_installed: + lock.release_read() + jobserver.close() + + def test_write_locked_read_locked_not_installed_still_blocked( + self, temporary_store, mock_packages, monkeypatch + ): + """Write lock fails, read lock succeeds, but spec is not in DB: retry later. + + Simulates the case where a concurrent process was killed mid-build. The read lock is + released and the spec stays in pending; blocked should remain True. + """ + spec = self._make_spec("trivial-install-test-package") + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + lock = temporary_store.prefix_locker.lock(spec) + + def write_timeout(timeout=None): + raise spack.util.lock.LockTimeoutError("write", lock.path, 0, 1) + + monkeypatch.setattr(lock, "acquire_write", write_timeout) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + capacity=2, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert blocked + assert not to_start + assert not newly_installed + assert pending == [spec.dag_hash()] # spec stays in pending for retry + finally: + jobserver.close() From 66cec545482fe46267e400060e6db073a27debd1 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 9 Mar 2026 11:58:57 +0100 Subject: [PATCH 121/337] config.py: atomic writes to prevent parallel test races (#52041) Signed-off-by: Harmen Stoppels --- lib/spack/spack/config.py | 25 +++++++++++++++++-------- lib/spack/spack/test/conftest.py | 7 ++----- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index b1ed1fcd5d9ce7..7f074613f1f192 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -34,6 +34,7 @@ import pathlib import re import sys +import tempfile from collections import defaultdict from itertools import chain from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union @@ -270,8 +271,14 @@ def _write_section(self, section: str) -> None: try: filesystem.mkdirp(self.path) - with open(filename, "w", encoding="utf-8") as f: - syaml.dump_config(data, stream=f, default_flow_style=False) + fd, tmp = tempfile.mkstemp(dir=self.path, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + syaml.dump_config(data, stream=f, default_flow_style=False) + filesystem.rename(tmp, filename) + except Exception: + os.unlink(tmp) + raise except (syaml.SpackYAMLError, OSError) as e: raise ConfigFileError(f"cannot write to '{filename}'") from e @@ -409,12 +416,14 @@ def _write_section(self, section: str) -> None: try: parent = os.path.dirname(self.path) filesystem.mkdirp(parent) - - tmp = os.path.join(parent, f".{os.path.basename(self.path)}.tmp") - with open(tmp, "w", encoding="utf-8") as f: - syaml.dump_config(data_to_write, stream=f, default_flow_style=False) - filesystem.rename(tmp, self.path) - + fd, tmp = tempfile.mkstemp(dir=parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + syaml.dump_config(data_to_write, stream=f, default_flow_style=False) + filesystem.rename(tmp, self.path) + except Exception: + os.unlink(tmp) + raise except (syaml.SpackYAMLError, OSError) as e: raise ConfigFileError(f"cannot write to config file {str(e)}") from e diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index b09a73984bcba1..c5ef19420b092b 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -1410,21 +1410,18 @@ def module_configuration(monkeypatch, request, mutable_config): @pytest.fixture() -def mock_gnupghome(monkeypatch, tmp_path): +def mock_gnupghome(monkeypatch): # GNU PGP can't handle paths longer than 108 characters (wtf!@#$) so we # have to make our own tmp_path with a shorter name than pytest's. # This comes up because tmp paths on macOS are already long-ish, and # pytest makes them longer. - short_name_tmpdir = tempfile.mkdtemp() - # Redirect bootstrap root before gpg.init() so each xdist worker writes - # bootstrap config to its own isolated directory. - monkeypatch.setattr(spack.paths, "default_user_bootstrap_path", str(tmp_path / "bootstrap")) try: spack.util.gpg.init() except spack.util.gpg.SpackGPGError: if not spack.util.gpg.GPG: pytest.skip("This test requires gpg") + short_name_tmpdir = tempfile.mkdtemp() with spack.util.gpg.gnupghome_override(short_name_tmpdir): yield short_name_tmpdir From f0731aed9aef1e683707eef2f582cc385d7cf0b0 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 9 Mar 2026 12:00:02 +0100 Subject: [PATCH 122/337] new_installer.py: concurrent overwrite installs (#52013) If two concurrent install processes do an overwrite install of the same spec, the second one now realizes that the first one has overwritten the install by comparing against the timestamp at which the installer was started. Mimics the older installer. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 15 ++++++++++- lib/spack/spack/test/new_installer.py | 37 +++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 125aad0235caaa..5c5cdfe703fa43 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1290,6 +1290,7 @@ def schedule_builds( db: spack.database.Database, prefix_locker: spack.database.SpecLocker, overwrite: Set[str], + overwrite_time: float, capacity: int, needs_jobserver_token: bool, jobserver: JobServer, @@ -1312,6 +1313,9 @@ def schedule_builds( db: Package database; used for read lock and installed-status queries. prefix_locker: Per-spec write locker. overwrite: Set of dag hashes to overwrite even if already installed. + overwrite_time: Timestamp (from time.time()) at which the overwrite install was requested. + A spec in ``overwrite`` whose DB installation_time >= overwrite_time was installed by + a concurrent process after our request started and should be treated as done. capacity: Maximum number of new builds to add to to_start in this call. needs_jobserver_token: True if a jobserver token is required for the first new build. jobserver: Jobserver for acquiring tokens. @@ -1358,7 +1362,13 @@ def schedule_builds( upstream, record = db.query_by_spec_hash(dag_hash) # If the spec is already installed, treat it as done regardless of lock type. - if dag_hash not in overwrite and record and record.installed: + # A spec in the overwrite set is also treated as done if another process installed it + # after our overwrite request was created (installation_time >= overwrite_time). + if ( + record + and record.installed + and (dag_hash not in overwrite or record.installation_time >= overwrite_time) + ): if have_write: lock.downgrade_write_to_read() # keep the read lock (either downgraded or already a read lock) @@ -1446,6 +1456,8 @@ def __init__( self.include_build_deps = include_build_deps #: Set of DAG hashes to overwrite (if already installed) self.overwrite: Set[str] = set(overwrite) if overwrite else set() + #: Time at which the overwrite install was requested; used to detect concurrent overwrites. + self.overwrite_time: float = time.time() self.keep_prefix = keep_prefix self.fail_fast = fail_fast @@ -1785,6 +1797,7 @@ def _schedule_builds( db=self.db, prefix_locker=spack.store.STORE.prefix_locker, overwrite=self.overwrite, + overwrite_time=self.overwrite_time, capacity=self.capacity, needs_jobserver_token=bool(self.running_builds), jobserver=jobserver, diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index d298418d7f0e54..c214588f19699d 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -5,6 +5,7 @@ import pathlib import sys +import time import pytest @@ -298,6 +299,7 @@ def test_not_installed_no_running_starts_build(self, temporary_store, mock_packa temporary_store.db, temporary_store.prefix_locker, overwrite=set(), + overwrite_time=0.0, capacity=1, needs_jobserver_token=False, jobserver=jobserver, @@ -326,6 +328,7 @@ def test_already_installed_yields_newly_installed(self, temporary_store, mock_pa temporary_store.db, temporary_store.prefix_locker, overwrite=set(), + overwrite_time=0.0, capacity=1, needs_jobserver_token=False, jobserver=jobserver, @@ -354,6 +357,7 @@ def test_no_jobserver_token_returns_empty(self, temporary_store, mock_packages): temporary_store.db, temporary_store.prefix_locker, overwrite=set(), + overwrite_time=0.0, capacity=2, needs_jobserver_token=True, jobserver=jobserver, @@ -385,6 +389,7 @@ def always_timeout(timeout=None): temporary_store.db, temporary_store.prefix_locker, overwrite=set(), + overwrite_time=0.0, capacity=2, needs_jobserver_token=False, jobserver=jobserver, @@ -410,6 +415,7 @@ def test_overwrite_installed_spec_is_started(self, temporary_store, mock_package temporary_store.db, temporary_store.prefix_locker, overwrite={spec.dag_hash()}, + overwrite_time=time.time() + 100, capacity=1, needs_jobserver_token=False, jobserver=jobserver, @@ -444,6 +450,7 @@ def always_timeout(timeout=None): temporary_store.db, temporary_store.prefix_locker, overwrite=set(), + overwrite_time=0.0, capacity=2, needs_jobserver_token=False, jobserver=jobserver, @@ -485,6 +492,7 @@ def write_timeout(timeout=None): temporary_store.db, temporary_store.prefix_locker, overwrite=set(), + overwrite_time=0.0, capacity=2, needs_jobserver_token=False, jobserver=jobserver, @@ -526,6 +534,7 @@ def write_timeout(timeout=None): temporary_store.db, temporary_store.prefix_locker, overwrite=set(), + overwrite_time=0.0, capacity=2, needs_jobserver_token=False, jobserver=jobserver, @@ -536,3 +545,31 @@ def write_timeout(timeout=None): assert pending == [spec.dag_hash()] # spec stays in pending for retry finally: jobserver.close() + + def test_overwrite_handled_by_concurrent_process(self, temporary_store, mock_packages): + """When a spec in overwrite was installed AFTER overwrite_time, another process did it.""" + spec = self._make_spec("trivial-install-test-package") + self._mark_installed(spec, temporary_store) # installation_time = now() + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + try: + blocked, to_start, newly_installed = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite={spec.dag_hash()}, + overwrite_time=0.0, # earlier than now() + capacity=1, + needs_jobserver_token=False, + jobserver=jobserver, + ) + assert not blocked + assert not to_start + assert len(newly_installed) == 1 + assert newly_installed[0][0] == spec.dag_hash() + finally: + for _, _, lock in newly_installed: + lock.release_read() + jobserver.close() From b47619603d05fa41a20278af0ac190b2fd7b2540 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 9 Mar 2026 12:52:03 +0100 Subject: [PATCH 123/337] ci: try new installer in bootstrap.yml (#51784) Signed-off-by: Harmen Stoppels --- .github/workflows/bootstrap.yml | 12 ++++++++---- lib/spack/spack/bootstrap/core.py | 12 ++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index a4952804d01298..4b0177031bd1a7 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -44,9 +44,10 @@ jobs: - name: Bootstrap clingo run: | . share/spack/setup-env.sh + spack config add config:installer:new spack bootstrap disable github-actions-v2 spack bootstrap disable github-actions-v0.6 - spack -d solve zlib + spack solve zlib tree ~/.spack/bootstrap/store/ clingo-sources: @@ -69,10 +70,11 @@ jobs: - name: Bootstrap clingo run: | . share/spack/setup-env.sh + spack config add config:installer:new spack bootstrap disable github-actions-v2 spack bootstrap disable github-actions-v0.6 export PATH="$(brew --prefix bison)/bin:$(brew --prefix cmake)/bin:$PATH" - spack -d solve zlib + spack solve zlib tree ~/.spack/bootstrap/store/ gnupg-sources: @@ -97,10 +99,11 @@ jobs: - name: Bootstrap GnuPG run: | . share/spack/setup-env.sh + spack config add config:installer:new spack solve zlib spack bootstrap disable github-actions-v2 spack bootstrap disable github-actions-v0.6 - spack -d gpg list + spack gpg list tree ~/.spack/bootstrap/store/ from-binaries: @@ -153,7 +156,8 @@ jobs: - name: Bootstrap GnuPG run: | . share/spack/setup-env.sh - spack -d gpg list + spack config add config:installer:new + spack gpg list tree ~/.spack/bootstrap/store/ windows: diff --git a/lib/spack/spack/bootstrap/core.py b/lib/spack/spack/bootstrap/core.py index 025088256acdb8..7a788108894f7b 100644 --- a/lib/spack/spack/bootstrap/core.py +++ b/lib/spack/spack/bootstrap/core.py @@ -44,7 +44,6 @@ import spack.util.spack_yaml import spack.util.url import spack.version -from spack.installer import PackageInstaller from spack.llnl.util import tty from spack.llnl.util.lang import GroupedExceptionHandler @@ -291,6 +290,11 @@ def try_import(self, module: str, abstract_spec_str: str) -> bool: # Install the spec that should make the module importable with spack.config.override(self.mirror_scope): + if spack.config.get("config:installer", "old") == "new": + from spack.new_installer import PackageInstaller # type: ignore + else: + from spack.installer import PackageInstaller # type: ignore + PackageInstaller( [concrete_spec.package], fail_fast=True, @@ -319,7 +323,11 @@ def try_search_path(self, executables: Tuple[str], abstract_spec_str: str) -> bo msg = "[BOOTSTRAP] Try installing '{0}' from sources" tty.debug(msg.format(abstract_spec_str)) with spack.config.override(self.mirror_scope): - PackageInstaller([concrete_spec.package], fail_fast=True).install() + if spack.config.get("config:installer", "old") == "new": + from spack.new_installer import PackageInstaller # type: ignore + else: + from spack.installer import PackageInstaller # type: ignore + PackageInstaller([concrete_spec.package]).install() if _executables_in_store(executables, concrete_spec, query_info=info): self.last_search = info return True From ec3367bb060951135990a43afd75895986e96387 Mon Sep 17 00:00:00 2001 From: Xavier Delaruelle Date: Mon, 9 Mar 2026 18:41:17 +0100 Subject: [PATCH 124/337] Fix some typos found with codespell (#52038) Signed-off-by: Xavier Delaruelle --- lib/spack/spack/binary_distribution.py | 2 +- lib/spack/spack/build_environment.py | 2 +- lib/spack/spack/buildcache_migrate.py | 2 +- lib/spack/spack/buildcache_prune.py | 2 +- lib/spack/spack/ci/__init__.py | 2 +- lib/spack/spack/ci/common.py | 2 +- lib/spack/spack/cmd/__init__.py | 4 ++-- lib/spack/spack/cmd/buildcache.py | 6 +++--- lib/spack/spack/cmd/ci.py | 2 +- lib/spack/spack/cmd/create.py | 8 ++++---- lib/spack/spack/cmd/develop.py | 2 +- lib/spack/spack/cmd/env.py | 8 ++++---- lib/spack/spack/cmd/installer/CMakeLists.txt | 4 ++-- lib/spack/spack/cmd/installer/README.md | 2 +- lib/spack/spack/cmd/repo.py | 4 ++-- lib/spack/spack/cmd/style.py | 2 +- lib/spack/spack/cmd/tags.py | 2 +- lib/spack/spack/cmd/test.py | 2 +- lib/spack/spack/cmd/verify.py | 2 +- lib/spack/spack/config.py | 2 +- lib/spack/spack/deptypes.py | 2 +- lib/spack/spack/detection/common.py | 2 +- lib/spack/spack/filesystem_view.py | 8 ++++---- lib/spack/spack/llnl/util/filesystem.py | 2 +- lib/spack/spack/llnl/util/tty/color.py | 2 +- lib/spack/spack/llnl/util/tty/log.py | 2 +- .../spack/operating_systems/windows_os.py | 2 +- lib/spack/spack/package_base.py | 8 ++++---- lib/spack/spack/platforms/_platform.py | 2 +- lib/spack/spack/relocate.py | 2 +- lib/spack/spack/repo.py | 4 ++-- lib/spack/spack/reporters/cdash.py | 4 ++-- lib/spack/spack/schema/__init__.py | 2 +- lib/spack/spack/solver/asp.py | 18 +++++++++--------- lib/spack/spack/solver/concretize.lp | 4 ++-- lib/spack/spack/spec.py | 4 ++-- lib/spack/spack/stage.py | 4 ++-- lib/spack/spack/test/build_environment.py | 2 +- lib/spack/spack/test/cc.py | 2 +- lib/spack/spack/test/cmd/buildcache.py | 6 +++--- lib/spack/spack/test/cmd/ci.py | 2 +- lib/spack/spack/test/cmd/commands.py | 2 +- lib/spack/spack/test/cmd/diff.py | 2 +- lib/spack/spack/test/cmd/install.py | 2 +- lib/spack/spack/test/cmd/repo.py | 2 +- lib/spack/spack/test/cmd_extensions.py | 2 +- lib/spack/spack/test/concretization/core.py | 10 +++++----- .../spack/test/concretization/requirements.py | 2 +- lib/spack/spack/test/config.py | 6 +++--- lib/spack/spack/test/conftest.py | 14 +++++++------- .../test/data/directory_search/README.txt | 2 +- lib/spack/spack/test/installer.py | 2 +- lib/spack/spack/test/oci/image.py | 2 +- lib/spack/spack/test/oci/urlopen.py | 4 ++-- lib/spack/spack/test/relocate_text.py | 2 +- lib/spack/spack/test/sbang.py | 6 +++--- lib/spack/spack/test/spec_dag.py | 2 +- lib/spack/spack/test/spec_semantics.py | 4 ++-- lib/spack/spack/test/traverse.py | 6 +++--- lib/spack/spack/test/util/editor.py | 2 +- lib/spack/spack/test/util/path.py | 2 +- lib/spack/spack/test/util/spack_yaml.py | 2 +- lib/spack/spack/test/util/timer.py | 2 +- lib/spack/spack/test/variant.py | 2 +- lib/spack/spack/test/versions.py | 2 +- lib/spack/spack/traverse.py | 12 ++++++------ lib/spack/spack/url.py | 4 ++-- lib/spack/spack/url_buildcache.py | 4 ++-- lib/spack/spack/util/archive.py | 6 +++--- lib/spack/spack/util/compression.py | 4 ++-- lib/spack/spack/util/cpus.py | 2 +- lib/spack/spack/util/crypto.py | 2 +- lib/spack/spack/util/elf.py | 2 +- lib/spack/spack/util/environment.py | 4 ++-- lib/spack/spack/util/module_cmd.py | 2 +- lib/spack/spack/util/package_hash.py | 4 ++-- lib/spack/spack/util/path.py | 4 ++-- lib/spack/spack/util/spack_yaml.py | 2 +- lib/spack/spack/util/windows_registry.py | 2 +- lib/spack/spack/verify_libraries.py | 2 +- lib/spack/spack/version/version_types.py | 2 +- share/spack/bash/spack-completion.bash | 4 ++-- share/spack/setup-env.ps1 | 2 +- share/spack/spack-completion.bash | 4 ++-- share/spack/spack-completion.fish | 6 +++--- .../packages/attributes_foo/package.py | 4 ++-- .../autotools_config_replacement/package.py | 4 ++-- .../builtin_mock/packages/boost/package.py | 2 +- .../conditionally_patch_dependency/package.py | 2 +- .../builtin_mock/packages/patch/package.py | 2 +- .../packages/patch_a_dependency/package.py | 2 +- .../patch_several_dependencies/package.py | 2 +- .../tutorial/packages/hdf5/package.py | 2 +- 93 files changed, 162 insertions(+), 162 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index e36a1574dd035f..2d4f6728f58d92 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -247,7 +247,7 @@ def _associate_built_specs_with_mirror(self, cache_key, mirror_metadata: MirrorM spec_list = [ s for s in db.query_local(installed=InstallRecordStatus.ANY) - # todo, make it easer to get install records associated with specs + # todo, make it easier to get install records associated with specs if s.external or db._data[s.dag_hash()].in_buildcache ] diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py index 24591e6a351913..b41412e6f281c8 100644 --- a/lib/spack/spack/build_environment.py +++ b/lib/spack/spack/build_environment.py @@ -14,7 +14,7 @@ This is how things are set up when install() is called. Spack takes advantage of each package being in its own module by adding a bunch of command-like functions (like configure(), make(), etc.) in - the package's module scope. Ths allows package writers to call + the package's module scope. This allows package writers to call them all directly in Package.install() without writing 'self.' everywhere. No, this isn't Pythonic. Yes, it makes the code more readable and more like the shell script from which someone is diff --git a/lib/spack/spack/buildcache_migrate.py b/lib/spack/spack/buildcache_migrate.py index 0782bba17f4525..663996b867853e 100644 --- a/lib/spack/spack/buildcache_migrate.py +++ b/lib/spack/spack/buildcache_migrate.py @@ -295,7 +295,7 @@ def migrate( specs_to_migrate = [ s for s in db.query_local(installed=InstallRecordStatus.ANY) - # todo, make it easer to get install records associated with specs + # todo, make it easier to get install records associated with specs if not s.external and db._data[s.dag_hash()].in_buildcache ] diff --git a/lib/spack/spack/buildcache_prune.py b/lib/spack/spack/buildcache_prune.py index b5d3a144e5176f..e695f10248c333 100644 --- a/lib/spack/spack/buildcache_prune.py +++ b/lib/spack/spack/buildcache_prune.py @@ -463,7 +463,7 @@ def get_buildcache_normalized_time(mirror: Mirror) -> float: This is necessary because different buildcache implementations may use different time formats/time zones. This function creates a temporary file, calls `stat_url` - on it, and then deletes it. This guarentees that the time used for the beginning + on it, and then deletes it. This guarantees that the time used for the beginning of the pruning is consistent across all buildcache implementations. """ with tempfile.TemporaryDirectory(dir=spack.stage.get_stage_root()) as f: diff --git a/lib/spack/spack/ci/__init__.py b/lib/spack/spack/ci/__init__.py index e6bd5583d0121f..37fd6c0c7437e8 100644 --- a/lib/spack/spack/ci/__init__.py +++ b/lib/spack/spack/ci/__init__.py @@ -989,7 +989,7 @@ def reproduce_ci_job(url, work_dir, autostart, gpg_url, runtime, use_local_head) # Regular expressions for parsing that HEAD commit. If the pipeline # was on the gitlab spack mirror, it will have been a merge commit made by - # gitub and pushed by the sync script. If the pipeline was run on some + # github and pushed by the sync script. If the pipeline was run on some # environment repo, then the tested spack commit will likely have been # a regular commit. commit_1 = None diff --git a/lib/spack/spack/ci/common.py b/lib/spack/spack/ci/common.py index 26b75f59b47988..4b7b016b3c2e30 100644 --- a/lib/spack/spack/ci/common.py +++ b/lib/spack/spack/ci/common.py @@ -543,7 +543,7 @@ def __job_name(name, suffix=""): return jname def __apply_submapping(self, dest, spec, section): - """Apply submapping setion to the IR dict""" + """Apply submapping section to the IR dict""" matched = False only_first = section.get("match_behavior", "first") == "first" diff --git a/lib/spack/spack/cmd/__init__.py b/lib/spack/spack/cmd/__init__.py index 3f621e902b66b6..d642711a1820b9 100644 --- a/lib/spack/spack/cmd/__init__.py +++ b/lib/spack/spack/cmd/__init__.py @@ -760,7 +760,7 @@ def group_arguments( prefix_length: length of any additional arguments (including spaces) to be passed before the groups from args; default is 0 characters max_group_length: max length of characters that if a group of args is joined by ``" "`` - On unix, ths defaults to SC_ARG_MAX from sysconf. On Windows the default is + On unix, this defaults to SC_ARG_MAX from sysconf. On Windows the default is the max usable for CreateProcess (32,768 chars) """ @@ -770,7 +770,7 @@ def group_arguments( max_group_length = 32766 if hasattr(os, "sysconf"): # sysconf is only on unix try: - # returns -1 if an option isn't present (soem older POSIXes) + # returns -1 if an option isn't present (some older POSIXes) sysconf_max = os.sysconf("SC_ARG_MAX") max_group_length = sysconf_max if sysconf_max != -1 else max_group_length except (ValueError, OSError): diff --git a/lib/spack/spack/cmd/buildcache.py b/lib/spack/spack/cmd/buildcache.py index f102806b6948f0..12bc0806168bd8 100644 --- a/lib/spack/spack/cmd/buildcache.py +++ b/lib/spack/spack/cmd/buildcache.py @@ -332,7 +332,7 @@ def setup_parser(subparser: argparse.ArgumentParser): "-a", action="store_true", help="Append the listed specs to the current view index if it already exists. " - "This operation does not guarentee atomic write and should be run with care.", + "This operation does not guarantee atomic write and should be run with care.", ) update_index_view_mode_args.add_argument( "--force", @@ -757,7 +757,7 @@ def sync_fn(args): # specified, the second is ignored and the first is the override # destination. if args.dest_mirror: - tty.warn(f"Ignoring unused arguemnt: {args.dest_mirror.name}") + tty.warn(f"Ignoring unused argument: {args.dest_mirror.name}") manifest_copy(glob.glob(args.manifest_glob), args.src_mirror) return 0 @@ -796,7 +796,7 @@ def manifest_copy( manifest_file_list: List[str], dest_mirror: Optional[spack.mirrors.mirror.Mirror] = None ): """Read manifest files containing information about specific specs to copy - from source to destination, remove duplicates since any binary packge for + from source to destination, remove duplicates since any binary package for a given hash should be the same as any other, and copy all files specified in the manifest files.""" deduped_manifest = {} diff --git a/lib/spack/spack/cmd/ci.py b/lib/spack/spack/cmd/ci.py index 13ae4509793e36..83ec401eee9d3c 100644 --- a/lib/spack/spack/cmd/ci.py +++ b/lib/spack/spack/cmd/ci.py @@ -140,7 +140,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: help="path to the root of the artifacts directory\n\n" "The spack ci module assumes it will normally be run from within your project " "directory, wherever that is checked out to run your ci. The artifacts root directory " - "should specifiy a name that can safely be used for artifacts within your project " + "should specify a name that can safely be used for artifacts within your project " "directory.", ) generate.add_argument( diff --git a/lib/spack/spack/cmd/create.py b/lib/spack/spack/cmd/create.py index 997d72958cb7a8..74db857d36b343 100644 --- a/lib/spack/spack/cmd/create.py +++ b/lib/spack/spack/cmd/create.py @@ -327,7 +327,7 @@ def install(self, spec, prefix): class RacketPackageTemplate(PackageTemplate): - """Provides approriate overrides for Racket extensions""" + """Provides appropriate overrides for Racket extensions""" base_class_name = "RacketPackage" package_class_import = "from spack_repo.builtin.build_systems.racket import RacketPackage" @@ -340,7 +340,7 @@ class RacketPackageTemplate(PackageTemplate): dependencies = """\ # FIXME: Add dependencies if required. Only add the racket dependency # if you need specific versions. A generic racket dependency is - # added implicity by the RacketPackage class. + # added implicitly by the RacketPackage class. # with default_args(type=("build", "run")): # depends_on("racket@8.3:")""" @@ -374,7 +374,7 @@ class PythonPackageTemplate(PackageTemplate): dependencies = """\ # FIXME: Only add the python/pip/wheel dependencies if you need specific versions # or need to change the dependency type. Generic python/pip/wheel dependencies are - # added implicity by the PythonPackage base class. + # added implicitly by the PythonPackage base class. # depends_on("python@2.X:2.Y,3.Z:", type=("build", "run")) # depends_on("py-pip@X.Y:", type="build") # depends_on("py-wheel@X.Y:", type="build") @@ -570,7 +570,7 @@ class RubyPackageTemplate(PackageTemplate): dependencies = """\ # FIXME: Add dependencies if required. Only add the ruby dependency # if you need specific versions. A generic ruby dependency is - # added implicity by the RubyPackage class. + # added implicitly by the RubyPackage class. # with default_args(type=("build", "run")): # depends_on("ruby@X.Y.Z:") # depends_on("ruby-foo")""" diff --git a/lib/spack/spack/cmd/develop.py b/lib/spack/spack/cmd/develop.py index ec29462dbdfa33..b1604f62eb17ee 100644 --- a/lib/spack/spack/cmd/develop.py +++ b/lib/spack/spack/cmd/develop.py @@ -108,7 +108,7 @@ def assure_concrete_spec(env: spack.environment.Environment, spec: spack.spec.Sp if not m_spec.satisfies(test_spec): raise SpackError( f"{spec.name}: has multiple concrete instances in the graph that can't be" - " satisified by a single develop spec. To use `spack develop` ensure one" + " satisfied by a single develop spec. To use `spack develop` ensure one" " of the following:" f"\n a) {spec.name} nodes can satisfy the same develop spec (minimally " "this means they all share the same version)" diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py index aba222de4fd2a4..a1e59b9bf5aea6 100644 --- a/lib/spack/spack/cmd/env.py +++ b/lib/spack/spack/cmd/env.py @@ -547,7 +547,7 @@ def _env_untrack_or_remove( else: env_names_to_remove = known_env_names - # initalize all environments with valid spack.yaml configs + # initialize all environments with valid spack.yaml configs all_valid_envs = get_valid_envs(all_env_names) # build a task list of environments and bad env names to remove @@ -569,7 +569,7 @@ def _env_untrack_or_remove( envs_to_remove.remove(remove_env) # ask the user if they really want to remove the known environments - # force should do the same as yes to all here following the symantics of rm + # force should do the same as yes to all here following the semantics of rm if not (yes_to_all or force) and (envs_to_remove or bad_env_names_to_remove): environments = string.plural(len(env_names_to_remove), "environment", show_n=False) envs = string.comma_and(list(env_names_to_remove)) @@ -595,7 +595,7 @@ def _env_untrack_or_remove( real_env_path = os.path.realpath(env.path) os.unlink(env.path) tty.msg( - f"Sucessfully untracked environment '{name}', " + f"Successfully untracked environment '{name}', " "but it can still be found at:\n\n" f" {real_env_path}\n" ) @@ -615,7 +615,7 @@ def _env_untrack_or_remove( # Following the design of linux rm we should exit with a status of 1 # anytime we cannot delete every environment the user asks for. # However, we should still process all the environments we know about - # and delete them instead of failing on the first unknown enviornment. + # and delete them instead of failing on the first unknown environment. if len(removed_env_names) < len(known_env_names): sys.exit(1) diff --git a/lib/spack/spack/cmd/installer/CMakeLists.txt b/lib/spack/spack/cmd/installer/CMakeLists.txt index efa9f2b6df9711..0c9e5638fb6556 100644 --- a/lib/spack/spack/cmd/installer/CMakeLists.txt +++ b/lib/spack/spack/cmd/installer/CMakeLists.txt @@ -11,7 +11,7 @@ if (SPACK_VERSION) set(SPACK_FILENAME "spack-${SPACK_VERSION}.tar.gz") set(SPACK_DIR "spack-${SPACK_VERSION}") - # SPACK DOWLOAD AND EXTRACTION----------------------------------- + # SPACK DOWNLOAD AND EXTRACTION----------------------------------- file(DOWNLOAD "${SPACK_DL}/${SPACK_FILENAME}" "${CMAKE_CURRENT_BINARY_DIR}/${SPACK_FILENAME}" STATUS download_status @@ -54,7 +54,7 @@ endif() message(STATUS "Successfully downloaded ${GIT_FILENAME}") -# PYTHON DOWLOAD AND EXTRACTION----------------------------------- +# PYTHON DOWNLOAD AND EXTRACTION----------------------------------- file(DOWNLOAD "${PY_DOWNLOAD_LINK}/${PY_FILENAME}" "${CMAKE_CURRENT_BINARY_DIR}/${PY_FILENAME}" STATUS download_status diff --git a/lib/spack/spack/cmd/installer/README.md b/lib/spack/spack/cmd/installer/README.md index 602c594f093671..53566871d7168a 100644 --- a/lib/spack/spack/cmd/installer/README.md +++ b/lib/spack/spack/cmd/installer/README.md @@ -73,7 +73,7 @@ install. When given the option of adjusting your ``PATH``, choose the ``Git from the command line and also from 3rd-party software`` option. This will automatically update your ``PATH`` variable to include the ``git`` command. Certain Spack commands expect ``git`` to be part of the ``PATH``. If this step -is not performed properly, certain Spack comands will not work. +is not performed properly, certain Spack commands will not work. If your Spack installation needs to be modified, repaired, or uninstalled, you can do any of these things by rerunning ``Spack.exe``. diff --git a/lib/spack/spack/cmd/repo.py b/lib/spack/spack/cmd/repo.py index 50ca50874227fe..58b929f61195ff 100644 --- a/lib/spack/spack/cmd/repo.py +++ b/lib/spack/spack/cmd/repo.py @@ -288,7 +288,7 @@ def _remove_repo(namespace_or_path, scope): for name, descriptor in descriptors.items(): descriptor.initialize(fetch=False) - # For now you cannot delete monorepos with multipe package repositories from config, + # For now you cannot delete monorepos with multiple package repositories from config, # hence "all" and not "any". We can improve this later if needed. if all( r.namespace == namespace_or_path or r.root == canon_path @@ -613,7 +613,7 @@ def repo_update(args: Any) -> int: ) else: - tty.msg(f"{name}: Updated sucessfully.") + tty.msg(f"{name}: Updated successfully.") if active_flag: spack.config.set("repos", scope_repos, args.scope) diff --git a/lib/spack/spack/cmd/style.py b/lib/spack/spack/cmd/style.py index ca843ca7951689..d7b8fe09380343 100644 --- a/lib/spack/spack/cmd/style.py +++ b/lib/spack/spack/cmd/style.py @@ -220,7 +220,7 @@ def cwd_relative(path, root, initial_working_dir): def rewrite_and_print_output( output, args, re_obj=re.compile(r"^(.+):([0-9]+):"), replacement=r"{0}:{1}:" ): - """rewrite ouput with :: format to respect path args""" + """rewrite output with :: format to respect path args""" # print results relative to current working directory def translate(match): diff --git a/lib/spack/spack/cmd/tags.py b/lib/spack/spack/cmd/tags.py index 39e6166c18a369..7b71376ca62768 100644 --- a/lib/spack/spack/cmd/tags.py +++ b/lib/spack/spack/cmd/tags.py @@ -93,7 +93,7 @@ def tags(parser, args): tag_pkgs = packages_with_tags(tags, args.installed, False) missing = "No installed packages" if args.installed else "None" for tag in sorted(tag_pkgs): - # TODO: Remove the sorting once we're sure noone has an old + # TODO: Remove the sorting once we're sure no one has an old # TODO: tag cache since it can accumulate duplicates. packages = sorted(list(set(tag_pkgs[tag]))) if isatty: diff --git a/lib/spack/spack/cmd/test.py b/lib/spack/spack/cmd/test.py index 3b2085e68a89a9..0c4736045dac4e 100644 --- a/lib/spack/spack/cmd/test.py +++ b/lib/spack/spack/cmd/test.py @@ -132,7 +132,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: "Test results will be filtered by space-" "separated suite name(s) and installed\nspecs when provided. " "If names are provided, then only results for those test\nsuites " - "will be shown. If installed specs are provided, then ony results" + "will be shown. If installed specs are provided, then only results" "\nmatching those specs will be shown." ) diff --git a/lib/spack/spack/cmd/verify.py b/lib/spack/spack/cmd/verify.py index 051757d9849ff6..81cfec77692d75 100644 --- a/lib/spack/spack/cmd/verify.py +++ b/lib/spack/spack/cmd/verify.py @@ -34,7 +34,7 @@ def setup_parser(subparser: argparse.ArgumentParser): "-l", "--local", action="store_true", help="verify only locally installed packages" ) MANIFEST_SUBPARSER.add_argument( - "-j", "--json", action="store_true", help="ouptut json-formatted errors" + "-j", "--json", action="store_true", help="output json-formatted errors" ) MANIFEST_SUBPARSER.add_argument("-a", "--all", action="store_true", help="verify all packages") MANIFEST_SUBPARSER.add_argument( diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 7f074613f1f192..1c5a90e8c82303 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -2154,7 +2154,7 @@ def __init__( super().__init__(message) def _get_mark(self, validation_error, data): - """Get the file/line mark fo a validation error from a Spack YAML file.""" + """Get the file/line mark for a validation error from a Spack YAML file.""" # Try various places, starting with instance and parent for obj in (validation_error.instance, validation_error.parent): diff --git a/lib/spack/spack/deptypes.py b/lib/spack/spack/deptypes.py index 0ebaaa2a21cbfe..470d8c4b27ca70 100644 --- a/lib/spack/spack/deptypes.py +++ b/lib/spack/spack/deptypes.py @@ -148,7 +148,7 @@ def flag_to_chars(depflag: DepFlag) -> str: For a single dependency, this just indicates that the dependency has the indicated deptypes. For a list of dependnecies, this shows - whether ANY dpeendency in the list has the deptypes (so the deptypes + whether ANY dependency in the list has the deptypes (so the deptypes are merged).""" return "".join( t_str[0] if t_flag & depflag else " " for t_str, t_flag in zip(ALL_TYPES, ALL_FLAGS) diff --git a/lib/spack/spack/detection/common.py b/lib/spack/spack/detection/common.py index 866cc951566fa1..6a4e6ac68eecc3 100644 --- a/lib/spack/spack/detection/common.py +++ b/lib/spack/spack/detection/common.py @@ -185,7 +185,7 @@ def library_prefix(library_dir: str) -> str: assert os.path.isdir(library_dir) components = library_dir.split(os.sep) - # covert to lowercase to match lib, LIB, Lib, etc. + # convert to lowercase to match lib, LIB, Lib, etc. lowered_components = library_dir.lower().split(os.sep) if "lib64" in lowered_components: idx = lowered_components.index("lib64") diff --git a/lib/spack/spack/filesystem_view.py b/lib/spack/spack/filesystem_view.py index 72aa02a713cd09..c65bc01d273f71 100644 --- a/lib/spack/spack/filesystem_view.py +++ b/lib/spack/spack/filesystem_view.py @@ -191,7 +191,7 @@ def add_specs(self, *specs: spack.spec.Spec, **kwargs) -> None: Add given specs to view. Should accept ``with_dependencies`` as keyword argument (default - True) to indicate wether or not dependencies should be activated as + True) to indicate whether or not dependencies should be activated as well. Should except an ``exclude`` keyword argument containing a list of @@ -218,11 +218,11 @@ def remove_specs(self, *specs: spack.spec.Spec, **kwargs) -> None: Removes given specs from view. Should accept ``with_dependencies`` as keyword argument (default - True) to indicate wether or not dependencies should be deactivated + True) to indicate whether or not dependencies should be deactivated as well. Should accept ``with_dependents`` as keyword argument (default True) - to indicate wether or not dependents on the deactivated specs + to indicate whether or not dependents on the deactivated specs should be removed as well. Should except an ``exclude`` keyword argument containing a list of @@ -270,7 +270,7 @@ def print_status(self, *specs: spack.spec.Spec, **kwargs) -> None: * ..they are active in the view. * ..they are active but the activated version differs. - * ..they are not activte in the view. + * ..they are not active in the view. Takes ``with_dependencies`` keyword argument so that the status of dependencies is printed as well. diff --git a/lib/spack/spack/llnl/util/filesystem.py b/lib/spack/spack/llnl/util/filesystem.py index eba14481a01caf..8c0d3472655fdf 100644 --- a/lib/spack/spack/llnl/util/filesystem.py +++ b/lib/spack/spack/llnl/util/filesystem.py @@ -2890,7 +2890,7 @@ def _windows_symlink( src: str, dst: str, target_is_directory: bool = False, *, dir_fd: Union[int, None] = None ): """On Windows with System Administrator privileges this will be a normal symbolic link via - os.symlink. On Windows without privledges the link will be a junction for a directory and a + os.symlink. On Windows without privileges the link will be a junction for a directory and a hardlink for a file. On Windows the various link types are: Symbolic Link: A link to a file or directory on the same or different volume (drive letter) or diff --git a/lib/spack/spack/llnl/util/tty/color.py b/lib/spack/spack/llnl/util/tty/color.py index 539af5ae3a3e1d..25e5357c99b2d7 100644 --- a/lib/spack/spack/llnl/util/tty/color.py +++ b/lib/spack/spack/llnl/util/tty/color.py @@ -302,7 +302,7 @@ def plain_to_color(self, index: int) -> int: def cmapping(string: str) -> ColorMapping: """Return a mapping for translating indices in a plain string to indices in colored text. - The returned dictionary maps indices in the plain string to the offset of the cooresponding + The returned dictionary maps indices in the plain string to the offset of the corresponding indices in the colored string. """ diff --git a/lib/spack/spack/llnl/util/tty/log.py b/lib/spack/spack/llnl/util/tty/log.py index 5072624e5e305f..c0489ddedfeb6b 100644 --- a/lib/spack/spack/llnl/util/tty/log.py +++ b/lib/spack/spack/llnl/util/tty/log.py @@ -158,7 +158,7 @@ class keyboard_input(preserve_terminal_settings): [Running] <------- bg sends SIGCONT ---------- [Stopped] [ in BG ] [ in BG ] - We handle all transitions exept for ``SIGTSTP`` generated by Ctrl-Z + We handle all transitions except for ``SIGTSTP`` generated by Ctrl-Z by periodically calling ``check_fg_bg()``. This routine notices if we are in the background with canonical mode or echo disabled, or if we are in the foreground without canonical disabled and echo enabled, diff --git a/lib/spack/spack/operating_systems/windows_os.py b/lib/spack/spack/operating_systems/windows_os.py index c9f32efc46c666..e965991f1c63d7 100755 --- a/lib/spack/spack/operating_systems/windows_os.py +++ b/lib/spack/spack/operating_systems/windows_os.py @@ -21,7 +21,7 @@ def windows_version(): # include the build number as this provides important information # for low lever packages and components like the SDK and WDK # The build number is the version component that would otherwise - # be the patch version in sematic versioning, i.e. z of x.y.z + # be the patch version in semantic versioning, i.e. z of x.y.z return Version(platform.version()) diff --git a/lib/spack/spack/package_base.py b/lib/spack/spack/package_base.py index e5fc56c000da4a..d62825a8382686 100644 --- a/lib/spack/spack/package_base.py +++ b/lib/spack/spack/package_base.py @@ -303,7 +303,7 @@ def __new__(cls, name, bases, attr_dict): def on_package_attributes(**attr_dict): - """Decorator: executes instance function only if object has attr valuses. + """Decorator: executes instance function only if object has attr values. Executes the decorated method only if at the moment of calling the instance has attributes that are equal to certain values. @@ -412,7 +412,7 @@ def _by_subkey( """Convert a dict of dicts keyed by when/subkey into a dict of lists keyed by subkey. Optional Arguments: - when: if ``True``, don't discared the ``when`` specs; return a 2-level dictionary + when: if ``True``, don't discard the ``when`` specs; return a 2-level dictionary keyed by subkey and when spec. """ # very hard to define this type to be conditional on `when` @@ -1466,7 +1466,7 @@ def provides(self, vpkg_name: str) -> bool: def intersects(self, spec: spack.spec.Spec) -> bool: """Context-ful intersection that takes into account package information. - By design, ``Spec.intersects()`` does not know anything about package metdata. + By design, ``Spec.intersects()`` does not know anything about package metadata. This avoids unnecessary package lookups and keeps things efficient where extra information is not needed, and it decouples ``Spec`` from ``PackageBase``. @@ -2334,7 +2334,7 @@ def rpath(self): # Do not include Windows system libraries in the rpath interface # these libraries are handled automatically by VS/VCVARS and adding # Spack derived system libs into the link path or address space of a program - # can result in conflicting versions, which makes Spack packages less useable + # can result in conflicting versions, which makes Spack packages less usable if sys.platform == "win32": rpaths = [self.prefix.bin] rpaths.extend( diff --git a/lib/spack/spack/platforms/_platform.py b/lib/spack/spack/platforms/_platform.py index 3aa493cd068246..d0c1f189495a4e 100644 --- a/lib/spack/spack/platforms/_platform.py +++ b/lib/spack/spack/platforms/_platform.py @@ -83,7 +83,7 @@ def operating_system(self, name): def setup_platform_environment(self, pkg, env): """Platform-specific build environment modifications. - This method is meant toi be overridden by subclasses, when needed. + This method is meant to be overridden by subclasses, when needed. """ pass diff --git a/lib/spack/spack/relocate.py b/lib/spack/spack/relocate.py index 0f42b191ff1f4f..6cfefecf608f9d 100644 --- a/lib/spack/spack/relocate.py +++ b/lib/spack/spack/relocate.py @@ -197,7 +197,7 @@ def _set_elf_rpaths_and_interpreter( def relocate_macho_binaries(path_names, prefix_to_prefix): """ - Use macholib python package to get the rpaths, depedent libraries + Use macholib python package to get the rpaths, dependent libraries and library identity for libraries from the MachO object. Modify them with the replacement paths queried from the dictionary mapping old layout prefixes to hashes and the dictionary mapping hashes to the new layout diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index c8a590e929cb96..65b4f50205e801 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -1173,7 +1173,7 @@ def real_name(self, import_name: str) -> Optional[str]: package directory. From Package API v2.0 there is a one-to-one mapping between Spack package names and Python module names, so there is no guessing. - For Packge API v1.x we support the following one-to-many mappings: + For Package API v1.x we support the following one-to-many mappings: * ``num3proxy`` -> ``3proxy`` * ``foo_bar`` -> ``foo_bar``, ``foo-bar`` @@ -1879,7 +1879,7 @@ def construct( class BrokenRepoDescriptor(RepoDescriptor): """A descriptor for a broken repository, used to indicate errors in the configuration that - aren't fatal untill the repository is used.""" + aren't fatal until the repository is used.""" def __init__(self, name: Optional[str], error: str) -> None: super().__init__(name) diff --git a/lib/spack/spack/reporters/cdash.py b/lib/spack/spack/reporters/cdash.py index 358c121866c301..409361e7fb22e9 100644 --- a/lib/spack/spack/reporters/cdash.py +++ b/lib/spack/spack/reporters/cdash.py @@ -174,7 +174,7 @@ def build_report_for_package(self, report_dir, package, duration): report_data[cdash_phase]["loglines"].append(xml.sax.saxutils.escape(line)) # something went wrong pre-cdash "configure" phase b/c we have an exception and only - # "update" was encounterd. + # "update" was encountered. # dump the report in the configure line so teams can see what the issue is if len(phases_encountered) == 1 and package.get("exception"): # TODO this mapping is not ideal since these are pre-configure errors @@ -184,7 +184,7 @@ def build_report_for_package(self, report_dir, package, duration): phases_encountered.append(cdash_phase) log_message = ( - "Pre-configure errors occured in Spack's process that terminated the " + "Pre-configure errors occurred in Spack's process that terminated the " "build process prematurely.\nSpack output::\n{0}".format( xml.sax.saxutils.escape(package["exception"]) ) diff --git a/lib/spack/spack/schema/__init__.py b/lib/spack/spack/schema/__init__.py index bda7246f8f7daa..206fcf6aa8b6dd 100644 --- a/lib/spack/spack/schema/__init__.py +++ b/lib/spack/spack/schema/__init__.py @@ -128,7 +128,7 @@ def merge_yaml(dest, source, prepend=False, append=False): parent instead of merging. ``+:`` will extend the default prepend merge strategy to include string concatenation - ``-:`` will change the merge strategy to append, it also includes string concatentation + ``-:`` will change the merge strategy to append, it also includes string concatenation """ def they_are(t): diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index bdec4276fabf4d..89a0c745127e69 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -617,7 +617,7 @@ def _stats_from_cache(self, cache_entry_file: str) -> Union[Dict, None]: """Returns concretization statistic from the concretization associated with the cache. - Deserialzes the the json representation of the + Deserializes the the json representation of the statistics covering the cached concretization run and returns the Python data structures """ @@ -645,13 +645,13 @@ def _safe_remove(self, cache_dir: pathlib.Path) -> bool: except OSError as e: # Catch other timing/access related issues tty.debug( - f"Exception occured while attempting to remove Concretization Cache entry, {e}" + f"Exception occurred while attempting to remove Concretization Cache entry, {e}" ) pass return False def _lock(self, path: pathlib.Path) -> lk.Lock: - """Returns a lock over the byte range correspnding to the hash of the asp problem. + """Returns a lock over the byte range corresponding to the hash of the asp problem. ``path`` is a path to a file in the cache, and its basename is the hash of the problem. @@ -853,7 +853,7 @@ def handle_error(self, msg, *args): msg = msg.format(*msg_args) # For variant formatting, we sometimes have to construct specs - # to format values properly. Find/replace all occurances of + # to format values properly. Find/replace all occurrences of # Spec(...) with the string representation of the spec mentioned specs_to_construct = re.findall(r"Spec\(([^)]*)\)", msg) for spec_str in specs_to_construct: @@ -966,7 +966,7 @@ def _run_clingo( fetch a result from cache. See ``solve()`` for caching and setup logic. """ # We could just take the cache_key and add it to clingo (since it is the - # full problem representation), but we load conrol files separately as it + # full problem representation), but we load control files separately as it # makes clingo give us better, file-aware error messages. with timer.measure("load"): # Add the problem instance @@ -1248,7 +1248,7 @@ def __iter__(self): class ConstraintOrigin(enum.Enum): - """Generates identifiers that can be pased into the solver attached + """Generates identifiers that can be passed into the solver attached to constraints, and then later retrieved to determine the origin of those constraints when ``SpecBuilder`` creates Specs from the solve result. @@ -1434,7 +1434,7 @@ def spec_versions( ) -> List[AspFunction]: """Return list of clauses expressing spec's version constraints.""" name = spec.name or name - assert name, "Internal Error: spec with no name occured. Please file an issue." + assert name, "Internal Error: spec with no name occurred. Please file an issue." if spec.concrete: return [fn.attr("version", name, spec.version)] @@ -1450,7 +1450,7 @@ def target_ranges( self, spec: spack.spec.Spec, single_target_fn, *, name: Optional[str] = None ) -> List[AspFunction]: name = spec.name or name - assert name, "Internal Error: spec with no name occured. Please file an issue." + assert name, "Internal Error: spec with no name occurred. Please file an issue." target = spec.architecture.target # Check if the target is a concrete target @@ -2213,7 +2213,7 @@ def _spec_clauses( concrete_build_deps: if False, do not include pure build deps of concrete specs (as they have no effect on runtime constraints) include_runtimes: generate full dependency clauses from runtime libraries that - are ommitted from the solve. + are omitted from the solve. context: tracks what constraint this clause set is generated for (e.g. a ``depends_on`` constraint in a package.py file) seen: set of ids of specs that have already been processed (for internal use only) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index aa5f9eb102df72..c4c98369d3b4ee 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1326,7 +1326,7 @@ error(50000, Message) :- % Variant semantics %----------------------------------------------------------------------------- % Packages define potentially several definitions for each variant, and depending -% on their attibutes, duplicate nodes for the same package may use different +% on their attributes, duplicate nodes for the same package may use different % definitions. So the variant logic has several jobs: % A. Associate a variant definition with a node, by VariantID % B. Associate defaults and attributes (sticky, etc.) for the selected variant ID with the node. @@ -2014,7 +2014,7 @@ build(PackageNode) :- attr("node", PackageNode), not concrete(PackageNode). % Minimizing builds is tricky. We want a minimizing criterion -% because we want to reuse what is avaialble, but +% because we want to reuse what is available, but % we also want things that are built to stick to *default preferences* from % the package and from the user. We therefore treat built specs differently and apply % a different set of optimization criteria to them. Spack's *first* priority is to diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 3a6cf2e7189f9e..2b42755e7fa70b 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -866,7 +866,7 @@ def format(self, *, unconditional: bool = False) -> str: return f"{parent_str} {dep_sigil}{child_str}" def flip(self) -> "DependencySpec": - """Flips the dependency and keeps its type. Drops all othe information.""" + """Flips the dependency and keeps its type. Drops all other information.""" return DependencySpec( parent=self.spec, spec=self.parent, depflag=self.depflag, virtuals=() ) @@ -3976,7 +3976,7 @@ def _cmp_iter(self): # to do fast equality comparison. See _cmp_fast_eq() above for the # short-circuit logic for hashes. # - # A full traversal involves constructing data structurs, visitor objects, etc., + # A full traversal involves constructing data structures, visitor objects, etc., # and it can be expensive if we have to do it to compare a bunch of tiny # abstract specs. Therefore, there are 3 cases below, which avoid calling # `spack.traverse.traverse_edges()` unless necessary. diff --git a/lib/spack/spack/stage.py b/lib/spack/spack/stage.py index 94a6d9053be34f..059f11bbc0514f 100644 --- a/lib/spack/spack/stage.py +++ b/lib/spack/spack/stage.py @@ -67,7 +67,7 @@ def compute_stage_name(spec): else: spec_stage_structure += "{name}-{version}" # TODO (psakiev, scheibelp) Technically a user could still reintroduce a hash via - # config:stage_name. This is a fix for how to handle staging an abstact spec (see #51305) + # config:stage_name. This is a fix for how to handle staging an abstract spec (see #51305) stage_name_structure = spack.config.get("config:stage_name", default=spec_stage_structure) return spec.format_path(format_string=stage_name_structure) @@ -396,7 +396,7 @@ class Stage(AbstractStage): When used as a context manager, the stage is automatically destroyed if no exception is raised by the context. If an - excpetion is raised, the stage is left in the filesystem and NOT + exception is raised, the stage is left in the filesystem and NOT destroyed, for potential reuse later. You can also use the stage's create/destroy functions manually, diff --git a/lib/spack/spack/test/build_environment.py b/lib/spack/spack/test/build_environment.py index 1ccd2d4be3e718..3c6c68f6a30971 100644 --- a/lib/spack/spack/test/build_environment.py +++ b/lib/spack/spack/test/build_environment.py @@ -607,7 +607,7 @@ def test_effective_deptype_build_environment(default_mock_concretization): # [b ] ^dtbuild1@1.0 # <- direct build dep # [b ] ^dtbuild2@1.0 # <- indirect build-only dep is dropped # [bl ] ^dtlink2@1.0 # <- linkable, and runtime dep of build dep - # [ r ] ^dtrun2@1.0 # <- non-linkable, exectuable runtime dep of build dep + # [ r ] ^dtrun2@1.0 # <- non-linkable, executable runtime dep of build dep # [bl ] ^dtlink1@1.0 # <- direct build dep # [bl ] ^dtlink3@1.0 # <- linkable, and runtime dep of build dep # [b ] ^dtbuild2@1.0 # <- indirect build-only dep is dropped diff --git a/lib/spack/spack/test/cc.py b/lib/spack/spack/test/cc.py index e9aea962085805..aa69658bb242ba 100644 --- a/lib/spack/spack/test/cc.py +++ b/lib/spack/spack/test/cc.py @@ -340,7 +340,7 @@ def test_expected_args(wrapper_environment, wrapper_dir): # Xlinker_parsing # - # -Xlinker ... -Xlinker may have compiler flags inbetween, like -O3 in this + # -Xlinker ... -Xlinker may have compiler flags in between, like -O3 in this # example. Also check that a trailing -Xlinker (which is a compiler error) is not # dropped or given an empty argument. check_args( diff --git a/lib/spack/spack/test/cmd/buildcache.py b/lib/spack/spack/test/cmd/buildcache.py index 7b4b43166ed0b8..d644c7360332e3 100644 --- a/lib/spack/spack/test/cmd/buildcache.py +++ b/lib/spack/spack/test/cmd/buildcache.py @@ -308,7 +308,7 @@ def manifest_insert(manifest, spec, dest_url): # Trigger the warning output = buildcache("sync", "--manifest-glob", manifest_file, "dest", "ignored") - assert "Ignoring unused arguemnt: ignored" in output + assert "Ignoring unused argument: ignored" in output verify_mirror_contents() shutil.rmtree(str(dest_mirror_dir)) @@ -990,7 +990,7 @@ def create_env_from_concrete_spec(spec: spack.spec.Spec): e = ev.environment_from_name_or_dir(env_name) with e: add(f"{spec.name}/{spec.dag_hash()}") - # This should handle updating the environment to mark all packges as installed + # This should handle updating the environment to mark all packages as installed install() return e @@ -1058,7 +1058,7 @@ def test_buildcache_create_view_empty( with pytest.raises(spack.binary_distribution.FetchIndexError): hashes_in_view = read_specs_in_index(mirror_directory, "test_view") - # Write a minimal lockfile (this is not validated/read by an enviornment) + # Write a minimal lockfile (this is not validated/read by an environment) empty_manifest = tmp_path / "emptylock" / "spack.yaml" empty_manifest.parent.mkdir(exist_ok=False) empty_manifest.write_text("spack: {}", encoding="utf-8") diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index cba5469f4fcb5c..502fcffe9f8d89 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -1026,7 +1026,7 @@ def test_ci_generate_override_runner_attrs( assert the_elt["after_script"][0] == "post step one" if "dependent-install" in ci_key: # The dependent-install match specifies that we keep the two - # top level variables, but add a third specifc one. It + # top level variables, but add a third specific one. It # also adds a custom tag which should be combined with # the top-level tag. the_elt = yaml_contents[ci_key] diff --git a/lib/spack/spack/test/cmd/commands.py b/lib/spack/spack/test/cmd/commands.py index e68a0809963f0a..29f4e3857cfe26 100644 --- a/lib/spack/spack/test/cmd/commands.py +++ b/lib/spack/spack/test/cmd/commands.py @@ -65,7 +65,7 @@ def test_subcommands(): def test_alias_overrides_builtin(mutable_config: spack.config.Configuration, capfd): - """Test that spack commands cannot be overriden by aliases.""" + """Test that spack commands cannot be overridden by aliases.""" mutable_config.set("config:aliases", {"install": "find"}) cmd, args = spack.main.resolve_alias("install", ["install", "-v"]) assert cmd == "install" and args == ["install", "-v"] diff --git a/lib/spack/spack/test/cmd/diff.py b/lib/spack/spack/test/cmd/diff.py index 1e703c4849f109..db28be8a2b5fa2 100644 --- a/lib/spack/spack/test/cmd/diff.py +++ b/lib/spack/spack/test/cmd/diff.py @@ -85,7 +85,7 @@ def test_diff_cmd(install_mockery, mock_fetch, mock_archive, mock_packages): # Calculate the comparison (c) c = spack.cmd.diff.compare_specs(specA, specB, to_string=True) - # these particular diffs should have the same length b/c thre aren't + # these particular diffs should have the same length b/c there aren't # any node differences -- just value differences. assert len(c["a_not_b"]) == len(c["b_not_a"]) diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index c3a6307fb80950..52c1e79b43024c 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -265,7 +265,7 @@ def test_install_commit(mock_git_version_info, install_mockery, mock_packages, m monkeypatch.setattr(spack.package_base.PackageBase, "git", file_url, raising=False) - # Use the earliest commit in the respository + # Use the earliest commit in the repository spec = spack.concretize.concretize_one(f"git-test-commit@{commits[-1]}") PackageInstaller([spec.package], explicit=True).install() diff --git a/lib/spack/spack/test/cmd/repo.py b/lib/spack/spack/test/cmd/repo.py index 4f564b1cea1b57..7fb720b634f6e0 100644 --- a/lib/spack/spack/test/cmd/repo.py +++ b/lib/spack/spack/test/cmd/repo.py @@ -84,7 +84,7 @@ def test_repo_remove_by_scope(mutable_config, tmp_path: pathlib.Path): def test_env_repo_path_vars_substitution( tmp_path: pathlib.Path, install_mockery, mutable_mock_env_path, monkeypatch ): - """Test Spack correctly substitues repo paths with environment variables when creating an + """Test Spack correctly substitutes repo paths with environment variables when creating an environment from a manifest file.""" monkeypatch.setenv("CUSTOM_REPO_PATH", ".") diff --git a/lib/spack/spack/test/cmd_extensions.py b/lib/spack/spack/test/cmd_extensions.py index 93dcf527535d90..ad63d8e559d4b4 100644 --- a/lib/spack/spack/test/cmd_extensions.py +++ b/lib/spack/spack/test/cmd_extensions.py @@ -94,7 +94,7 @@ def hello_world(parser, args): @pytest.fixture(scope="function") def hello_world_cmd(hello_world_extension): - """Create and return an invokable "hello-world" extension command.""" + """Create and return an invocable "hello-world" extension command.""" yield spack.main.SpackCommand("hello-world") diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 8a2a7f130a63b5..de95e55a86891b 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -764,7 +764,7 @@ def test_concretize_propagate_one_variant(self): def test_concretize_propagate_through_first_level_deps(self): """Test that boolean valued variants can be propagated past first level - dependecies even if the first level dependency does have the variant""" + dependencies even if the first level dependency does have the variant""" spec = Spec("parent-foo-bar-fee ++fee") spec = spack.concretize.concretize_one(spec) @@ -802,7 +802,7 @@ def test_concretize_propagate_single_valued_variant(self): def test_concretize_propagate_multivalue_variant(self): """Test that multivalue variants are propagating the specified value(s) - to their dependecies. The dependencies should not have the default value""" + to their dependencies. The dependencies should not have the default value""" spec = Spec("multivalue-variant foo==baz,fee") spec = spack.concretize.concretize_one(spec) @@ -1921,7 +1921,7 @@ def test_best_effort_coconcretize(self, specs, checks): assert len(matches) == expected_count @pytest.mark.parametrize( - "specs,expected_spec,occurances", + "specs,expected_spec,occurrences", [ # The algorithm is greedy, and it might decide to solve the "best" # spec early in which case reuse is suboptimal. In this case the most @@ -1950,7 +1950,7 @@ def test_best_effort_coconcretize(self, specs, checks): (["hdf5+mpi", "zmpi", "mpich"], "mpich", 2), ], ) - def test_best_effort_coconcretize_preferences(self, specs, expected_spec, occurances): + def test_best_effort_coconcretize_preferences(self, specs, expected_spec, occurrences): """Test package preferences during coconcretization.""" specs = [Spec(s) for s in specs] solver = spack.solver.asp.Solver() @@ -1963,7 +1963,7 @@ def test_best_effort_coconcretize_preferences(self, specs, expected_spec, occura for spec in concrete_specs.values(): if expected_spec in spec: counter += 1 - assert counter == occurances, concrete_specs + assert counter == occurrences, concrete_specs def test_solve_in_rounds_all_unsolved(self, monkeypatch, mock_packages): specs = [Spec(x) for x in ["libdwarf%gcc", "libdwarf%clang"]] diff --git a/lib/spack/spack/test/concretization/requirements.py b/lib/spack/spack/test/concretization/requirements.py index fe9afb2c851060..1b3f4894186447 100644 --- a/lib/spack/spack/test/concretization/requirements.py +++ b/lib/spack/spack/test/concretization/requirements.py @@ -312,7 +312,7 @@ def test_requirement_is_successfully_applied(concretize_scope, test_repo): """ update_packages_config(conf_str) s2 = spack.concretize.concretize_one("x") - # The requirement forces choosing the eariler version + # The requirement forces choosing the earlier version assert s2.satisfies("@1.0") diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index 7d4493ea06b54b..7761223f9ef166 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -1180,7 +1180,7 @@ def test_single_file_scope_cache_clearing(env_yaml): assert before # Clear the cache of the Single file scope scope.clear() - # Check that the section can be retireved again and it's + # Check that the section can be retrieved again and it's # the same as before after = scope.get_section("config") assert after @@ -1526,7 +1526,7 @@ def test_config_file_read_perms_failure(tmp_path: pathlib.Path, mutable_empty_co def test_config_file_read_invalid_yaml(tmp_path: pathlib.Path, mutable_empty_config): - """Test reading a configuration file with invalid (unparseable) YAML + """Test reading a configuration file with invalid (unparsable) YAML raises a ConfigFileError.""" filename = join_path(str(tmp_path), "test.yaml") with open(filename, "w", encoding="utf-8") as f: @@ -1943,7 +1943,7 @@ def test_missing_include_scope_not_readable_list(mock_missing_dir_include_scopes def test_missing_include_scope_default_created_as_dir_scope(mock_missing_dir_include_scopes): - """Tests that an optional include with no existing file/directory and no yaml extention + """Tests that an optional include with no existing file/directory and no yaml extension is created as a directoryscope object""" missing_inc_scope = spack.config.CONFIG.scopes["sub_base"] assert isinstance(missing_inc_scope, spack.config.DirectoryConfigScope) diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index c5ef19420b092b..dedeba1eee809e 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -460,7 +460,7 @@ def use_concretization_cache(mock_packages, mutable_config, tmp_path: Path): # @pytest.fixture(scope="function", autouse=True) def no_chdir(): - """Ensure that no test changes Spack's working dirctory. + """Ensure that no test changes Spack's working directory. This prevents Spack tests (and therefore Spack commands) from changing the working directory and causing other tests to fail @@ -478,7 +478,7 @@ def no_chdir(): def onerror(func, path, error_info): - # Python on Windows is unable to remvove paths without + # Python on Windows is unable to remove paths without # write (IWUSR) permissions (such as those generated by Git on Windows) # This method changes file permissions to allow removal by Python os.chmod(path, stat.S_IWUSR) @@ -1105,7 +1105,7 @@ def create_config_scope(path: Path, name: str) -> spack.config.DirectoryConfigSc @pytest.fixture() def mock_missing_dir_include_scopes(tmp_path: Path): """Mocks a config scope containing optional directory scope - includes that do not have represetation on the filesystem""" + includes that do not have representation on the filesystem""" scope = create_config_scope(tmp_path, "sub") with spack.config.use_configuration(scope) as config: @@ -1115,7 +1115,7 @@ def mock_missing_dir_include_scopes(tmp_path: Path): @pytest.fixture def mock_missing_file_include_scopes(tmp_path: Path): """Mocks a config scope containing optional file scope - includes that do not have represetation on the filesystem""" + includes that do not have representation on the filesystem""" scope = create_config_scope(tmp_path, "sub.yaml") with spack.config.use_configuration(scope) as config: @@ -1539,7 +1539,7 @@ def get_cvs_timestamp(output): # We use this to record the time stamps for when we create CVS revisions, # so that we can later check that we retrieve the proper commits when - # specifying a date. (CVS guarantees checking out the lastest revision + # specifying a date. (CVS guarantees checking out the latest revision # before or on the specified date). As we create each revision, we # separately record the time by querying CVS. revision_date = {} @@ -2039,10 +2039,10 @@ def mock_directive_bundle(): @pytest.fixture def clear_directive_functions(): - """Clear all overidden directive functions for subsequent tests.""" + """Clear all overridden directive functions for subsequent tests.""" yield - # Make sure any directive functions overidden by tests are cleared before + # Make sure any directive functions overridden by tests are cleared before # proceeding with subsequent tests that may depend on the original # functions. spack.directives_meta.DirectiveMeta._directives_to_be_executed.clear() diff --git a/lib/spack/spack/test/data/directory_search/README.txt b/lib/spack/spack/test/data/directory_search/README.txt index 9c43a4224d7108..abef744b44feda 100644 --- a/lib/spack/spack/test/data/directory_search/README.txt +++ b/lib/spack/spack/test/data/directory_search/README.txt @@ -1 +1 @@ -This directory tree is made up to test that search functions wil return a stable ordered sequence. \ No newline at end of file +This directory tree is made up to test that search functions will return a stable ordered sequence. \ No newline at end of file diff --git a/lib/spack/spack/test/installer.py b/lib/spack/spack/test/installer.py index 4a5a1955ca5f9a..99696edb15a6b0 100644 --- a/lib/spack/spack/test/installer.py +++ b/lib/spack/spack/test/installer.py @@ -1082,7 +1082,7 @@ def test_install_fail_fast_on_detect(install_mockery, monkeypatch, capfd): installer.install() assert b_id in installer.failed, "Expected b to be marked as failed" - assert c_id in installer.failed, "Exepected c to be marked as failed" + assert c_id in installer.failed, "Expected c to be marked as failed" assert ( a_id not in installer.installed ), "Package a cannot install due to its dependencies failing" diff --git a/lib/spack/spack/test/oci/image.py b/lib/spack/spack/test/oci/image.py index bf724e20a402e4..9334d4235bba15 100644 --- a/lib/spack/spack/test/oci/image.py +++ b/lib/spack/spack/test/oci/image.py @@ -52,7 +52,7 @@ def test_name_parsing(image_ref, expected): "example.com:1234/a/b/c:", # empty digest "example.com:1234/a/b/c@sha256:", - # unsupport digest algorithm + # unsupported digest algorithm f"example.com:1234/a/b/c@sha512:{'a'*128}", # invalid digest length f"example.com:1234/a/b/c@sha256:{'a'*63}", diff --git a/lib/spack/spack/test/oci/urlopen.py b/lib/spack/spack/test/oci/urlopen.py index 9c340a78bc148d..28249c7e617a2c 100644 --- a/lib/spack/spack/test/oci/urlopen.py +++ b/lib/spack/spack/test/oci/urlopen.py @@ -365,8 +365,8 @@ def test_registry_with_short_lived_bearer_tokens(): ("GET", "/v2/"), # 2: retry with bearer token ("GET", "/v2/"), # 3: with incorrect bearer token ("GET", "/v2/"), # 4: retry with new bearer token - ("GET", "/v2/"), # 5: with recyled correct bearer token - ("GET", "/v2/"), # 6: with recyled correct bearer token + ("GET", "/v2/"), # 5: with recycled correct bearer token + ("GET", "/v2/"), # 6: with recycled correct bearer token ] diff --git a/lib/spack/spack/test/relocate_text.py b/lib/spack/spack/test/relocate_text.py index 418c5c3c766215..f80fc24ee118ef 100644 --- a/lib/spack/spack/test/relocate_text.py +++ b/lib/spack/spack/test/relocate_text.py @@ -163,7 +163,7 @@ def replace_and_expect(prefix_map, before, after=None, suffix_safety_size=7): # Finally, make sure that the regex is not greedily finding the LAST null byte # it should find the first null byte in the window. In this test we put one null - # at a distance where we cant keep a long enough suffix, and one where we can, + # at a distance where we can't keep a long enough suffix, and one where we can, # so we should expect failure when the first null is used. error_msg = "Cannot replace {!r} with {!r} in the C-string {!r}.".format( b"pkg-abcdef", b"pkg-xyzabc", b"pkg-abcdef" diff --git a/lib/spack/spack/test/sbang.py b/lib/spack/spack/test/sbang.py index a220a1291d2db3..0ae04c3a0ec3ac 100644 --- a/lib/spack/spack/test/sbang.py +++ b/lib/spack/spack/test/sbang.py @@ -111,7 +111,7 @@ def __init__(self, sbang_line): f.write(last_line) self.make_executable(self.luajit_shebang) - # Luajit occuring in text, not in shebang + # Luajit occurring in text, not in shebang self.luajit_textbang = os.path.join(self.tempdir, "luajit_in_text") with open(self.luajit_textbang, "w", encoding="utf-8") as f: f.write(short_line) @@ -126,7 +126,7 @@ def __init__(self, sbang_line): f.write(last_line) self.make_executable(self.node_shebang) - # Node occuring in text, not in shebang + # Node occurring in text, not in shebang self.node_textbang = os.path.join(self.tempdir, "node_in_text") with open(self.node_textbang, "w", encoding="utf-8") as f: f.write(short_line) @@ -141,7 +141,7 @@ def __init__(self, sbang_line): f.write(last_line) self.make_executable(self.php_shebang) - # php occuring in text, not in shebang + # php occurring in text, not in shebang self.php_textbang = os.path.join(self.tempdir, "php_in_text") with open(self.php_textbang, "w", encoding="utf-8") as f: f.write(short_line) diff --git a/lib/spack/spack/test/spec_dag.py b/lib/spack/spack/test/spec_dag.py index 4a283c55be029a..798603c08d9442 100644 --- a/lib/spack/spack/test/spec_dag.py +++ b/lib/spack/spack/test/spec_dag.py @@ -917,7 +917,7 @@ def test_query_dependents_edges(self, default_mock_concretization): edges_with_mpi = mpich.edges_from_dependents(virtuals=["mpi"]) assert edges_with_mpi == edges_of_link_type - # Check a node dependend upon by 2 parents + # Check a node depended upon by 2 parents assert len(mpileaks["libelf"].edges_from_dependents(depflag=dt.LINK)) == 2 diff --git a/lib/spack/spack/test/spec_semantics.py b/lib/spack/spack/test/spec_semantics.py index 18ff918a53f83d..38c73fed4fb772 100644 --- a/lib/spack/spack/test/spec_semantics.py +++ b/lib/spack/spack/test/spec_semantics.py @@ -444,7 +444,7 @@ def _propagated_flags(_spec): assert set(propagated_rhs) <= _propagated_flags(c2) def test_constrain_specs_by_hash(self, default_mock_concretization, database): - """Test that Specs specified only by their hashes can constrain eachother.""" + """Test that Specs specified only by their hashes can constrain each other.""" mpich_dag_hash = "/" + database.query_one("mpich").dag_hash() spec = Spec(mpich_dag_hash[:7]) assert spec.constrain(Spec(mpich_dag_hash)) is False @@ -1169,7 +1169,7 @@ def test_splice_intransitive_complex(self, setup_complex_splice): assert spliced["pkg-e"]._build_spec is None # Because a copy of e is used, it does not have dependnets in the original specs assert set(spliced["pkg-e"].dependents()) == {spliced["pkg-b"], spliced["pkg-f"]} - # Build dependent edge to f because f originally dependended on the e this was copied from + # Build dependent edge to f because f originally depended on the e this was copied from assert set(spliced["pkg-e"].dependents(deptype=dt.BUILD)) == {spliced["pkg-b"]} assert spliced["pkg-f"].satisfies("pkg-f color=blue ^pkg-e color=red ^pkg-g@2 color=red") diff --git a/lib/spack/spack/test/traverse.py b/lib/spack/spack/test/traverse.py index 4de1b0a32d0c48..b4b68ccc8f5b6c 100644 --- a/lib/spack/spack/test/traverse.py +++ b/lib/spack/spack/test/traverse.py @@ -237,7 +237,7 @@ def test_breadth_first_versus_depth_first_tree(abstract_specs_chain): for (depth, edge) in traverse.traverse_tree([s], cover="nodes", depth_first=False) ] == [(0, "chain-a"), (1, "chain-b"), (1, "chain-c"), (1, "chain-d")] - # DFS will disover all nodes along the chain a -> b -> c -> d. + # DFS will discover all nodes along the chain a -> b -> c -> d. assert [ (depth, edge.spec.name) for (depth, edge) in traverse.traverse_tree([s], cover="nodes", depth_first=True) @@ -405,7 +405,7 @@ def test_traverse_edges_topo(abstract_specs_toposort): for e in traverse.traverse_edges(input_specs, order="topo", cover="edges", root=False) ] - # See figure above, we have 7 edges (excluding artifical ones to the root) + # See figure above, we have 7 edges (excluding artificial ones to the root) assert set(edges) == set( [("A", "B"), ("A", "C"), ("B", "F"), ("B", "G"), ("C", "D"), ("D", "B"), ("E", "D")] ) @@ -435,7 +435,7 @@ def test_traverse_nodes_no_deps(abstract_specs_dtuse): def test_topo_is_bfs_for_trees(cover): """For trees, both DFS and BFS produce a topological order, but BFS is the most sensible for our applications, where we typically want to avoid that transitive dependencies shadow direct - depenencies in global search paths, etc. This test ensures that for trees, the default topo + dependencies in global search paths, etc. This test ensures that for trees, the default topo order coincides with BFS.""" binary_tree = create_dag( nodes=["A", "B", "C", "D", "E", "F", "G"], diff --git a/lib/spack/spack/test/util/editor.py b/lib/spack/spack/test/util/editor.py index ba4cdf6b98bcf4..2cfe5b409420ab 100644 --- a/lib/spack/spack/test/util/editor.py +++ b/lib/spack/spack/test/util/editor.py @@ -30,7 +30,7 @@ def clean_env_vars(): @pytest.fixture(autouse=True) def working_editor_test_env(working_env): - """Don't leak environent variables between functions here.""" + """Don't leak environment variables between functions here.""" # parameterized fixture for editor var names diff --git a/lib/spack/spack/test/util/path.py b/lib/spack/spack/test/util/path.py index 8f4ad0c7122a69..0f8903925d0f09 100644 --- a/lib/spack/spack/test/util/path.py +++ b/lib/spack/spack/test/util/path.py @@ -41,7 +41,7 @@ def test_sanitize_filename(): # This class pertains to path string padding manipulation specifically # which is used for binary caching. This functionality is not supported # on Windows as of yet. -@pytest.mark.not_on_windows("Padding funtionality unsupported on Windows") +@pytest.mark.not_on_windows("Padding functionality unsupported on Windows") class TestPathPadding: @pytest.mark.parametrize("padded,fixed", zip(padded_lines, fixed_lines)) def test_padding_substitution(self, padded, fixed): diff --git a/lib/spack/spack/test/util/spack_yaml.py b/lib/spack/spack/test/util/spack_yaml.py index 52809e67fb9a75..cef0dfaacf8a99 100644 --- a/lib/spack/spack/test/util/spack_yaml.py +++ b/lib/spack/spack/test/util/spack_yaml.py @@ -21,7 +21,7 @@ def check_blame(element, file_name, line=None): """Check that `config blame config` gets right file/line for an element. This runs `spack config blame config` and scrapes the output for a - particular YAML key. It thne checks that the requested file/line info + particular YAML key. It then checks that the requested file/line info is also on that line. Line is optional; if it is ``None`` we just check for the diff --git a/lib/spack/spack/test/util/timer.py b/lib/spack/spack/test/util/timer.py index 1523dfe308a1fb..ea5a096402ab74 100644 --- a/lib/spack/spack/test/util/timer.py +++ b/lib/spack/spack/test/util/timer.py @@ -10,7 +10,7 @@ class Tick: """Timer that increments the seconds passed by 1 - everytime tick is called.""" + every time tick is called.""" def __init__(self): self.time = 0.0 diff --git a/lib/spack/spack/test/variant.py b/lib/spack/spack/test/variant.py index b43794e5777547..1f6d20f338b4e4 100644 --- a/lib/spack/spack/test/variant.py +++ b/lib/spack/spack/test/variant.py @@ -66,7 +66,7 @@ def test_satisfies(self): assert not a.satisfies(c) and not c.satisfies(a) # SingleValuedVariant and MultiValuedVariant with the same single concrete value do satisfy - # eachother + # each other b_sv = SingleValuedVariant("foo", "bar") assert b.satisfies(b_sv) and b_sv.satisfies(b) d_sv = SingleValuedVariant("foo", True) diff --git a/lib/spack/spack/test/versions.py b/lib/spack/spack/test/versions.py index 1f9d73fb75d4dc..89c7f713cbb64e 100644 --- a/lib/spack/spack/test/versions.py +++ b/lib/spack/spack/test/versions.py @@ -934,7 +934,7 @@ def test_git_versions_without_explicit_reference( def test_total_order_versions_and_ranges(): # The set of version ranges and individual versions are comparable, which is used in - # VersionList. The comparsion across types is based on default version comparsion + # VersionList. The comparison across types is based on default version comparison # of StandardVersion, GitVersion.ref_version, and ClosedOpenRange.lo. # StandardVersion / GitVersion (at equal ref version) diff --git a/lib/spack/spack/traverse.py b/lib/spack/spack/traverse.py index f0f960d408a552..2975fdc71b63f4 100644 --- a/lib/spack/spack/traverse.py +++ b/lib/spack/spack/traverse.py @@ -183,7 +183,7 @@ def neighbors(self, item: EdgeAndDepth) -> List[EdgeAndDepth]: edges = item.edge.spec.edges_to_dependencies(depflag=follow) - # filter direct_type edges already followed before becuase they were also transitive_type. + # filter direct_type edges already followed before because they were also transitive_type. if seen: edges = [edge for edge in edges if not edge.depflag & self.transitive_type] @@ -200,7 +200,7 @@ def get_visitor_from_args( cover (str): Determines how extensively to cover the dag. Possible values: ``nodes`` -- Visit each unique node in the dag only once. ``edges`` -- If a node has been visited once but is reached along a - new path, it's accepted, but not recurisvely followed. This traverses + new path, it's accepted, but not recursively followed. This traverses each 'edge' in the DAG once. ``paths`` -- Explore every unique path reachable from the root. This descends into visited subtrees and will accept nodes multiple @@ -246,7 +246,7 @@ def traverse_depth_first_edges_generator(edges, visitor, post_order=False, root= Arguments: edges (list): List of EdgeAndDepth instances - visitor: class instance implementing accept() and neigbors() + visitor: class instance implementing accept() and neighbors() post_order (bool): Whether to yield nodes when backtracking root (bool): whether to yield at depth 0 depth (bool): when ``True`` yield a tuple of depth and edge, otherwise only the @@ -514,7 +514,7 @@ def traverse_edges( cover: Determines how extensively to cover the dag. Possible values: ``nodes`` -- Visit each unique node in the dag only once. ``edges`` -- If a node has been visited once but is reached along a new path, it's - accepted, but not recurisvely followed. This traverses each 'edge' in the DAG once. + accepted, but not recursively followed. This traverses each 'edge' in the DAG once. ``paths`` -- Explore every unique path reachable from the root. This descends into visited subtrees and will accept nodes multiple times if they're reachable by multiple paths. @@ -626,7 +626,7 @@ def traverse_nodes( cover: Determines how extensively to cover the dag. Possible values: ``nodes`` -- Visit each unique node in the dag only once. ``edges`` -- If a node has been visited once but is reached along a new path, it's - accepted, but not recurisvely followed. This traverses each 'edge' in the DAG once. + accepted, but not recursively followed. This traverses each 'edge' in the DAG once. ``paths`` -- Explore every unique path reachable from the root. This descends into visited subtrees and will accept nodes multiple times if they're reachable by multiple paths. @@ -673,7 +673,7 @@ def traverse_tree( cover: Determines how extensively to cover the dag. Possible values: ``nodes`` -- Visit each unique node in the dag only once. ``edges`` -- If a node has been visited once but is reached along a - new path, it's accepted, but not recurisvely followed. This traverses each 'edge' in + new path, it's accepted, but not recursively followed. This traverses each 'edge' in the DAG once. ``paths`` -- Explore every unique path reachable from the root. This descends into visited subtrees and will accept nodes multiple times if they're reachable by multiple diff --git a/lib/spack/spack/url.py b/lib/spack/spack/url.py index 0aabc7e40d5525..682db8cad91937 100644 --- a/lib/spack/spack/url.py +++ b/lib/spack/spack/url.py @@ -169,7 +169,7 @@ def parse_version_offset(path: str) -> Tuple[str, int, int, int, str]: # ] # # The first regex that matches string will be used to determine - # the version of the package. Thefore, hyperspecific regexes should + # the version of the package. Therefore, hyperspecific regexes should # come first while generic, catch-all regexes should come last. # With that said, regular expressions are slow, so if possible, put # ones that only catch one or two URLs at the bottom. @@ -357,7 +357,7 @@ def parse_name_offset( # ] # # The first regex that matches string will be used to determine - # the name of the package. Thefore, hyperspecific regexes should + # the name of the package. Therefore, hyperspecific regexes should # come first while generic, catch-all regexes should come last. # With that said, regular expressions are slow, so if possible, put # ones that only catch one or two URLs at the bottom. diff --git a/lib/spack/spack/url_buildcache.py b/lib/spack/spack/url_buildcache.py index 24a3c0cca61822..ac382f86e7cfd3 100644 --- a/lib/spack/spack/url_buildcache.py +++ b/lib/spack/spack/url_buildcache.py @@ -159,7 +159,7 @@ class URLBuildcacheEntry: This class manages access to a versioned buildcache entry by providing a means to download both the metadata (spec file) and compressed archive. - It also provides methods for accessing the paths/urls associcated with + It also provides methods for accessing the paths/urls associated with buildcache entries. Starting with buildcache layout version 3, it is not possible to know @@ -448,7 +448,7 @@ def read_manifest(self, manifest_url: Optional[str] = None) -> BuildcacheManifes if self.manifest: if not manifest_url or manifest_url == self.remote_manifest_url: # We already have a manifest, so now calling this method without a specific - # manifiest url, or with the same one we have internally, then skip reading + # manifest url, or with the same one we have internally, then skip reading # again, and just return the manifest we already read. return self.manifest diff --git a/lib/spack/spack/util/archive.py b/lib/spack/spack/util/archive.py index 99c155f1b21112..c96ce32619e79a 100644 --- a/lib/spack/spack/util/archive.py +++ b/lib/spack/spack/util/archive.py @@ -101,7 +101,7 @@ def gzip_compressed_tarfile( path: str, ) -> Generator[Tuple[tarfile.TarFile, ChecksumWriter, ChecksumWriter], None, None]: """Create a reproducible, gzip compressed tarfile, and keep track of shasums of both the - compressed and uncompressed tarfile. Reproduciblity is achived by normalizing the gzip header + compressed and uncompressed tarfile. Reproducibility is achieved by normalizing the gzip header (no file name and zero mtime). Yields: @@ -114,7 +114,7 @@ def gzip_compressed_tarfile( # Create gzip compressed tarball of the install prefix # 1) Use explicit empty filename and mtime 0 for gzip header reproducibility. # If the filename="" is dropped, Python will use fileobj.name instead. - # This should effectively mimick `gzip --no-name`. + # This should effectively mimic `gzip --no-name`. # 2) On AMD Ryzen 3700X and an SSD disk, we have the following on compression speed: # compresslevel=6 gzip default: llvm takes 4mins, roughly 2.1GB # compresslevel=9 python default: llvm takes 12mins, roughly 2.1GB @@ -259,7 +259,7 @@ def retrieve_commit_from_archive(archive_path, ref): try: with tarfile.open(archive_path, "r") as tar: names = tar.getnames() - # since we always have a prefix and can't gaurantee the value we need this lookup. + # since we always have a prefix and can't guarantee the value we need this lookup. prefix = "" for name in names: if name.endswith(".git"): diff --git a/lib/spack/spack/util/compression.py b/lib/spack/spack/util/compression.py index dc7ba72ca95d1a..32f360fb6eb9db 100644 --- a/lib/spack/spack/util/compression.py +++ b/lib/spack/spack/util/compression.py @@ -128,7 +128,7 @@ def _gunzip(archive_file: str) -> str: def _py_gunzip(archive_file: str) -> str: - """Returns path to gunzip'd file. Decompresses `.gz` compressed archvies via python gzip + """Returns path to gunzip'd file. Decompresses `.gz` compressed archives via python gzip module""" decompressed_file = os.path.basename( spack.llnl.url.strip_compression_extension(archive_file, "gz") @@ -522,7 +522,7 @@ def extension_from_magic_numbers_by_stream( """Returns the typical extension for the opened file, without leading ``.``, based on its magic numbers. - If the stream does not represent file type recongized by Spack (see + If the stream does not represent file type recognized by Spack (see :py:data:`SUPPORTED_FILETYPES`), the method will return None Args: diff --git a/lib/spack/spack/util/cpus.py b/lib/spack/spack/util/cpus.py index feabcc10798724..9676120c8b0365 100644 --- a/lib/spack/spack/util/cpus.py +++ b/lib/spack/spack/util/cpus.py @@ -9,7 +9,7 @@ def cpus_available(): """ Returns the number of CPUs available for the current process, or the number - of phyiscal CPUs when that information cannot be retrieved. The number + of physical CPUs when that information cannot be retrieved. The number of available CPUs might differ from the number of physical CPUs when using spack through Slurm or container runtimes. """ diff --git a/lib/spack/spack/util/crypto.py b/lib/spack/spack/util/crypto.py index 9d514f6c514043..6019ac24721dd2 100644 --- a/lib/spack/spack/util/crypto.py +++ b/lib/spack/spack/util/crypto.py @@ -14,7 +14,7 @@ # Note: keys are ordered by popularity for earliest return in ``hash_key in version_dict`` checks. -#: size of hash digests in bytes, mapped to algoritm names +#: size of hash digests in bytes, mapped to algorithm names _size_to_hash = dict((v, k) for k, v in hashes.items()) diff --git a/lib/spack/spack/util/elf.py b/lib/spack/spack/util/elf.py index d46b50e47235c6..d4d5e68d548ed1 100644 --- a/lib/spack/spack/util/elf.py +++ b/lib/spack/spack/util/elf.py @@ -336,7 +336,7 @@ def parse_pt_dynamic(f: BinaryIO, elf: ElfFile) -> None: except OSError: raise ElfParsingError("Could not seek to PT_DYNAMIC entry") - # In case of broken ELF files, don't read beyond the advertized size. + # In case of broken ELF files, don't read beyond the advertised size. for _ in range(elf.pt_dynamic_p_filesz // dynamic_array_size): data = read_exactly(f, dynamic_array_size, "Malformed dynamic array entry") tag, val = unpack(dynamic_array_fmt, data) diff --git a/lib/spack/spack/util/environment.py b/lib/spack/spack/util/environment.py index 82140855cd360e..41cccf754ae8cf 100644 --- a/lib/spack/spack/util/environment.py +++ b/lib/spack/spack/util/environment.py @@ -22,7 +22,7 @@ from spack.llnl.util.lang import dedupe # List is invariant, so List[str] is not a subtype of List[Union[str, pathlib.PurePath]]. -# Sequence is covariant, but because str itself is a subtype of Sequence[str], we cannot exlude it +# Sequence is covariant, but because str itself is a subtype of Sequence[str], we cannot exclude it # in the type hint. So, use an awkward union type to allow (mixed) str and PurePath items. ListOfPaths = Union[List[str], List[pathlib.PurePath], List[Union[str, pathlib.PurePath]]] @@ -165,7 +165,7 @@ def dump_environment(path: Path, environment: Optional[MutableMapping[str, str]] Args: path: path of the file to write - environment: environment to be writte. If None os.environ is used. + environment: environment to be written. If None os.environ is used. """ use_env = environment or os.environ hidden_vars = {"PS1", "PWD", "OLDPWD", "TERM_SESSION_ID"} diff --git a/lib/spack/spack/util/module_cmd.py b/lib/spack/spack/util/module_cmd.py index a6cb1c8cd3daa9..f6c63467e27925 100644 --- a/lib/spack/spack/util/module_cmd.py +++ b/lib/spack/spack/util/module_cmd.py @@ -188,7 +188,7 @@ def match_pattern_and_strip(line, pattern, strip=[]): def match_flag_and_strip(line, flag, strip=[]): flag_idx = line.find(flag) if flag_idx >= 0: - # Search for the first occurence of any separator marking the end of + # Search for the first occurrence of any separator marking the end of # the path. separators = (" ", '"', "'") occurrences = [line.find(s, flag_idx) for s in separators] diff --git a/lib/spack/spack/util/package_hash.py b/lib/spack/spack/util/package_hash.py index 563843ae9f1369..806e08cede62f3 100644 --- a/lib/spack/spack/util/package_hash.py +++ b/lib/spack/spack/util/package_hash.py @@ -96,7 +96,7 @@ def visit_Expr(self, node): # opposed to function calls through a variable callback). We remove them. # # Note that changes to directives (e.g., a preferred version change or a hash - # chnage on an archive) are already represented in the spec *outside* the + # change on an archive) are already represented in the spec *outside* the # package hash. return ( None @@ -157,7 +157,7 @@ def visit_ClassDef(self, node): if self.in_classdef: return node - # guard against recrusive class definitions + # guard against recursive class definitions self.in_classdef = True self.generic_visit(node) self.in_classdef = False diff --git a/lib/spack/spack/util/path.py b/lib/spack/spack/util/path.py index afa7936ba27a93..aa983840f55fe4 100644 --- a/lib/spack/spack/util/path.py +++ b/lib/spack/spack/util/path.py @@ -96,7 +96,7 @@ def replacements(): #: Padded paths comprise directories with this name (or some prefix of it). : #: It starts with two underscores to make it unlikely that prefix matches would -#: include some other component of the intallation path. +#: include some other component of the installation path. SPACK_PATH_PADDING_CHARS = "__spack_path_placeholder__" #: Special padding char if the padded string would otherwise end with a path @@ -380,7 +380,7 @@ def filter_padding(): padding = spack.config.get("config:install_tree:padded_length", None) if padding: - # filter out all padding from the intsall command output + # filter out all padding from the install command output with tty.output_filter(padding_filter): yield else: diff --git a/lib/spack/spack/util/spack_yaml.py b/lib/spack/spack/util/spack_yaml.py index f1aa1dead2a779..f771368025d62f 100644 --- a/lib/spack/spack/util/spack_yaml.py +++ b/lib/spack/spack/util/spack_yaml.py @@ -8,7 +8,7 @@ us to access file and line information later. - ``Our load methods use ``OrderedDict`` class instead of YAML's - default unorderd dict. + default unordered dict. """ import ctypes diff --git a/lib/spack/spack/util/windows_registry.py b/lib/spack/spack/util/windows_registry.py index 12d2b3b2359ad7..e205cff5ffd991 100644 --- a/lib/spack/spack/util/windows_registry.py +++ b/lib/spack/spack/util/windows_registry.py @@ -408,7 +408,7 @@ def __init__(self, key): class InvalidRegistryOperation(RegistryError): - """A Runtime Error ecountered when a registry operation is invalid for + """A Runtime Error encountered when a registry operation is invalid for an indeterminate reason""" def __init__(self, name, e, *args, **kwargs): diff --git a/lib/spack/spack/verify_libraries.py b/lib/spack/spack/verify_libraries.py index c69c9de28b5453..c6e4c8c7f62682 100644 --- a/lib/spack/spack/verify_libraries.py +++ b/lib/spack/spack/verify_libraries.py @@ -110,7 +110,7 @@ def visit_file(self, root: str, rel_path: str, depth: int) -> None: # We work with byte strings for paths. path = os.path.join(root, rel_path).encode("utf-8") - # For $ORIGIN interpolation: should not have trailing dir seperator. + # For $ORIGIN interpolation: should not have trailing dir separator. origin = os.path.dirname(path) # Retrieve the needed libs + rpaths. diff --git a/lib/spack/spack/version/version_types.py b/lib/spack/spack/version/version_types.py index 63641ecc3a5814..3d59c7a36b8d6e 100644 --- a/lib/spack/spack/version/version_types.py +++ b/lib/spack/spack/version/version_types.py @@ -684,7 +684,7 @@ def __le__(self, other: object) -> bool: if isinstance(other, GitVersion): return (self.ref_version, self.ref) <= (other.ref_version, other.ref) if isinstance(other, StandardVersion): - # Note: GitVersion hash=1.2.3 > StandardVersion 1.2.3, so use < comparsion. + # Note: GitVersion hash=1.2.3 > StandardVersion 1.2.3, so use < comparison. return self.ref_version < other if isinstance(other, ClosedOpenRange): # Equality is not a thing diff --git a/share/spack/bash/spack-completion.bash b/share/spack/bash/spack-completion.bash index aa5072ed43013a..cf4b273937177d 100755 --- a/share/spack/bash/spack-completion.bash +++ b/share/spack/bash/spack-completion.bash @@ -104,7 +104,7 @@ _bash_completion_spack() { local COMP_CWORD_NO_FLAGS=$((${#COMP_WORDS_NO_FLAGS[@]} - 1)) # There is no guarantee that the cursor is at the end of the command line - # when tab completion is envoked. For example, in the following situation: + # when tab completion is invoked. For example, in the following situation: # `spack -d [] install` # if the user presses the TAB key, a list of valid flags should be listed. # Note that we cannot simply ignore everything after the cursor. In the @@ -119,7 +119,7 @@ _bash_completion_spack() { list_options=true fi - # In general, when envoking tab completion, the user is not expecting to + # In general, when invoking tab completion, the user is not expecting to # see optional flags mixed in with subcommands or package names. Tab # completion is used by those who are either lazy or just bad at spelling. # If someone doesn't remember what flag to use, seeing single letter flags diff --git a/share/spack/setup-env.ps1 b/share/spack/setup-env.ps1 index 99683099f8a4d9..16399d403f0264 100644 --- a/share/spack/setup-env.ps1 +++ b/share/spack/setup-env.ps1 @@ -11,7 +11,7 @@ Pop-Location Set-Variable -Name python_pf_ver -Value (Get-Command -Name python -ErrorAction SilentlyContinue).Path # If python_pf_ver is not defined, we cannot find Python on the Path -# We next look for Spack vendored copys +# We next look for Spack vendored copies if ($null -eq $python_pf_ver) { $python_pf_ver_list = Resolve-Path -Path "$PWD\Python*" diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 888b1d5a5f0821..5e19d81e070ddc 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -104,7 +104,7 @@ _bash_completion_spack() { local COMP_CWORD_NO_FLAGS=$((${#COMP_WORDS_NO_FLAGS[@]} - 1)) # There is no guarantee that the cursor is at the end of the command line - # when tab completion is envoked. For example, in the following situation: + # when tab completion is invoked. For example, in the following situation: # `spack -d [] install` # if the user presses the TAB key, a list of valid flags should be listed. # Note that we cannot simply ignore everything after the cursor. In the @@ -119,7 +119,7 @@ _bash_completion_spack() { list_options=true fi - # In general, when envoking tab completion, the user is not expecting to + # In general, when invoking tab completion, the user is not expecting to # see optional flags mixed in with subcommands or package names. Tab # completion is used by those who are either lazy or just bad at spelling. # If someone doesn't remember what flag to use, seeing single letter flags diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 4b2ad9f2730060..17d8693285a4ff 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -881,7 +881,7 @@ complete -c spack -n '__fish_spack_using_command buildcache update-index' -s h - complete -c spack -n '__fish_spack_using_command buildcache update-index' -l name -s n -r -f -a name complete -c spack -n '__fish_spack_using_command buildcache update-index' -l name -s n -r -d 'Name of the view index to update' complete -c spack -n '__fish_spack_using_command buildcache update-index' -l append -s a -f -a append -complete -c spack -n '__fish_spack_using_command buildcache update-index' -l append -s a -d 'Append the listed specs to the current view index if it already exists. This operation does not guarentee atomic write and should be run with care.' +complete -c spack -n '__fish_spack_using_command buildcache update-index' -l append -s a -d 'Append the listed specs to the current view index if it already exists. This operation does not guarantee atomic write and should be run with care.' complete -c spack -n '__fish_spack_using_command buildcache update-index' -l force -s f -f -a force complete -c spack -n '__fish_spack_using_command buildcache update-index' -l force -s f -d 'If a view index already exists, overwrite it and suppress warnings (this is the default for non-view indices)' complete -c spack -n '__fish_spack_using_command buildcache update-index' -s k -l keys -f -a keys @@ -897,7 +897,7 @@ complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -s h complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -l name -s n -r -f -a name complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -l name -s n -r -d 'Name of the view index to update' complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -l append -s a -f -a append -complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -l append -s a -d 'Append the listed specs to the current view index if it already exists. This operation does not guarentee atomic write and should be run with care.' +complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -l append -s a -d 'Append the listed specs to the current view index if it already exists. This operation does not guarantee atomic write and should be run with care.' complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -l force -s f -f -a force complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -l force -s f -d 'If a view index already exists, overwrite it and suppress warnings (this is the default for non-view indices)' complete -c spack -n '__fish_spack_using_command buildcache rebuild-index' -s k -l keys -f -a keys @@ -3362,7 +3362,7 @@ complete -c spack -n '__fish_spack_using_command verify manifest' -s h -l help - complete -c spack -n '__fish_spack_using_command verify manifest' -s l -l local -f -a local complete -c spack -n '__fish_spack_using_command verify manifest' -s l -l local -d 'verify only locally installed packages' complete -c spack -n '__fish_spack_using_command verify manifest' -s j -l json -f -a json -complete -c spack -n '__fish_spack_using_command verify manifest' -s j -l json -d 'ouptut json-formatted errors' +complete -c spack -n '__fish_spack_using_command verify manifest' -s j -l json -d 'output json-formatted errors' complete -c spack -n '__fish_spack_using_command verify manifest' -s a -l all -f -a all complete -c spack -n '__fish_spack_using_command verify manifest' -s a -l all -d 'verify all packages' complete -c spack -n '__fish_spack_using_command verify manifest' -s s -l specs -f -a type diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/attributes_foo/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/attributes_foo/package.py index 75d7c359f902ba..23197eec546358 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/attributes_foo/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/attributes_foo/package.py @@ -42,12 +42,12 @@ def headers(self): def libs(self): return find_libraries("libFoo", root=self.home, recursive=True) - # Header provided by the bar virutal package + # Header provided by the bar virtual package @property def bar_headers(self): return find_headers("bar", root=self.home.include, recursive=True) - # Libary provided by the bar virtual package + # Library provided by the bar virtual package @property def bar_libs(self): return find_libraries("libFooBar", root=self.home, recursive=True) diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/autotools_config_replacement/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/autotools_config_replacement/package.py index c042286982fe1b..5b27890ea2deab 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/autotools_config_replacement/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/autotools_config_replacement/package.py @@ -47,7 +47,7 @@ def install(self, spec, prefix): def create_the_package_sources(self): # Creates the following file structure: # ./broken/config.sub -- not executable - # ./broken/config.guess -- exectuable & exit code 1 + # ./broken/config.guess -- executable & exit code 1 # ./working/config.sub -- executable & exit code 0 # ./working/config.guess -- executable & exit code 0 # Automatic config helper script substitution should replace the two @@ -70,7 +70,7 @@ def create_the_package_sources(self): with open(broken_config_sub, "w", encoding="utf-8") as f: f.write("#!/bin/sh\nexit 0") - # broken config.guess (exectuable but with error return code) + # broken config.guess (executable but with error return code) broken_config_guess = join_path(broken, "config.guess") with open(broken_config_guess, "w", encoding="utf-8") as f: f.write("#!/bin/sh\nexit 1") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/boost/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/boost/package.py index 66c21a34669322..e05d9c6766fc25 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/boost/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/boost/package.py @@ -61,6 +61,6 @@ class Boost(Package): variant( "singlethreaded", default=False, description="Build single-threaded versions of libraries" ) - variant("icu", default=False, description="Build with Unicode and ICU suport") + variant("icu", default=False, description="Build with Unicode and ICU support") variant("graph", default=False, description="Build the Boost Graph library") variant("taggedlayout", default=False, description="Augment library names with build options") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/conditionally_patch_dependency/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/conditionally_patch_dependency/package.py index 3c7df9fb283f9c..3fd2322aa5719d 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/conditionally_patch_dependency/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/conditionally_patch_dependency/package.py @@ -9,7 +9,7 @@ class ConditionallyPatchDependency(Package): - """Package that conditionally requries a patched version + """Package that conditionally requires a patched version of a dependency.""" homepage = "http://www.example.com" diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/patch/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/patch/package.py index d02c8a5f84c58c..7ab97dd06e428a 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/patch/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/patch/package.py @@ -8,7 +8,7 @@ class Patch(Package): - """Package that requries a patched version of a dependency.""" + """Package that requires a patched version of a dependency.""" homepage = "http://www.example.com" url = "http://www.example.com/patch-1.0.tar.gz" diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_a_dependency/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_a_dependency/package.py index 8960d60a680d3d..db3113c0d6348d 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_a_dependency/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_a_dependency/package.py @@ -8,7 +8,7 @@ class PatchADependency(Package): - """Package that requries a patched version of a dependency.""" + """Package that requires a patched version of a dependency.""" homepage = "http://www.example.com" url = "http://www.example.com/patch-a-dependency-1.0.tar.gz" diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_several_dependencies/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_several_dependencies/package.py index ded1209b307178..978db4841e6d07 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_several_dependencies/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/patch_several_dependencies/package.py @@ -8,7 +8,7 @@ class PatchSeveralDependencies(Package): - """Package that requries multiple patches on a dependency.""" + """Package that requires multiple patches on a dependency.""" homepage = "http://www.example.com" url = "http://www.example.com/patch-a-dependency-1.0.tar.gz" diff --git a/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py b/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py index d18a8c5006020f..2d754eefcf4615 100644 --- a/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py +++ b/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py @@ -269,7 +269,7 @@ def libs(self): # starting version 1.8.10) does not produce it. Instead, the # basename of the library file is 'libhdf5_hl_fortran'. Which # means that switching to CMake requires rebuilding of all - # dependant packages that use the High-level Fortran interface. + # dependent packages that use the High-level Fortran interface. # Therefore, we do not try to preserve backward compatibility # with Autotools installations by creating symlinks. The only # packages that could benefit from it would be those that From d6df756508ad3294c7234b8c3ce227cbacfd1499 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 10 Mar 2026 08:35:07 +0100 Subject: [PATCH 125/337] new_installer.py: archive build provenance metadata (#51999) Archive spack-build-out.txt.gz, spack-build-env.txt, spack-configure-args.txt, install-time-test-log.txt, repos/, and archived-files/ into the .spack metadata directory after a successful build, matching what the old installer writes. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 93 +++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 5c5cdfe703fa43..9fc945456a3d03 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -21,11 +21,13 @@ of logs), and for output from the build process.""" import fcntl +import glob import io import json import os import re import selectors +import shlex import shutil import signal import sys @@ -62,6 +64,7 @@ import spack.deptypes as dt import spack.error import spack.hooks +import spack.llnl.util.filesystem as fs import spack.llnl.util.tty import spack.paths import spack.report @@ -70,7 +73,9 @@ import spack.store import spack.traverse import spack.url_buildcache +import spack.util.environment import spack.util.lock +from spack.installer import dump_packages if TYPE_CHECKING: import spack.package_base @@ -445,6 +450,75 @@ def handle_sigterm(signum, frame): sys.exit(exit_code) +def _archive_build_metadata(pkg: "spack.package_base.PackageBase") -> None: + """Copy build metadata from stage to install prefix .spack directory. + + Mirrors what the old installer's log() function does in the parent process. + Only called after a successful source build (not for binary cache installs). + Errors are suppressed to avoid failing the build over metadata archiving.""" + + try: + if os.path.lexists(pkg.env_mods_path): + shutil.copy2(pkg.env_mods_path, pkg.install_env_path) + except OSError as e: + spack.llnl.util.tty.debug(e) + try: + if os.path.lexists(pkg.configure_args_path): + shutil.copy2(pkg.configure_args_path, pkg.install_configure_args_path) + except OSError as e: + spack.llnl.util.tty.debug(e) + + # Archive install-phase test log if present + try: + pkg.archive_install_test_log() + except Exception as e: + spack.llnl.util.tty.debug(e) + + # Archive package-specific files matched by archive_files glob patterns + try: + with fs.working_dir(pkg.stage.path): + target_dir = os.path.join( + spack.store.STORE.layout.metadata_path(pkg.spec), "archived-files" + ) + errors = io.StringIO() + for glob_expr in spack.builder.create(pkg).archive_files: + abs_expr = os.path.realpath(glob_expr) + if os.path.realpath(pkg.stage.path) not in abs_expr: + errors.write(f"[OUTSIDE SOURCE PATH]: {glob_expr}\n") + continue + if os.path.isabs(glob_expr): + glob_expr = os.path.relpath(glob_expr, pkg.stage.path) + for f in glob.glob(glob_expr): + try: + target = os.path.join(target_dir, f) + fs.mkdirp(os.path.dirname(target)) + fs.install(f, target) + except Exception as e: + spack.llnl.util.tty.debug(e) + errors.write(f"[FAILED TO ARCHIVE]: {f}") + if errors.getvalue(): + error_file = os.path.join(target_dir, "errors.txt") + fs.mkdirp(target_dir) + with open(error_file, "w", encoding="utf-8") as err: + err.write(errors.getvalue()) + spack.llnl.util.tty.warn( + f"Errors occurred when archiving files.\n\tSee: {error_file}" + ) + except Exception as e: + spack.llnl.util.tty.debug(e) + + try: + packages_dir = spack.store.STORE.layout.build_packages_path(pkg.spec) + dump_packages(pkg.spec, packages_dir) + except Exception as e: + spack.llnl.util.tty.debug(e) + + try: + spack.store.STORE.layout.write_host_environment(pkg.spec) + except Exception as e: + spack.llnl.util.tty.debug(e) + + def _install( spec: spack.spec.Spec, explicit: bool, @@ -474,7 +548,8 @@ def _install( send_state("no binary available", state_stream) raise spack.error.InstallError(f"No binary available for {spec}") - spack.build_environment.setup_package(pkg, dirty=dirty) + unmodified_env = os.environ.copy() + env_mods = spack.build_environment.setup_package(pkg, dirty=dirty) store.layout.create_install_directory(spec) stage = pkg.stage @@ -486,6 +561,21 @@ def _install( stage.destroy() stage.create() + # Write build environment and env-mods to stage + spack.util.environment.dump_environment(pkg.env_path) + with open(pkg.env_mods_path, "w", encoding="utf-8") as f: + f.write(env_mods.shell_modifications(explicit=True, env=unmodified_env)) + + # Try to snapshot configure/cmake args before phases run + for attr in ("configure_args", "cmake_args"): + try: + args = getattr(pkg, attr)() + with open(pkg.configure_args_path, "w", encoding="utf-8") as f: + f.write(" ".join(shlex.quote(a) for a in args)) + break + except Exception: + pass + # For develop packages or non-develop packages with --keep-stage there may be a # pre-existing symlink at pkg.log_path which would cause the new symlink to fail. # Try removing it if it exists. @@ -510,6 +600,7 @@ def _install( send_state(phase.name, state_stream) phase.execute() + _archive_build_metadata(pkg) spack.hooks.post_install(spec, explicit) From 7ab5c181a2605467217614288e411301f51d8a53 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 10 Mar 2026 18:23:59 +0100 Subject: [PATCH 126/337] mailmap: add Harmen Stoppels (#52035) Signed-off-by: Harmen Stoppels --- .mailmap | 1 + 1 file changed, 1 insertion(+) diff --git a/.mailmap b/.mailmap index 564a8ad2bc58ce..cb48793427abab 100644 --- a/.mailmap +++ b/.mailmap @@ -29,6 +29,7 @@ Gregory L. Lee Gregory L. Lee Gregory L. Lee Gregory L. Lee Gregory L. Lee Gregory L. Lee Gregory L. Lee Gregory Lee +Harmen Stoppels Harmen Stoppels Ian Lee Ian Lee James Wynne III James Riley Wynne III James Wynne III James Wynne III From 638120ff90a464a0bd24deaa304604868dcf4af3 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 10 Mar 2026 21:02:13 +0100 Subject: [PATCH 127/337] lock.py: re-affirm POSIX lock to support forking (#52004) * lock.py: re-affirm lock to support forking When doing acquire_write, fork, acquire_write, the forked process currently does not error, because the `_writes` counter is `1` after the fork. But forking does not inherit locks, because it's a different pid. With this change, on a nested lock, we always do the lock syscall, which is a no-op for the process that acquired the lock, but an error for the forked process. Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/lock.py | 34 ++++++++++---- lib/spack/spack/test/database.py | 2 +- lib/spack/spack/test/llnl/util/lock.py | 61 ++++++++++++++++++++++++++ lib/spack/spack/util/lock.py | 4 ++ 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/lib/spack/spack/llnl/util/lock.py b/lib/spack/spack/llnl/util/lock.py index 7424dcbf145d30..cd2ec4a3b733a3 100644 --- a/lib/spack/spack/llnl/util/lock.py +++ b/lib/spack/spack/llnl/util/lock.py @@ -381,9 +381,7 @@ def _lock(self, op: int, timeout: Optional[float] = None) -> Tuple[float, int]: time.sleep(next(poll_intervals)) num_attempts += 1 - raise LockTimeoutError( - op_str.lower(), self.path, time.monotonic() - start_time, num_attempts - ) + raise LockTimeoutError(op, self.path, time.monotonic() - start_time, num_attempts) def _poll_lock(self, op: int) -> bool: """Attempt to acquire the lock in a non-blocking manner. Return whether @@ -482,6 +480,7 @@ def acquire_read(self, timeout: Optional[float] = None) -> bool: return True else: # Increment the read count for nested lock tracking + self._reaffirm_lock() self._reads += 1 return False @@ -510,9 +509,25 @@ def acquire_write(self, timeout: Optional[float] = None) -> bool: return self._reads == 0 else: # Increment the write count for nested lock tracking + self._reaffirm_lock() self._writes += 1 return False + def _reaffirm_lock(self) -> None: + """Fork-safety: always re-affirm the lock with one non-blocking attempt. In the same + process, re-locking an already-held byte range succeeds instantly (POSIX). In a forked + child that doesn't own the POSIX lock, the call fails immediately and we raise. Use WRITE + if we hold an exclusive lock so we don't accidentally downgrade it.""" + if self._writes > 0: + op = LockType.WRITE + elif self._reads > 0: + op = LockType.READ + else: + return + self._ensure_valid_handle() + if not self._poll_lock(op): + raise LockTimeoutError(op, self.path, time=0, attempts=1) + def is_write_locked(self) -> bool: """Returns ``True`` if the path is write locked, otherwise, ``False``""" try: @@ -790,7 +805,7 @@ class LockError(Exception): class LockDowngradeError(LockError): """Raised when unable to downgrade from a write to a read lock.""" - def __init__(self, path): + def __init__(self, path: str) -> None: msg = "Cannot downgrade lock from write to read on file: %s" % path super().__init__(msg) @@ -798,11 +813,12 @@ def __init__(self, path): class LockTimeoutError(LockError): """Raised when an attempt to acquire a lock times out.""" - def __init__(self, lock_type, path, time, attempts): + def __init__(self, lock_type: int, path: str, time: float, attempts: int) -> None: + lock_type_str = LockType.to_str(lock_type).lower() fmt = "Timed out waiting for a {} lock after {}.\n Made {} {} on file: {}" super().__init__( fmt.format( - lock_type, + lock_type_str, lang.pretty_seconds(time), attempts, "attempt" if attempts == 1 else "attempts", @@ -814,7 +830,7 @@ def __init__(self, lock_type, path, time, attempts): class LockUpgradeError(LockError): """Raised when unable to upgrade from a read to a write lock.""" - def __init__(self, path): + def __init__(self, path: str) -> None: msg = "Cannot upgrade lock from read to write on file: %s" % path super().__init__(msg) @@ -826,7 +842,7 @@ class LockPermissionError(LockError): class LockROFileError(LockPermissionError): """Tried to take an exclusive lock on a read-only file.""" - def __init__(self, path): + def __init__(self, path: str) -> None: msg = "Can't take write lock on read-only file: %s" % path super().__init__(msg) @@ -834,7 +850,7 @@ def __init__(self, path): class CantCreateLockError(LockPermissionError): """Attempt to create a lock in an unwritable location.""" - def __init__(self, path): + def __init__(self, path: str) -> None: msg = "cannot create lock '%s': " % path msg += "file does not exist and location is not writable" super().__init__(msg) diff --git a/lib/spack/spack/test/database.py b/lib/spack/spack/test/database.py index 42fdde1b9010f6..7f6eaa7d682037 100644 --- a/lib/spack/spack/test/database.py +++ b/lib/spack/spack/test/database.py @@ -1009,7 +1009,7 @@ def test_mark_failed(mutable_database, monkeypatch, tmp_path: pathlib.Path, capf """Add coverage to mark_failed.""" def _raise_exc(lock): - raise lk.LockTimeoutError("write", "/mock-lock", 1.234, 10) + raise lk.LockTimeoutError(lk.LockType.WRITE, "/mock-lock", 1.234, 10) with fs.working_dir(str(tmp_path)): s = spack.concretize.concretize_one("pkg-a") diff --git a/lib/spack/spack/test/llnl/util/lock.py b/lib/spack/spack/test/llnl/util/lock.py index 577a73ade2832a..a01591afa511b1 100644 --- a/lib/spack/spack/test/llnl/util/lock.py +++ b/lib/spack/spack/test/llnl/util/lock.py @@ -29,6 +29,7 @@ import errno import getpass import glob +import multiprocessing import os import pathlib import shutil @@ -1357,3 +1358,63 @@ def test_upgrade_read_fails(tmp_path: pathlib.Path): with pytest.raises(lk.LockUpgradeError, match=msg): lock.upgrade_read_to_write() lock.release_write() + + +@pytest.mark.parametrize("acquire", ["acquire_write", "acquire_read"]) +def test_acquire_after_fork(tmp_path: pathlib.Path, acquire: str): + """After fork, acquire_write/read must not silently succeed due to inherited counters.""" + try: + ctx = multiprocessing.get_context("fork") + except ValueError: + pytest.skip("fork start method not available on this platform") + + lockfile = str(tmp_path / "lockfile") + lock = lk.Lock(lockfile) + result = ctx.Queue() + + def child(): + assert lock._writes == 1 # due to forking, but POSIX lock is NOT held by this process + try: + if acquire == "acquire_write": + lock.acquire_write(lock_fail_timeout) + elif acquire == "acquire_read": + lock.acquire_read(lock_fail_timeout) + else: + assert False # should never get here + result.put("no_error") + except lk.LockTimeoutError: + result.put("timed_out") + + lock.acquire_write() + try: + p = ctx.Process(target=child) + p.start() + p.join() + assert result.get() == "timed_out" + finally: + lock.release_write() + + +def _child_fails_to_acquire_read(_lock: lk.Lock): + try: + _lock.acquire_read(timeout=1e-9) + except lk.LockTimeoutError: + return + assert False, "Child process should not have been able to acquire read lock" + + +def test_read_after_write_does_not_accidentally_downgrade(tmp_path: pathlib.Path): + """Test that acquiring a read lock after a write lock does not accidentally downgrade the + write lock, by having another process attempt to acquire a read lock.""" + lock = lk.Lock(str(tmp_path / "lockfile")) + lock.acquire_write() + lock.acquire_read() # should not downgrade the write lock + try: + # No matter the start method, the child process shouldn't be able to acquire a read lock. + p = multiprocessing.Process(target=_child_fails_to_acquire_read, args=(lock,)) + p.start() + p.join() + assert p.exitcode == 0 + finally: + lock.release_read() + lock.release_write() diff --git a/lib/spack/spack/util/lock.py b/lib/spack/spack/util/lock.py index e407f7099cbd67..060005347b49b9 100644 --- a/lib/spack/spack/util/lock.py +++ b/lib/spack/spack/util/lock.py @@ -48,6 +48,10 @@ def __init__( desc=desc, ) + def _reaffirm_lock(self) -> None: + if self._enable: + super()._reaffirm_lock() + def _lock(self, op: int, timeout: Optional[float] = 0.0) -> Tuple[float, int]: if self._enable: return super()._lock(op, timeout) From c8dc13e8531d74e2e2d2c74d2488389bc850832e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 11 Mar 2026 09:04:13 +0100 Subject: [PATCH 128/337] new_installer.py: reset stdout/stderr on Tee exit (#52053) Previously the build subprocess would close stdout/stderr, which in turn would unblock the Tee thread so it could be `.join`ed and then exit the build process. That does not work in certain hard to troubleshoot edge cases witnessed on macOS + pytest-xdist + coverage, where presumably stdout/stderr is flushed between sys.exit and end-of-process. Since Python already makes it hard to close stdout/stderr (sys.stdout.close() is a no-op), the approach taken here is to dup the file descriptor and restore it afterwards to not break possible atexit handlers or cpython internals. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 9fc945456a3d03..982d36e32e4856 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -205,6 +205,8 @@ class Tee: def __init__(self, control: Connection, parent: Connection, log_fd: int) -> None: self.control = control self.parent = parent + self.saved_stdout = os.dup(sys.stdout.fileno()) + self.saved_stderr = os.dup(sys.stderr.fileno()) #: The file descriptor of the log file self.log_fd = log_fd r, w = os.pipe() @@ -220,11 +222,15 @@ def __init__(self, control: Connection, parent: Connection, log_fd: int) -> None def close(self) -> None: # Closing stdout and stderr should close the last reference to the write end of the pipe, - # causing the tee thread to wake up, flush the last data, and exit. + # causing the tee thread to wake up, flush the last data, and exit. We restore stdout and + # stderr, because between sys.exit and the actual process exit buffers may be flushed, and + # can cause exit code 120 (witnessed under pytest+coverage on macOS). sys.stdout.flush() sys.stderr.flush() - os.close(sys.stdout.fileno()) - os.close(sys.stderr.fileno()) + os.dup2(self.saved_stdout, sys.stdout.fileno()) + os.dup2(self.saved_stderr, sys.stderr.fileno()) + os.close(self.saved_stdout) + os.close(self.saved_stderr) self.tee_thread.join() # Only then close the other fds. self.control.close() From f6ae52c35b516e1f75b0b76d4bd6397ab1affb41 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 11 Mar 2026 09:42:45 +0100 Subject: [PATCH 129/337] new_installer.py: no log file for externals (#52055) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 982d36e32e4856..260e28680c8e0a 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -735,12 +735,16 @@ def start_build( makeflags = jobserver.makeflags(gmake) fifo = "--jobserver-auth=fifo:" in makeflags - log_fd, log_path = tempfile.mkstemp( - prefix=f"spack-stage-{spec.name}-{spec.version}-{spec.dag_hash()}-", - suffix=".log", - dir=spack.stage.get_stage_root(), - ) - os.close(log_fd) # child will open it + # TODO: remove once external specs do not create a build process + if spec.external: + log_path = os.devnull + else: + log_fd, log_path = tempfile.mkstemp( + prefix=f"spack-stage-{spec.name}-{spec.version}-{spec.dag_hash()}-", + suffix=".log", + dir=spack.stage.get_stage_root(), + ) + os.close(log_fd) # child will open it proc = Process( target=worker_function, From a7bfaf7ebd45e7c93ec176602f57f6ced410fda7 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 11 Mar 2026 10:55:29 +0100 Subject: [PATCH 130/337] spack buildcache update-index: add timing when verbose is active (#52051) When verbose, displays a summary of where the time was spent at the end of the execution. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/binary_distribution.py | 68 ++++++++++++++++---------- lib/spack/spack/ci/common.py | 2 +- lib/spack/spack/cmd/buildcache.py | 17 +++++-- lib/spack/spack/test/cmd/ci.py | 3 +- 4 files changed, 58 insertions(+), 32 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index 2d4f6728f58d92..dc582245fedf50 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -670,6 +670,8 @@ def _read_specs_and_push_index( cache_prefix: str, db: BuildCacheDatabase, temp_dir: str, + *, + timer=timer.NULL_TIMER, ): """Read listed specs, generate the index, and push it to the mirror. @@ -681,32 +683,34 @@ def _read_specs_and_push_index( db: A spack database used for adding specs and then writing the index. temp_dir: Location to write index.json and hash for pushing """ - for file in file_list: - # All supported versions of build caches put the hash as the last - # parameter before the extension - try: - x = file.split("/")[-1].split("-")[-1].split(".")[0] - except IndexError: - raise GenerateIndexError(f"Malformed metadata file name detected {file}") + with timer.measure("read"): + for file in file_list: + # All supported versions of build caches put the hash as the last + # parameter before the extension + try: + x = file.split("/")[-1].split("-")[-1].split(".")[0] + except IndexError: + raise GenerateIndexError(f"Malformed metadata file name detected {file}") - if not filter_fn(x): - continue + if not filter_fn(x): + continue - cache_entry: Optional[URLBuildcacheEntry] = None - try: - cache_entry = read_method(file) - spec_dict = cache_entry.fetch_metadata() - fetched_spec = spack.spec.Spec.from_dict(spec_dict) - except Exception as e: - tty.warn(f"Unable to fetch spec for manifest {file} due to: {e}") - continue - finally: - if cache_entry: - cache_entry.destroy() - db.add(fetched_spec) - db.mark(fetched_spec, "in_buildcache", True) + cache_entry: Optional[URLBuildcacheEntry] = None + try: + cache_entry = read_method(file) + spec_dict = cache_entry.fetch_metadata() + fetched_spec = spack.spec.Spec.from_dict(spec_dict) + except Exception as e: + tty.warn(f"Unable to fetch spec for manifest {file} due to: {e}") + continue + finally: + if cache_entry: + cache_entry.destroy() + db.add(fetched_spec) + db.mark(fetched_spec, "in_buildcache", True) - _push_index(db, temp_dir, cache_prefix, name) + with timer.measure("push"): + _push_index(db, temp_dir, cache_prefix, name) def _url_generate_package_index( @@ -715,6 +719,8 @@ def _url_generate_package_index( db: Optional[BuildCacheDatabase] = None, name: str = "", filter_fn: Callable[[str], bool] = lambda x: True, + *, + timer=timer.NULL_TIMER, ): """Create or replace the build cache index on the given mirror. The buildcache index contains an entry for each binary package under the @@ -728,9 +734,10 @@ def _url_generate_package_index( """ with tempfile.TemporaryDirectory(dir=spack.stage.get_stage_root()) as tmpspecsdir: try: - filename_to_mtime_mapping, read_fn = get_entries_from_cache( - url, tmpspecsdir, component_type=BuildcacheComponent.SPEC - ) + with timer.measure("list"): + filename_to_mtime_mapping, read_fn = get_entries_from_cache( + url, tmpspecsdir, component_type=BuildcacheComponent.SPEC + ) file_list = list(filename_to_mtime_mapping.keys()) except ListMirrorSpecsError as e: raise GenerateIndexError(f"Unable to generate package index: {e}") from e @@ -743,7 +750,14 @@ def _url_generate_package_index( try: _read_specs_and_push_index( - file_list, read_fn, name, filter_fn, url, db, str(db.database_directory) + file_list, + read_fn, + name, + filter_fn, + url, + db, + str(db.database_directory), + timer=timer, ) except Exception as e: raise GenerateIndexError( diff --git a/lib/spack/spack/ci/common.py b/lib/spack/spack/ci/common.py index 4b7b016b3c2e30..51fad1cc2900dd 100644 --- a/lib/spack/spack/ci/common.py +++ b/lib/spack/spack/ci/common.py @@ -593,7 +593,7 @@ def generate_ir(self): # Reindex script { "reindex-job": { - "script:": ["spack buildcache update-index --keys {index_target_mirror}"] + "script:": ["spack -v buildcache update-index --keys {index_target_mirror}"] } }, # Cleanup script diff --git a/lib/spack/spack/cmd/buildcache.py b/lib/spack/spack/cmd/buildcache.py index 12bc0806168bd8..0a9646c2d033dc 100644 --- a/lib/spack/spack/cmd/buildcache.py +++ b/lib/spack/spack/cmd/buildcache.py @@ -25,6 +25,7 @@ import spack.stage import spack.store import spack.util.parallel +import spack.util.timer as timer_mod import spack.util.web as web_util from spack import traverse from spack.binary_distribution import BINARY_INDEX @@ -824,7 +825,10 @@ def manifest_copy( copy_buildcache_entry(src_cache_entry, destination_url) -def update_index(mirror: spack.mirrors.mirror.Mirror, update_keys=False): +def update_index( + mirror: spack.mirrors.mirror.Mirror, update_keys=False, timer=timer_mod.NULL_TIMER +): + timer.start() # Special case OCI images for now. try: image_ref = spack.oci.oci.image_from_mirror(mirror) @@ -842,7 +846,7 @@ def update_index(mirror: spack.mirrors.mirror.Mirror, update_keys=False): url = mirror.push_url with tempfile.TemporaryDirectory(dir=spack.stage.get_stage_root()) as tmpdir: - spack.binary_distribution._url_generate_package_index(url, tmpdir) + spack.binary_distribution._url_generate_package_index(url, tmpdir, timer=timer) if update_keys: mirror_update_keys(mirror) @@ -1083,6 +1087,8 @@ def check_index_fn(args): def update_index_fn(args): """update a buildcache index or index view if extra arguments are provided.""" + t = timer_mod.Timer() if tty.is_verbose() else timer_mod.NullTimer() + update_view_index = ( args.append or args.force or args.name or args.sources or args.mirror.push_view ) @@ -1103,7 +1109,12 @@ def update_index_fn(args): yes_to_all=args.yes_to_all, ) else: - return update_index(args.mirror, update_keys=args.keys) + update_index(args.mirror, update_keys=args.keys, timer=t) + + if tty.is_verbose(): + tty.msg("Timing summary:") + t.stop() + t.write_tty() def migrate_fn(args): diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index 502fcffe9f8d89..7fad8e48de44b6 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -200,7 +200,8 @@ def test_ci_generate_with_env(ci_generate_test, tmp_path: pathlib.Path, mock_bin assert "rebuild-index" in yaml_contents rebuild_job = yaml_contents["rebuild-index"] assert ( - rebuild_job["script"][0] == f"spack buildcache update-index --keys {mirror_url.as_uri()}" + rebuild_job["script"][0] + == f"spack -v buildcache update-index --keys {mirror_url.as_uri()}" ) assert rebuild_job["custom_attribute"] == "custom!" From fee5e77ba2db555f6e7a84ce5a30c44a60b4f094 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 11 Mar 2026 12:00:15 +0100 Subject: [PATCH 131/337] new_installer.py: integration testing (#52049) * new_installer.py: integration testing * Mimic GlobalStateMarshaller, but (a) drop unnecessary package related serialization bits and (b) do not serialize the active env * Add support for `--fake` used in tests. * Enable some unit tests for the new installer by parametrizing the installer config (old/new). Signed-off-by: Harmen Stoppels * GlobalState: dynamic instead of at a class definition Signed-off-by: Harmen Stoppels --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 61 +++++++++++++++++++++------ lib/spack/spack/test/cmd/dev_build.py | 8 ++-- lib/spack/spack/test/cmd/env.py | 2 +- lib/spack/spack/test/cmd/install.py | 4 +- lib/spack/spack/test/conftest.py | 9 ++++ 5 files changed, 65 insertions(+), 19 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 260e28680c8e0a..7e0491d3291df1 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -24,6 +24,7 @@ import glob import io import json +import multiprocessing import os import re import selectors @@ -71,11 +72,12 @@ import spack.spec import spack.stage import spack.store +import spack.subprocess_context import spack.traverse import spack.url_buildcache import spack.util.environment import spack.util.lock -from spack.installer import dump_packages +from spack.installer import _do_fake_install, dump_packages if TYPE_CHECKING: import spack.package_base @@ -265,6 +267,30 @@ def install_from_buildcache( return True +class GlobalState: + """Global state needed in a build subprocess. This is similar to spack.subprocess_context, + but excludes the Spack environment, which is slow to serialize and should not be needed + during the build.""" + + __slots__ = ("store", "config", "monkey_patches", "spack_working_dir") + + def __init__(self): + if multiprocessing.get_start_method() == "fork": + return + self.config = spack.config.CONFIG.ensure_unwrapped() + self.store = spack.store.STORE + self.monkey_patches = spack.subprocess_context.TestPatches.create() + self.spack_working_dir = spack.paths.spack_working_dir + + def restore(self): + if multiprocessing.get_start_method() == "fork": + return + spack.store.STORE = self.store + spack.config.CONFIG = self.config + self.monkey_patches.restore() + spack.paths.spack_working_dir = self.spack_working_dir + + class PrefixPivoter: """Manages the installation prefix of a build.""" @@ -349,15 +375,15 @@ def worker_function( restage: bool, keep_prefix: bool, skip_patch: bool, + fake: bool, state: Connection, parent: Connection, echo_control: Connection, makeflags: str, js1: Optional[Connection], js2: Optional[Connection], - store: spack.store.Store, - config: spack.config.Configuration, log_path: str, + global_state: GlobalState, ): """ Function run in the build child process. Installs the specified spec, sending state updates @@ -380,15 +406,16 @@ def worker_function( makeflags: MAKEFLAGS to set, so that the build process uses the POSIX jobserver js1: Connection for old style jobserver read fd (if any). Unused, just to inherit fd. js2: Connection for old style jobserver write fd (if any). Unused, just to inherit fd. - store: global store instance from parent - config: global config instance from parent log_path: Path to the log file to write build output to + global_state: Global state to restore """ # TODO: don't start a build for external packages if spec.external: return + global_state.restore() + # Start a new session, so our SIGTERM handler can kill all child processes. os.setsid() @@ -406,9 +433,6 @@ def handle_sigterm(signum, frame): signal.signal(signal.SIGTERM, handle_sigterm) os.environ["MAKEFLAGS"] = makeflags - spack.store.STORE = store - spack.config.CONFIG = config - spack.paths.set_working_dir() # Open the log file created by the parent process. log_fd = os.open(log_path, os.O_WRONLY | os.O_TRUNC, 0o644) @@ -430,9 +454,10 @@ def handle_sigterm(signum, frame): keep_stage, restage, skip_patch, + fake, state_stream, log_path, - store, + spack.store.STORE, ) except Exception: traceback.print_exc() # log the traceback to the log file @@ -535,6 +560,7 @@ def _install( keep_stage: bool, restage: bool, skip_patch: bool, + fake: bool, state_stream: io.TextIOWrapper, log_path: str, store: spack.store.Store = spack.store.STORE, @@ -544,6 +570,12 @@ def _install( # Create the stage and log file before starting the tee thread. pkg = spec.package + if fake: + store.layout.create_install_directory(spec) + _do_fake_install(pkg) + spack.hooks.post_install(spec, explicit) + return + # Try to install from buildcache, unless user asked for source only if install_policy != "source_only": if mirrors and install_from_buildcache(mirrors, spec, unsigned, state_stream): @@ -721,6 +753,7 @@ def start_build( restage: bool, keep_prefix: bool, skip_patch: bool, + fake: bool, jobserver: JobServer, ) -> ChildInfo: """Start a new build.""" @@ -759,15 +792,15 @@ def start_build( restage, keep_prefix, skip_patch, + fake, state_w_conn, output_w_conn, control_r_conn, makeflags, None if fifo else jobserver.r_conn, None if fifo else jobserver.w_conn, - spack.store.STORE, - spack.config.CONFIG, log_path, + GlobalState(), ), ) proc.start() @@ -1537,9 +1570,7 @@ def __init__( ) -> None: assert install_package or install_deps, "Must install package, dependencies or both" - if fake: - raise NotImplementedError("Fake installs are not implemented") - elif install_source: + if install_source: raise NotImplementedError("Installing sources is not implemented") elif stop_at is not None: raise NotImplementedError("Stopping at an install phase is not implemented") @@ -1585,6 +1616,7 @@ def __init__( } self.unsigned = unsigned self.dirty = dirty + self.fake = fake self.restage = restage self.keep_stage = keep_stage self.skip_patch = skip_patch @@ -1940,6 +1972,7 @@ def _start( restage=self.restage and not is_develop, keep_prefix=self.keep_prefix, skip_patch=self.skip_patch, + fake=self.fake, jobserver=jobserver, ) self.log_paths[dag_hash] = child_info.log_path diff --git a/lib/spack/spack/test/cmd/dev_build.py b/lib/spack/spack/test/cmd/dev_build.py index dba112b2691f06..3659917b75862a 100644 --- a/lib/spack/spack/test/cmd/dev_build.py +++ b/lib/spack/spack/test/cmd/dev_build.py @@ -196,7 +196,9 @@ def test_dev_build_can_parse_path_with_at_symbol(tmp_path: pathlib.Path, install assert spec.package.filename in os.listdir(spec.prefix) -def test_dev_build_env(tmp_path: pathlib.Path, install_mockery, mutable_mock_env_path): +def test_dev_build_env( + tmp_path: pathlib.Path, install_mockery, mutable_mock_env_path, installer_variant +): """Test Spack does dev builds for packages in develop section of env.""" # setup dev-build-test-install package for dev build build_dir = tmp_path / "build" @@ -236,7 +238,7 @@ def test_dev_build_env(tmp_path: pathlib.Path, install_mockery, mutable_mock_env def test_dev_build_env_with_vars( - tmp_path: pathlib.Path, install_mockery, mutable_mock_env_path, monkeypatch + tmp_path: pathlib.Path, install_mockery, mutable_mock_env_path, monkeypatch, installer_variant ): """Test Spack does dev builds for packages in develop section of env (path with variables).""" # setup dev-build-test-install package for dev build @@ -279,7 +281,7 @@ def test_dev_build_env_with_vars( def test_dev_build_env_version_mismatch( - tmp_path: pathlib.Path, install_mockery, mutable_mock_env_path + tmp_path: pathlib.Path, install_mockery, mutable_mock_env_path, installer_variant ): """Test Spack constraints concretization by develop specs.""" # setup dev-build-test-install package for dev build diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 7990895c3cee81..3ced9d4dba4638 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -514,7 +514,7 @@ def test_env_install_all(install_mockery, mock_fetch): assert spec.installed -def test_env_install_single_spec(install_mockery, mock_fetch): +def test_env_install_single_spec(install_mockery, mock_fetch, installer_variant): env("create", "test") install = SpackCommand("install") diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index 52c1e79b43024c..db71046665cd38 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -277,7 +277,9 @@ def test_install_commit(mock_git_version_info, install_mockery, mock_packages, m assert content == "[0]" # contents are weird for another test -def test_install_overwrite_multiple(mock_packages, mock_archive, mock_fetch, install_mockery): +def test_install_overwrite_multiple( + mock_packages, mock_archive, mock_fetch, install_mockery, installer_variant +): # Try to install a spec and then to reinstall it. libdwarf = spack.concretize.concretize_one("libdwarf") cmake = spack.concretize.concretize_one("cmake") diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index dedeba1eee809e..3829e2b9203a32 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -2582,3 +2582,12 @@ def reset_extension_paths(): spack.extensions.extension_paths_from_entry_points.cache_clear() yield spack.extensions.extension_paths_from_entry_points.cache_clear() + + +@pytest.fixture(params=["old", "new"]) +def installer_variant(request): + """Parametrize a test over the old and new installer.""" + if request.param == "new" and sys.platform == "win32": + pytest.skip("New installer not supported on Windows") + with spack.config.override("config:installer", request.param): + yield request.param From 22f600d3293b906f26ab0fc71dc56eeabef64ccd Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 11 Mar 2026 15:13:21 +0100 Subject: [PATCH 132/337] Revert "variant.py: Sequence -> Iterable (#51859)" (#52056) This reverts commit 341772bbab8673059dfc10913bd3ad5092050c8f. --- lib/spack/spack/variant.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/variant.py b/lib/spack/spack/variant.py index 23c8410389bab4..30a0581e3a5d15 100644 --- a/lib/spack/spack/variant.py +++ b/lib/spack/spack/variant.py @@ -247,10 +247,10 @@ def __str__(self) -> str: ) -def _flatten(values) -> Tuple: +def _flatten(values) -> Collection: """Flatten instances of _ConditionalVariantValues for internal representation""" if isinstance(values, DisjointSetsOfValues): - return tuple(values) + return values flattened: List = [] for item in values: @@ -511,7 +511,9 @@ def __init__(self, name): super().__init__(VariantType.INDICATOR, name, (None,)) -class DisjointSetsOfValues(collections.abc.Iterable): +# The class below inherit from Sequence to disguise as a tuple and comply +# with the semantic expected by the 'values' argument of the variant directive +class DisjointSetsOfValues(collections.abc.Sequence): """Allows combinations from one of many mutually exclusive sets. The value ``('none',)`` is reserved to denote the empty set @@ -595,8 +597,11 @@ def prohibit_empty_set(self): ) return object_without_empty_set - def __iter__(self): - return itertools.chain.from_iterable(self.sets) + def __getitem__(self, idx): + return tuple(itertools.chain.from_iterable(self.sets))[idx] + + def __len__(self): + return sum(len(x) for x in self.sets) @property def validator(self): From d5c1d9ed5169eb8274a48930dd2a79602fb7898f Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 11 Mar 2026 21:41:57 +0100 Subject: [PATCH 133/337] package_base.py: unit_test_check is not public API (#52060) This function exists for the purpose of unit tests; it looks a lot like a hook to be used in spack-packages, which is rather unfortunate. Make it private. Signed-off-by: Harmen Stoppels --- lib/spack/spack/installer.py | 4 ++-- lib/spack/spack/package_base.py | 4 ++-- lib/spack/spack/test/cmd/install.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index 7b6181379a2ad2..b174474eedebaf 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -1357,8 +1357,8 @@ def complete(self): self.fail(self.error_result) # hook that allows tests to inspect the Package before installation - # see unit_test_check() docs. - if not pkg.unit_test_check(): + # see _unit_test_check() docs. + if not pkg._unit_test_check(): self.succeed() return ExecuteResult.FAILED diff --git a/lib/spack/spack/package_base.py b/lib/spack/spack/package_base.py index d62825a8382686..353db92f7540a6 100644 --- a/lib/spack/spack/package_base.py +++ b/lib/spack/spack/package_base.py @@ -2041,8 +2041,8 @@ def do_test(self, *, dirty=False, externals=False, timeout: Optional[int] = None self.tester.stand_alone_tests(kwargs, timeout=timeout) - def unit_test_check(self) -> bool: - """Hook for unit tests to assert things about package internals. + def _unit_test_check(self) -> bool: + """Hook for Spack's own unit tests to assert things about package internals. Unit tests can override this function to perform checks after ``Package.install`` and all post-install hooks run, but before diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index db71046665cd38..836c16c89751a2 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -81,19 +81,19 @@ def _check_runtests_all(pkg): @pytest.mark.disable_clean_stage_check def test_install_runtests_notests(monkeypatch, mock_packages, install_mockery): - monkeypatch.setattr(spack.package_base.PackageBase, "unit_test_check", _check_runtests_none) + monkeypatch.setattr(spack.package_base.PackageBase, "_unit_test_check", _check_runtests_none) install("-v", "dttop") @pytest.mark.disable_clean_stage_check def test_install_runtests_root(monkeypatch, mock_packages, install_mockery): - monkeypatch.setattr(spack.package_base.PackageBase, "unit_test_check", _check_runtests_dttop) + monkeypatch.setattr(spack.package_base.PackageBase, "_unit_test_check", _check_runtests_dttop) install("--test=root", "dttop") @pytest.mark.disable_clean_stage_check def test_install_runtests_all(monkeypatch, mock_packages, install_mockery): - monkeypatch.setattr(spack.package_base.PackageBase, "unit_test_check", _check_runtests_all) + monkeypatch.setattr(spack.package_base.PackageBase, "_unit_test_check", _check_runtests_all) install("--test=all", "pkg-a") From a8fb9ef2478ffac5f2e015d511fd481c78e4fe05 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 11 Mar 2026 23:35:00 +0100 Subject: [PATCH 134/337] new_installer.py: add --test support (#52047) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 21 ++++- lib/spack/spack/test/installer_build_graph.py | 80 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 7e0491d3291df1..e76c119e60d9a7 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -376,6 +376,7 @@ def worker_function( keep_prefix: bool, skip_patch: bool, fake: bool, + run_tests: bool, state: Connection, parent: Connection, echo_control: Connection, @@ -400,6 +401,7 @@ def worker_function( restage: Whether to restage the source before building keep_prefix: Whether to keep a failed installation prefix skip_patch: Whether to skip the patch phase + run_tests: Whether to run install-time tests for this package state: Connection to send state updates to parent: Connection to send build output to echo_control: Connection to receive echo control messages from @@ -458,6 +460,7 @@ def handle_sigterm(signum, frame): state_stream, log_path, spack.store.STORE, + run_tests, ) except Exception: traceback.print_exc() # log the traceback to the log file @@ -564,11 +567,13 @@ def _install( state_stream: io.TextIOWrapper, log_path: str, store: spack.store.Store = spack.store.STORE, + run_tests: bool = False, ) -> None: """Install a spec from build cache or source.""" # Create the stage and log file before starting the tee thread. pkg = spec.package + pkg.run_tests = run_tests if fake: store.layout.create_install_directory(spec) @@ -640,6 +645,7 @@ def _install( _archive_build_metadata(pkg) spack.hooks.post_install(spec, explicit) + pkg.archive_install_test_log() class JobServer: @@ -754,6 +760,7 @@ def start_build( keep_prefix: bool, skip_patch: bool, fake: bool, + run_tests: bool, jobserver: JobServer, ) -> ChildInfo: """Start a new build.""" @@ -793,6 +800,7 @@ def start_build( keep_prefix, skip_patch, fake, + run_tests, state_w_conn, output_w_conn, control_r_conn, @@ -1298,6 +1306,7 @@ def __init__( install_deps: bool, database: spack.database.Database, overwrite_set: Optional[Set[str]] = None, + tests: Union[bool, List[str], Set[str]] = False, ): """Construct a build graph from the given specs. This includes only packages that need to be installed. Installed packages are pruned from the graph, and build dependencies are only @@ -1334,7 +1343,10 @@ def __init__( elif install_policy == "cache_only" and not include_build_deps: dependencies = spec.dependencies(deptype=dt.LINK | dt.RUN) else: - dependencies = spec.dependencies(deptype=dt.BUILD | dt.LINK | dt.RUN) + deptype = dt.BUILD | dt.LINK | dt.RUN + if tests is True or (tests and spec.name in tests): + deptype |= dt.TEST + dependencies = spec.dependencies(deptype=deptype) self.parent_to_child[key] = {d.dag_hash() for d in dependencies} @@ -1576,8 +1588,7 @@ def __init__( raise NotImplementedError("Stopping at an install phase is not implemented") elif stop_before is not None: raise NotImplementedError("Stopping before an install phase is not implemented") - elif tests is not False: - raise NotImplementedError("Tests during install are not implemented") + self.tests: Union[bool, List[str], Set[str]] = tests self.db = spack.store.STORE.db @@ -1606,6 +1617,7 @@ def __init__( install_deps, self.db, self.overwrite, + tests, ) #: check what specs we could fetch from binaries (checks against cache, not remotely) @@ -1956,6 +1968,8 @@ def _start( explicit = dag_hash in self.explicit spec = self.build_graph.nodes[dag_hash] is_develop = spec.is_develop + tests = self.tests + run_tests = tests is True or bool(tests and spec.name in tests) child_info = start_build( spec, explicit=explicit, @@ -1973,6 +1987,7 @@ def _start( keep_prefix=self.keep_prefix, skip_patch=self.skip_patch, fake=self.fake, + run_tests=run_tests, jobserver=jobserver, ) self.log_paths[dag_hash] = child_info.log_path diff --git a/lib/spack/spack/test/installer_build_graph.py b/lib/spack/spack/test/installer_build_graph.py index 0aa5ca30069920..a06a2539ead8f3 100644 --- a/lib/spack/spack/test/installer_build_graph.py +++ b/lib/spack/spack/test/installer_build_graph.py @@ -584,3 +584,83 @@ def test_pruning_root_node_with_install_package_false( # dep1 should not appear in any mappings assert dep1_hash not in graph.parent_to_child assert dep1_hash not in graph.child_to_parent + + +@pytest.fixture +def specs_with_test_deps(): + """Create specs with test-typed dependencies. + + DAG structure: + root -> dep (link) + test_dep (test) + dep -> dep_test_dep (test) + """ + return create_dag( + nodes=["root", "dep", "test_dep", "dep_test_dep"], + edges=[ + ("root", "dep", ("build", "link")), + ("root", "test_dep", "test"), + ("dep", "dep_test_dep", "test"), + ], + ) + + +class TestBuildGraphTestDeps: + """Tests for BuildGraph handling of TEST-typed dependencies.""" + + def test_tests_false_excludes_test_deps( + self, specs_with_test_deps: Dict[str, Spec], temporary_store: Store + ): + """Test that tests=False excludes TEST-typed dependencies.""" + graph = BuildGraph( + specs=[specs_with_test_deps["root"]], + root_policy="auto", + dependencies_policy="auto", + include_build_deps=True, + install_package=True, + install_deps=True, + database=temporary_store.db, + tests=False, + ) + + assert specs_with_test_deps["dep"].dag_hash() in graph.nodes + assert specs_with_test_deps["test_dep"].dag_hash() not in graph.nodes + assert specs_with_test_deps["dep_test_dep"].dag_hash() not in graph.nodes + + def test_tests_root_includes_test_deps_for_root( + self, specs_with_test_deps: Dict[str, Spec], temporary_store: Store + ): + """Test that tests=[root_name] includes test deps only for the root package.""" + graph = BuildGraph( + specs=[specs_with_test_deps["root"]], + root_policy="auto", + dependencies_policy="auto", + include_build_deps=True, + install_package=True, + install_deps=True, + database=temporary_store.db, + tests=["root"], + ) + + assert specs_with_test_deps["dep"].dag_hash() in graph.nodes + assert specs_with_test_deps["test_dep"].dag_hash() in graph.nodes + # dep's test dep is NOT included because tests=["root"] only applies to "root" + assert specs_with_test_deps["dep_test_dep"].dag_hash() not in graph.nodes + + def test_tests_all_includes_test_deps_for_all( + self, specs_with_test_deps: Dict[str, Spec], temporary_store: Store + ): + """Test that tests=True includes TEST-typed deps for all packages.""" + graph = BuildGraph( + specs=[specs_with_test_deps["root"]], + root_policy="auto", + dependencies_policy="auto", + include_build_deps=True, + install_package=True, + install_deps=True, + database=temporary_store.db, + tests=True, + ) + + assert specs_with_test_deps["dep"].dag_hash() in graph.nodes + assert specs_with_test_deps["test_dep"].dag_hash() in graph.nodes + assert specs_with_test_deps["dep_test_dep"].dag_hash() in graph.nodes From cfccf3061d81bcdd0bd10a342f6f32ba1f5edf50 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 12 Mar 2026 10:36:14 +0100 Subject: [PATCH 135/337] new_installer.py: --verbose in non-TTY mode (#52048) This adds support for `spack install --verbose` in non-TTY mode. It balances readability and troubleshootability: * Follow logs of one build and one build only * Always follow a build from start to finish, never half-way the process * For concurrent builds: only print install phase state changes as a single line, interleaved with the active build's logs. In the case of `spack install --verbose -p1` this choice results in the same output as people were used to from the old installer before package parallelism Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 25 ++++-- lib/spack/spack/test/installer_tui.py | 110 +++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index e76c119e60d9a7..99157133fec9fd 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -941,6 +941,7 @@ def __init__( get_terminal_size: Callable[[], os.terminal_size] = os.get_terminal_size, get_time: Callable[[], float] = time.monotonic, is_tty: Optional[bool] = None, + verbose: bool = False, ) -> None: #: Ordered dict of build ID -> info self.total = total @@ -965,6 +966,8 @@ def __init__( self.terminal_size_changed: bool = True self.get_time = get_time self.is_tty = is_tty if is_tty is not None else self.stdout.isatty() + #: Verbose mode only applies to non-TTY where we want to track a single build log. + self.verbose = verbose and not self.is_tty def on_resize(self) -> None: """Refresh cached terminal size and trigger a redraw.""" @@ -977,6 +980,14 @@ def add_build( """Add a new build to the display and mark the display as dirty.""" self.builds[spec.dag_hash()] = BuildInfo(spec, explicit, control_w_conn) self.dirty = True + # Track the new build's logs when we're not already following another build. This applies + # only in non-TTY verbose mode. + if self.verbose and not self.tracked_build_id and control_w_conn is not None: + self.tracked_build_id = spec.dag_hash() + try: + os.write(control_w_conn.fileno(), b"1") + except OSError: + pass def toggle(self) -> None: """Toggle between overview mode and following a specific build.""" @@ -1082,8 +1093,12 @@ def update_state(self, build_id: str, state: str) -> None: self.completed += 1 build_info.finished_time = self.get_time() + CLEANUP_TIMEOUT - if build_id == self.tracked_build_id and not self.overview_mode: - self.toggle() + # Stop tracking the finished build's logs. + if build_id == self.tracked_build_id: + if not self.overview_mode: + self.toggle() + if self.verbose: + self.tracked_build_id = "" self.dirty = True @@ -1225,9 +1240,8 @@ def print_logs(self, build_id: str, data: bytes) -> None: # Discard logs we are not following. Generally this should not happen as we tell the child # to only send logs when we are following it. It could maybe happen while transitioning # between builds. - if self.overview_mode or build_id != self.tracked_build_id: + if build_id != self.tracked_build_id: return - # TODO: drop initial bytes from data until first newline (?) self.stdout.buffer.write(data) self.stdout.flush() @@ -1645,9 +1659,10 @@ def __init__( else: self.explicit = explicit + self.verbose = verbose self.running_builds: Dict[int, ChildInfo] = {} self.log_paths: Dict[str, str] = {} - self.build_status = BuildStatus(len(self.build_graph.nodes)) + self.build_status = BuildStatus(len(self.build_graph.nodes), verbose=verbose) self.jobs = spack.config.determine_number_of_jobs(parallel=True) if concurrent_packages is None: concurrent_packages_config = spack.config.get("config:concurrent_packages", 0) diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 78a424ee23bf12..86fafb9f5be4e7 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -3,16 +3,19 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for the BuildStatus terminal UI in new_installer.py""" -import io -import os import sys -from typing import List, Optional, Tuple import pytest if sys.platform == "win32": pytest.skip("No Windows support", allow_module_level=True) + +import io +import os +from multiprocessing import Pipe +from typing import List, Optional, Tuple + import spack.new_installer as inst from spack.new_installer import BuildStatus @@ -64,7 +67,11 @@ def clear(self): def create_build_status( - is_tty: bool = True, terminal_cols: int = 80, terminal_rows: int = 24, total: int = 0 + is_tty: bool = True, + terminal_cols: int = 80, + terminal_rows: int = 24, + total: int = 0, + verbose: bool = False, ) -> Tuple[BuildStatus, List[float], SimpleTextIOWrapper]: """Helper function to create BuildStatus with mocked dependencies""" fake_stdout = SimpleTextIOWrapper(tty=is_tty) @@ -83,6 +90,7 @@ def mock_get_terminal_size(): get_terminal_size=mock_get_terminal_size, get_time=mock_get_time, is_tty=is_tty, + verbose=verbose, ) return status, time_values, fake_stdout @@ -1102,3 +1110,97 @@ def test_update_progress_rounds_correctly(self): status.update_progress(build_id, 3, 3) assert status.builds[build_id].progress_percent == 100 + + +class TestBuildStatusVerbose: + """Tests for verbose non-TTY log tracking in BuildStatus.""" + + def test_verbose_tracks_first_build(self): + """First add_build() in verbose non-TTY mode sets tracked_build_id and enables echoing.""" + bs, _, _ = create_build_status(is_tty=False, verbose=True, total=4) + spec = MockSpec("trivial-install-test-package", "1.0") + + r_conn, w_conn = Pipe(duplex=False) + + with r_conn, w_conn: + bs.add_build(spec, explicit=True, control_w_conn=w_conn) + + assert bs.tracked_build_id == spec.dag_hash() + written = os.read(r_conn.fileno(), 1) + assert written == b"1" + + def test_verbose_does_not_track_when_already_tracking(self): + """Second add_build() while already tracking does not switch tracking.""" + bs, _, _ = create_build_status(is_tty=False, verbose=True, total=4) + spec1 = MockSpec("pkg1", "1.0") + spec2 = MockSpec("pkg2", "1.0") + + r1, w1 = Pipe(duplex=False) + r2, w2 = Pipe(duplex=False) + with r1, w1, r2, w2: + bs.add_build(spec1, explicit=True, control_w_conn=w1) + first_tracked = bs.tracked_build_id + + bs.add_build(spec2, explicit=False, control_w_conn=w2) + assert bs.tracked_build_id == first_tracked + assert bs.tracked_build_id == spec1.dag_hash() + + # Second build should not have received b"1" + assert not r2.poll(), "Second build should not be enabled" + + def test_verbose_switches_on_finish(self): + """After the tracked build finishes, tracked_build_id is cleared.""" + bs, _, _ = create_build_status(is_tty=False, verbose=True, total=4) + spec = MockSpec("trivial-install-test-package", "1.0") + + r_conn, w_conn = Pipe(duplex=False) + + with r_conn, w_conn: + bs.add_build(spec, explicit=True, control_w_conn=w_conn) + assert bs.tracked_build_id == spec.dag_hash() + + bs.update_state(spec.dag_hash(), "finished") + assert bs.tracked_build_id == "" + + def test_verbose_print_logs_tracked(self): + """print_logs() for the tracked build writes to stdout.""" + bs, _, stdout = create_build_status(is_tty=False, verbose=True, total=1) + spec = MockSpec("trivial-install-test-package", "1.0") + + r_conn, w_conn = Pipe(duplex=False) + + with r_conn, w_conn: + bs.add_build(spec, explicit=True, control_w_conn=w_conn) + bs.print_logs(spec.dag_hash(), b"hello log\n") + + stdout.flush() + assert stdout.buffer.getvalue() == b"hello log\n" + + def test_verbose_print_logs_untracked(self): + """print_logs() for an untracked build discards data.""" + bs, _, stdout = create_build_status(is_tty=False, verbose=True, total=2) + spec1 = MockSpec("pkg1", "1.0") + spec2 = MockSpec("pkg2", "1.0") + + r1, w1 = Pipe(duplex=False) + + with r1, w1: + bs.add_build(spec1, explicit=True, control_w_conn=w1) + bs.add_build(spec2, explicit=False, control_w_conn=None) + + # Only spec1 is tracked; spec2 logs should be discarded + bs.print_logs(spec2.dag_hash(), b"ignored\n") + + stdout.flush() + assert stdout.buffer.getvalue() == b"" + + def test_verbose_tty_no_effect(self): + """In TTY mode, add_build() does not set tracked_build_id automatically.""" + bs, _, _ = create_build_status(is_tty=True, verbose=True, total=4) + spec = MockSpec("trivial-install-test-package", "1.0") + + r_conn, w_conn = Pipe(duplex=False) + + with r_conn, w_conn: + bs.add_build(spec, explicit=True, control_w_conn=w_conn) + assert bs.tracked_build_id == "" From 9922e0216ec599a16fe4a7a0b4a6d69edf93a92c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 12 Mar 2026 17:28:29 +0100 Subject: [PATCH 136/337] new_installer.py: reporters (#52058) * new_installer.py: reporters Signed-off-by: Harmen Stoppels * communicate install from build cache Signed-off-by: Harmen Stoppels * debug message on report failure Signed-off-by: Harmen Stoppels * exitcode Signed-off-by: Harmen Stoppels --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 131 ++++++++++++++++++++++++-- lib/spack/spack/test/cmd/install.py | 43 +++++++-- lib/spack/spack/test/new_installer.py | 24 +++++ 3 files changed, 182 insertions(+), 16 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 99157133fec9fd..2a8aad6bdcac43 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -46,6 +46,7 @@ TYPE_CHECKING, Callable, Dict, + FrozenSet, Generator, List, NamedTuple, @@ -169,6 +170,12 @@ def send_progress(current: int, total: int, state_pipe: io.TextIOWrapper) -> Non state_pipe.write("\n") +def send_installed_from_binary_cache(state_pipe: io.TextIOWrapper) -> None: + """Send a notification that the package was installed from binary cache.""" + json.dump({"installed_from_binary_cache": True}, state_pipe, separators=(",", ":")) + state_pipe.write("\n") + + def tee(control_r: int, log_r: int, file_w: int, parent_w: int) -> None: """Forward log_r to file_w and parent_w (if echoing is enabled). Echoing is enabled and disabled by reading from control_r.""" @@ -264,6 +271,9 @@ def install_from_buildcache( pkg._post_buildcache_install_hook() pkg.installed_from_binary_cache = True + # inform also the parent that this package was installed from binary cache. + send_installed_from_binary_cache(state_stream) + return True @@ -641,6 +651,8 @@ def _install( for phase in spack.builder.create(pkg): send_state(phase.name, state_stream) + spack.llnl.util.tty.msg(f"{pkg.name}: Executing phase: '{phase.name}'") + sys.stdout.flush() phase.execute() _archive_build_metadata(pkg) @@ -1330,7 +1342,7 @@ def __init__( self.parent_to_child: Dict[str, Set[str]] = {} self.child_to_parent: Dict[str, Set[str]] = {} overwrite_set = overwrite_set or set() - specs_to_prune: Set[str] = set() + self.pruned: Set[str] = set() stack: List[Tuple[spack.spec.Spec, InstallPolicy]] = [ (s, root_policy) for s in self.nodes.values() ] @@ -1352,7 +1364,7 @@ def __init__( # Conditionally include build dependencies if record and record.installed and key not in overwrite_set: - specs_to_prune.add(key) + self.pruned.add(key) dependencies = spec.dependencies(deptype=dt.LINK | dt.RUN) elif install_policy == "cache_only" and not include_build_deps: dependencies = spec.dependencies(deptype=dt.LINK | dt.RUN) @@ -1381,11 +1393,11 @@ def __init__( # If we're not installing the package itself, mark root specs for pruning too if not install_package: - specs_to_prune.update(s.dag_hash() for s in specs) + self.pruned.update(s.dag_hash() for s in specs) # Prune specs from the build graph. Their parents become parents of their children and # their children become children of their parents. - for key in specs_to_prune: + for key in self.pruned: for parent in self.child_to_parent.get(key, ()): self.parent_to_child[parent].remove(key) self.parent_to_child[parent].update(self.parent_to_child.get(key, ())) @@ -1566,6 +1578,98 @@ def schedule_builds( return ScheduleResult(blocked, to_start, newly_installed) +def _node_to_roots(roots: List[spack.spec.Spec]) -> Dict[str, FrozenSet[str]]: + """Map each node in a graph to the set of root node DAG hashes that can reach it. + + Args: + roots: List of root specs. + + Returns: + A dictionary mapping each node's dag_hash to a frozenset of root dag_hashes. + """ + node_to_roots: Dict[str, FrozenSet[str]] = { + s.dag_hash(): frozenset([s.dag_hash()]) for s in roots + } + + for edge in spack.traverse.traverse_edges( + roots, order="topo", cover="edges", root=False, key=spack.traverse.by_dag_hash + ): + parent_roots = node_to_roots[edge.parent.dag_hash()] + child_hash = edge.spec.dag_hash() + existing = node_to_roots.get(child_hash) + + if existing is None: + node_to_roots[child_hash] = parent_roots # keep a reference if no mutation is needed + elif not parent_roots.issubset(existing): + node_to_roots[child_hash] = existing | parent_roots + + return node_to_roots + + +class ReportData: + """Data collected for reports during installation.""" + + def __init__(self, roots: List[spack.spec.Spec]): + self.roots = roots + self.build_records: Dict[str, spack.report.InstallRecord] = {} + + def start_record(self, spec: spack.spec.Spec) -> None: + """Begin an InstallRecord for a spec that is about to be built.""" + if spec.external: + return + record = spack.report.InstallRecord(spec) + record.start() + self.build_records[spec.dag_hash()] = record + + def finish_record(self, spec: spack.spec.Spec, exitcode: int) -> None: + """Mark the InstallRecord for a spec as succeeded or failed.""" + record = self.build_records.get(spec.dag_hash()) + if record is None or spec.external: + return + if exitcode == 0: + record.succeed() + else: + record.fail( + spack.error.InstallError( + f"Installation of {spec.name} failed; see log for details" + ) + ) + + def finalize( + self, reports: Dict[str, spack.report.RequestRecord], build_graph: BuildGraph + ) -> None: + """Finalize InstallRecords and append them to RequestRecords after all builds finish. + + Args: + reports: Map of root dag_hash to RequestRecord to append to. + build_graph: The build graph containing all nodes and their states. + """ + node_to_roots = _node_to_roots(self.roots) + + for spec in spack.traverse.traverse_nodes(self.roots): + h = spec.dag_hash() + if h in self.build_records: + record = self.build_records[h] + else: + record = spack.report.InstallRecord(spec) + if spec.external: + msg = "Spec is external" + elif h in build_graph.pruned: + msg = "Spec was not scheduled for installation" + elif h in build_graph.nodes: + msg = "Dependencies failed to install" + else: + # If not installed or failed (build_records), not statically pruned ahead of + # time (build_graph.pruned), and also not scheduled (build_graph.nodes), it + # means it was in pending_builds or running_builds but never started/finished. + # This branch is followed on KeyboardInterrupt and --fail-fast. + msg = "Installation was interrupted" + record.skip(msg=msg) + + for root_hash in node_to_roots[h]: + reports[root_hash].append_record(record) + + class PackageInstaller: def __init__( @@ -1673,7 +1777,11 @@ def __init__( self.capacity = concurrent_packages_config else: self.capacity = concurrent_packages - self.reports: Dict[str, spack.report.RequestRecord] = {} + + #: The reports property is what the old installer has and used as public interface. + self.reports = {spec.dag_hash(): spack.report.RequestRecord(spec) for spec in specs} + #: Internal data collected for reports during installation. + self.report_data = ReportData(specs) def install(self) -> None: self._installer() @@ -1754,7 +1862,10 @@ def _handle_sigwinch(signum: int, frame: object) -> None: self.capacity += 1 jobserver.release() build.cleanup(selector) - if build.proc.exitcode == 0: + exitcode = build.proc.exitcode + assert exitcode is not None, "Finished build should have exit code set" + self.report_data.finish_record(build.spec, exitcode) + if exitcode == 0: # Add successful builds for database insertion (after a short delay) finished_builds.append(build) self.build_graph.enqueue_parents( @@ -1889,6 +2000,11 @@ def _handle_sigwinch(signum: int, frame: object) -> None: if db_exc is not None: raise db_exc + try: + self.report_data.finalize(self.reports, build_graph=self.build_graph) + except Exception as e: + spack.llnl.util.tty.debug(f"[{__name__}]: Failed to finalize reports: {e}]") + if failures: for s in failures: log_path = self.log_paths.get(s.dag_hash()) @@ -2020,6 +2136,7 @@ def _start( self.build_status.add_build( child_info.spec, explicit=explicit, control_w_conn=child_info.control_w_conn ) + self.report_data.start_record(spec) def _handle_child_logs( self, r_fd: int, child_info: ChildInfo, selector: selectors.BaseSelector @@ -2078,3 +2195,5 @@ def _handle_child_state( self.build_status.update_progress( child_info.spec.dag_hash(), message["progress"], message["total"] ) + elif "installed_from_binary_cache" in message: + child_info.spec.package.installed_from_binary_cache = True diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index 836c16c89751a2..5e9f32a9143862 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -50,7 +50,12 @@ def noop(*args, **kwargs): def test_install_package_and_dependency( - tmp_path: pathlib.Path, mock_packages, mock_archive, mock_fetch, install_mockery + tmp_path: pathlib.Path, + mock_packages, + mock_archive, + mock_fetch, + install_mockery, + installer_variant, ): log = "test" with fs.working_dir(str(tmp_path)): @@ -98,7 +103,12 @@ def test_install_runtests_all(monkeypatch, mock_packages, install_mockery): def test_install_package_already_installed( - tmp_path: pathlib.Path, mock_packages, mock_archive, mock_fetch, install_mockery + tmp_path: pathlib.Path, + mock_packages, + mock_archive, + mock_fetch, + install_mockery, + installer_variant, ): with fs.working_dir(str(tmp_path)): install("--fake", "libdwarf") @@ -353,7 +363,7 @@ def test_install_invalid_spec(): "exc_typename,msg", [("RuntimeError", "something weird happened"), ("ValueError", "spec is not concrete")], ) -def test_junit_output_with_failures(tmp_path: pathlib.Path, exc_typename, msg): +def test_junit_output_with_failures(tmp_path: pathlib.Path, exc_typename, msg, installer_variant): with fs.working_dir(str(tmp_path)): install( "--verbose", @@ -365,9 +375,11 @@ def test_junit_output_with_failures(tmp_path: pathlib.Path, exc_typename, msg): fail_on_error=False, ) - assert isinstance(install.error, spack.build_environment.ChildError) - assert install.error.name == exc_typename - assert install.error.pkg.name == "raiser" + # New installer considers Python exceptions ordinary build failures. + if installer_variant == "old": + assert isinstance(install.error, spack.build_environment.ChildError) + assert install.error.name == exc_typename + assert install.error.pkg.name == "raiser" files = list(tmp_path.iterdir()) filename = tmp_path / "test.xml" @@ -539,7 +551,9 @@ def test_cdash_upload_build_error(capfd, tmp_path: pathlib.Path, mock_fetch, ins @pytest.mark.disable_clean_stage_check -def test_cdash_upload_clean_build(tmp_path: pathlib.Path, mock_fetch, install_mockery): +def test_cdash_upload_clean_build( + tmp_path: pathlib.Path, mock_fetch, install_mockery, installer_variant +): with fs.working_dir(str(tmp_path)): install("--log-file=cdash_reports", "--log-format=cdash", "pkg-c") report_dir = tmp_path / "cdash_reports" @@ -552,7 +566,9 @@ def test_cdash_upload_clean_build(tmp_path: pathlib.Path, mock_fetch, install_mo @pytest.mark.disable_clean_stage_check -def test_cdash_upload_extra_params(tmp_path: pathlib.Path, mock_fetch, install_mockery): +def test_cdash_upload_extra_params( + tmp_path: pathlib.Path, mock_fetch, install_mockery, installer_variant +): with fs.working_dir(str(tmp_path)): install( "--log-file=cdash_reports", @@ -573,7 +589,9 @@ def test_cdash_upload_extra_params(tmp_path: pathlib.Path, mock_fetch, install_m @pytest.mark.disable_clean_stage_check -def test_cdash_buildstamp_param(tmp_path: pathlib.Path, mock_fetch, install_mockery): +def test_cdash_buildstamp_param( + tmp_path: pathlib.Path, mock_fetch, install_mockery, installer_variant +): with fs.working_dir(str(tmp_path)): cdash_track = "some_mocked_track" buildstamp_format = f"%Y%m%d-%H%M-{cdash_track}" @@ -594,7 +612,12 @@ def test_cdash_buildstamp_param(tmp_path: pathlib.Path, mock_fetch, install_mock @pytest.mark.disable_clean_stage_check def test_cdash_install_from_spec_json( - tmp_path: pathlib.Path, mock_fetch, install_mockery, mock_packages, mock_archive + tmp_path: pathlib.Path, + mock_fetch, + install_mockery, + mock_packages, + mock_archive, + installer_variant, ): with fs.working_dir(str(tmp_path)): spec_json_path = str(tmp_path / "spec.json") diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index c214588f19699d..958dcbe7506479 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -19,8 +19,10 @@ JobServer, PackageInstaller, PrefixPivoter, + _node_to_roots, schedule_builds, ) +from spack.test.traverse import create_dag @pytest.fixture @@ -573,3 +575,25 @@ def test_overwrite_handled_by_concurrent_process(self, temporary_store, mock_pac for _, _, lock in newly_installed: lock.release_read() jobserver.close() + + +def test_nodes_to_roots(): + """Independent roots don't reach each other's exclusive nodes.""" + # A - B and C - D are disconnected graphs, A, B and C are "roots". + specs = create_dag(nodes=["A", "B", "C", "D"], edges=[("A", "B", "all"), ("C", "D", "all")]) + a, b, c, d = specs["A"], specs["B"], specs["C"], specs["D"] + node_to_roots = _node_to_roots([a, b, c]) + assert node_to_roots[a.dag_hash()] == frozenset([a.dag_hash()]) + assert node_to_roots[b.dag_hash()] == frozenset([a.dag_hash(), b.dag_hash()]) + assert node_to_roots[c.dag_hash()] == frozenset([c.dag_hash()]) + assert node_to_roots[d.dag_hash()] == frozenset([c.dag_hash()]) + + +def test_nodes_to_roots_shared_dependency(): + """A dependency shared by two roots is attributed to both.""" + specs = create_dag(nodes=["A", "B", "C"], edges=[("A", "C", "all"), ("B", "C", "all")]) + a, b, c = specs["A"], specs["B"], specs["C"] + node_to_roots = _node_to_roots([a, b]) + assert node_to_roots[a.dag_hash()] == frozenset([a.dag_hash()]) + assert node_to_roots[b.dag_hash()] == frozenset([b.dag_hash()]) + assert node_to_roots[c.dag_hash()] == frozenset([a.dag_hash(), b.dag_hash()]) From bbeff4b9a99ac93ece65b4488fbb0aa717660f6a Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Thu, 12 Mar 2026 12:56:05 -0700 Subject: [PATCH 137/337] installer.py: pass debug=True to logger for clarity (#52067) Signed-off-by: Gregory Becker --- lib/spack/spack/installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index b174474eedebaf..8a941c1f1a3895 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -2739,7 +2739,7 @@ def _real_install(self) -> None: # DEBUGGING TIP - to debug this section, insert an IPython # embed here, and run the sections below without log capture log_contextmanager = log_output( - log_file, self.echo, True, filter_fn=self.filter_fn + log_file, self.echo, debug=True, filter_fn=self.filter_fn ) with log_contextmanager as logger: From 6b91d635ad3d29c549edd067d32c20009a62c553 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 12 Mar 2026 22:06:55 +0100 Subject: [PATCH 138/337] new_installer.py: enable debug like old installer (#52059) * Set debug=1 for the tty during phase execution * Force line buffering for sys.stdout and sys.error to ensure correctly ordered output Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 2a8aad6bdcac43..5cdf294a14e486 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -446,6 +446,16 @@ def handle_sigterm(signum, frame): os.environ["MAKEFLAGS"] = makeflags + # Force line buffering for Python's textio wrappers of stdout/stderr. We're not going to print + # much ourselves, but what we print should appear before output from `make` and other build + # tools. + sys.stdout = os.fdopen( + sys.stdout.fileno(), "w", buffering=1, encoding=sys.stdout.encoding, closefd=False + ) + sys.stderr = os.fdopen( + sys.stderr.fileno(), "w", buffering=1, encoding=sys.stderr.encoding, closefd=False + ) + # Open the log file created by the parent process. log_fd = os.open(log_path, os.O_WRONLY | os.O_TRUNC, 0o644) tee = Tee(echo_control, parent, log_fd) @@ -652,8 +662,13 @@ def _install( for phase in spack.builder.create(pkg): send_state(phase.name, state_stream) spack.llnl.util.tty.msg(f"{pkg.name}: Executing phase: '{phase.name}'") - sys.stdout.flush() - phase.execute() + # Run the install phase with debug output enabled. + old_debug = spack.llnl.util.tty.debug_level() + spack.llnl.util.tty.set_debug(1) + try: + phase.execute() + finally: + spack.llnl.util.tty.set_debug(old_debug) _archive_build_metadata(pkg) spack.hooks.post_install(spec, explicit) From ad8b41095421b7ffe794556874674c48727032a5 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 12 Mar 2026 22:35:46 +0100 Subject: [PATCH 139/337] concretize.lp: avoid imposing transitive link deps of compiler run deps (#52029) When a compiler is reused as a pure build dependency, its run-reachable transitive deps are unified in the build environment (they appear in PATH etc.), but their pure link-type dependencies are local to the compiler's toolchain and must not be forced onto the package being built. Previously, avoid_link_dependency only suppressed direct link-only deps of the compiler itself. This missed the transitive case: if the compiler has a run+link dep on binutils and binutils has a pure link dep on zlib, the imposed hash on zlib from binutils was propagated to the package, causing an UnsatisfiableSpecError when the package requested a different zlib version. Fix: add compiler_non_lib_run_dep/2 rules that compute the full transitive run-dep closure of non-library compilers, then extend avoid_link_dependency to suppress hash/edge constraints for pure link deps at every level of that closure. asp.py also now includes transitive link deps of reusable compilers as reusable candidates (not just run deps), so their hash_attr facts are available for the new rules. When a compiler is used as a library, or for a fresh (--fresh) concretization, the existing behavior is unchanged. Signed-off-by: Harmen Stoppels --- lib/spack/spack/solver/asp.py | 11 +++---- lib/spack/spack/solver/concretize.lp | 25 +++++++++++++++ lib/spack/spack/test/cmd/dependencies.py | 11 +++++-- lib/spack/spack/test/concretization/core.py | 23 ++++++++++++++ lib/spack/spack/test/package_class.py | 6 ++++ .../packages/binutils_for_test/package.py | 21 +++++++++++++ .../packages/compiler_with_deps/package.py | 31 +++++++++++++++++++ .../packages/pkg_with_zlib_dep/package.py | 22 +++++++++++++ 8 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/binutils_for_test/package.py create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/compiler_with_deps/package.py create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_zlib_dep/package.py diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 89a0c745127e69..5c03f1aa80003c 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -2920,12 +2920,11 @@ def setup( candidate_compilers, self.rejected_compilers = possible_compilers( configuration=spack.config.CONFIG ) - for x in candidate_compilers: - if x.external or x in reuse: - continue - reuse.append(x) - for dep in x.traverse(root=False, deptype="run"): - reuse.extend(dep.traverse(deptype=("link", "run"))) + reuse_from_compilers = traverse.traverse_nodes( + [x for x in candidate_compilers if not x.external], deptype=("link", "run") + ) + reused_set = set(reuse) + reuse += [x for x in reuse_from_compilers if x not in reused_set] candidate_compilers.update(compilers_from_reuse) self.possible_compilers = list(candidate_compilers) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index c4c98369d3b4ee..0f7ffefe721e2f 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1979,6 +1979,31 @@ avoid_link_dependency(Hash, DepName) :- compiler_package(PackageName), not compiler_used_as_a_library(node(_, PackageName), Hash). +% When a compiler is not used as a library, its transitive run-type dependencies are unified in the +% build environment (they appear in PATH etc.), but their pure link-type dependencies are NOT. This +% is different from unification sets that include all link/run deps: the goal is to avoid imposing +% transitive link constraints from the toolchain, which would add to max_dupes and require us to +% increase the max_dupes threshold for many packages. + +% Base case: direct run dep of a non-library compiler +compiler_non_lib_run_dep(DepHash, DepName) :- + compiler_package(CompilerName), + not compiler_used_as_a_library(node(_, CompilerName), CompilerHash), + hash_attr(CompilerHash, "depends_on", CompilerName, DepName, "run"), + hash_attr(CompilerHash, "hash", DepName, DepHash). + +% Recursive case: run deps of run deps (full transitive closure) +compiler_non_lib_run_dep(TransDepHash, TransDepName) :- + compiler_non_lib_run_dep(DepHash, DepName), + hash_attr(DepHash, "depends_on", DepName, TransDepName, "run"), + hash_attr(DepHash, "hash", TransDepName, TransDepHash). + +% Pure link deps (link but not run) of any node in that closure are avoided +avoid_link_dependency(DepHash, LinkDepName) :- + compiler_non_lib_run_dep(DepHash, DepName), + hash_attr(DepHash, "depends_on", DepName, LinkDepName, "link"), + not hash_attr(DepHash, "depends_on", DepName, LinkDepName, "run"). + % Without splicing, we simply recover the exact semantics imposed_constraint(ParentHash, "hash", ChildName, ChildHash) :- hash_attr(ParentHash, "hash", ChildName, ChildHash), diff --git a/lib/spack/spack/test/cmd/dependencies.py b/lib/spack/spack/test/cmd/dependencies.py index 14925e6523f805..5447436aff0983 100644 --- a/lib/spack/spack/test/cmd/dependencies.py +++ b/lib/spack/spack/test/cmd/dependencies.py @@ -20,8 +20,9 @@ "multi-provider-mpi", "zmpi", ] -COMPILERS = ["gcc", "llvm"] +COMPILERS = ["gcc", "llvm", "compiler-with-deps"] MPI_DEPS = ["fake"] +COMPILER_DEPS = ["binutils-for-test", "zlib"] @pytest.mark.parametrize( @@ -30,7 +31,13 @@ (["mpileaks"], set(["callpath"] + MPIS + COMPILERS)), ( ["--transitive", "mpileaks"], - set(["callpath", "dyninst", "libdwarf", "libelf"] + MPIS + MPI_DEPS + COMPILERS), + set( + ["callpath", "dyninst", "libdwarf", "libelf"] + + MPIS + + MPI_DEPS + + COMPILERS + + COMPILER_DEPS + ), ), (["--transitive", "--deptype=link,run", "dtbuild1"], {"dtlink2", "dtrun2"}), (["--transitive", "--deptype=build", "dtbuild1"], {"dtbuild2", "dtlink2"}), diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index de95e55a86891b..d7e5c2bf5d20db 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -570,6 +570,29 @@ def test_disable_mixing_allow_compiler_link(self): assert x.satisfies("%c=gcc") assert "llvm" in x + def test_compiler_run_dep_link_dep_not_forced(self, temporary_store): + """When a compiler is used as a pure build dependency, its transitive run-reachable deps + are unified in the build environment, but their pure link-type dependencies must NOT be + forced onto the package. + + Scenario: compiler-with-deps has a run+link dep on binutils-for-test, which has a pure + link dep on zlib. A package that depends on zlib and uses compiler-with-deps as its C + compiler should be free to pick its own zlib (here: zlib@1.2.8) independently of the + toolchain's zlib (zlib@1.2.11). Without the fix the imposed hash from binutils-for-test + forces the toolchain version onto the package, causing a conflict. + """ + # Pre-install the compiler with its transitive deps binutils-for-test and zlib@1.2.11 + compiler = spack.concretize.concretize_one("compiler-with-deps ^zlib@1.2.11") + assert compiler["zlib"].satisfies("@1.2.11") + PackageInstaller([compiler.package], fake=True, explicit=True).install() + + # Concretize a package that depends on a different zlib from its compiler's toolchain. + pkg = spack.concretize.concretize_one( + "pkg-with-zlib-dep %c=compiler-with-deps ^zlib@1.2.8" + ) + + assert pkg["zlib"].satisfies("@1.2.8") + def test_disable_mixing_env( self, mutable_mock_env_path, tmp_path: pathlib.Path, mock_packages, mutable_config ): diff --git a/lib/spack/spack/test/package_class.py b/lib/spack/spack/test/package_class.py index ccc3f7df047596..c66ada17e43f0f 100644 --- a/lib/spack/spack/test/package_class.py +++ b/lib/spack/spack/test/package_class.py @@ -52,6 +52,9 @@ def mpileaks_possible_deps(mock_packages, mpi_names, compiler_names): "mpileaks": set(["callpath"] + mpi_names + compiler_names), "multi-provider-mpi": set(), "zmpi": set(["fake"] + compiler_names), + "compiler-with-deps": set(["binutils-for-test", "zlib"] + compiler_names), + "binutils-for-test": set(["zlib"] + compiler_names), + "zlib": set(), } return possible @@ -85,6 +88,9 @@ def mpi_names(mock_inspector): "mpileaks", "gcc", "llvm", + "compiler-with-deps", + "binutils-for-test", + "zlib", "multi-provider-mpi", "callpath", "dyninst", diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/binutils_for_test/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/binutils_for_test/package.py new file mode 100644 index 00000000000000..36626494005e8c --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/binutils_for_test/package.py @@ -0,0 +1,21 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class BinutilsForTest(Package): + """A mock binutils-like package with a pure link dependency on zlib. + Used to test that transitive link-only deps of compiler run-deps are + not forced onto packages that use the compiler as a build dependency.""" + + homepage = "http://www.example.com" + url = "http://www.example.com/binutils-for-test-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + depends_on("c", type="build") + depends_on("zlib", type="link") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/compiler_with_deps/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/compiler_with_deps/package.py new file mode 100644 index 00000000000000..25bf648f2b2039 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/compiler_with_deps/package.py @@ -0,0 +1,31 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.compiler import CompilerPackage +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class CompilerWithDeps(CompilerPackage, Package): + """A mock compiler that has a run+link dependency on binutils-for-test, + which itself has a pure link dependency on zlib. Used to test that + transitive link-only deps of compiler run-deps are not forced onto + packages that use this compiler as a build dependency.""" + + homepage = "http://www.example.com" + url = "http://www.example.com/compiler-with-deps-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + provides("c") + + depends_on("c", type="build") + depends_on("binutils-for-test", type=("run", "link")) + + c_names = ["compiler-with-deps-cc"] + compiler_version_regex = r"([0-9.]+)" + compiler_version_argument = "--version" + + compiler_wrapper_link_paths = {"c": "compiler-with-deps/cc"} diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_zlib_dep/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_zlib_dep/package.py new file mode 100644 index 00000000000000..4e05eb43ad897a --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/pkg_with_zlib_dep/package.py @@ -0,0 +1,22 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class PkgWithZlibDep(Package): + """A minimal mock package that depends on C (build) and zlib (link). + Used to test that the compiler's transitive link-only deps (reachable + through its run-dep binutils-for-test -> zlib) are not forced onto + this package's own zlib dependency.""" + + homepage = "http://www.example.com" + url = "http://www.example.com/pkg-with-zlib-dep-1.0.tar.gz" + + version("1.0", md5="0123456789abcdef0123456789abcdef") + + depends_on("c", type="build") + depends_on("zlib", type="link") From 5a8a79390c5a8dad8015897f9dc4d6d0b0921293 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Fri, 13 Mar 2026 01:10:03 -0700 Subject: [PATCH 140/337] Deprecate `include_concrete:` in favor of `include:` The `include_concrete` key in spack.yaml is deprecated. Concrete environments (lock files) should now be listed under the standard `include` key with an explicit `spack.lock` path: # Old (deprecated) include_concrete: - /path/to/env # New include: - /path/to/env/spack.lock Using `include_concrete` still works but emits a deprecation warning. Users can run `spack env update ` to automatically migrate the manifest to the new format. Signed-off-by: tldahlgren Signed-off-by: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Co-authored-by: Massimiliano Culpo --- lib/spack/docs/environments.rst | 125 +++++++++++---------- lib/spack/docs/include_yaml.rst | 10 +- lib/spack/spack/config.py | 40 +++++-- lib/spack/spack/environment/__init__.py | 2 + lib/spack/spack/environment/environment.py | 68 ++++++++--- lib/spack/spack/schema/env.py | 31 ++++- lib/spack/spack/test/cmd/env.py | 115 +++++++++++++++++++ lib/spack/spack/test/config.py | 41 ++++++- lib/spack/spack/test/schema.py | 6 + 9 files changed, 347 insertions(+), 91 deletions(-) diff --git a/lib/spack/docs/environments.rst b/lib/spack/docs/environments.rst index fc1683fdab918c..dd7444ece34efa 100644 --- a/lib/spack/docs/environments.rst +++ b/lib/spack/docs/environments.rst @@ -278,16 +278,16 @@ Adding Abstract Specs An abstract spec is the user-specified spec before Spack applies defaults or dependency information. -Users can add abstract specs to an environment using the ``spack add`` command. +You can add abstract specs to an environment using the ``spack add`` command. +This adds the abstract spec as a root of the environment in the ``spack.yaml`` file. The most important component of an environment is a list of abstract specs. -Adding a spec adds it as a root spec of the environment in the user input file (``spack.yaml``). -It does not affect the concrete specs in the lock file (``spack.lock``) and it does not install the spec. +Adding abstract specs does not immediately install anything, nor does it affect the ``spack.lock`` file. +To update the lockfile, the environment must be :ref:`re-concretized `, and to update any installations, the environment must be :ref:`(re)installed `. The ``spack add`` command is environment-aware. It adds the spec to the currently active environment. An error is generated if there isn't an active environment. -All environment-aware commands can also be called using the ``spack -e`` flag to specify the environment. .. code-block:: spec @@ -300,6 +300,10 @@ or $ spack -e myenv add python +.. note:: + + All environment-aware commands can also be called using the ``spack -e`` flag to specify the environment. + .. _cmd-spack-concretize: Concretizing @@ -495,58 +499,59 @@ The ``loads`` file may also be copied out of the environment, renamed, etc. .. _environment_include_concrete: -Included Concrete Environments ------------------------------- +Including Concrete Environments +------------------------------- -Spack environments can create an environment based off of information in already established environments. -You can think of it as a combination of existing environments. -It will gather information from the existing environment's ``spack.lock`` and use that during the creation of this included concrete environment. -When an included concrete environment is created it will generate a ``spack.lock`` file for the newly created environment. +Spack can create an environment that includes information from already concretized environments. +You can think of the new environment as a combination of existing environments. +It uses information from the existing environments' ``spack.lock`` files in the creation of the new environment. +When such an environment is concretized it will generate its own ``spack.lock`` file that contains relevant information from the included environments. -Creating included environments -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Creating combined concrete environments +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ To create a combined concrete environment, you must have at least one existing concrete environment. You will use the command ``spack env create`` with the argument ``--include-concrete`` followed by the name or path of the environment you'd like to include. -Here is an example of how to create a combined environment from the command line. - -.. code-block:: spec +Here is an example of how to create a combined environment from the command line:: $ spack env create myenv $ spack -e myenv add python $ spack -e myenv concretize - $ spack env create --include-concrete myenv included_env + $ spack env create --include-concrete myenv combined_env +You can also include concrete environments directly in the ``spack.yaml`` file. +It involves adding the absolute paths to the concrete environments ``spack.lock`` under the new environment's ``include`` heading. +Spack-specific configuration variables, such as ``$spack``, and environment variables can be used in the include paths as long as the expression expands to an absolute path. +(See :ref:`config-file-variables` for more information.) -You can also include an environment directly in the ``spack.yaml`` file. -It involves adding the ``include_concrete`` heading in the yaml followed by the absolute path to the independent environments. -Note that you may use Spack config variables such as ``$spack`` or environment variables as long as the expression expands to an absolute path. +For example, .. code-block:: yaml spack: + include: + - /absolute/path/to/environment1/spack.lock + - $spack/../path/to/environment2/spack.lock specs: [] concretizer: unify: true - include_concrete: - - /absolute/path/to/environment1 - - $spack/../path/to/environment2 +will include the specs from ``environment1`` and ``environment2`` where the second environment's path is the absolute path of the directory that is relative to the spack root. -Once the ``spack.yaml`` has been updated you must concretize the environment to get the concrete specs from the included environments. +.. note:: -Updating an included environment -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -If changes were made to the base environment and you want that reflected in the included environment you will need to re-concretize both the base environment and the included environment for the change to be implemented. -For example: + Once the ``spack.yaml`` file is updated you must concretize the new environment to get the concrete specs from the included environments. + This will produce the combined ``spack.lock`` file. -.. code-block:: spec +Updating a combined environment +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +If you want changes made to one of the included environments reflected in the combined environment, then you will need to re-concretize the included environment **then** the combined environment for the change to be incorporated. +For example:: $ spack env create myenv $ spack -e myenv add python $ spack -e myenv concretize - $ spack env create --include-concrete myenv included_env - + $ spack env create --include-concrete myenv combined_env $ spack -e myenv find ==> In environment myenv @@ -555,17 +560,16 @@ For example: ==> 0 installed packages - - $ spack -e included_env find - ==> In environment included_env + $ spack -e combined_env find + ==> In environment combined_env ==> No root specs ==> Included specs python ==> 0 installed packages -Here we see that ``included_env`` has access to the python package through the ``myenv`` environment. -But if we were to add another spec to ``myenv``, ``included_env`` will not be able to access the new information. +Here we see that ``combined_env`` contains the python package from ``myenv`` environment. +But if we were to add another spec to ``myenv``, ``combined_env`` will not know about the other spec. .. code-block:: spec @@ -578,22 +582,21 @@ But if we were to add another spec to ``myenv``, ``included_env`` will not be ab ==> 0 installed packages - - $ spack -e included_env find - ==> In environment included_env + $ spack -e combined_env find + ==> In environment combined_env ==> No root specs ==> Included specs python ==> 0 installed packages -It isn't until you run the ``spack concretize`` command that the combined environment will get the updated information from the re-concretized base environment. +It isn't until you run the ``spack concretize`` command that the combined environment will get the updated information from the re-concretized ``myenv``. .. code-block:: console - $ spack -e included_env concretize - $ spack -e included_env find - ==> In environment included_env + $ spack -e combined_env concretize + $ spack -e combined_env find + ==> In environment combined_env ==> No root specs ==> Included specs perl python @@ -977,25 +980,25 @@ That can be done with the following manifest file: .. code-block:: yaml spack: - - group: apps-x86_64_v3 - specs: - - gromacs - - quantum-espresso - override: - packages: - all: - prefer: - - target=x86_64_v3 - - - group: apps-x86_64_v4 - specs: - - gromacs - - quantum-espresso - override: - packages: - all: - prefer: - - target=x86_64_v4 + - group: apps-x86_64_v3 + specs: + - gromacs + - quantum-espresso + override: + packages: + all: + prefer: + - target=x86_64_v3 + + - group: apps-x86_64_v4 + specs: + - gromacs + - quantum-espresso + override: + packages: + all: + prefer: + - target=x86_64_v4 The ``override:`` attribute allows us to override the configuration for a single group of specs. The overridden part is always added as the *topmost* scope when the current group is concretized. diff --git a/lib/spack/docs/include_yaml.rst b/lib/spack/docs/include_yaml.rst index 48d179341a6f32..4e19dbb19d3b4a 100644 --- a/lib/spack/docs/include_yaml.rst +++ b/lib/spack/docs/include_yaml.rst @@ -25,12 +25,14 @@ You can include a single configuration file or an entire configuration *scope* l include: - /path/to/a/required/config.yaml + - $MY_SPECIAL_CONFIG_FILE + - path: $HOME/path/to/my/project/packages.yaml - path: /path/to/$os/$target/config optional: true - path: /path/to/os-specific/config-dir when: os == "ventura" -Included paths may be absolute, relative (to the configuration file), or they can be specified as URLs. +Included paths may be absolute, relative (to the configuration file), specified as URLs, or provided in an environment variable (e.g., ``$MY_SPECIAL_CONFIG_FILE``). * ``optional``: Spack will raise an error when an included configuration file does not exist, *unless* it is explicitly made ``optional: true``, like the second path above. * ``when``: Configuration scopes can also be included *conditionally* with ``when``. @@ -82,6 +84,10 @@ We would then configure the ``include.yaml`` file as follows:: - USC/config/config.yaml - USC/config/packages.yaml +.. note:: + + The git URL can be specified through an environment variable (e.g., ``$MY_USC_CONFIG_URL``). + If the condition is satisfied, then the ``main`` branch of the repository will be cloned when the configuration scopes are initially created. Once cloned, the settings for the two files under the ``USC/config`` directory will be integrated into Spack's configuration. In this example, the new scopes can be seen by running:: @@ -107,7 +113,7 @@ If only the ``USC/config`` directory was listed under ``paths``, then there woul ``git:``, ``branch:``, ``commit:``, and ``tag:`` attributes. .. versionadded:: 1.2 - ``name:`` attribute. + ``name:`` attribute and git environment variable support. Precedence ~~~~~~~~~~ diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 1c5a90e8c82303..2ad0d417d5210e 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -1055,6 +1055,16 @@ def _scope( # circular dependencies import spack.util.path + # Ignore included concrete environment files (i.e., ``spack.lock``) + # since they are not normal configuration (scope) files and their + # processing is handled when the environment is processed. + if path and os.path.basename(path) == "spack.lock": + tty.debug( + f"Ignoring inclusion of '{path}' since environment lock files " + "are processed elsewhere" + ) + return None + # Ensure the parent scope is valid self._validate_parent_scope(parent_scope) @@ -1153,12 +1163,17 @@ class IncludePath(OptionalInclude): destination: Optional[str] def __init__(self, entry: dict): + # circular dependencies + import spack.util.path + super().__init__(entry) path_override_env_var = entry.get("path_override_env_var", "") if path_override_env_var and path_override_env_var in os.environ: - self.path = os.environ[path_override_env_var] + path = os.environ[path_override_env_var] else: - self.path = entry.get("path", "") + path = entry.get("path", "") + self.path = spack.util.path.substitute_path_variables(path) + self.sha256 = entry.get("sha256", "") self.destination = None @@ -1219,7 +1234,7 @@ def paths(self) -> List[str]: class GitIncludePaths(OptionalInclude): - repo: str + git: str branch: str commit: str tag: str @@ -1227,12 +1242,17 @@ class GitIncludePaths(OptionalInclude): destination: Optional[str] def __init__(self, entry: dict): + # circular dependencies + import spack.util.path + super().__init__(entry) - self.repo = entry.get("git", "") + self.git = spack.util.path.substitute_path_variables(entry.get("git", "")) self.branch = entry.get("branch", "") self.commit = entry.get("commit", "") self.tag = entry.get("tag", "") - self._paths = entry.get("paths", []) + self._paths = [ + spack.util.path.substitute_path_variables(path) for path in entry.get("paths", []) + ] self.destination = None if not self.branch and not self.commit and not self.tag: @@ -1252,28 +1272,28 @@ def __repr__(self): identifier = f"commit={self.commit}, tag={self.tag}" return ( - f"GitIncludePaths({self.repo}, paths={self.paths}, " + f"GitIncludePaths({self.git}, paths={self.paths}, " f"{identifier}, when='{self.when}', optional={self.optional})" ) def _destination(self): - dir_name = spack.util.hash.b32_hash(self.repo)[-7:] + dir_name = spack.util.hash.b32_hash(self.git)[-7:] return os.path.join(_include_cache_location(), dir_name) def _clone(self) -> Optional[str]: """Clone the repository.""" if self.fetched(): - tty.debug(f"Repository ({self.repo}) already cloned to {self.destination}") + tty.debug(f"Repository ({self.git}) already cloned to {self.destination}") return self.destination destination = self._destination() with filesystem.working_dir(destination, create=True): if not os.path.exists(".git"): try: - spack.util.git.init_git_repo(self.repo) + spack.util.git.init_git_repo(self.git) except spack.util.executable.ProcessError as e: raise spack.error.ConfigError( - f"Unable to initialize repository ({self.repo}) under {destination}: {e}" + f"Unable to initialize repository ({self.git}) under {destination}: {e}" ) try: diff --git a/lib/spack/spack/environment/__init__.py b/lib/spack/spack/environment/__init__.py index 61c65224e35788..5bd16aec084906 100644 --- a/lib/spack/spack/environment/__init__.py +++ b/lib/spack/spack/environment/__init__.py @@ -624,6 +624,7 @@ lockfile_include_key, lockfile_name, manifest_file, + manifest_include_name, manifest_name, no_active_environment, read, @@ -663,6 +664,7 @@ "lockfile_include_key", "lockfile_name", "manifest_file", + "manifest_include_name", "manifest_name", "no_active_environment", "read", diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 85ae2b9518034b..f99e4502f510a8 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -182,9 +182,13 @@ def default_manifest_yaml(): # Default behavior to link all packages into views (vs. only root packages) default_view_link = "all" +# (DEPRECATED) Use as the heading/name in the manifest is deprecated. # The key for any concrete specs included in a lockfile. lockfile_include_key = "include_concrete" +# The name/heading for include paths in the manifest file. +manifest_include_name = "include" + def installed_specs(): """ @@ -320,7 +324,7 @@ def as_env_dir(name_or_dir): validate_env_name(name_or_dir) if not exists(name_or_dir): raise SpackEnvironmentError("no such environment '%s'" % name_or_dir) - return root(name_or_dir) + return _root(name_or_dir) def environment_from_name_or_dir(name_or_dir): @@ -1179,14 +1183,8 @@ def add_view(name, values): if self.views == dict(): self.views[default_view_name] = ViewDescriptor(self.path, self.view_path_default) - def _process_concrete_includes(self): - """Extract and load into memory included concrete spec data.""" - _included_concrete_envs = self.manifest[TOP_LEVEL_KEY].get(lockfile_include_key, []) - # Expand config and environment variables - self.included_concrete_env_root_dirs = [ - spack.util.path.canonicalize_path(_env) for _env in _included_concrete_envs - ] - + def _load_concrete_include_data(self): + """Load concrete include specs data from included concrete directories.""" if self.included_concrete_env_root_dirs: if os.path.exists(self.lock_path): with open(self.lock_path, encoding="utf-8") as f: @@ -1197,12 +1195,56 @@ def _process_concrete_includes(self): else: self.include_concrete_envs() + def _process_included_lockfiles(self): + """Extract and load into memory included lock file data.""" + includes = self.manifest[TOP_LEVEL_KEY].get(lockfile_include_key, []) + if includes: + tty.warn( + f"Use of '{lockfile_include_key}' in manifest files " + f"is deprecated. The key should be '{manifest_include_name}' " + f"and the path should end with '{lockfile_name}'. Run " + f"'spack env update {self.name}' to update the manifest." + ) + includes = [os.path.join(inc, lockfile_name) for inc in includes] + includes += self.manifest[TOP_LEVEL_KEY].get(manifest_include_name, []) + if not includes: + return + + # Expand config and environment variables for concrete environments, + # indicated by the inclusion of lock files. + self.included_concrete_env_root_dirs = [] + + for entry in includes: + include = spack.config.included_path(entry) + if isinstance(include, spack.config.GitIncludePaths): + # Git includes must be cloned first; paths are relative to the + # clone destination, not to the manifest directory. + destination = include._clone() + if destination is None: + continue + resolved = [os.path.join(destination, p) for p in include.paths] + else: + resolved = [ + spack.util.path.canonicalize_path(p, default_wd=self.path) + for p in include.paths + ] + + for path in resolved: + if os.path.basename(path) != lockfile_name: + continue + + tty.debug(f"Adding {path} to the concrete environment root directories") + self.included_concrete_env_root_dirs.append(os.path.dirname(path)) + + # Cache concrete environments for required lock files. + self._load_concrete_include_data() + def _construct_state_from_manifest(self): """Set up user specs and views from the manifest file.""" self.views = {} self._sync_speclists() self._process_view(spack.config.get("view", True)) - self._process_concrete_includes() + self._process_included_lockfiles() def _sync_speclists(self): self.spec_lists = {} @@ -1331,14 +1373,14 @@ def scope_name(self): return self.manifest.scope_name def include_concrete_envs(self): - """Copy and save the included envs' specs internally""" + """Copy and save the included environments' specs internally.""" root_hash_seen = set() concrete_hash_seen = set() self.included_concrete_spec_data = {} for env_path in self.included_concrete_env_root_dirs: - # Check that environment exists + # Check that the environment (lockfile) exists if not is_env_dir(env_path): raise SpackEnvironmentError(f"Unable to find env at {env_path}") @@ -3032,7 +3074,7 @@ def _ensure_env_dir(): return # TODO: make this recursive - includes = manifest[TOP_LEVEL_KEY].get("include", []) + includes = manifest[TOP_LEVEL_KEY].get(manifest_include_name, []) paths = spack.config.paths_from_includes(includes) for path in paths: if os.path.isabs(path): diff --git a/lib/spack/spack/schema/env.py b/lib/spack/spack/schema/env.py index 85c449e86d26f0..30f0da737ca3b9 100644 --- a/lib/spack/spack/schema/env.py +++ b/lib/spack/spack/schema/env.py @@ -8,6 +8,7 @@ :lines: 19- """ +import os from typing import Any, Dict import spack.schema.merged @@ -17,6 +18,7 @@ #: Top level key in a manifest file TOP_LEVEL_KEY = "spack" +# (DEPRECATED) include concrete entries to be merged under the include key include_concrete = { "type": "array", "default": [], @@ -84,6 +86,7 @@ ] }, }, + # (DEPRECATED) include concrete to be merged under the include key "include_concrete": include_concrete, }, } @@ -99,7 +102,7 @@ def update(data: Dict[str, Any]) -> bool: - """Update the spack.yaml data in place to remove deprecated properties. + """Update the spack.yaml data to the new format. Args: data: dictionary to be updated @@ -107,6 +110,26 @@ def update(data: Dict[str, Any]) -> bool: Returns: ``True`` if data was changed, ``False`` otherwise """ - # There are not currently any deprecated attributes in this section - # that have not been removed - return False + if not isinstance(data, dict): + return False + + if "include_concrete" not in data: + return False + + # Move the old 'include_concrete' paths to reside under the 'include', + # ensuring that the lock file name is appended. + includes = [] + for path in data["include_concrete"]: + if os.path.basename(path) != "spack.lock": + path = os.path.join(path, "spack.lock") + includes.append(path) + + # Now add back the includes the environment file already has. + if "include" in data: + for path in data["include"]: + includes.append(path) + + data["include"] = includes + del data["include_concrete"] + + return True diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 3ced9d4dba4638..88229939a4a89e 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -28,6 +28,7 @@ import spack.package_base import spack.paths import spack.repo +import spack.schema.env import spack.solver.asp import spack.stage import spack.store @@ -4739,3 +4740,117 @@ def test_view_can_select_group_of_specs_using_string( # Assertions are based on the behavior of the "--fake" install bin_file = pathlib.Path(test.default_view.view()._root) / "bin" / item.root.name assert not bin_file.exists() if item.group == "apps2" else bin_file.exists() + + +def test_env_include_concrete_only(tmp_path, mock_packages, mutable_config): + """Confirm that an environment that only includes a concrete environment actually loads it.""" + specs = ["libdwarf", "libelf"] + + include_dir = tmp_path / "includes" + include_dir.mkdir() + include_manifest = include_dir / ev.manifest_name + include_manifest.write_text( + f"""\ +spack: + specs: + - {specs[0]} + - {specs[1]} +""" + ) + include_env = ev.create("test_include", include_manifest) + include_env.concretize() + include_env.write() + + include_lockfile = include_env.lock_path + assert os.path.exists(include_lockfile) + + manifest_file = tmp_path / ev.manifest_name + manifest_file.write_text( + f"""\ +spack: + include: + - {str(include_lockfile)} +""" + ) + e = ev.create("test", manifest_file) + + # Confirm the only specs the environment has are those loaded from the + # lockfile. + assert len(e.user_specs) == 0 + all_concrete = [s for s, _ in e.concretized_specs()] + for spec in specs: + assert Spec(spec) in all_concrete + + +@pytest.mark.parametrize( + "concrete,includes", + [ + (["$HOME/path/to/other/environment"], []), + (["$HOME/path/to/another/environment"], ["a/b", "$HOME/includes"]), + ], +) +def test_env_update_include_concrete(tmp_path: pathlib.Path, concrete, includes): + """Confirm update of include_concrete converts it to include.""" + + config = {"include_concrete": concrete} + if includes: + config["include"] = includes + new_concrete = [os.path.join(p, ev.lockfile_name) for p in concrete] + assert spack.schema.env.update(config) + assert "include_concrete" not in config + assert config["include"] == new_concrete + includes + + +def test_include_concrete_deprecation_warning( + tmp_path: pathlib.Path, environment_from_manifest, capfd +): + try: + environment_from_manifest( + """\ +spack: + include_concrete: + - /path/to/some/environment +""" + ) + except ev.SpackEnvironmentError: + pass + + _, err = capfd.readouterr() + assert "should be 'include'" in err + + +def test_env_include_concrete_relative_path(tmp_path, mock_packages, mutable_config): + """Tests that a relative path in 'include' for a spack.lock is resolved relative to the + manifest file, not the current working directory. + """ + # Create and concretize the included environment. + include_dir = tmp_path / "include_env" + include_dir.mkdir() + (include_dir / ev.manifest_name).write_text( + """\ +spack: + specs: + - libdwarf +""" + ) + with ev.Environment(str(include_dir)) as e: + e.concretize() + e.write() + assert os.path.exists(e.lock_path) + + # Create the main environment in a sibling directory, using a *relative* path + main_dir = tmp_path / "main_env" + main_dir.mkdir() + relative_lockfile = f"../include_env/{ev.lockfile_name}" + (main_dir / ev.manifest_name).write_text( + f"""\ +spack: + include: + - {relative_lockfile} +""" + ) + with ev.Environment(str(main_dir)) as e: + e.concretize() + e.write() + assert len(e.user_specs) == 0 + assert [s for s, _ in e.concretized_specs()] == [Spec("libdwarf")] diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index 7761223f9ef166..46d6c6a529500f 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -1727,6 +1727,20 @@ def test_included_path_string_no_parent_path( assert curr_dir == os.path.commonprefix([curr_dir, destination]) # type: ignore[list-item] +def test_included_path_substitution(): + # check a straight path substitution + entry = {"path": "$user_cache_path/path/to/config.yaml"} + include = spack.config.included_path(entry) + assert spack.paths.user_cache_path in include.path + + # check path through an environment variable + path = "/path/to/project/packages.yaml" + os.environ["SPACK_TEST_PATH_SUB"] = path + entry = {"name": "vartest", "path": "$SPACK_TEST_PATH_SUB"} + include = spack.config.included_path(entry) + assert path in include.path + + def test_included_path_conditional_bad_when( tmp_path: pathlib.Path, mock_low_high_config, ensure_debug, capfd ): @@ -1787,7 +1801,7 @@ def test_included_path_git_unsat( } include = spack.config.included_path(entry) assert isinstance(include, spack.config.GitIncludePaths) - assert include.repo == entry["git"] + assert include.git == entry["git"] assert include.tag == entry["tag"] assert include.paths == entry["paths"] assert include.when == entry["when"] @@ -1799,6 +1813,31 @@ def test_included_path_git_unsat( assert not scopes +def test_included_path_git_substitutions(): + # check path substitutions for the git url *and* paths + paths = ["./$platform/config.yaml", "$platform/packages.yaml"] + entry = { + "git": "https://example.com/$platform/configs.git", + "branch": "develop", + "name": "site", + "paths": paths, + "when": 'platform == "test"', + } + include = spack.config.included_path(entry) + assert isinstance(include, spack.config.GitIncludePaths) + assert not include.optional and include.evaluate_condition() + assert "test" in include.git, "Expected the git url to contain the platform" + for path in include.paths: + assert "test" in path, "Expected the included git path to contain the platform" + + # check environment substitution for the git url + url = "https://example.com/path/to/configs.git" + os.environ["SPACK_TEST_URL_SUB"] = url + entry["git"] = "$SPACK_TEST_URL_SUB" + include = spack.config.included_path(entry) + assert include.git == url, "Expected git url environment var substitution" + + @pytest.mark.parametrize( "key,value", [("branch", "main"), ("commit", "abcdef123456"), ("tag", "v1.0")] ) diff --git a/lib/spack/spack/test/schema.py b/lib/spack/spack/test/schema.py index 52675874125c7f..cb68cfdc0e07b5 100644 --- a/lib/spack/spack/test/schema.py +++ b/lib/spack/spack/test/schema.py @@ -10,6 +10,7 @@ from spack.vendor import jsonschema import spack.schema +import spack.schema.env import spack.util.spack_yaml as syaml from spack.llnl.util.lang import list_modules @@ -252,3 +253,8 @@ def test_spack_schemas_are_valid(): jsonschema.validate(module_schema, _draft_07_with_spack_extensions) except jsonschema.ValidationError as e: raise RuntimeError(f"Invalid JSON schema in {module_name}: {e.message}") from e + + +def test_env_schema_update_wrong_type(): + """Confirm passing the wrong type to env.update() results in no changes.""" + assert not spack.schema.env.update(["a/b"]) From 54135ed30a7eb50f3b6bbf070981a4cdd8ee61a6 Mon Sep 17 00:00:00 2001 From: Xavier Delaruelle Date: Fri, 13 Mar 2026 09:39:08 +0100 Subject: [PATCH 141/337] modules: ease tcl template & modulefile readability (#52046) Update "tcl" modulefile template to ease its readability and readability of generated modulefiles: * always use "depends-on" command to auto load dependencies and define this command if it is not found (when Environment Modules <5.1 is used) * always specify a path delimiter when using append-path, prepend-path and remove-path Signed-off-by: Xavier Delaruelle --- lib/spack/spack/test/modules/tcl.py | 78 +++++++++++--------- share/spack/templates/modules/modulefile.tcl | 32 +++----- 2 files changed, 53 insertions(+), 57 deletions(-) diff --git a/lib/spack/spack/test/modules/tcl.py b/lib/spack/spack/test/modules/tcl.py index 38642ed64531bf..e613fb0e96b7dd 100644 --- a/lib/spack/spack/test/modules/tcl.py +++ b/lib/spack/spack/test/modules/tcl.py @@ -42,11 +42,12 @@ def test_autoload_direct(self, modulefile_content, module_configuration): content = modulefile_content(mpileaks_spec_string) assert ( - len([x for x in content if "if {![info exists ::env(LMOD_VERSION_MAJOR)]} {" in x]) - == 1 + len([x for x in content if "if {![llength [info commands depends-on]]} {" in x]) == 1 ) - assert len([x for x in content if "depends-on " in x]) == 3 - assert len([x for x in content if "module load " in x]) == 3 + assert len([x for x in content if " proc depends-on {args} {" in x]) == 1 + assert len([x for x in content if " module load {*}$args" in x]) == 1 + # depends-on command defined once and used 3 times + assert len([x for x in content if "depends-on " in x]) == 4 # dtbuild1 has # - 1 ('run',) dependency @@ -56,11 +57,12 @@ def test_autoload_direct(self, modulefile_content, module_configuration): content = modulefile_content("dtbuild1") assert ( - len([x for x in content if "if {![info exists ::env(LMOD_VERSION_MAJOR)]} {" in x]) - == 1 + len([x for x in content if "if {![llength [info commands depends-on]]} {" in x]) == 1 ) - assert len([x for x in content if "depends-on " in x]) == 2 - assert len([x for x in content if "module load " in x]) == 2 + assert len([x for x in content if " proc depends-on {args} {" in x]) == 1 + assert len([x for x in content if " module load {*}$args" in x]) == 1 + # depends-on command defined once and used twice + assert len([x for x in content if "depends-on " in x]) == 3 # The configuration file sets the verbose keyword to False messages = [x for x in content if 'puts stderr "Autoloading' in x] @@ -73,11 +75,12 @@ def test_autoload_all(self, modulefile_content, module_configuration): content = modulefile_content(mpileaks_spec_string) assert ( - len([x for x in content if "if {![info exists ::env(LMOD_VERSION_MAJOR)]} {" in x]) - == 1 + len([x for x in content if "if {![llength [info commands depends-on]]} {" in x]) == 1 ) - assert len([x for x in content if "depends-on " in x]) == 6 - assert len([x for x in content if "module load " in x]) == 6 + assert len([x for x in content if " proc depends-on {args} {" in x]) == 1 + assert len([x for x in content if " module load {*}$args" in x]) == 1 + # depends-on command defined once and used 6 times + assert len([x for x in content if "depends-on " in x]) == 7 # dtbuild1 has # - 1 ('run',) dependency @@ -87,11 +90,12 @@ def test_autoload_all(self, modulefile_content, module_configuration): content = modulefile_content("dtbuild1") assert ( - len([x for x in content if "if {![info exists ::env(LMOD_VERSION_MAJOR)]} {" in x]) - == 1 + len([x for x in content if "if {![llength [info commands depends-on]]} {" in x]) == 1 ) - assert len([x for x in content if "depends-on " in x]) == 2 - assert len([x for x in content if "module load " in x]) == 2 + assert len([x for x in content if " proc depends-on {args} {" in x]) == 1 + assert len([x for x in content if " module load {*}$args" in x]) == 1 + # depends-on command defined once and used twice + assert len([x for x in content if "depends-on " in x]) == 3 def test_prerequisites_direct( self, modulefile_content, module_configuration, host_architecture_str @@ -132,7 +136,6 @@ def test_alter_environment(self, modulefile_content, module_configuration): assert len([x for x in content if "setenv FOO {foo}" in x]) == 0 assert len([x for x in content if "unsetenv BAR" in x]) == 0 assert len([x for x in content if "depends-on foo/bar" in x]) == 1 - assert len([x for x in content if "module load foo/bar" in x]) == 1 assert len([x for x in content if "setenv LIBDWARF_ROOT" in x]) == 1 def test_prepend_path_separator(self, modulefile_content, module_configuration): @@ -141,14 +144,14 @@ def test_prepend_path_separator(self, modulefile_content, module_configuration): module_configuration("module_path_separator") content = modulefile_content("module-path-separator") - assert len([x for x in content if "append-path COLON {foo}" in x]) == 1 - assert len([x for x in content if "prepend-path COLON {foo}" in x]) == 1 - assert len([x for x in content if "remove-path COLON {foo}" in x]) == 1 - assert len([x for x in content if "append-path --delim {;} SEMICOLON {bar}" in x]) == 1 - assert len([x for x in content if "prepend-path --delim {;} SEMICOLON {bar}" in x]) == 1 - assert len([x for x in content if "remove-path --delim {;} SEMICOLON {bar}" in x]) == 1 - assert len([x for x in content if "append-path --delim { } SPACE {qux}" in x]) == 1 - assert len([x for x in content if "remove-path --delim { } SPACE {qux}" in x]) == 1 + assert len([x for x in content if "append-path -d {:} COLON {foo}" in x]) == 1 + assert len([x for x in content if "prepend-path -d {:} COLON {foo}" in x]) == 1 + assert len([x for x in content if "remove-path -d {:} COLON {foo}" in x]) == 1 + assert len([x for x in content if "append-path -d {;} SEMICOLON {bar}" in x]) == 1 + assert len([x for x in content if "prepend-path -d {;} SEMICOLON {bar}" in x]) == 1 + assert len([x for x in content if "remove-path -d {;} SEMICOLON {bar}" in x]) == 1 + assert len([x for x in content if "append-path -d { } SPACE {qux}" in x]) == 1 + assert len([x for x in content if "remove-path -d { } SPACE {qux}" in x]) == 1 @pytest.mark.regression("11355") def test_manpath_setup(self, modulefile_content, module_configuration): @@ -162,13 +165,16 @@ def test_manpath_setup(self, modulefile_content, module_configuration): # manpath set by module with prepend-path content = modulefile_content("module-manpath-prepend") - assert len([x for x in content if "prepend-path MANPATH {/path/to/man}" in x]) == 1 - assert len([x for x in content if "prepend-path MANPATH {/path/to/share/man}" in x]) == 1 + assert len([x for x in content if "prepend-path -d {:} MANPATH {/path/to/man}" in x]) == 1 + assert ( + len([x for x in content if "prepend-path -d {:} MANPATH {/path/to/share/man}" in x]) + == 1 + ) assert len([x for x in content if "append-path MANPATH {}" in x]) == 1 # manpath set by module with append-path content = modulefile_content("module-manpath-append") - assert len([x for x in content if "append-path MANPATH {/path/to/man}" in x]) == 1 + assert len([x for x in content if "append-path -d {:} MANPATH {/path/to/man}" in x]) == 1 assert len([x for x in content if "append-path MANPATH {}" in x]) == 1 # manpath set by module with setenv @@ -237,14 +243,16 @@ def test_exclude(self, modulefile_content, module_configuration, host_architectu module_configuration("exclude") content = modulefile_content("mpileaks ^zmpi") - assert len([x for x in content if "module load " in x]) == 2 + # depends-on command defined once and used twice + assert len([x for x in content if "depends-on " in x]) == 3 with pytest.raises(FileNotFoundError): modulefile_content(f"callpath target={host_architecture_str}") content = modulefile_content(f"zmpi target={host_architecture_str}") - assert len([x for x in content if "module load " in x]) == 2 + # depends-on command defined once and used twice + assert len([x for x in content if "depends-on " in x]) == 3 def test_naming_scheme_compat(self, factory, module_configuration): """Tests backwards compatibility for naming_scheme key""" @@ -484,17 +492,17 @@ def test_autoload_with_constraints(self, modulefile_content, module_configuratio # Test the mpileaks that should have the autoloaded dependencies content = modulefile_content("mpileaks ^mpich2") - assert len([x for x in content if "depends-on " in x]) == 3 - assert len([x for x in content if "module load " in x]) == 3 + # depends-on command defined once and used 3 times + assert len([x for x in content if "depends-on " in x]) == 4 # Test the mpileaks that should NOT have the autoloaded dependencies content = modulefile_content("mpileaks ^mpich") assert ( - len([x for x in content if "if {![info exists ::env(LMOD_VERSION_MAJOR)]} {" in x]) - == 0 + len([x for x in content if "if {![llength [info commands depends-on]]} {" in x]) == 0 ) + assert len([x for x in content if " proc depends-on {args} {" in x]) == 0 + assert len([x for x in content if " module load {*}$args" in x]) == 0 assert len([x for x in content if "depends-on " in x]) == 0 - assert len([x for x in content if "module load " in x]) == 0 def test_modules_no_arch(self, factory, module_configuration): module_configuration("no_arch") diff --git a/share/spack/templates/modules/modulefile.tcl b/share/spack/templates/modules/modulefile.tcl index b162e3f62eb863..447ec9e0e75f31 100644 --- a/share/spack/templates/modules/modulefile.tcl +++ b/share/spack/templates/modules/modulefile.tcl @@ -27,15 +27,15 @@ proc ModulesHelp { } { {% block autoloads %} {% if autoload|length > 0 %} -if {![info exists ::env(LMOD_VERSION_MAJOR)]} { -{% for module in autoload %} - module load {{ module }} -{% endfor %} -} else { +# define missing command if using Environment Modules <5.1 +if {![llength [info commands depends-on]]} { + proc depends-on {args} { + module load {*}$args + } +} {% for module in autoload %} - depends-on {{ module }} +depends-on {{ module }} {% endfor %} -} {% endif %} {% endblock %} {# #} @@ -54,23 +54,11 @@ conflict {{ name }} {% block environment %} {% for command_name, cmd in environment_modifications %} {% if command_name == 'PrependPath' %} -{% if cmd.separator == ':' %} -prepend-path {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} -{% else %} -prepend-path --delim {{ '{' }}{{ cmd.separator }}{{ '}' }} {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} -{% endif %} +prepend-path -d {{ '{' }}{{ cmd.separator }}{{ '}' }} {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} {% elif command_name in ('AppendPath', 'AppendFlagsEnv') %} -{% if cmd.separator == ':' %} -append-path {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} -{% else %} -append-path --delim {{ '{' }}{{ cmd.separator }}{{ '}' }} {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} -{% endif %} +append-path -d {{ '{' }}{{ cmd.separator }}{{ '}' }} {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} {% elif command_name in ('RemovePath', 'RemoveFlagsEnv') %} -{% if cmd.separator == ':' %} -remove-path {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} -{% else %} -remove-path --delim {{ '{' }}{{ cmd.separator }}{{ '}' }} {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} -{% endif %} +remove-path -d {{ '{' }}{{ cmd.separator }}{{ '}' }} {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} {% elif command_name == 'SetEnv' %} setenv {{ cmd.name }} {{ '{' }}{{ cmd.value }}{{ '}' }} {% elif command_name == 'UnsetEnv' %} From 2e154c5f1c1fe594223428938280689db6305660 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 13 Mar 2026 18:50:04 +0100 Subject: [PATCH 142/337] new_installer.py: log filtering of path padding (#52071) Do log filtering in `BuildStatus`, which is a class that handles the UI of the new installer. The Tee class in the build process still duplexes build output verbatim to the log file and to the parent process; this is just a UI enhancement, and therefore part of the parent process. * Support both `str` and `bytes` in `path.py` log filtering utility * Use bytes based log filtering in `new_installer.py` (it treats stdout/stderr as raw bytes) * Parametrize existing tests over `str` and `bytes`. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 11 ++- lib/spack/spack/store.py | 4 + lib/spack/spack/test/installer_tui.py | 21 +++++ lib/spack/spack/test/util/path.py | 107 +++++++++++++++----------- lib/spack/spack/util/path.py | 81 ++++++++++++------- 5 files changed, 147 insertions(+), 77 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 5cdf294a14e486..b09bb081905d7d 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -78,6 +78,7 @@ import spack.url_buildcache import spack.util.environment import spack.util.lock +import spack.util.path from spack.installer import _do_fake_install, dump_packages if TYPE_CHECKING: @@ -969,6 +970,7 @@ def __init__( get_time: Callable[[], float] = time.monotonic, is_tty: Optional[bool] = None, verbose: bool = False, + filter_padding: bool = False, ) -> None: #: Ordered dict of build ID -> info self.total = total @@ -995,6 +997,7 @@ def __init__( self.is_tty = is_tty if is_tty is not None else self.stdout.isatty() #: Verbose mode only applies to non-TTY where we want to track a single build log. self.verbose = verbose and not self.is_tty + self.log_filter = spack.util.path.padding_filter_bytes if filter_padding else None def on_resize(self) -> None: """Refresh cached terminal size and trigger a redraw.""" @@ -1269,6 +1272,8 @@ def print_logs(self, build_id: str, data: bytes) -> None: # between builds. if build_id != self.tracked_build_id: return + if self.log_filter is not None: + data = self.log_filter(data) self.stdout.buffer.write(data) self.stdout.flush() @@ -1781,7 +1786,11 @@ def __init__( self.verbose = verbose self.running_builds: Dict[int, ChildInfo] = {} self.log_paths: Dict[str, str] = {} - self.build_status = BuildStatus(len(self.build_graph.nodes), verbose=verbose) + self.build_status = BuildStatus( + len(self.build_graph.nodes), + verbose=verbose, + filter_padding=spack.store.STORE.has_padding(), + ) self.jobs = spack.config.determine_number_of_jobs(parallel=True) if concurrent_packages is None: concurrent_packages_config = spack.config.get("config:concurrent_packages", 0) diff --git a/lib/spack/spack/store.py b/lib/spack/spack/store.py index 4068398586a790..bfacc45c99d7d2 100644 --- a/lib/spack/spack/store.py +++ b/lib/spack/spack/store.py @@ -185,6 +185,10 @@ def __init__( self.root, default_timeout=lock_cfg.package_timeout ) + def has_padding(self) -> bool: + """Returns True if the store layout includes path padding.""" + return self.root != self.unpadded_root + def reindex(self) -> None: """Convenience function to reindex the store DB with its own layout.""" return self.db.reindex() diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 86fafb9f5be4e7..111971e26ee0dd 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -72,6 +72,7 @@ def create_build_status( terminal_rows: int = 24, total: int = 0, verbose: bool = False, + filter_padding: bool = False, ) -> Tuple[BuildStatus, List[float], SimpleTextIOWrapper]: """Helper function to create BuildStatus with mocked dependencies""" fake_stdout = SimpleTextIOWrapper(tty=is_tty) @@ -91,6 +92,7 @@ def mock_get_terminal_size(): get_time=mock_get_time, is_tty=is_tty, verbose=verbose, + filter_padding=filter_padding, ) return status, time_values, fake_stdout @@ -943,6 +945,25 @@ def test_update_state_finished_triggers_toggle_when_tracking(self): assert status.overview_mode is True assert status.tracked_build_id == "" + @pytest.mark.parametrize("filter_padding", [True, False]) + def test_print_logs_filters_padding(self, filter_padding): + """print_logs strips path-padding placeholders before writing to stdout.""" + status, _, fake_stdout = create_build_status(filter_padding=filter_padding) + (spec,) = add_mock_builds(status, 1) + build_id = spec.dag_hash() + log_output = b"--with-foo=/base/__spack_path_placeholder__/__spack_path_placeholder__/bin" + + # track the build and print logs with the relevant path. + status.overview_mode = False + status.tracked_build_id = build_id + status.print_logs(build_id, log_output) + written = fake_stdout._buffer.getvalue() + + if filter_padding: + assert written == b"--with-foo=/base/[padded-to-59-chars]/bin" + else: + assert written == log_output + class TestSearchFilteringIntegration: """Test search mode with display filtering""" diff --git a/lib/spack/spack/test/util/path.py b/lib/spack/spack/test/util/path.py index 0f8903925d0f09..8907e0cad497d5 100644 --- a/lib/spack/spack/test/util/path.py +++ b/lib/spack/spack/test/util/path.py @@ -42,79 +42,92 @@ def test_sanitize_filename(): # which is used for binary caching. This functionality is not supported # on Windows as of yet. @pytest.mark.not_on_windows("Padding functionality unsupported on Windows") +@pytest.mark.parametrize("as_bytes", [False, True]) class TestPathPadding: + @pytest.fixture(autouse=True) + def setup(self, as_bytes: bool): + #: The filter function, either for bytes or str + self.filter = sup.padding_filter_bytes if as_bytes else sup.padding_filter + #: A converter of str -> bytes if we're testing the bytes filter + self.convert = lambda s: s.encode("ascii") if as_bytes else s + @pytest.mark.parametrize("padded,fixed", zip(padded_lines, fixed_lines)) def test_padding_substitution(self, padded, fixed): """Ensure that all padded lines are unpadded correctly.""" - assert fixed == sup.padding_filter(padded) + assert self.convert(fixed) == self.filter(self.convert(padded)) def test_no_substitution(self): """Ensure that a line not containing one full path placeholder is not modified.""" partial = "--prefix=/Users/gamblin2/padding-log-test/opt/__spack_path_pla/darwin-bigsur-skylake/apple-clang-12.0.5/zlib-1.2.11-74mwnxgn6nujehpyyalhwizwojwn5zga'" # noqa: E501 - assert sup.padding_filter(partial) == partial + p = self.convert(partial) + assert self.filter(p) is p # Test fast-path identity def test_short_substitution(self): """Ensure that a single placeholder path component is replaced""" short = "--prefix=/Users/gamblin2/padding-log-test/opt/__spack_path_placeholder__/darwin-bigsur-skylake/apple-clang-12.0.5/zlib-1.2.11-74mwnxgn6nujehpyyalhwizwojwn5zga'" # noqa: E501 short_subst = "--prefix=/Users/gamblin2/padding-log-test/opt/[padded-to-63-chars]/darwin-bigsur-skylake/apple-clang-12.0.5/zlib-1.2.11-74mwnxgn6nujehpyyalhwizwojwn5zga'" # noqa: E501 - assert short_subst == sup.padding_filter(short) + assert self.convert(short_subst) == self.filter(self.convert(short)) def test_partial_substitution(self): """Ensure that a single placeholder path component is replaced""" short = "--prefix=/Users/gamblin2/padding-log-test/opt/__spack_path_placeholder__/__spack_p/darwin-bigsur-skylake/apple-clang-12.0.5/zlib-1.2.11-74mwnxgn6nujehpyyalhwizwojwn5zga'" # noqa: E501 short_subst = "--prefix=/Users/gamblin2/padding-log-test/opt/[padded-to-73-chars]/darwin-bigsur-skylake/apple-clang-12.0.5/zlib-1.2.11-74mwnxgn6nujehpyyalhwizwojwn5zga'" # noqa: E501 - assert short_subst == sup.padding_filter(short) + assert self.convert(short_subst) == self.filter(self.convert(short)) def test_longest_prefix_re(self): """Test that longest_prefix_re generates correct regular expressions.""" assert "(s(?:t(?:r(?:i(?:ng?)?)?)?)?)" == sup.longest_prefix_re("string", capture=True) assert "(?:s(?:t(?:r(?:i(?:ng?)?)?)?)?)" == sup.longest_prefix_re("string", capture=False) - def test_output_filtering(self, capfd, install_mockery, mutable_config): - """Test filtering padding out of tty messages.""" - long_path = "/" + "/".join([sup.SPACK_PATH_PADDING_CHARS] * 200) - padding_string = "[padded-to-%d-chars]" % len(long_path) - - # test filtering when padding is enabled - with spack.config.override("config:install_tree", {"padded_length": 256}): - # tty.msg with filtering on the first argument - with sup.filter_padding(): - tty.msg("here is a long path: %s/with/a/suffix" % long_path) - out, err = capfd.readouterr() - assert padding_string in out - - # tty.msg with filtering on a laterargument - with sup.filter_padding(): - tty.msg("here is a long path:", "%s/with/a/suffix" % long_path) - out, err = capfd.readouterr() - assert padding_string in out - - # tty.error with filtering on the first argument - with sup.filter_padding(): - tty.error("here is a long path: %s/with/a/suffix" % long_path) - out, err = capfd.readouterr() - assert padding_string in err - - # tty.error with filtering on a later argument - with sup.filter_padding(): - tty.error("here is a long path:", "%s/with/a/suffix" % long_path) - out, err = capfd.readouterr() - assert padding_string in err - - # test no filtering - tty.msg("here is a long path: %s/with/a/suffix" % long_path) + +@pytest.mark.not_on_windows("Padding functionality unsupported on Windows") +def test_output_filtering(capfd, install_mockery, mutable_config): + """Test filtering padding out of tty messages.""" + long_path = "/" + "/".join([sup.SPACK_PATH_PADDING_CHARS] * 200) + padding_string = "[padded-to-%d-chars]" % len(long_path) + + # test filtering when padding is enabled + with spack.config.override("config:install_tree", {"padded_length": 256}): + # tty.msg with filtering on the first argument + with sup.filter_padding(): + tty.msg("here is a long path: %s/with/a/suffix" % long_path) + out, err = capfd.readouterr() + assert padding_string in out + + # tty.msg with filtering on a laterargument + with sup.filter_padding(): + tty.msg("here is a long path:", "%s/with/a/suffix" % long_path) + out, err = capfd.readouterr() + assert padding_string in out + + # tty.error with filtering on the first argument + with sup.filter_padding(): + tty.error("here is a long path: %s/with/a/suffix" % long_path) out, err = capfd.readouterr() - assert padding_string not in out - - def test_pad_on_path_sep_boundary(self): - """Ensure that padded paths do not end with path separator.""" - pad_length = len(sup.SPACK_PATH_PADDING_CHARS) - padded_length = 128 - remainder = padded_length % (pad_length + 1) - path = "a" * (remainder - 1) - result = sup.add_padding(path, padded_length) - assert 128 == len(result) and not result.endswith(os.path.sep) + assert padding_string in err + + # tty.error with filtering on a later argument + with sup.filter_padding(): + tty.error("here is a long path:", "%s/with/a/suffix" % long_path) + out, err = capfd.readouterr() + assert padding_string in err + + # test no filtering + tty.msg("here is a long path: %s/with/a/suffix" % long_path) + out, err = capfd.readouterr() + assert padding_string not in out + + +@pytest.mark.not_on_windows("Padding functionality unsupported on Windows") +def test_pad_on_path_sep_boundary(): + """Ensure that padded paths do not end with path separator.""" + pad_length = len(sup.SPACK_PATH_PADDING_CHARS) + padded_length = 128 + remainder = padded_length % (pad_length + 1) + path = "a" * (remainder - 1) + result = sup.add_padding(path, padded_length) + assert 128 == len(result) and not result.endswith(os.path.sep) @pytest.mark.parametrize("debug", [1, 2]) diff --git a/lib/spack/spack/util/path.py b/lib/spack/spack/util/path.py index aa983840f55fe4..d3d897541f9a6f 100644 --- a/lib/spack/spack/util/path.py +++ b/lib/spack/spack/util/path.py @@ -15,7 +15,7 @@ import sys import tempfile from datetime import date -from typing import Optional +from typing import Optional, Union import spack.llnl.util.tty as tty import spack.util.spack_yaml as syaml @@ -99,6 +99,9 @@ def replacements(): #: include some other component of the installation path. SPACK_PATH_PADDING_CHARS = "__spack_path_placeholder__" +#: Bytes equivalent of SPACK_PATH_PADDING_CHARS. +SPACK_PATH_PADDING_BYTES = SPACK_PATH_PADDING_CHARS.encode("ascii") + #: Special padding char if the padded string would otherwise end with a path #: separator (since the path separator would otherwise get collapsed out, #: causing inconsistent padding). @@ -318,12 +321,29 @@ def longest_prefix_re(string, capture=True): ) -#: regex cache for padding_filter function -_filter_re = None +def _build_padding_re(as_bytes: bool = False): + """Build and return a compiled regex for filtering path padding placeholders.""" + pad = re.escape(SPACK_PATH_PADDING_CHARS) + extra = SPACK_PATH_PADDING_EXTRA_CHAR + longest_prefix = longest_prefix_re(SPACK_PATH_PADDING_CHARS, capture=False) + + regex = ( + r"((?:/[^/\s]*)*?)" # zero or more leading non-whitespace path components + r"(?:/{pad})+" # the padding string repeated one or more times + # trailing prefix of padding as path component + r"(?:/{longest_prefix}|/{longest_prefix}{extra})?(?=/)" + ) + regex = regex.replace("/", re.escape(os.sep)) + regex = regex.format(pad=pad, extra=extra, longest_prefix=longest_prefix) + + if as_bytes: + return re.compile(regex.encode("ascii")) + else: + return re.compile(regex) -def padding_filter(string): - """Filter used to reduce output from path padding in log output. +class _PaddingFilter: + """Callable that filters path-padding placeholders from a string or bytes buffer. This turns paths like this: @@ -335,7 +355,7 @@ def padding_filter(string): Where ``padded-to-512-chars`` indicates that the prefix was padded with placeholders until it hit 512 characters. The actual value of this number - depends on what the `install_tree``'s ``padded_length`` is configured to. + depends on what the ``install_tree``'s ``padded_length`` is configured to. For a path to match and be filtered, the placeholder must appear in its entirety at least one time. e.g., "/spack/" would not be filtered, but @@ -343,29 +363,32 @@ def padding_filter(string): Note that only the first padded path in the string is filtered. """ - global _filter_re - - pad = SPACK_PATH_PADDING_CHARS - if not _filter_re: - longest_prefix = longest_prefix_re(pad) - regex = ( - r"((?:/[^/\s]*)*?)" # zero or more leading non-whitespace path components - r"(/{pad})+" # the padding string repeated one or more times - # trailing prefix of padding as path component - r"(/{longest_prefix}|/{longest_prefix}{extra_pad_character})?(?=/)" - ) - regex = regex.replace("/", re.escape(os.sep)) - regex = regex.format( - pad=pad, - extra_pad_character=SPACK_PATH_PADDING_EXTRA_CHAR, - longest_prefix=longest_prefix, - ) - _filter_re = re.compile(regex) - - def replacer(match): - return "%s%s[padded-to-%d-chars]" % (match.group(1), os.sep, len(match.group(0))) - - return _filter_re.sub(replacer, string) + + __slots__ = ("_re", "_needle", "_fmt") + + def __init__(self, as_bytes: bool = False) -> None: + self._re = _build_padding_re(as_bytes=as_bytes) + if as_bytes: + self._needle: Union[str, bytes] = SPACK_PATH_PADDING_BYTES + self._fmt: Union[str, bytes] = b"%b" + os.sep.encode("ascii") + b"[padded-to-%d-chars]" + else: + self._needle = SPACK_PATH_PADDING_CHARS + self._fmt = "%s" + os.sep + "[padded-to-%d-chars]" + + def _replace(self, match): + return self._fmt % (match.group(1), len(match.group(0))) + + def __call__(self, data): + if self._needle not in data: + return data + return self._re.sub(self._replace, data) + + +#: Callable that filters path-padding placeholders from strings +padding_filter = _PaddingFilter(as_bytes=False) + +#: Callable that filters path-padding placeholders from bytes buffers +padding_filter_bytes = _PaddingFilter(as_bytes=True) @contextlib.contextmanager From dfead316f4c6e8d6774289adbcce8ab94df654bf Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sat, 14 Mar 2026 17:31:58 +0100 Subject: [PATCH 143/337] new_installer.py: drain logs pipe before close (#52080) --- lib/spack/spack/new_installer.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index b09bb081905d7d..fdb558027a2ce6 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1885,6 +1885,7 @@ def _handle_sigwinch(signum: int, frame: object) -> None: build = self.running_builds.pop(pid) self.capacity += 1 jobserver.release() + self._drain_child_output(build) build.cleanup(selector) exitcode = build.proc.exitcode assert exitcode is not None, "Finished build should have exit code set" @@ -2182,6 +2183,19 @@ def _handle_child_logs( self.build_status.print_logs(child_info.spec.dag_hash(), data) + def _drain_child_output(self, child_info: ChildInfo) -> None: + """Read and print any remaining output from a finished child's pipe.""" + dag_hash = child_info.spec.dag_hash() + r_fd = child_info.output_r_conn.fileno() + try: + while True: + data = os.read(r_fd, OUTPUT_BUFFER_SIZE) + if not data: + break + self.build_status.print_logs(dag_hash, data) + except OSError: + pass + def _handle_child_state( self, r_fd: int, child_info: ChildInfo, selector: selectors.BaseSelector ) -> None: From 9a0390d3457bafa7e7ccae4fb3f712d407edec46 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sat, 14 Mar 2026 17:32:27 +0100 Subject: [PATCH 144/337] new_installer.py: always try build cache (#52073) --- lib/spack/spack/binary_distribution.py | 9 ++++++++- lib/spack/spack/new_installer.py | 9 +++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index dc582245fedf50..f177dea5b742ee 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -1714,7 +1714,7 @@ def download_tarball( spack.mirrors.mirror.MirrorCollection(binary=True).values() ) if not configured_mirrors: - tty.die("Please add a spack mirror to allow download of pre-compiled packages.") + raise NoConfiguredBinaryMirrors() # Note on try_first and try_next: # mirrors_for_spec mostly likely came from spack caching remote @@ -2958,3 +2958,10 @@ class CannotListKeys(GenerateIndexError): class PushToBuildCacheError(spack.error.SpackError): """Raised when unable to push objects to binary mirror""" + + +class NoConfiguredBinaryMirrors(spack.error.SpackError): + """Raised when no binary mirrors are configured but an operation requires them""" + + def __init__(self): + super().__init__("Please add a spack mirror to allow download of pre-compiled packages.") diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index fdb558027a2ce6..0f02c3e7879827 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -255,7 +255,12 @@ def install_from_buildcache( state_stream: io.TextIOWrapper, ) -> bool: send_state("fetching from build cache", state_stream) - tarball_stage = spack.binary_distribution.download_tarball(spec.build_spec, unsigned, mirrors) + try: + tarball_stage = spack.binary_distribution.download_tarball( + spec.build_spec, unsigned, mirrors + ) + except spack.binary_distribution.NoConfiguredBinaryMirrors: + return False if tarball_stage is None: return False @@ -604,7 +609,7 @@ def _install( # Try to install from buildcache, unless user asked for source only if install_policy != "source_only": - if mirrors and install_from_buildcache(mirrors, spec, unsigned, state_stream): + if install_from_buildcache(mirrors, spec, unsigned, state_stream): spack.hooks.post_install(spec, explicit) return elif install_policy == "cache_only": From 08dac53c99ad9a865331c4f5ae5bbe7d86b3b396 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sun, 15 Mar 2026 20:55:47 +0100 Subject: [PATCH 145/337] new_installer.py: better color support (and a small path filtering fix) (#52072) * In non-TTY mode use colors when forced (`spack --color=always` or `SPACK_COLOR=always`) * In TTY mode do not use colors when explicitly disabled with the flag `spack --color=never` or `SPACK_COLOR=never`. * Use single code path for status line whether TTY or non-TTY. * While at it: apply path filtering of the install prefix also in the status line to `[+] ... /path/to/__spack_path_placeholder__/...`. Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/tty/color.py | 6 +- lib/spack/spack/new_installer.py | 91 +++++++++++++++----------- lib/spack/spack/test/installer_tui.py | 48 ++++++++++++++ 3 files changed, 106 insertions(+), 39 deletions(-) diff --git a/lib/spack/spack/llnl/util/tty/color.py b/lib/spack/spack/llnl/util/tty/color.py index 25e5357c99b2d7..57d8fdfc9eb8ed 100644 --- a/lib/spack/spack/llnl/util/tty/color.py +++ b/lib/spack/spack/llnl/util/tty/color.py @@ -185,11 +185,13 @@ def _err_check(result, func, args): _force_color = False -def get_color_when() -> bool: +def get_color_when(stdout=None) -> bool: """Return whether commands should print color or not.""" if _force_color is not None: return _force_color - return sys.stdout.isatty() + if stdout is None: + stdout = sys.stdout + return stdout.isatty() def set_color_when(when: Union[str, bool, None]) -> None: diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 0f02c3e7879827..16506d7221145b 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -68,6 +68,7 @@ import spack.hooks import spack.llnl.util.filesystem as fs import spack.llnl.util.tty +import spack.llnl.util.tty.color import spack.paths import spack.report import spack.spec @@ -78,8 +79,8 @@ import spack.url_buildcache import spack.util.environment import spack.util.lock -import spack.util.path from spack.installer import _do_fake_install, dump_packages +from spack.util.path import padding_filter, padding_filter_bytes if TYPE_CHECKING: import spack.package_base @@ -974,6 +975,7 @@ def __init__( get_terminal_size: Callable[[], os.terminal_size] = os.get_terminal_size, get_time: Callable[[], float] = time.monotonic, is_tty: Optional[bool] = None, + color: Optional[bool] = None, verbose: bool = False, filter_padding: bool = False, ) -> None: @@ -999,10 +1001,14 @@ def __init__( self.terminal_size = os.terminal_size((0, 0)) self.terminal_size_changed: bool = True self.get_time = get_time - self.is_tty = is_tty if is_tty is not None else self.stdout.isatty() + self.is_tty = is_tty if is_tty is not None else stdout.isatty() + if color is not None: + self.color = color + else: + self.color = spack.llnl.util.tty.color.get_color_when(stdout) #: Verbose mode only applies to non-TTY where we want to track a single build log. self.verbose = verbose and not self.is_tty - self.log_filter = spack.util.path.padding_filter_bytes if filter_padding else None + self.filter_padding = filter_padding def on_resize(self) -> None: """Refresh cached terminal size and trigger a redraw.""" @@ -1107,9 +1113,10 @@ def next(self, direction: int = 1) -> None: self.tracked_build_id = new_build_id # Tell the user we're following new logs, and instruct the child to start sending them. - self.stdout.write( - f"\n==> Following logs of {new_build.name}" f"\033[0;36m@{new_build.version}\033[0m\n" + version_str = ( + f"\033[0;36m@{new_build.version}\033[0m" if self.color else f"@{new_build.version}" ) + self.stdout.write(f"\n==> Following logs of {new_build.name}{version_str}\n") self.stdout.flush() try: conn = new_build.control_w_conn @@ -1137,20 +1144,10 @@ def update_state(self, build_id: str, state: str) -> None: self.dirty = True - # For non-TTY output, print state changes immediately without colors + # For non-TTY output, print state changes immediately if not self.is_tty: - if build_info.external: - indicator = "[e]" - elif state == "finished": - indicator = "[+]" - elif state == "failed": - indicator = "[x]" - else: - indicator = "[ ]" - suffix = build_info.prefix if state == "finished" else state - self.stdout.write( - f"{indicator} {build_info.hash} {build_info.name}@{build_info.version} {suffix}\n" - ) + line = "".join(self._generate_line_components(build_info, static=True)) + self.stdout.write(line + "\n") self.stdout.flush() def update_progress(self, build_id: str, current: int, total: int) -> None: @@ -1214,18 +1211,25 @@ def update(self, finalize: bool = False) -> None: self.finished_builds.clear() # Then a header followed by the active builds. This is the "mutable" part of the display. + if self.color: + bold = "\033[1m" + reset = "\033[0m" + cyan = "\033[36m" + else: + bold = reset = cyan = "" + long_header_len = len( f"Progress: {self.completed}/{self.total} /: filter v: logs n/p: next/prev" ) if long_header_len < max_width: self._println( buffer, - f"\033[1mProgress:\033[0m {self.completed}/{self.total}" - " \033[36m/\033[0m: filter \033[36mv\033[0m: logs" - " \033[36mn\033[0m/\033[36mp\033[0m: next/prev", + f"{bold}Progress:{reset} {self.completed}/{self.total}" + f" {cyan}/{reset}: filter {cyan}v{reset}: logs" + f" {cyan}n{reset}/{cyan}p{reset}: next/prev", ) else: - self._println(buffer, f"\033[1mProgress:\033[0m {self.completed}/{self.total}") + self._println(buffer, f"{bold}Progress:{reset} {self.completed}/{self.total}") displayed_builds = ( [b for b in self.builds.values() if self._is_displayed(b)] @@ -1277,8 +1281,8 @@ def print_logs(self, build_id: str, data: bytes) -> None: # between builds. if build_id != self.tracked_build_id: return - if self.log_filter is not None: - data = self.log_filter(data) + if self.filter_padding: + data = padding_filter_bytes(data) self.stdout.buffer.write(data) self.stdout.flush() @@ -1293,7 +1297,9 @@ def _render_build(self, build_info: BuildInfo, buffer: io.StringIO, max_width: i buffer.write(component) self._println(buffer) - def _generate_line_components(self, build_info: BuildInfo) -> Generator[str, None, None]: + def _generate_line_components( + self, build_info: BuildInfo, static: bool = False + ) -> Generator[str, None, None]: """Yield formatted line components for a package. Escape sequences are yielded as separate strings so they do not contribute to the line width.""" if build_info.external: @@ -1302,40 +1308,51 @@ def _generate_line_components(self, build_info: BuildInfo) -> Generator[str, Non indicator = "[+]" elif build_info.state == "failed": indicator = "[x]" + elif static: + indicator = "[ ]" else: indicator = f"[{self.spinner_chars[self.spinner_index]}]" - if build_info.state == "failed": - yield "\033[31m" # red - elif build_info.state == "finished": - yield "\033[32m" # green + if self.color: + if build_info.state == "failed": + yield "\033[31m" # red + elif build_info.state == "finished": + yield "\033[32m" # green yield indicator - yield "\033[0m" # reset + if self.color: + yield "\033[0m" # reset yield " " - yield "\033[0;90m" # dark gray + if self.color: + yield "\033[0;90m" # dark gray yield build_info.hash - yield "\033[0m" # reset + if self.color: + yield "\033[0m" # reset yield " " # Package name in bold white if explicit, default otherwise if build_info.explicit: - yield "\033[1;37m" # bold white + if self.color: + yield "\033[1;37m" # bold white yield build_info.name - yield "\033[0m" # reset + if self.color: + yield "\033[0m" # reset else: yield build_info.name - yield "\033[0;36m" # cyan + if self.color: + yield "\033[0;36m" # cyan yield f"@{build_info.version}" - yield "\033[0m" # reset + if self.color: + yield "\033[0m" # reset # progress or state if build_info.progress_percent is not None: yield " fetching" yield f": {build_info.progress_percent}%" elif build_info.state == "finished": - yield f" {build_info.prefix}" + prefix = build_info.prefix + yield f" {padding_filter(prefix) if self.filter_padding else prefix}" else: yield f" {build_info.state}" diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 111971e26ee0dd..6ba8cf520b9eb4 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -73,6 +73,7 @@ def create_build_status( total: int = 0, verbose: bool = False, filter_padding: bool = False, + color: Optional[bool] = None, ) -> Tuple[BuildStatus, List[float], SimpleTextIOWrapper]: """Helper function to create BuildStatus with mocked dependencies""" fake_stdout = SimpleTextIOWrapper(tty=is_tty) @@ -93,6 +94,7 @@ def mock_get_terminal_size(): is_tty=is_tty, verbose=verbose, filter_padding=filter_padding, + color=color, ) return status, time_values, fake_stdout @@ -964,6 +966,22 @@ def test_print_logs_filters_padding(self, filter_padding): else: assert written == log_output + @pytest.mark.parametrize("filter_padding", [True, False]) + def test_prefix_padding_filter_in_status(self, filter_padding): + """Test that prefix in status indicator applies padding filter.""" + padded_prefix = "/base/__spack_path_placeholder__/__spack_path_placeholder__/mypackage" + status, _, fake_stdout = create_build_status(is_tty=False, filter_padding=filter_padding) + spec = MockSpec("mypackage", "1.0", prefix=padded_prefix) + status.add_build(spec, explicit=True, control_w_conn=MockConnection()) + build_id = spec.dag_hash() + status.update_state(build_id, "finished") + output = fake_stdout.getvalue() + common = f"[+] {spec.dag_hash(7)} {spec.name}@{spec.version}" + if filter_padding: + assert output == f"{common} /base/[padded-to-59-chars]/mypackage\n" + else: + assert output == f"{common} {padded_prefix}\n" + class TestSearchFilteringIntegration: """Test search mode with display filtering""" @@ -1225,3 +1243,33 @@ def test_verbose_tty_no_effect(self): with r_conn, w_conn: bs.add_build(spec, explicit=True, control_w_conn=w_conn) assert bs.tracked_build_id == "" + + +class TestBuildStatusColor: + """Tests that BuildStatus respects the explicit color=True/False parameter.""" + + def test_non_tty_finished_color_true_emits_green(self): + """color=True in non-TTY mode: finished line has per-component ANSI colors.""" + spec = MockSpec("pkg", "1.0") + status, _, stdout = create_build_status(is_tty=False, total=1, color=True) + status.add_build(spec, explicit=True) + status.update_state(spec.dag_hash(), "finished") + # green indicator, reset, dark-gray hash + assert stdout.getvalue().startswith("\033[32m[+]\033[0m \033[0;90m") + + def test_non_tty_failed_color_true_emits_red(self): + """color=True in non-TTY mode: failed line has per-component ANSI colors.""" + spec = MockSpec("pkg", "1.0") + status, _, stdout = create_build_status(is_tty=False, total=1, color=True) + status.add_build(spec, explicit=True) + status.update_state(spec.dag_hash(), "failed") + # red indicator, reset, dark-gray hash + assert stdout.getvalue().startswith("\033[31m[x]\033[0m \033[0;90m") + + def test_non_tty_finished_color_false_no_ansi(self): + """color=False in non-TTY mode: finished line has no ANSI escape codes.""" + spec = MockSpec("pkg", "1.0") + status, _, stdout = create_build_status(is_tty=False, total=1, color=False) + status.add_build(spec, explicit=True) + status.update_state(spec.dag_hash(), "finished") + assert "\033[" not in stdout.getvalue() From 428b2ab2de4846bc791f5cdd64352ed0be65bb3e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Mar 2026 11:24:08 +0100 Subject: [PATCH 146/337] fetch: retry urllib downloads with back-off (#52081) In CI, transient fetch errors (S3 rate limits, server errors, mid-stream disconnects) cause spurious failures. To handle this uniformly: - Add `web_util.is_transient_error(e)` covering HTTP 5xx/429, socket timeouts, and botocore ResponseStreamingError. - Rewrite `_fetch_urllib` to retry up to `_FETCH_RETRIES` times with exponential back-off (1s, 2s, 4s, 8s), writing to a `.part` file for atomicity (consistent with `_fetch_curl`). - Simplify `oci/opener.py`'s retry logic to delegate to `is_transient_error`. Signed-off-by: Harmen Stoppels --- lib/spack/spack/fetch_strategy.py | 64 ++++++++++++++++++------------- lib/spack/spack/oci/opener.py | 15 +------- lib/spack/spack/util/web.py | 16 ++++++++ 3 files changed, 55 insertions(+), 40 deletions(-) diff --git a/lib/spack/spack/fetch_strategy.py b/lib/spack/spack/fetch_strategy.py index 2a592063feb4fe..76f222b0ca59c1 100644 --- a/lib/spack/spack/fetch_strategy.py +++ b/lib/spack/spack/fetch_strategy.py @@ -427,43 +427,53 @@ def _check_headers(self, headers): tty.warn(msg) @_needs_stage - def _fetch_urllib(self, url, chunk_size=65536): + def _fetch_urllib(self, url, chunk_size=65536, retries=5): + """Fetch a URL using urllib, with retries on transient errors and progress reporting.""" save_file = self.stage.save_filename + part_file = save_file + ".part" request = urllib.request.Request( url, headers={"User-Agent": web_util.SPACK_USER_AGENT, "Accept": "*/*"} ) - if os.path.lexists(save_file): - os.remove(save_file) - - try: - response = web_util.urlopen(request) - tty.verbose(f"Fetching {url}") - progress = FetchProgress.from_headers(response.headers, enabled=sys.stdout.isatty()) - with open(save_file, "wb") as f: - while True: - chunk = response.read(chunk_size) - if not chunk: - break - f.write(chunk) - progress.advance(len(chunk)) - progress.print(final=True) - except OSError as e: - # clean up archive on failure. - if self.archive_file: - os.remove(self.archive_file) - if os.path.lexists(save_file): - os.remove(save_file) - raise FailedDownloadError(e) from e + response_headers_str = None + for attempt in range(retries): + try: + with web_util.urlopen(request) as response: + tty.verbose(f"Fetching {url}") + progress = FetchProgress.from_headers( + response.headers, enabled=sys.stdout.isatty() + ) + with open(part_file, "wb") as f: + while True: + chunk = response.read(chunk_size) + if not chunk: + break + f.write(chunk) + progress.advance(len(chunk)) + progress.print(final=True) + # Capture metadata before context manager closes the connection + if isinstance(response, http.client.HTTPResponse): + self._effective_url = response.geturl() + response_headers_str = str(response.headers) + os.replace(part_file, save_file) + break # success: exit retry loop + except OSError as e: + # clean up archive on failure. + if self.archive_file: + os.remove(self.archive_file) + if os.path.lexists(part_file): + os.remove(part_file) + # Raise if this was the last attempt, or if the error was not transient. + if (attempt + 1 == retries) or not web_util.is_transient_error(e): + raise FailedDownloadError(e) from e + tty.debug(f"Retrying fetch (attempt {attempt + 1}): {e}") + time.sleep(2**attempt) # Save the redirected URL for error messages. Sometimes we're redirected to an arbitrary # mirror that is broken, leading to spurious download failures. In that case it's helpful # for users to know which URL was actually fetched. - if isinstance(response, http.client.HTTPResponse): - self._effective_url = response.geturl() - - self._check_headers(str(response.headers)) + self._check_headers(response_headers_str) @_needs_stage def _fetch_curl(self, url, config_args=[]): diff --git a/lib/spack/spack/oci/opener.py b/lib/spack/spack/oci/opener.py index 76e7c1a6cc023a..affe65b151f0a2 100644 --- a/lib/spack/spack/oci/opener.py +++ b/lib/spack/spack/oci/opener.py @@ -7,7 +7,6 @@ import base64 import json import re -import socket import time import urllib.error import urllib.parse @@ -441,20 +440,10 @@ def wrapper(*args, **kwargs): try: return f(*args, **kwargs) except OSError as e: - # Retry on internal server errors, and rate limit errors + # Retry on internal server errors, rate limits, and timeouts. # Potentially this could take into account the Retry-After header # if registries support it - if i + 1 != retries and ( - ( - isinstance(e, urllib.error.HTTPError) - and (500 <= e.code < 600 or e.code == 429) - ) - or ( - isinstance(e, urllib.error.URLError) - and isinstance(e.reason, socket.timeout) - ) - or isinstance(e, socket.timeout) - ): + if i + 1 != retries and spack.util.web.is_transient_error(e): # Exponential backoff sleep(2**i) continue diff --git a/lib/spack/spack/util/web.py b/lib/spack/spack/util/web.py index 40a6a625ccbfa0..0567fbf275de32 100644 --- a/lib/spack/spack/util/web.py +++ b/lib/spack/spack/util/web.py @@ -9,6 +9,7 @@ import os import re import shutil +import socket import ssl import stat import sys @@ -36,6 +37,21 @@ from .s3 import UrllibS3Handler, get_s3_session +def is_transient_error(e: Exception) -> bool: + """Return True for HTTP/network errors that are worth retrying.""" + + if isinstance(e, HTTPError) and (500 <= e.code < 600 or e.code == 429): + return True + if isinstance(e, URLError) and isinstance(e.reason, socket.timeout): + return True + if isinstance(e, socket.timeout): + return True + # botocore.exceptions.ResponseStreamingError (IncompleteRead mid-stream) + if type(e).__name__ == "ResponseStreamingError": + return True + return False + + class DetailedHTTPError(HTTPError): def __init__( self, req: Request, code: int, msg: str, hdrs: email.message.Message, fp: Optional[IO] From e0997bb279d5cc1e1a243dcd91e55ac63089ff29 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Mon, 16 Mar 2026 17:14:27 +0100 Subject: [PATCH 147/337] spec_parser.py: extract toolchain expansion from SpecParser (#52076) Toolchain references are now parsed as regular dependencies and expanded in a separate post-parse step via `expand_toolchains`. Toolchain expansion now happens in these entry points: * SpecList/SpecListParser for environment YAML * RequirementParser for packages.yaml requirements * spack.cmd.parse_specs for CLI Signed-off-by: Massimiliano Culpo --- lib/spack/spack/cmd/__init__.py | 3 +- lib/spack/spack/cmd/change.py | 2 +- lib/spack/spack/environment/environment.py | 3 + lib/spack/spack/environment/list.py | 18 +- lib/spack/spack/solver/requirements.py | 10 +- lib/spack/spack/spec.py | 3 +- lib/spack/spack/spec_parser.py | 241 +++++++++++---------- lib/spack/spack/test/env.py | 4 +- lib/spack/spack/test/spec_syntax.py | 8 +- 9 files changed, 161 insertions(+), 131 deletions(-) diff --git a/lib/spack/spack/cmd/__init__.py b/lib/spack/spack/cmd/__init__.py index d642711a1820b9..989cef3ff16ce4 100644 --- a/lib/spack/spack/cmd/__init__.py +++ b/lib/spack/spack/cmd/__init__.py @@ -182,7 +182,8 @@ def parse_specs( args = [args] if isinstance(args, str) else args arg_string = " ".join([quote_kvp(arg) for arg in args]) - specs = spack.spec_parser.parse(arg_string) + toolchains = spack.config.CONFIG.get("toolchains", {}) + specs = spack.spec_parser.parse(arg_string, toolchains=toolchains) if not concretize: return specs diff --git a/lib/spack/spack/cmd/change.py b/lib/spack/spack/cmd/change.py index 576eeb6aaa718f..91ac5729fbce84 100644 --- a/lib/spack/spack/cmd/change.py +++ b/lib/spack/spack/cmd/change.py @@ -61,7 +61,7 @@ def change(parser, args): match_spec = None if args.match_spec: - match_spec = spack.spec.Spec(args.match_spec) + match_spec = spack.cmd.parse_specs([args.match_spec])[0] specs = spack.cmd.parse_specs(args.specs) with env.write_transaction(): diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index f99e4502f510a8..b750c6452dc5d7 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -1247,6 +1247,9 @@ def _construct_state_from_manifest(self): self._process_included_lockfiles() def _sync_speclists(self): + self._spec_lists_parser = SpecListParser( + toolchains=spack.config.CONFIG.get("toolchains", {}) + ) self.spec_lists = {} self.spec_lists.update( self._spec_lists_parser.parse_definitions( diff --git a/lib/spack/spack/environment/list.py b/lib/spack/spack/environment/list.py index 9bb258fa777ce3..a2097f8e80493c 100644 --- a/lib/spack/spack/environment/list.py +++ b/lib/spack/spack/environment/list.py @@ -9,10 +9,13 @@ import spack.variant from spack.error import SpackError from spack.spec import Spec +from spack.spec_parser import expand_toolchains class SpecList: - def __init__(self, *, name: str = "specs", yaml_list=None, expanded_list=None): + def __init__( + self, *, name: str = "specs", yaml_list=None, expanded_list=None, toolchains=None + ): self.name = name self.yaml_list = yaml_list[:] if yaml_list is not None else [] # Expansions can be expensive to compute and difficult to keep updated @@ -20,6 +23,7 @@ def __init__(self, *, name: str = "specs", yaml_list=None, expanded_list=None): self.specs_as_yaml_list = expanded_list or [] self._constraints = None self._specs: Optional[List[Spec]] = None + self._toolchains = toolchains @property def is_matrix(self): @@ -51,6 +55,8 @@ def specs(self) -> List[Spec]: spec = constraint_list[0].copy() for const in constraint_list[1:]: spec.constrain(const) + if self._toolchains: + expand_toolchains(spec, self._toolchains) specs.append(spec) self._specs = specs @@ -178,8 +184,9 @@ class Definition(NamedTuple): class SpecListParser: """Parse definitions and user specs from data in environments""" - def __init__(self): + def __init__(self, *, toolchains=None): self.definitions: Dict[str, SpecList] = {} + self._toolchains = toolchains def parse_definitions(self, *, data: List[Dict[str, Any]]) -> Dict[str, SpecList]: definitions_from_yaml: Dict[str, List[Definition]] = {} @@ -225,7 +232,12 @@ def _speclist_from_definitions(self, name, definitions) -> SpecList: continue combined_yaml_list.extend(def_part.yaml_list) expanded_list = self._expand_yaml_list(combined_yaml_list) - return SpecList(name=name, yaml_list=combined_yaml_list, expanded_list=expanded_list) + return SpecList( + name=name, + yaml_list=combined_yaml_list, + expanded_list=expanded_list, + toolchains=self._toolchains, + ) def _expand_yaml_list(self, raw_yaml_list): result = [] diff --git a/lib/spack/spack/solver/requirements.py b/lib/spack/spack/solver/requirements.py index 8f0de29f939d51..d8117e8fc05ee2 100644 --- a/lib/spack/spack/solver/requirements.py +++ b/lib/spack/spack/solver/requirements.py @@ -9,6 +9,7 @@ import spack.package_base import spack.repo import spack.spec +import spack.spec_parser import spack.traverse from spack.enums import PropagationPolicy from spack.llnl.util import tty @@ -104,6 +105,13 @@ def __init__(self, configuration: spack.config.Configuration): self.runtime_pkgs = spack.repo.PATH.packages_with_tags("runtime") self.compiler_pkgs = spack.repo.PATH.packages_with_tags("compiler") self.preferences_from_input: List[Tuple[spack.spec.Spec, str]] = [] + self.toolchains = configuration.get_config("toolchains") + + def _parse_and_expand(self, string: str, *, named: bool = False) -> spack.spec.Spec: + result = parse_spec_from_yaml_string(string, named=named) + if self.toolchains: + spack.spec_parser.expand_toolchains(result, self.toolchains) + return result def rules(self, pkg: spack.package_base.PackageBase) -> List[RequirementRule]: result = [] @@ -248,7 +256,7 @@ def _rules_from_requirements( # validate specs from YAML first, and fail with line numbers if parsing fails. constraints = [ - parse_spec_from_yaml_string(constraint, named=kind == RequirementKind.VIRTUAL) + self._parse_and_expand(constraint, named=kind == RequirementKind.VIRTUAL) for constraint in constraints ] when_str = requirement.get("when") diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 2b42755e7fa70b..b6820ff93947b1 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -1497,8 +1497,7 @@ def __init__(self, spec_like=None, *, external_path=None, external_modules=None) Keyword arguments: external_path: prefix, if this is a spec for an external package - external_modules: list of external modules, if this is an external package - using modules. + external_modules: list of external modules, for an external package using modules """ # Copy if spec_like is a Spec. if isinstance(spec_like, Spec): diff --git a/lib/spack/spack/spec_parser.py b/lib/spack/spack/spec_parser.py index c2b067c9f9dd07..25b39ffb190fab 100644 --- a/lib/spack/spack/spec_parser.py +++ b/lib/spack/spack/spec_parser.py @@ -62,7 +62,6 @@ import sys from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Tuple, Union -import spack.config import spack.deptypes import spack.error import spack.version @@ -290,19 +289,12 @@ def parse_virtual_assignment(context: TokenContext) -> Tuple[str]: class SpecParser: """Parse text into specs""" - __slots__ = "literal_str", "ctx", "toolchains", "parsed_toolchains" + __slots__ = "literal_str", "ctx" def __init__(self, literal_str: str): self.literal_str = literal_str self.ctx = TokenContext(parseable_tokens(literal_str)) - # TODO: Move toolchains out of the parser, and expand them as a separate step - self.toolchains = {} - configuration = getattr(spack.config, "CONFIG", None) - if configuration is not None: - self.toolchains = configuration.get_config("toolchains") - self.parsed_toolchains: Dict[str, "spack.spec.Spec"] = {} - def tokens(self) -> List[Token]: """Return the entire list of token from the initial text. White spaces are filtered out. @@ -339,71 +331,39 @@ def add_dependency(dep, **edge_properties): current_spec = root_spec while True: if self.ctx.accept(SpecTokens.START_EDGE_PROPERTIES): - is_direct = self.ctx.current_token.value[0] == "%" + has_edge_attrs = True + elif self.ctx.accept(SpecTokens.DEPENDENCY): + has_edge_attrs = False + else: + break - propagation = PropagationPolicy.NONE - if is_direct and self.ctx.current_token.value.startswith("%%"): - propagation = PropagationPolicy.PREFERENCE + is_direct = self.ctx.current_token.value[0] == "%" + propagation = PropagationPolicy.NONE + if is_direct and self.ctx.current_token.value.startswith("%%"): + propagation = PropagationPolicy.PREFERENCE + if has_edge_attrs: edge_properties = EdgeAttributeParser(self.ctx, self.literal_str).parse() edge_properties.setdefault("virtuals", ()) - edge_properties["direct"] = is_direct edge_properties.setdefault("depflag", 0) - edge_properties["propagation"] = propagation - - dependency = self._parse_node(root_spec) - - if is_direct: - target_spec = current_spec - if dependency.name in LEGACY_COMPILER_TO_BUILTIN: - dependency.name = LEGACY_COMPILER_TO_BUILTIN[dependency.name] - else: - current_spec = dependency - target_spec = root_spec - - add_dependency(dependency, **edge_properties) - - elif self.ctx.accept(SpecTokens.DEPENDENCY): - is_direct = self.ctx.current_token.value[0] == "%" - propagation = PropagationPolicy.NONE - - if is_direct and self.ctx.current_token.value.startswith("%%"): - propagation = PropagationPolicy.PREFERENCE - + else: virtuals = parse_virtual_assignment(self.ctx) + edge_properties = {"virtuals": virtuals, "depflag": 0} - # if no virtual assignment, check for a toolchain - look ahead to find the - # toolchain and substitute it - if not virtuals and is_direct and self.ctx.next_token.value in self.toolchains: - assert self.ctx.accept(SpecTokens.UNQUALIFIED_PACKAGE_NAME) - try: - self._apply_toolchain( - current_spec, self.ctx.current_token.value, propagation=propagation - ) - except spack.error.SpecError as e: - raise SpecParsingError(str(e), self.ctx.current_token, self.literal_str) - continue - - edge_properties = { - "direct": is_direct, - "virtuals": virtuals, - "depflag": 0, - "propagation": propagation, - } - dependency = self._parse_node(root_spec) - - if is_direct: - target_spec = current_spec - if dependency.name in LEGACY_COMPILER_TO_BUILTIN: - dependency.name = LEGACY_COMPILER_TO_BUILTIN[dependency.name] - else: - current_spec = dependency - target_spec = root_spec - - add_dependency(dependency, **edge_properties) + edge_properties["direct"] = is_direct + edge_properties["propagation"] = propagation + dependency = self._parse_node(root_spec) + + if is_direct: + target_spec = current_spec + if dependency.name in LEGACY_COMPILER_TO_BUILTIN: + dependency.name = LEGACY_COMPILER_TO_BUILTIN[dependency.name] else: - break + current_spec = dependency + target_spec = root_spec + + add_dependency(dependency, **edge_properties) return root_spec @@ -419,54 +379,6 @@ def _parse_node(self, root_spec: "spack.spec.Spec", root: bool = True): raise spack.error.SpecError(str(root_spec), "^" + str(dependency)) return dependency - def _apply_toolchain( - self, spec: "spack.spec.Spec", name: str, *, propagation: PropagationPolicy - ) -> None: - if name not in self.parsed_toolchains: - toolchain = self._parse_toolchain(name) - self.parsed_toolchains[name] = toolchain - - propagation_arg = None if propagation != PropagationPolicy.PREFERENCE else propagation - # Here we need to copy because we want "foo %toolc ^bar %toolc" to generate different - # objects for the toolc attached to foo and bar, since the solver depends on that to - # generate facts - toolchain = self.parsed_toolchains[name].copy(propagation=propagation_arg) - spec.constrain(toolchain) - - def _parse_toolchain(self, name: str) -> "spack.spec.Spec": - toolchain_config = self.toolchains[name] - if isinstance(toolchain_config, str): - toolchain = parse_one_or_raise(toolchain_config) - self._ensure_all_direct_edges(toolchain) - else: - from spack.spec import EMPTY_SPEC, Spec - - toolchain = Spec() - for entry in toolchain_config: - toolchain_part = parse_one_or_raise(entry["spec"]) - when = entry.get("when", "") - self._ensure_all_direct_edges(toolchain_part) - - # Apply global "when" to all edges in toolchain part - if when: - when_spec = Spec(when) - for edge in toolchain_part.traverse_edges(): - # EMPTY_SPEC is immutable by convention, so create a mutable instance. - if edge.when is EMPTY_SPEC: - edge.when = when_spec.copy() - else: - edge.when.constrain(when_spec) - toolchain.constrain(toolchain_part) - return toolchain - - def _ensure_all_direct_edges(self, constraint: "spack.spec.Spec") -> None: - for edge in constraint.traverse_edges(root=False): - if not edge.direct: - raise spack.error.SpecError( - f"cannot use '^' in toolchain definitions, and the current " - f"toolchain contains '{edge.format()}'" - ) - def all_specs(self) -> List["spack.spec.Spec"]: """Return all the specs that remain to be parsed""" return list(iter(self.next_spec, None)) @@ -661,26 +573,36 @@ def parse(self): return attributes -def parse(text: str) -> List["spack.spec.Spec"]: - """Parse text into a list of strings +def parse(text: str, *, toolchains: Optional[Dict] = None) -> List["spack.spec.Spec"]: + """Parse text into a list of specs Args: - text (str): text to be parsed + text: text to be parsed + toolchains: optional toolchain definitions to expand after parsing Return: List of specs """ - return SpecParser(text).all_specs() + specs = SpecParser(text).all_specs() + if toolchains: + cache: Dict[str, "spack.spec.Spec"] = {} + for spec in specs: + expand_toolchains(spec, toolchains, _cache=cache) + return specs def parse_one_or_raise( - text: str, initial_spec: Optional["spack.spec.Spec"] = None + text: str, + initial_spec: Optional["spack.spec.Spec"] = None, + *, + toolchains: Optional[Dict] = None, ) -> "spack.spec.Spec": """Parse exactly one spec from text and return it, or raise Args: - text (str): text to be parsed + text: text to be parsed initial_spec: buffer where to parse the spec. If None a new one will be created. + toolchains: optional toolchain definitions to expand after parsing """ parser = SpecParser(text) result = parser.next_spec(initial_spec) @@ -695,9 +617,90 @@ def parse_one_or_raise( if result is None: raise ValueError("expected a single spec, but got none") + if toolchains: + expand_toolchains(result, toolchains) + return result +def _parse_toolchain_config(toolchain_config: Union[str, List[Dict]]) -> "spack.spec.Spec": + """Parse a toolchain config entry (string or list) into a Spec.""" + if isinstance(toolchain_config, str): + toolchain = parse_one_or_raise(toolchain_config) + _ensure_all_direct_edges(toolchain) + else: + from spack.spec import EMPTY_SPEC, Spec + + toolchain = Spec() + for entry in toolchain_config: + toolchain_part = parse_one_or_raise(entry["spec"]) + when = entry.get("when", "") + _ensure_all_direct_edges(toolchain_part) + + if when: + when_spec = Spec(when) + for edge in toolchain_part.traverse_edges(): + if edge.when is EMPTY_SPEC: + edge.when = when_spec.copy() + else: + edge.when.constrain(when_spec) + toolchain.constrain(toolchain_part) + return toolchain + + +def _ensure_all_direct_edges(constraint: "spack.spec.Spec") -> None: + """Validate that a toolchain spec only has direct (%) edges.""" + for edge in constraint.traverse_edges(root=False): + if not edge.direct: + raise spack.error.SpecError( + f"cannot use '^' in toolchain definitions, and the current " + f"toolchain contains '{edge.format()}'" + ) + + +def expand_toolchains( + spec: "spack.spec.Spec", + toolchains: Dict, + *, + _cache: Optional[Dict[str, "spack.spec.Spec"]] = None, +) -> None: + """Replace toolchain placeholder deps with expanded toolchain constraints. + + Walks every node in the spec DAG. For each node, finds direct dependency + edges whose child name is a key in ``toolchains``. Removes the placeholder + edge, parses the toolchain config, copies with the edge's propagation + policy, and constrains the node. + """ + if _cache is None: + _cache = {} + + for node in list(spec.traverse()): + for edge in list(node.edges_to_dependencies()): + if not edge.direct: + continue + name = edge.spec.name + if name not in toolchains: + continue + + # Remove the placeholder edge (both directions) + node._dependencies[name].remove(edge) + if not node._dependencies[name]: + del node._dependencies[name] + edge.spec._dependents[node.name].remove(edge) + if not edge.spec._dependents[node.name]: + del edge.spec._dependents[node.name] + + # Parse and cache toolchain + if name not in _cache: + _cache[name] = _parse_toolchain_config(toolchains[name]) + + propagation = edge.propagation + propagation_arg = None if propagation != PropagationPolicy.PREFERENCE else propagation + # Copy so each usage gets a distinct object (solver depends on this) + toolchain = _cache[name].copy(propagation=propagation_arg) + node.constrain(toolchain) + + class SpecParsingError(spack.error.SpecSyntaxError): """Error when parsing tokens""" diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index dc291055832294..95fefe8ddc5e81 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -16,6 +16,7 @@ import spack.platforms import spack.solver.asp import spack.spec +import spack.spec_parser from spack.enums import ConfigScopePriority from spack.environment import SpackEnvironmentConfigError from spack.environment.environment import ( @@ -1602,7 +1603,8 @@ def test_ids_when_using_toolchain_twice_in_a_spec(tmp_path, mutable_config): manifest.write_text(spack_yaml) with ev.Environment(tmp_path): # We rely on this behavior when emitting facts for the solver - s = spack.spec.Spec("mpileaks %gnu ^callpath %gnu") + toolchains = spack.config.CONFIG.get("toolchains", {}) + s = spack.spec_parser.parse("mpileaks %gnu ^callpath %gnu", toolchains=toolchains)[0] assert id(s["gcc"]) != id(s["callpath"]["gcc"]) diff --git a/lib/spack/spack/test/spec_syntax.py b/lib/spack/spack/test/spec_syntax.py index f1e43742d2ea41..37592611224fe7 100644 --- a/lib/spack/spack/test/spec_syntax.py +++ b/lib/spack/spack/test/spec_syntax.py @@ -12,7 +12,6 @@ import spack.binary_distribution import spack.cmd import spack.concretize -import spack.config import spack.error import spack.llnl.util.filesystem as fs import spack.platforms.test @@ -26,6 +25,7 @@ SpecParsingError, SpecTokenizationError, SpecTokens, + expand_toolchains, parse_one_or_raise, ) from spack.tokenize import Token @@ -1211,10 +1211,12 @@ def test_cli_spec_roundtrip(args, expected): ], ) def test_parse_toolchain(spec_str, toolchain, expected_roundtrip, mutable_config): - spack.config.CONFIG.set("toolchains", toolchain) + """Tests that toolchains are expanded correctly""" parser = SpecParser(spec_str) for expected in expected_roundtrip: - assert expected == str(parser.next_spec()) + result = parser.next_spec() + expand_toolchains(result, toolchain) + assert expected == str(result) @pytest.mark.parametrize( From 58f0e6a27217f9dec747230acf6f35874f61d1ee Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Mon, 16 Mar 2026 18:21:21 +0100 Subject: [PATCH 148/337] Add test for resolving git-based relative includes in environments (#52069) The test ensures that `spack.lock` entries inside git-based includes are correctly resolved using the clone destination as the base directory, rather than the manifest directory. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/test/cmd/env.py | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 88229939a4a89e..445a84e9ff005b 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -4854,3 +4854,53 @@ def test_env_include_concrete_relative_path(tmp_path, mock_packages, mutable_con e.write() assert len(e.user_specs) == 0 assert [s for s, _ in e.concretized_specs()] == [Spec("libdwarf")] + + +def test_env_include_concrete_git_lockfile(tmp_path, mock_packages, mutable_config, monkeypatch): + """Tests that a spack.lock listed inside a git-based include is resolved using the + clone destination as the base, not the manifest directory. + """ + # Create and concretize the included environment. + include_dir = tmp_path / "include_env" + include_dir.mkdir() + (include_dir / ev.manifest_name).write_text( + """\ +spack: + specs: + - libdwarf +""" + ) + with ev.Environment(str(include_dir)) as e: + e.concretize() + e.write() + assert os.path.exists(e.lock_path) + + # Simulate a cloned git repo: the spack.lock lives at a subpath within the clone. + clone_dest = tmp_path / "git_clone" + lock_subpath = "envs/staging/spack.lock" + lock_in_clone = clone_dest / "envs" / "staging" / ev.lockfile_name + lock_in_clone.parent.mkdir(parents=True) + shutil.copy(e.lock_path, lock_in_clone) + # is_env_dir() requires spack.yaml alongside spack.lock + shutil.copy(os.path.join(e.path, ev.manifest_name), lock_in_clone.parent) + + # Prevent actual git operations; return the pre-built clone destination. + monkeypatch.setattr(spack.config.GitIncludePaths, "_clone", lambda self: str(clone_dest)) + + main_dir = tmp_path / "main_env" + main_dir.mkdir() + (main_dir / ev.manifest_name).write_text( + f"""\ +spack: + include: + - git: https://example.com/configs.git + branch: main + paths: + - {lock_subpath} +""" + ) + with ev.Environment(str(main_dir)) as e: + e.concretize() + e.write() + assert len(e.user_specs) == 0 + assert [s for s, _ in e.concretized_specs()] == [Spec("libdwarf")] From 2f1ef9ab3f919fe3a889a5861e7eea0d505813e4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Mar 2026 21:25:02 +0100 Subject: [PATCH 149/337] new_installer.py: join timeout in finally block (#52082) If a grandchild process ignores SIGTERM, the cleanup path hangs forever, leaving locks, jobserver, and terminal unreleased. Add a 30-second timeout and escalate to SIGKILL. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 16506d7221145b..3b84467c064fed 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1993,7 +1993,10 @@ def _handle_sigwinch(signum: int, frame: object) -> None: for child in self.running_builds.values(): try: jobserver.release() - child.proc.join() + child.proc.join(timeout=30) + if child.proc.is_alive(): + child.proc.kill() + child.proc.join() except Exception: pass From f65b58cd129d4bad4dfb89a7032d8408609447c5 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Mar 2026 21:31:17 +0100 Subject: [PATCH 150/337] new_installer.py: clean up state_buffers on exit (#52083) When a child exits, if the sentinel fires before the state pipe EOF, state_buffers retains a partial JSON fragment. If the OS reuses that fd number for a new child's state pipe, the stale data is prepended, corrupting the JSON stream. Pop the buffer before cleanup closes the fd. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 3b84467c064fed..de65c2588a425c 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1908,6 +1908,7 @@ def _handle_sigwinch(signum: int, frame: object) -> None: self.capacity += 1 jobserver.release() self._drain_child_output(build) + self.state_buffers.pop(build.state_r_conn.fileno(), None) build.cleanup(selector) exitcode = build.proc.exitcode assert exitcode is not None, "Finished build should have exit code set" From bf896946a56ea1ad8c10bcef0e61d96b2d961ac5 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Mar 2026 21:33:16 +0100 Subject: [PATCH 151/337] new_installer.py: use longer timeout for non-TTY (#52085) --- lib/spack/spack/new_installer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index de65c2588a425c..8114cbb5e18315 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1881,7 +1881,8 @@ def _handle_sigwinch(signum: int, frame: object) -> None: stdin_ready = False - events = selector.select(timeout=SPINNER_INTERVAL) + timeout = SPINNER_INTERVAL if self.build_status.is_tty else DATABASE_WRITE_INTERVAL + events = selector.select(timeout=timeout) finished_pids = [] From 7344031b9bc19116e47dc1ec3696d8244dceacba Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Mar 2026 21:35:02 +0100 Subject: [PATCH 152/337] new_installer.py: fix non-blocking I/O (#52086) --- lib/spack/spack/new_installer.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 8114cbb5e18315..15b19886d6ddba 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1848,6 +1848,8 @@ def _installer(self) -> None: if sys.stdout.isatty(): # Listen to terminal resizing events with self-pipe trick. sigwinch_r, sigwinch_w = os.pipe() + os.set_blocking(sigwinch_r, False) + os.set_blocking(sigwinch_w, False) def _handle_sigwinch(signum: int, frame: object) -> None: try: @@ -2198,6 +2200,8 @@ def _handle_child_logs( # There might be more data than OUTPUT_BUFFER_SIZE, but we will read that in the next # iteration of the event loop to keep things responsive. data = os.read(r_fd, OUTPUT_BUFFER_SIZE) + except BlockingIOError: + return except OSError: data = None @@ -2231,6 +2235,8 @@ def _handle_child_state( # There might be more data than OUTPUT_BUFFER_SIZE, but we will read that in the next # iteration of the event loop to keep things responsive. data = os.read(r_fd, OUTPUT_BUFFER_SIZE) + except BlockingIOError: + return except OSError: data = None From 95a4aabfda6aee34ad2ec2540a173eb1b8191bc8 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Mar 2026 21:39:05 +0100 Subject: [PATCH 153/337] new_installer.py: ensure newlines between logs (#52087) If a log line does not end with a newline, the UI should add one when switching to overview mode or the new log. If a log line did end with a newline, we shouldn't add double newlines. This prevents things like: ``` checking for foo.h header... ==> following log of xyz ... ``` Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 9 ++++++++- lib/spack/spack/test/installer_tui.py | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 15b19886d6ddba..71747fe41986ee 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -995,6 +995,7 @@ def __init__( self.tracked_build_id = "" # identifier of the package whose logs we follow self.search_term = "" self.search_mode = False + self.log_ends_with_newline = True self.stdout = stdout self.get_terminal_size = get_terminal_size @@ -1035,6 +1036,9 @@ def toggle(self) -> None: if self.overview_mode: self.next() else: + if not self.log_ends_with_newline: + self.stdout.buffer.write(b"\n") + self.log_ends_with_newline = True self.active_area_rows = 0 self.search_term = "" self.search_mode = False @@ -1116,7 +1120,9 @@ def next(self, direction: int = 1) -> None: version_str = ( f"\033[0;36m@{new_build.version}\033[0m" if self.color else f"@{new_build.version}" ) - self.stdout.write(f"\n==> Following logs of {new_build.name}{version_str}\n") + prefix = "" if self.log_ends_with_newline else "\n" + self.stdout.write(f"{prefix}==> Following logs of {new_build.name}{version_str}\n") + self.log_ends_with_newline = True self.stdout.flush() try: conn = new_build.control_w_conn @@ -1285,6 +1291,7 @@ def print_logs(self, build_id: str, data: bytes) -> None: data = padding_filter_bytes(data) self.stdout.buffer.write(data) self.stdout.flush() + self.log_ends_with_newline = data.endswith(b"\n") def _render_build(self, build_info: BuildInfo, buffer: io.StringIO, max_width: int) -> None: line_width = 0 diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 6ba8cf520b9eb4..a1778461bf6f3a 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -947,6 +947,33 @@ def test_update_state_finished_triggers_toggle_when_tracking(self): assert status.overview_mode is True assert status.tracked_build_id == "" + def test_partial_line_newline_on_toggle_and_next(self): + """Ensure newline is inserted before mode transitions when log doesn't end with newline.""" + status, _, fake_stdout = create_build_status(total=2) + specs = add_mock_builds(status, 2) + build_a, build_b = specs[0].dag_hash(), specs[1].dag_hash() + + # Follow a build, toggle back and forth between logs and overview mode, and receive logs + # that may or may not end with newlines. + status.next() + status.print_logs(build_a, b"checking for foo...") + status.toggle() + status.next() + status.print_logs(build_a, b"checking for bar... yes\n") + status.next(1) + status.print_logs(build_b, b"checking for baz...") + status.next(-1) + + written = fake_stdout.getvalue() + + # There shouldn't be any double newlines: + assert "\n\n" not in written + + # All partial and newline-terminated logs should be present with appropriate newlines: + assert "checking for foo...\n" in written + assert "checking for bar... yes\n" in written + assert "checking for baz...\n" in written + @pytest.mark.parametrize("filter_padding", [True, False]) def test_print_logs_filters_padding(self, filter_padding): """print_logs strips path-padding placeholders before writing to stdout.""" From e2c49f2b3a7aabc324a2d54f6c8319b854191bd4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 16 Mar 2026 22:05:59 +0100 Subject: [PATCH 154/337] new_installer.py: catch JSONDecodeError (#52084) Malformed data from a crashing child causes an uncaught exception that crashes the event loop. Catch JSONDecodeError and skip the malformed line instead. In practice this should never happen, but it does not hurt to be a bit more defensive. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 71747fe41986ee..ecff89341db263 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2266,7 +2266,10 @@ def _handle_child_state( for line in lines: if not line: continue - message = json.loads(line) + try: + message = json.loads(line) + except json.JSONDecodeError: + continue if "state" in message: self.build_status.update_state(child_info.spec.dag_hash(), message["state"]) elif "progress" in message and "total" in message: From b80b32c4a23d8a56be25cc517debb03461ffeddf Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 17 Mar 2026 07:50:06 +0100 Subject: [PATCH 155/337] ci: only use --verbose for root install, not deps (#52090) When using the new installer in CI, it's not particularly pretty to do `--verbose` when installing dependencies. Only apply --verbose to the root package to see build logs. Also drop --backtrace. In the new installer the child process always prints the full backtrace to the build log on failure. The parent process shouldn't show a backtrace to the installer event loop on install failure, that suggests the problem is with Spack instead of with the build, causing confusion. Signed-off-by: Harmen Stoppels --- lib/spack/spack/cmd/ci.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/cmd/ci.py b/lib/spack/spack/cmd/ci.py index 83ec401eee9d3c..ce41ba078c42a8 100644 --- a/lib/spack/spack/cmd/ci.py +++ b/lib/spack/spack/cmd/ci.py @@ -464,7 +464,7 @@ def ci_rebuild(args): # No hash match anywhere means we need to rebuild spec # Start with spack arguments - spack_cmd = [SPACK_COMMAND, "--color=always", "--backtrace", "--verbose", "install"] + spack_cmd = [SPACK_COMMAND, "--color=always", "install"] config = cfg.get("config") if not config["verify_ssl"]: @@ -490,7 +490,7 @@ def ci_rebuild(args): # Arguments when installing the root from sources deps_install_args = install_args + ["--only=dependencies"] - root_install_args = install_args + ["--keep-stage", "--only=package"] + root_install_args = install_args + ["--verbose", "--keep-stage", "--only=package"] if cdash_handler: # Add additional arguments to `spack install` for CDash reporting. From 96cae55e9b09e12cf82326541b020a06f372deb4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 17 Mar 2026 08:00:12 +0100 Subject: [PATCH 156/337] new_installer.py: fork safety of ssl/boto3/urllib3 (#52089) It turns out Gitlab CI was failing with parallel downloads not because of concurrent requests that need retry, but due to fork-safety issues of SSL context objects. The likely explanation is that shared resources are mutated by forked process A that invalidate them for concurrent forked process B. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index ecff89341db263..c81ea68b60e681 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -301,6 +301,14 @@ def __init__(self): def restore(self): if multiprocessing.get_start_method() == "fork": + # In the forking case we must erase SSL contexts. + from spack.oci import opener + from spack.util import web + from spack.util.s3 import s3_client_cache + + web.urlopen._instance = None + opener.urlopen._instance = None + s3_client_cache.clear() return spack.store.STORE = self.store spack.config.CONFIG = self.config From 0a126d610686a069d8e56d5332d715156dfcee78 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 17 Mar 2026 16:43:39 +0100 Subject: [PATCH 157/337] install_test.py: break circular import (#52092) Signed-off-by: Harmen Stoppels --- lib/spack/spack/install_test.py | 17 +++++++---------- lib/spack/spack/test/test_suite.py | 6 ------ 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/lib/spack/spack/install_test.py b/lib/spack/spack/install_test.py index c38f218a6b829b..7f92c05110cf80 100644 --- a/lib/spack/spack/install_test.py +++ b/lib/spack/spack/install_test.py @@ -12,14 +12,13 @@ import shutil import sys from collections import Counter, OrderedDict -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, Union +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, Union import spack.config import spack.error import spack.llnl.util.filesystem as fs import spack.llnl.util.tty as tty import spack.llnl.util.tty.log -import spack.package_base import spack.paths import spack.repo import spack.report @@ -34,6 +33,9 @@ from spack.spec import Spec from spack.util.prefix import Prefix +if TYPE_CHECKING: + import spack.package_base + #: Stand-alone test failure info type TestFailureType = Tuple[BaseException, str] @@ -570,7 +572,7 @@ def copy_test_files(pkg: "spack.package_base.PackageBase", test_spec: spack.spec shutil.copytree(data_source, data_dir) -def test_function_names(pkg: PackageObjectOrClass, add_virtuals: bool = False) -> List[str]: +def test_function_names(pkg: "PackageObjectOrClass", add_virtuals: bool = False) -> List[str]: """Grab the names of all non-empty test functions. Args: @@ -589,7 +591,7 @@ def test_function_names(pkg: PackageObjectOrClass, add_virtuals: bool = False) - def test_functions( - pkg: PackageObjectOrClass, add_virtuals: bool = False + pkg: "PackageObjectOrClass", add_virtuals: bool = False ) -> List[Tuple[str, Callable]]: """Grab all non-empty test functions. @@ -604,12 +606,7 @@ def test_functions( Raises: ValueError: occurs if pkg is not a package class """ - instance = isinstance(pkg, spack.package_base.PackageBase) - if not (instance or issubclass(pkg, spack.package_base.PackageBase)): # type: ignore[arg-type] - raise ValueError(f"Expected a package (class), not {pkg} ({type(pkg)})") - - pkg_cls = pkg.__class__ if instance else pkg - classes = [pkg_cls] + classes = [pkg if isinstance(pkg, type) else pkg.__class__] if add_virtuals: vpkgs = virtuals(pkg) for vname in vpkgs: diff --git a/lib/spack/spack/test/test_suite.py b/lib/spack/spack/test/test_suite.py index 678a5db2fb3e87..c876428a0fd4ca 100644 --- a/lib/spack/spack/test/test_suite.py +++ b/lib/spack/spack/test/test_suite.py @@ -205,12 +205,6 @@ def test_test_function_names(mock_packages, install_mockery, virtuals, expected) assert sorted(tests) == sorted(expected) -def test_test_functions_fails(): - """Confirm test_functions raises error if no package.""" - with pytest.raises(ValueError, match="Expected a package"): - spack.install_test.test_functions(str) - - def test_test_functions_pkgless(mock_packages, install_mockery, ensure_debug, capfd): """Confirm works for package providing a package-less virtual.""" spec = spack.concretize.concretize_one("simple-standalone-test") From 585e25c9d2347062a8b6df3d79e3ee029c15658a Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 17 Mar 2026 16:45:27 +0100 Subject: [PATCH 158/337] urlopen: clean up resources (#52091) Add read_text() and read_json() helpers to spack.util.web that encapsulate the fetch-decode-close cycle, and use them to replace repeated read_from_url + TextIOWrapper boilerplate. Signed-off-by: Harmen Stoppels --- lib/spack/spack/audit.py | 9 ++- lib/spack/spack/binary_distribution.py | 90 +++++++++++++----------- lib/spack/spack/buildcache_migrate.py | 7 +- lib/spack/spack/ci/__init__.py | 10 ++- lib/spack/spack/ci/common.py | 7 +- lib/spack/spack/oci/oci.py | 94 ++++++++++++-------------- lib/spack/spack/reporters/cdash.py | 14 ++-- lib/spack/spack/url_buildcache.py | 3 +- lib/spack/spack/util/web.py | 36 +++++++--- 9 files changed, 144 insertions(+), 126 deletions(-) diff --git a/lib/spack/spack/audit.py b/lib/spack/spack/audit.py index 2b0fa346816712..b928e7bad31b8b 100644 --- a/lib/spack/spack/audit.py +++ b/lib/spack/spack/audit.py @@ -888,16 +888,15 @@ def _linting_package_file(pkgs, error_cls): # Does the homepage have http, and if so, does https work? if homepage.startswith("http://"): try: - response = urlopen(f"https://{homepage[7:]}") + with urlopen(f"https://{homepage[7:]}") as response: + if response.getcode() == 200: + msg = 'Package "{0}" uses http but has a valid https endpoint.' + errors.append(msg.format(pkg_cls.name)) except Exception as e: msg = 'Error with attempting https for "{0}": ' errors.append(error_cls(msg.format(pkg_cls.name), [str(e)])) continue - if response.getcode() == 200: - msg = 'Package "{0}" uses http but has a valid https endpoint.' - errors.append(msg.format(pkg_cls.name)) - return spack.llnl.util.lang.dedupe(errors) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index f177dea5b742ee..287d7e56c98866 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -1759,18 +1759,18 @@ def fetch_url_to_mirror( # Fetch the manifest try: - response = spack.oci.opener.urlopen( + with spack.oci.opener.urlopen( urllib.request.Request( url=ref.manifest_url(), headers={"Accept": ", ".join(spack.oci.oci.manifest_content_type)}, ) - ) + ) as response: + manifest = json.load(response) except Exception: continue # Download the config = spec.json and the relevant tarball try: - manifest = json.load(response) spec_digest = spack.oci.image.Digest.from_string(manifest["config"]["digest"]) tarball_digest = spack.oci.image.Digest.from_string( manifest["layers"][-1]["digest"] @@ -2335,8 +2335,7 @@ def _get_keys_v2(mirror_url, install=False, trust=False, force=False): tty.debug("Finding public keys in {0}".format(url_util.format(mirror_url))) try: - _, _, json_file = web_util.read_from_url(keys_index) - json_index = sjson.load(json_file) + json_index = web_util.read_json(keys_index) except (web_util.SpackWebError, OSError, ValueError) as url_err: # TODO: avoid repeated request if web_util.url_exists(keys_index): @@ -2621,8 +2620,10 @@ def get_remote_hash(self): # Failure to fetch index.json.hash is not fatal url_index_hash = url_util.join(self.url, "build_cache", "index.json.hash") try: - response = self.urlopen(urllib.request.Request(url_index_hash, headers=self.headers)) - remote_hash = response.read(64) + with self.urlopen( + urllib.request.Request(url_index_hash, headers=self.headers) + ) as response: + remote_hash = response.read(64) except OSError: return None @@ -2647,10 +2648,20 @@ def conditional_fetch(self) -> FetchIndexResult: except OSError as e: raise FetchIndexError(f"Could not fetch index from {url_index}", e) from e - try: - result = io.TextIOWrapper(response, encoding="utf-8").read() - except (ValueError, OSError) as e: - raise FetchIndexError(f"Remote index {url_index} is invalid") from e + with response: + try: + result = io.TextIOWrapper(response, encoding="utf-8").read() + except (ValueError, OSError) as e: + raise FetchIndexError(f"Remote index {url_index} is invalid") from e + + # For now we only handle etags on http(s), since 304 error handling + # in s3:// is not there yet. + if urllib.parse.urlparse(self.url).scheme not in ("http", "https"): + etag = None + else: + etag = web_util.parse_etag( + response.headers.get("Etag", None) or response.headers.get("etag", None) + ) computed_hash = compute_hash(result) @@ -2662,15 +2673,6 @@ def conditional_fetch(self) -> FetchIndexResult: # wrong, as it's more of an issue with race conditions in the cache # invalidation strategy. - # For now we only handle etags on http(s), since 304 error handling - # in s3:// is not there yet. - if urllib.parse.urlparse(self.url).scheme not in ("http", "https"): - etag = None - else: - etag = web_util.parse_etag( - response.headers.get("Etag", None) or response.headers.get("etag", None) - ) - warn_v2_layout(self.url, "Fetching an index") return FetchIndexResult(etag=etag, hash=computed_hash, data=result, fresh=False) @@ -2699,15 +2701,18 @@ def conditional_fetch(self) -> FetchIndexResult: except OSError as e: # URLError, socket.timeout, etc. raise FetchIndexError(f"Could not fetch index {url}", e) from e - try: - result = io.TextIOWrapper(response, encoding="utf-8").read() - except (ValueError, OSError) as e: - raise FetchIndexError(f"Remote index {url} is invalid", e) from e + with response: + try: + result = io.TextIOWrapper(response, encoding="utf-8").read() + except (ValueError, OSError) as e: + raise FetchIndexError(f"Remote index {url} is invalid", e) from e - warn_v2_layout(self.url, "Fetching an index") + warn_v2_layout(self.url, "Fetching an index") + + etag_header_value = response.headers.get("Etag", None) or response.headers.get( + "etag", None + ) - headers = response.headers - etag_header_value = headers.get("Etag", None) or headers.get("etag", None) return FetchIndexResult( etag=web_util.parse_etag(etag_header_value), hash=compute_hash(result), @@ -2735,10 +2740,11 @@ def conditional_fetch(self) -> FetchIndexResult: except OSError as e: raise FetchIndexError(f"Could not fetch manifest from {url_manifest}", e) from e - try: - manifest = json.load(response) - except Exception as e: - raise FetchIndexError(f"Remote index {url_manifest} is invalid", e) from e + with response: + try: + manifest = json.load(response) + except Exception as e: + raise FetchIndexError(f"Remote index {url_manifest} is invalid", e) from e # Get first blob hash, which should be the index.json try: @@ -2752,13 +2758,13 @@ def conditional_fetch(self) -> FetchIndexResult: # Otherwise fetch the blob / index.json try: - response = self.urlopen( + with self.urlopen( urllib.request.Request( url=self.ref.blob_url(index_digest), headers={"Accept": "application/vnd.oci.image.layer.v1.tar+gzip"}, ) - ) - result = io.TextIOWrapper(response, encoding="utf-8").read() + ) as response: + result = io.TextIOWrapper(response, encoding="utf-8").read() except (OSError, ValueError) as e: raise FetchIndexError(f"Remote index {url_manifest} is invalid", e) from e @@ -2793,7 +2799,8 @@ def conditional_fetch(self) -> FetchIndexResult: f"Could not read index manifest from {url_index_manifest}" ) from e - index_blob_record = self.get_index_manifest(response) + with response: + index_blob_record = self.get_index_manifest(response) # Early exit if our cache is up to date. if self.local_hash and self.local_hash == index_blob_record.checksum: @@ -2856,15 +2863,16 @@ def conditional_fetch(self) -> FetchIndexResult: raise FetchIndexError(f"Could not fetch index manifest {manifest_url}", e) from e # We need to read the index manifest and fetch the associated blob + with response: + index_blob_record = self.get_index_manifest(response) + etag_header_value = response.headers.get("Etag", None) or response.headers.get( + "etag", None + ) + cache_entry = cache_class(self.url, allow_unsigned=True) - computed_hash, result = self.fetch_index_blob( - cache_entry, self.get_index_manifest(response) - ) + computed_hash, result = self.fetch_index_blob(cache_entry, index_blob_record) cache_entry.destroy() - headers = response.headers - etag_header_value = headers.get("Etag", None) or headers.get("etag", None) - return FetchIndexResult( etag=web_util.parse_etag(etag_header_value), hash=computed_hash, diff --git a/lib/spack/spack/buildcache_migrate.py b/lib/spack/spack/buildcache_migrate.py index 663996b867853e..2b6e28d1ed4f0a 100644 --- a/lib/spack/spack/buildcache_migrate.py +++ b/lib/spack/spack/buildcache_migrate.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) -import io import json import os import pathlib @@ -104,8 +103,7 @@ def _migrate_spec( for meta_url in v2_metadata_urls: try: - _, _, meta_file = web_util.read_from_url(meta_url) - spec_contents = io.TextIOWrapper(meta_file, encoding="utf-8").read() + spec_contents = web_util.read_text(meta_url) v2_spec_url = meta_url break except (web_util.SpackWebError, OSError): @@ -279,8 +277,7 @@ def migrate( contents = None try: - _, _, index_file = web_util.read_from_url(index_url) - contents = io.TextIOWrapper(index_file, encoding="utf-8").read() + contents = web_util.read_text(index_url) except (web_util.SpackWebError, OSError): raise MigrationException("Buildcache migration requires a buildcache index") diff --git a/lib/spack/spack/ci/__init__.py b/lib/spack/spack/ci/__init__.py index 37fd6c0c7437e8..0e60854be88f5e 100644 --- a/lib/spack/spack/ci/__init__.py +++ b/lib/spack/spack/ci/__init__.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import base64 -import io import json import os import pathlib @@ -715,9 +714,9 @@ def download_and_extract_artifacts(url: str, work_dir: str) -> str: os.makedirs(work_dir, exist_ok=True) try: - response = urlopen(request, timeout=SPACK_CDASH_TIMEOUT) - with open(artifacts_zip_path, "wb") as out_file: - shutil.copyfileobj(response, out_file) + with urlopen(request, timeout=SPACK_CDASH_TIMEOUT) as response: + with open(artifacts_zip_path, "wb") as out_file: + shutil.copyfileobj(response, out_file) with zipfile.ZipFile(artifacts_zip_path) as zip_file: zip_file.extractall(work_dir) @@ -1299,12 +1298,11 @@ def read_broken_spec(broken_spec_url): object. """ try: - _, _, fs = web_util.read_from_url(broken_spec_url) + broken_spec_contents = web_util.read_text(broken_spec_url) except web_util.SpackWebError: tty.warn(f"Unable to read broken spec from {broken_spec_url}") return None - broken_spec_contents = io.TextIOWrapper(fs, encoding="utf-8").read() return syaml.load(broken_spec_contents) diff --git a/lib/spack/spack/ci/common.py b/lib/spack/spack/ci/common.py index 51fad1cc2900dd..53e7ad2bb1d953 100644 --- a/lib/spack/spack/ci/common.py +++ b/lib/spack/spack/ci/common.py @@ -268,7 +268,8 @@ def create_buildgroup(self): group_id = None try: - response_text = _urlopen(request, timeout=SPACK_CDASH_TIMEOUT).read() + with _urlopen(request, timeout=SPACK_CDASH_TIMEOUT) as response: + response_text = response.read() except OSError as e: tty.warn(f"Failed to create CDash buildgroup: {e}") @@ -713,8 +714,8 @@ def job_query(job): endpoint_url._replace(query=query).geturl(), headers=header, method="GET" ) try: - response = _urlopen(request) - config = json.load(response) + with _urlopen(request) as response: + config = json.load(response) except Exception as e: # For now just ignore any errors from dynamic mapping and continue # This is still experimental, and failures should not stop CI diff --git a/lib/spack/spack/oci/oci.py b/lib/spack/spack/oci/oci.py index 0f4c4bb2d13ee5..0bc95bcf94b917 100644 --- a/lib/spack/spack/oci/oci.py +++ b/lib/spack/spack/oci/oci.py @@ -7,7 +7,6 @@ import os import urllib.error import urllib.parse -from http.client import HTTPResponse from typing import List, NamedTuple, Tuple from urllib.request import Request @@ -59,12 +58,12 @@ def list_tags(ref: ImageReference, _urlopen: spack.oci.opener.MaybeOpen = None) while True: # Fetch tags request = Request(url=fetch_url) - response = _urlopen(request) - spack.oci.opener.ensure_status(request, response, 200) - tags.update(json.load(response)["tags"]) + with _urlopen(request) as response: + spack.oci.opener.ensure_status(request, response, 200) + tags.update(json.load(response)["tags"]) - # Check for pagination - link_header = response.headers["Link"] + # Check for pagination + link_header = response.headers["Link"] if link_header is None: break @@ -141,20 +140,20 @@ def upload_blob( url=ref.uploads_url(), method="POST", headers={"Content-Length": "0"} ) - response = _urlopen(request) + with _urlopen(request) as response: + # Created the blob in one go. + if response.status == 201: + return True - # Created the blob in one go. - if response.status == 201: - return True + # Otherwise, do another PUT request. + spack.oci.opener.ensure_status(request, response, 202) + assert "Location" in response.headers - # Otherwise, do another PUT request. - spack.oci.opener.ensure_status(request, response, 202) - assert "Location" in response.headers + # Can be absolute or relative, joining handles both + upload_url = with_query_param( + ref.endpoint(response.headers["Location"]), "digest", str(digest) + ) - # Can be absolute or relative, joining handles both - upload_url = with_query_param( - ref.endpoint(response.headers["Location"]), "digest", str(digest) - ) f.seek(0) request = Request( @@ -164,9 +163,8 @@ def upload_blob( headers={"Content-Type": "application/octet-stream", "Content-Length": str(file_size)}, ) - response = _urlopen(request) - - spack.oci.opener.ensure_status(request, response, 201) + with _urlopen(request) as response: + spack.oci.opener.ensure_status(request, response, 201) return True @@ -205,9 +203,8 @@ def upload_manifest( headers={"Content-Type": manifest["mediaType"]}, ) - response = _urlopen(request) - - spack.oci.opener.ensure_status(request, response, 201) + with _urlopen(request) as response: + spack.oci.opener.ensure_status(request, response, 201) return digest, size @@ -222,8 +219,8 @@ def blob_exists( """Checks if a blob exists in an OCI registry""" try: _urlopen = _urlopen or spack.oci.opener.urlopen - response = _urlopen(Request(url=ref.blob_url(digest), method="HEAD")) - return response.status == 200 + with _urlopen(Request(url=ref.blob_url(digest), method="HEAD")) as response: + return response.status == 200 except urllib.error.HTTPError as e: if e.getcode() == 404: return False @@ -314,34 +311,33 @@ def get_manifest_and_config( _urlopen = _urlopen or spack.oci.opener.urlopen # Get manifest - response: HTTPResponse = _urlopen( + with _urlopen( Request(url=ref.manifest_url(), headers={"Accept": ", ".join(all_content_type)}) - ) - - # Recurse when we find an index - if response.headers["Content-Type"] in index_content_type: - if recurse == 0: - raise Exception("Maximum recursion depth reached while fetching OCI manifest") - - index = json.load(response) - manifest_meta = next( - manifest - for manifest in index["manifests"] - if manifest["platform"]["architecture"] == architecture - ) + ) as response: + # Recurse when we find an index + if response.headers["Content-Type"] in index_content_type: + if recurse == 0: + raise Exception("Maximum recursion depth reached while fetching OCI manifest") + + index = json.load(response) + manifest_meta = next( + manifest + for manifest in index["manifests"] + if manifest["platform"]["architecture"] == architecture + ) - return get_manifest_and_config( - ref.with_digest(manifest_meta["digest"]), - architecture=architecture, - recurse=recurse - 1, - _urlopen=_urlopen, - ) + return get_manifest_and_config( + ref.with_digest(manifest_meta["digest"]), + architecture=architecture, + recurse=recurse - 1, + _urlopen=_urlopen, + ) - # Otherwise, require a manifest - if response.headers["Content-Type"] not in manifest_content_type: - raise Exception(f"Unknown content type {response.headers['Content-Type']}") + # Otherwise, require a manifest + if response.headers["Content-Type"] not in manifest_content_type: + raise Exception(f"Unknown content type {response.headers['Content-Type']}") - manifest = json.load(response) + manifest = json.load(response) # Download, verify and cache config file config_digest = Digest.from_string(manifest["config"]["digest"]) diff --git a/lib/spack/spack/reporters/cdash.py b/lib/spack/spack/reporters/cdash.py index 409361e7fb22e9..f7a72561cf4836 100644 --- a/lib/spack/spack/reporters/cdash.py +++ b/lib/spack/spack/reporters/cdash.py @@ -452,13 +452,13 @@ def upload(self, filename): if self.authtoken: request.add_header("Authorization", "Bearer {0}".format(self.authtoken)) try: - response = web_util.urlopen(request, timeout=SPACK_CDASH_TIMEOUT) - if self.current_package_name not in self.buildIds: - resp_value = io.TextIOWrapper(response, encoding="utf-8").read() - match = self.buildid_regexp.search(resp_value) - if match: - buildid = match.group(1) - self.buildIds[self.current_package_name] = buildid + with web_util.urlopen(request, timeout=SPACK_CDASH_TIMEOUT) as response: + if self.current_package_name not in self.buildIds: + resp_value = io.TextIOWrapper(response, encoding="utf-8").read() + match = self.buildid_regexp.search(resp_value) + if match: + buildid = match.group(1) + self.buildIds[self.current_package_name] = buildid except Exception as e: print(f"Upload to CDash failed: {e}") diff --git a/lib/spack/spack/url_buildcache.py b/lib/spack/spack/url_buildcache.py index ac382f86e7cfd3..dc7d32dab38d45 100644 --- a/lib/spack/spack/url_buildcache.py +++ b/lib/spack/spack/url_buildcache.py @@ -465,8 +465,7 @@ def read_manifest(self, manifest_url: Optional[str] = None) -> BuildcacheManifes manifest_contents = "" try: - _, _, manifest_file = web_util.read_from_url(manifest_url) - manifest_contents = io.TextIOWrapper(manifest_file, encoding="utf-8").read() + manifest_contents = web_util.read_text(manifest_url) except (web_util.SpackWebError, OSError) as e: raise BuildcacheEntryError(f"Error reading manifest at {manifest_url}") from e diff --git a/lib/spack/spack/util/web.py b/lib/spack/spack/util/web.py index 0567fbf275de32..3b0bf459fc8cd7 100644 --- a/lib/spack/spack/util/web.py +++ b/lib/spack/spack/util/web.py @@ -268,6 +268,26 @@ def read_from_url(url, accept_content_type=None): return response.url, response.headers, response +def read_text(url: str) -> str: + """Fetch url and return the response body decoded as UTF-8 text.""" + request = Request(url, headers={"User-Agent": SPACK_USER_AGENT}) + try: + with urlopen(request) as response: + return io.TextIOWrapper(response, encoding="utf-8").read() + except OSError as e: + raise SpackWebError(f"Download of {url} failed: {e.__class__.__name__}: {e}") + + +def read_json(url: str): + """Fetch url and return the response body parsed as JSON.""" + request = Request(url, headers={"User-Agent": SPACK_USER_AGENT}) + try: + with urlopen(request) as response: + return json.load(response) + except OSError as e: + raise SpackWebError(f"Download of {url} failed: {e.__class__.__name__}: {e}") + + def push_to_url(local_file_path, remote_path, keep_original=True, extra_args=None): remote_url = urllib.parse.urlparse(remote_path) if remote_url.scheme == "file": @@ -441,9 +461,7 @@ def fetch_url_text(url, curl: Optional[Executable] = None, dest_dir="."): else: try: - _, _, response = read_from_url(url) - - output = io.TextIOWrapper(response, encoding="utf-8").read() + output = read_text(url) if output: with working_dir(dest_dir, create=True): with open(filename, "w", encoding="utf-8") as f: @@ -490,11 +508,11 @@ def url_exists(url, curl=None): # Otherwise use urllib. try: - urlopen( + with urlopen( Request(url, method="HEAD", headers={"User-Agent": SPACK_USER_AGENT}), timeout=spack.config.get("config:connect_timeout", 10), - ) - return True + ) as _: + return True except OSError as e: tty.debug(f"Failure reading {url}: {e}") return False @@ -761,7 +779,8 @@ def _spider(url: urllib.parse.ParseResult, collect_nested: bool, _visited: Set[s if not response_url or not response: return pages, links, subcalls, _visited - page = io.TextIOWrapper(response, encoding="utf-8").read() + with response: + page = io.TextIOWrapper(response, encoding="utf-8").read() pages[response_url] = page # Parse out the include-fragments in the page @@ -788,7 +807,8 @@ def _spider(url: urllib.parse.ParseResult, collect_nested: bool, _visited: Set[s if not fragment_response_url or not fragment_response: continue - fragment = io.TextIOWrapper(fragment_response, encoding="utf-8").read() + with fragment_response: + fragment = io.TextIOWrapper(fragment_response, encoding="utf-8").read() fragments.add(fragment) pages[fragment_response_url] = fragment From 721add3a6bc32ba19b6932f9ea0390f29a41d6cb Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 17 Mar 2026 16:51:19 +0100 Subject: [PATCH 159/337] environment: reconstruct groups from lockfiles (#52095) When a spack.yaml is created from a lockfile, the groups are now preserved. `needs` dependencies are not preserved yet. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/environment/environment.py | 52 +++++++++++++++++++--- lib/spack/spack/test/env.py | 41 +++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index b750c6452dc5d7..bae93c99b36d6e 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -3127,13 +3127,22 @@ def from_lockfile(manifest_dir: Union[pathlib.Path, str]) -> "EnvironmentManifes lockfile = manifest_dir / lockfile_name with lockfile.open("r", encoding="utf-8") as f: data = sjson.load(f) - user_specs = data["roots"] + roots = data["roots"] + + user_specs_by_group: Dict[str, List[str]] = {} + for item in roots: + # "group" is not there for Lockfile v6 and lower + group = item.get("group", DEFAULT_USER_SPEC_GROUP) + user_specs_by_group.setdefault(group, []).append(item["spec"]) default_content = manifest_dir / manifest_name default_content.write_text(default_manifest_yaml()) manifest = EnvironmentManifestFile(manifest_dir) - for item in user_specs: - manifest.add_user_spec(item["spec"]) + + for group, specs in user_specs_by_group.items(): + for spec in specs: + manifest.add_user_spec(spec, group=group) + manifest.flush() return manifest @@ -3244,16 +3253,45 @@ def _ensure_group_exists(self, group: Optional[str]) -> str: raise ValueError(f"user specs group '{group}' not found in {self.manifest_file}") return group - def add_user_spec(self, user_spec: str) -> None: - """Appends the user spec passed as input to the default list of root specs. + def add_user_spec(self, user_spec: str, *, group: Optional[str] = None) -> None: + """Appends the user spec passed as input to the list of root specs for the given group. Args: user_spec: user spec to be appended + group: group where the spec should be added. If None, the default group is used. """ - self.configuration.setdefault("specs", []).append(user_spec) - self._user_specs[DEFAULT_USER_SPEC_GROUP].append(user_spec) + group = group or DEFAULT_USER_SPEC_GROUP + + if group == DEFAULT_USER_SPEC_GROUP: + # Append to top-most specs: attribute + specs_yaml = self.configuration.setdefault("specs", []) + specs_yaml.append(user_spec) + else: + # Append to specs: attribute within a group + group_in_yaml = self._get_group(group) + group_in_yaml.setdefault("specs", []).append(user_spec) + + self._user_specs[group].append(user_spec) self.changed = True + def _get_group(self, group: str) -> Dict: + """Find or create the group entry in the manifest""" + specs_yaml = self.configuration.setdefault("specs", []) + group_entry = None + for item in specs_yaml: + if isinstance(item, dict) and item.get("group") == group: + group_entry = item + break + + if group_entry is None: + group_entry = {"group": group, "specs": []} + specs_yaml.append(group_entry) + self._groups[group] = tuple() + self._config_override[group] = None + self._user_specs[group] = [] + + return group_entry + def remove_user_spec(self, user_spec: str) -> None: """Removes the user spec passed as input from the default list of root specs diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index 95fefe8ddc5e81..4c7d1c096166fa 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -4,6 +4,7 @@ """Test environment internals without CLI""" import filecmp +import json import os import pathlib import pickle @@ -1983,6 +1984,46 @@ def test_cyclic_group_dependencies_give_clear_error(self, create_temporary_manif with pytest.raises(ev.SpackEnvironmentConfigError, match=r"among groups: alpha, beta"): e.concretize() + def test_from_lockfile_preserves_groups(self, tmp_path): + """Tests that EnvironmentManifestFile.from_lockfile reconstructs groups correctly + from a v7 lockfile that contains group information in its roots. + """ + lockfile_data = { + "_meta": {"file-type": "spack-lockfile", "lockfile-version": 7, "specfile-version": 5}, + "roots": [ + {"hash": "aaa", "spec": "mpileaks", "group": "default"}, + {"hash": "bbb", "spec": "libelf", "group": "default"}, + {"hash": "ccc", "spec": "gcc@14", "group": "compilers"}, + ], + "concrete_specs": {}, + } + lockfile_path = tmp_path / "spack.lock" + lockfile_path.write_text(json.dumps(lockfile_data)) + + manifest = EnvironmentManifestFile.from_lockfile(tmp_path) + + # The reconstructed manifest must have both groups + assert set(manifest.groups()) == {"default", "compilers"} + assert manifest.user_specs(group="default") == ["mpileaks", "libelf"] + assert manifest.user_specs(group="compilers") == ["gcc@14"] + + def test_from_lockfile_without_groups_stays_default(self, tmp_path): + """Tests that a lockfile without group info (v6 and earlier) reconstructs all specs + into the default group only. + """ + lockfile_data = { + "_meta": {"file-type": "spack-lockfile", "lockfile-version": 6, "specfile-version": 5}, + "roots": [{"hash": "aaa", "spec": "mpileaks"}, {"hash": "bbb", "spec": "libelf"}], + "concrete_specs": {}, + } + lockfile_path = tmp_path / "spack.lock" + lockfile_path.write_text(json.dumps(lockfile_data)) + + manifest = EnvironmentManifestFile.from_lockfile(tmp_path) + + assert set(manifest.groups()) == {"default"} + assert manifest.user_specs(group="default") == ["mpileaks", "libelf"] + @pytest.mark.regression("51995") def test_mixed_compilers_and_libllvm(tmp_path, config): From 3878e80946c70b64c73c18377877f6eb37dcb0a3 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 17 Mar 2026 17:11:00 +0100 Subject: [PATCH 160/337] new_installer.py: build failure ui improvements (#52093) * Parse logs on error to get a log summary * Allow users to navigate through logs with `n`/`p` * Print the full log path in overview mode: `[x] ... failed: ` * Print the full log path in log mode after the log summary. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 90 ++++++++++++++++++++------- lib/spack/spack/test/installer_tui.py | 69 +++++++++++++++++--- 2 files changed, 129 insertions(+), 30 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index c81ea68b60e681..1b3967dc0a81ca 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -956,10 +956,16 @@ class BuildInfo: "finished_time", "progress_percent", "control_w_conn", + "log_path", + "log_summary", ) def __init__( - self, spec: spack.spec.Spec, explicit: bool, control_w_conn: Optional[Connection] + self, + spec: spack.spec.Spec, + explicit: bool, + control_w_conn: Optional[Connection], + log_path: Optional[str] = None, ) -> None: self.state: str = "starting" self.explicit: bool = explicit @@ -971,6 +977,8 @@ def __init__( self.finished_time: Optional[float] = None self.progress_percent: Optional[int] = None self.control_w_conn = control_w_conn + self.log_path: Optional[str] = log_path + self.log_summary: Optional[str] = None class BuildStatus: @@ -1025,10 +1033,14 @@ def on_resize(self) -> None: self.dirty = True def add_build( - self, spec: spack.spec.Spec, explicit: bool, control_w_conn: Optional[Connection] = None + self, + spec: spack.spec.Spec, + explicit: bool, + control_w_conn: Optional[Connection] = None, + log_path: Optional[str] = None, ) -> None: """Add a new build to the display and mark the display as dirty.""" - self.builds[spec.dag_hash()] = BuildInfo(spec, explicit, control_w_conn) + self.builds[spec.dag_hash()] = BuildInfo(spec, explicit, control_w_conn, log_path) self.dirty = True # Track the new build's logs when we're not already following another build. This applies # only in non-TTY verbose mode. @@ -1063,6 +1075,7 @@ def toggle(self) -> None: def search_input(self, input: str) -> None: """Handle keyboard input when in search mode""" if input in ("\r", "\n"): + self.log_ends_with_newline = False self.next(1) elif input == "\x1b": # Escape self.search_mode = False @@ -1090,7 +1103,8 @@ def _get_next(self, direction: int) -> Optional[str]: matching = [ build_id for build_id, build in self.builds.items() - if build.finished_time is None and self._is_displayed(build) + if (build.finished_time is None or build.state == "failed") + and self._is_displayed(build) ] if not matching: return None @@ -1124,20 +1138,35 @@ def next(self, direction: int = 1) -> None: self.tracked_build_id = new_build_id - # Tell the user we're following new logs, and instruct the child to start sending them. version_str = ( f"\033[0;36m@{new_build.version}\033[0m" if self.color else f"@{new_build.version}" ) prefix = "" if self.log_ends_with_newline else "\n" - self.stdout.write(f"{prefix}==> Following logs of {new_build.name}{version_str}\n") - self.log_ends_with_newline = True - self.stdout.flush() - try: - conn = new_build.control_w_conn - if conn is not None: - os.write(conn.fileno(), b"1") - except (KeyError, OSError): - pass + + if new_build.state == "failed": + # For failed builds, show the stored log summary instead of following live logs. + self.stdout.write(f"{prefix}==> Log summary of {new_build.name}{version_str}\n") + self.log_ends_with_newline = True + if new_build.log_summary: + self.stdout.write(new_build.log_summary) + if new_build.log_path: + if not new_build.log_summary: + self.stdout.write("No errors parsed from log, see full log: ") + else: + self.stdout.write("Full log: ") + self.stdout.write(f"{new_build.log_path}\n") + self.stdout.flush() + else: + # Tell the user we're following new logs, and instruct the child to start sending. + self.stdout.write(f"{prefix}==> Following logs of {new_build.name}{version_str}\n") + self.log_ends_with_newline = True + self.stdout.flush() + try: + conn = new_build.control_w_conn + if conn is not None: + os.write(conn.fileno(), b"1") + except (KeyError, OSError): + pass def update_state(self, build_id: str, state: str) -> None: """Update the state of a package and mark the display as dirty.""" @@ -1164,6 +1193,19 @@ def update_state(self, build_id: str, state: str) -> None: self.stdout.write(line + "\n") self.stdout.flush() + def parse_log_summary(self, build_id: str) -> None: + """Parse the build log for errors/warnings and store the summary.""" + build_info = self.builds[build_id] + if not build_info.log_path or not os.path.exists(build_info.log_path): + return + buf = io.StringIO() + spack.build_environment.write_log_summary( + buf, f"{build_info.name}@{build_info.version} build", build_info.log_path + ) + summary = buf.getvalue() + if summary: + build_info.log_summary = summary + def update_progress(self, build_id: str, current: int, total: int) -> None: """Update the progress of a package and mark the display as dirty.""" percent = int((current / total) * 100) @@ -1368,6 +1410,10 @@ def _generate_line_components( elif build_info.state == "finished": prefix = build_info.prefix yield f" {padding_filter(prefix) if self.filter_padding else prefix}" + elif build_info.state == "failed": + yield " failed" + if build_info.log_path: + yield f": {build_info.log_path}" else: yield f" {build_info.state}" @@ -1945,6 +1991,7 @@ def _handle_sigwinch(signum: int, frame: object) -> None: # reported as failures in the UI. failures.append(build.spec) self.build_status.update_state(build.spec.dag_hash(), "failed") + self.build_status.parse_log_summary(build.spec.dag_hash()) if failures and self.fail_fast: # Terminate other builds to actually fail fast. We continue in the event loop @@ -2076,13 +2123,9 @@ def _handle_sigwinch(signum: int, frame: object) -> None: if failures: for s in failures: - log_path = self.log_paths.get(s.dag_hash()) - if log_path and os.path.exists(log_path): - out = io.StringIO() - spack.build_environment.write_log_summary(out, f"{s} build", log_path) - summary = out.getvalue() - if summary: - sys.stderr.write(summary) + build_info = self.build_status.builds[s.dag_hash()] + if build_info and build_info.log_summary: + sys.stderr.write(build_info.log_summary) lines = [f"{s}: {self.log_paths[s.dag_hash()]}" for s in failures] raise spack.error.InstallError( "The following packages failed to install:\n" + "\n".join(lines) @@ -2203,7 +2246,10 @@ def _start( ) selector.register(child_info.proc.sentinel, selectors.EVENT_READ, FdInfo(pid, "sentinel")) self.build_status.add_build( - child_info.spec, explicit=explicit, control_w_conn=child_info.control_w_conn + child_info.spec, + explicit=explicit, + control_w_conn=child_info.control_w_conn, + log_path=child_info.log_path, ) self.report_data.start_record(spec) diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index a1778461bf6f3a..86364f5b0be4d6 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -179,6 +179,40 @@ def test_update_state_failed(self): assert status.completed == 1 assert status.builds[build_id].finished_time == fake_time[0] + inst.CLEANUP_TIMEOUT + def test_parse_log_summary(self, tmp_path): + """Test that parse_log_summary parses the build log and stores the summary.""" + status, _, _ = create_build_status() + (spec,) = add_mock_builds(status, 1) + build_id = spec.dag_hash() + + # Create a fake log file with an error + log_file = tmp_path / "build.log" + log_file.write_text("error: something went wrong\n") + + status.builds[build_id].log_path = str(log_file) + status.parse_log_summary(build_id) + assert status.builds[build_id].log_summary is not None + assert "error" in status.builds[build_id].log_summary.lower() + + def test_parse_log_summary_no_log_path(self): + """Test that parse_log_summary is a no-op when log_path is not set.""" + status, _, _ = create_build_status() + (spec,) = add_mock_builds(status, 1) + build_id = spec.dag_hash() + + status.parse_log_summary(build_id) + assert status.builds[build_id].log_summary is None + + def test_parse_log_summary_missing_file(self, tmp_path): + """Test that parse_log_summary is a no-op when log file doesn't exist.""" + status, _, _ = create_build_status() + (spec,) = add_mock_builds(status, 1) + build_id = spec.dag_hash() + + status.builds[build_id].log_path = str(tmp_path / "nonexistent.log") + status.parse_log_summary(build_id) + assert status.builds[build_id].log_summary is None + def test_update_progress(self): """Test that update_progress updates percentages""" status, _, _ = create_build_status() @@ -757,22 +791,41 @@ def test_print_logs_discarded_when_not_tracked(self): # Nothing should be printed since we're tracking pkg1, not pkg2 assert fake_stdout.getvalue() == "" - def test_cannot_follow_failed_build(self): - """Test that navigation skips failed builds""" - status, _, _ = create_build_status(total=3) + def test_can_navigate_to_failed_build(self): + """Test that navigating to a failed build shows log summary and path""" + status, _, fake_stdout = create_build_status(total=3) specs = add_mock_builds(status, 3) - # Mark the middle build as failed + # Mark the middle build as failed and set log info status.update_state(specs[1].dag_hash(), "failed") + build_info = status.builds[specs[1].dag_hash()] + build_info.log_summary = "Error: something went wrong\n" + build_info.log_path = "/tmp/spack/pkg1.log" + + # Navigate from pkg0 to next -- should land on failed pkg1 + status.tracked_build_id = specs[0].dag_hash() + next_id = status._get_next(1) + assert next_id == specs[1].dag_hash() - # The failed build should have finished_time set - assert status.builds[specs[1].dag_hash()].finished_time is not None + # Actually navigate to it + status.next(1) + output = fake_stdout.getvalue() + assert "Log summary of pkg1" in output + assert "Error: something went wrong" in output + assert "/tmp/spack/pkg1.log" in output + + def test_navigation_skips_finished_build(self): + """Test that navigation skips successfully finished builds""" + status, _, _ = create_build_status(total=3) + specs = add_mock_builds(status, 3) + + # Mark the middle build as finished (successful) + status.update_state(specs[1].dag_hash(), "finished") - # Try to get next build, should skip the failed one + # Try to get next build, should skip the finished one status.tracked_build_id = specs[0].dag_hash() next_id = status._get_next(1) - # Should skip pkg1 (failed) and return pkg2 assert next_id == specs[2].dag_hash() From 453adc9c42940134fc326c1dcca33916c2c2d4d4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 17 Mar 2026 18:41:35 +0100 Subject: [PATCH 161/337] new_installer.py: two log fixes (#52096) 1. Unlink the log file from the stage dir if successful and not --keep-stage. 2. Drop second call to install test logs. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 1b3967dc0a81ca..eed554109bf410 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -504,17 +504,26 @@ def handle_sigterm(signum, frame): tee.close() state_stream.close() - if exit_code == 0 and not os.path.lexists(spec.package.install_log_path): + if exit_code == 0: # Try to install the compressed log file - try: - with open(log_path, "rb") as f, open(spec.package.install_log_path, "wb") as g: - # Use GzipFile directly so we can omit filename / mtime in header - gzip_file = GzipFile(filename="", mode="wb", compresslevel=6, mtime=0, fileobj=g) - shutil.copyfileobj(f, gzip_file) - gzip_file.close() - os.unlink(log_path) - except Exception: - pass # don't fail the build just because log compression failed + if not os.path.lexists(spec.package.install_log_path): + try: + with open(log_path, "rb") as f, open(spec.package.install_log_path, "wb") as g: + # Use GzipFile directly so we can omit filename / mtime in header + gzip_file = GzipFile( + filename="", mode="wb", compresslevel=6, mtime=0, fileobj=g + ) + shutil.copyfileobj(f, gzip_file) + gzip_file.close() + except Exception: + pass # don't fail the build just because log compression failed + + # Remove the uncompressed log file from the stage dir on successful install. + if not keep_stage: + try: + os.unlink(log_path) + except OSError: + pass sys.exit(exit_code) @@ -687,7 +696,6 @@ def _install( _archive_build_metadata(pkg) spack.hooks.post_install(spec, explicit) - pkg.archive_install_test_log() class JobServer: From e90f050a0e732e4ae00773873b5d86dafd55469e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 18 Mar 2026 10:11:29 +0100 Subject: [PATCH 162/337] new_installer.py: no status line at the end (#52098) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 36 ++++++++++++++------------- lib/spack/spack/test/installer_tui.py | 17 +++++++++++++ 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index eed554109bf410..5005adc5091eea 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1275,25 +1275,27 @@ def update(self, finalize: bool = False) -> None: self.finished_builds.clear() # Then a header followed by the active builds. This is the "mutable" part of the display. - if self.color: - bold = "\033[1m" - reset = "\033[0m" - cyan = "\033[36m" - else: - bold = reset = cyan = "" - long_header_len = len( - f"Progress: {self.completed}/{self.total} /: filter v: logs n/p: next/prev" - ) - if long_header_len < max_width: - self._println( - buffer, - f"{bold}Progress:{reset} {self.completed}/{self.total}" - f" {cyan}/{reset}: filter {cyan}v{reset}: logs" - f" {cyan}n{reset}/{cyan}p{reset}: next/prev", + if not finalize: + if self.color: + bold = "\033[1m" + reset = "\033[0m" + cyan = "\033[36m" + else: + bold = reset = cyan = "" + + long_header_len = len( + f"Progress: {self.completed}/{self.total} /: filter v: logs n/p: next/prev" ) - else: - self._println(buffer, f"{bold}Progress:{reset} {self.completed}/{self.total}") + if long_header_len < max_width: + self._println( + buffer, + f"{bold}Progress:{reset} {self.completed}/{self.total}" + f" {cyan}/{reset}: filter {cyan}v{reset}: logs" + f" {cyan}n{reset}/{cyan}p{reset}: next/prev", + ) + else: + self._println(buffer, f"{bold}Progress:{reset} {self.completed}/{self.total}") displayed_builds = ( [b for b in self.builds.values() if self._is_displayed(b)] diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 86364f5b0be4d6..33cc0c974db13a 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -1197,6 +1197,23 @@ def test_empty_build_list(self): assert "Progress:" in output assert "0/0" in output + def test_no_header_with_finalize(self): + """Test that we don't print a header with finalize=True""" + status, _, fake_stdout = create_build_status(total=2, color=False) + spec_a, spec_b = add_mock_builds(status, 2) + status.update_state(spec_a.dag_hash(), "finished") + status.update_state(spec_b.dag_hash(), "failed") + status.update(finalize=True) + + output = fake_stdout.getvalue() + + # Should not contain header + assert "Progress:" not in output + + # Should contain final status lines for both builds + assert f"[+] {spec_a.dag_hash(7)} {spec_a.name}@{spec_a.version}" in output + assert f"[x] {spec_b.dag_hash(7)} {spec_b.name}@{spec_b.version}" in output + def test_all_builds_finished(self): """Test when all builds are finished""" status, fake_time, _ = create_build_status(total=2) From b18ad342d4472361f65b5b2618c7db592d242e7f Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 18 Mar 2026 11:47:42 +0100 Subject: [PATCH 163/337] Remove amdblis and amdlibflame as defaults (#52099) The two packages are not very well maintained, and vendor-specific. So it may be surprising to see `amdblis` selected when `openblas` is not possible. Let Spack users decide the defaults instead. Signed-off-by: Massimiliano Culpo --- etc/spack/defaults/base/packages.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/etc/spack/defaults/base/packages.yaml b/etc/spack/defaults/base/packages.yaml index a91d8dd1bbd91b..57bc3e8fd6574d 100644 --- a/etc/spack/defaults/base/packages.yaml +++ b/etc/spack/defaults/base/packages.yaml @@ -18,7 +18,7 @@ packages: providers: awk: [gawk] armci: [armcimpi] - blas: [openblas, amdblis] + blas: [openblas] c: [gcc, llvm, intel-oneapi-compilers] cxx: [gcc, llvm, intel-oneapi-compilers] daal: [intel-oneapi-daal] @@ -36,7 +36,7 @@ packages: ipp: [intel-oneapi-ipp] java: [openjdk, jdk] jpeg: [libjpeg-turbo, libjpeg] - lapack: [openblas, amdlibflame] + lapack: [openblas] libc: [glibc, musl] libgfortran: [gcc-runtime] libglx: [mesa+glx] From 248e95e798f453a69a2e2af2ac6e9c9a2f88c042 Mon Sep 17 00:00:00 2001 From: Phil Sakievich Date: Wed, 18 Mar 2026 09:22:34 -0400 Subject: [PATCH 164/337] git: ensure git server configured for tests (#51989) Our full clone test is failing to pull specific commits on ubuntu containers. This is because the git version on the container supports `--filter=blob:none` but does not turn it on by default. This change ensures that filter is configured before fetching when it will be used in the option list. Note that if the server is not configured to support filters things still proceed without erroring. Git logs a message that the server isn't configured and won't apply the filter. This is the expected behavior. Signed-off-by: psakievich * Fix fetch logic error Signed-off-by: Phil Sakievich * Clarify code per review Signed-off-by: psakievich --------- Signed-off-by: psakievich Signed-off-by: Phil Sakievich --- lib/spack/spack/test/git_fetch.py | 3 +++ lib/spack/spack/util/git.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/test/git_fetch.py b/lib/spack/spack/test/git_fetch.py index 4f5270ced30bc2..0eccf2dab412f4 100644 --- a/lib/spack/spack/test/git_fetch.py +++ b/lib/spack/spack/test/git_fetch.py @@ -281,6 +281,9 @@ def test_get_full_repo( url = mock_git_repository.url commit = git_exe("ls-remote", url, t.revision, output=str).strip().split()[0] s.variants["commit"] = SingleValuedVariant("commit", commit) + if can_use_direct_commit: + path = mock_git_repository.path + git_exe("-C", path, "config", "uploadpack.allowReachableSHA1InWant", "true") with s.package.stage: with spack.config.override("config:verify_ssl", secure): diff --git a/lib/spack/spack/util/git.py b/lib/spack/spack/util/git.py index 4cbddc340d89ac..ef80d8a0857cd6 100644 --- a/lib/spack/spack/util/git.py +++ b/lib/spack/spack/util/git.py @@ -321,8 +321,16 @@ def git_init_fetch(url, ref, depth=None, debug=False, dest=None, git_exe=None): if depth and protocol_supports_shallow_clone(url): fetch.extend(DEPTH(version, str(depth))) - fetch.extend([*FILTER_BLOB_NONE(version), url, ref]) - cmds = [init, remote, config, fetch] + filter_args = FILTER_BLOB_NONE(version) + if filter_args: + fetch.extend(filter_args) + fetch.extend([url, ref]) + + partial_clone = ["config", "extensions.partialClone", "true"] if filter_args else None + if partial_clone is not None: + cmds = [init, partial_clone, remote, config, fetch] + else: + cmds = [init, remote, config, fetch] _exec_git_commands_unique_dir(git_exe, cmds, debug, dest) From e958367e3f7c0a6424234bfcd4daaf1f13c94ded Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 19 Mar 2026 16:50:11 +0100 Subject: [PATCH 165/337] new_installer.py: clear progress line on ^C (#52103) Very minor: on ^C the status line "Progress: ..." is sometimes cleared and sometimes not, depending on what moment ^C was pressed. If right after a spinner tick it would be removed, else it would not because then the UI was not "dirty". Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 5005adc5091eea..80b1e803a8774d 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1251,7 +1251,7 @@ def update(self, finalize: bool = False) -> None: del self.builds[build_id] self.dirty = True - if not self.dirty: + if not self.dirty and not finalize: return # Build the overview output in a buffer and print all at once to avoid flickering. From c052a3265717dca0e8c68be0d1be73d9422af9f2 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 19 Mar 2026 16:53:33 +0100 Subject: [PATCH 166/337] test/cmd/install.py: enable new installer more (#52097) * test/cmd/install.py: enable new installer more * fix case of forking under pytest Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 17 +++++----- lib/spack/spack/test/cmd/install.py | 50 ++++++++++++++++++++--------- lib/spack/spack/test/conftest.py | 3 -- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 80b1e803a8774d..7b084327c3cf69 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -216,8 +216,10 @@ class Tee: def __init__(self, control: Connection, parent: Connection, log_fd: int) -> None: self.control = control self.parent = parent - self.saved_stdout = os.dup(sys.stdout.fileno()) - self.saved_stderr = os.dup(sys.stderr.fileno()) + # sys.stdout and sys.stderr may have been replaced with file objects under pytest, so + # redirect their file descriptors in addition to the original fds 1 and 2. + fds = {sys.stdout.fileno(), sys.stderr.fileno(), 1, 2} + self.saved_fds = {fd: os.dup(fd) for fd in fds} #: The file descriptor of the log file self.log_fd = log_fd r, w = os.pipe() @@ -227,8 +229,8 @@ def __init__(self, control: Connection, parent: Connection, log_fd: int) -> None daemon=True, ) self.tee_thread.start() - os.dup2(w, sys.stdout.fileno()) - os.dup2(w, sys.stderr.fileno()) + for fd in fds: + os.dup2(w, fd) os.close(w) def close(self) -> None: @@ -238,10 +240,9 @@ def close(self) -> None: # can cause exit code 120 (witnessed under pytest+coverage on macOS). sys.stdout.flush() sys.stderr.flush() - os.dup2(self.saved_stdout, sys.stdout.fileno()) - os.dup2(self.saved_stderr, sys.stderr.fileno()) - os.close(self.saved_stdout) - os.close(self.saved_stderr) + for fd, saved_fd in self.saved_fds.items(): + os.dup2(saved_fd, fd) + os.close(saved_fd) self.tee_thread.join() # Only then close the other fds. self.control.close() diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index 5e9f32a9143862..93c1f7e79961df 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -204,7 +204,9 @@ def test_install_with_source(mock_packages, mock_archive, mock_fetch, install_mo ) -def test_install_env_variables(mock_packages, mock_archive, mock_fetch, install_mockery): +def test_install_env_variables( + mock_packages, mock_archive, mock_fetch, install_mockery, installer_variant +): spec = spack.concretize.concretize_one("pkg-c") install("pkg-c") assert os.path.isfile(spec.package.install_env_path) @@ -223,7 +225,9 @@ def test_show_log_on_error(mock_packages, mock_archive, mock_fetch, install_mock assert "See build log for details:" in out -def test_install_overwrite(mock_packages, mock_archive, mock_fetch, install_mockery): +def test_install_overwrite( + mock_packages, mock_archive, mock_fetch, install_mockery, installer_variant +): """Tests installing a spec, and then re-installing it in the same prefix.""" spec = spack.concretize.concretize_one("pkg-c") install("pkg-c") @@ -255,7 +259,9 @@ def test_install_overwrite(mock_packages, mock_archive, mock_fetch, install_mock assert fs.hash_directory(spec.prefix, ignore=ignores) != bad_md5 -def test_install_overwrite_not_installed(mock_packages, mock_archive, mock_fetch, install_mockery): +def test_install_overwrite_not_installed( + mock_packages, mock_archive, mock_fetch, install_mockery, installer_variant +): """Tests that overwrite doesn't fail if the package is not installed""" spec = spack.concretize.concretize_one("pkg-c") assert not os.path.exists(spec.prefix) @@ -499,7 +505,9 @@ def test_install_mix_cli_and_files(spec_format, clispecs, filespecs, tmp_path: p assert install.returncode == 0 -def test_extra_files_are_archived(mock_packages, mock_archive, mock_fetch, install_mockery): +def test_extra_files_are_archived( + mock_packages, mock_archive, mock_fetch, install_mockery, installer_variant +): s = spack.concretize.concretize_one("archive-files") install("archive-files") @@ -514,7 +522,7 @@ def test_extra_files_are_archived(mock_packages, mock_archive, mock_fetch, insta @pytest.mark.disable_clean_stage_check def test_cdash_report_concretization_error( - tmp_path: pathlib.Path, mock_fetch, install_mockery, conflict_spec + tmp_path: pathlib.Path, mock_fetch, install_mockery, conflict_spec, installer_variant ): with fs.working_dir(str(tmp_path)): with pytest.raises(SpackError): @@ -533,7 +541,9 @@ def test_cdash_report_concretization_error( @pytest.mark.not_on_windows("Windows log_output logs phase header out of order") @pytest.mark.disable_clean_stage_check -def test_cdash_upload_build_error(capfd, tmp_path: pathlib.Path, mock_fetch, install_mockery): +def test_cdash_upload_build_error( + capfd, tmp_path: pathlib.Path, mock_fetch, install_mockery, installer_variant +): with fs.working_dir(str(tmp_path)): with pytest.raises(SpackError): install( @@ -686,7 +696,7 @@ def test_cache_only_fails(mock_fetch, install_mockery): assert "libdwarf" in failed_packages -def test_install_only_dependencies(mock_fetch, install_mockery): +def test_install_only_dependencies(mock_fetch, install_mockery, installer_variant): dep = spack.concretize.concretize_one("dependency-install") root = spack.concretize.concretize_one("dependent-install") @@ -707,7 +717,7 @@ def test_install_only_package(mock_fetch, install_mockery): assert "1 uninstalled dependency" in msg -def test_install_deps_then_package(mock_fetch, install_mockery): +def test_install_deps_then_package(mock_fetch, install_mockery, installer_variant): dep = spack.concretize.concretize_one("dependency-install") root = spack.concretize.concretize_one("dependent-install") @@ -722,7 +732,9 @@ def test_install_deps_then_package(mock_fetch, install_mockery): # Unit tests should not be affected by the user's managed environments @pytest.mark.not_on_windows("Environment views not supported on windows. Revisit after #34701") @pytest.mark.regression("12002") -def test_install_only_dependencies_in_env(mutable_mock_env_path, mock_fetch, install_mockery): +def test_install_only_dependencies_in_env( + mutable_mock_env_path, mock_fetch, install_mockery, installer_variant +): env("create", "test") with ev.read("test"): @@ -738,7 +750,7 @@ def test_install_only_dependencies_in_env(mutable_mock_env_path, mock_fetch, ins # Unit tests should not be affected by the user's managed environments @pytest.mark.regression("12002") def test_install_only_dependencies_of_all_in_env( - mutable_mock_env_path, mock_fetch, install_mockery + mutable_mock_env_path, mock_fetch, install_mockery, installer_variant ): env("create", "--without-view", "test") @@ -760,7 +772,7 @@ def test_install_only_dependencies_of_all_in_env( # Unit tests should not be affected by the user's managed environments def test_install_no_add_in_env( - tmp_path: pathlib.Path, mutable_mock_env_path, mock_fetch, install_mockery + tmp_path: pathlib.Path, mutable_mock_env_path, mock_fetch, install_mockery, installer_variant ): # To test behavior of --add option, we create the following environment: # @@ -877,7 +889,9 @@ def test_install_help_cdash(): @pytest.mark.disable_clean_stage_check -def test_cdash_auth_token(tmp_path: pathlib.Path, mock_fetch, install_mockery, monkeypatch): +def test_cdash_auth_token( + tmp_path: pathlib.Path, mock_fetch, install_mockery, monkeypatch, installer_variant +): with fs.working_dir(str(tmp_path)): monkeypatch.setenv("SPACK_CDASH_AUTH_TOKEN", "asdf") out = install("--fake", "-v", "--log-file=cdash_reports", "--log-format=cdash", "pkg-a") @@ -886,7 +900,9 @@ def test_cdash_auth_token(tmp_path: pathlib.Path, mock_fetch, install_mockery, m @pytest.mark.not_on_windows("Windows log_output logs phase header out of order") @pytest.mark.disable_clean_stage_check -def test_cdash_configure_warning(tmp_path: pathlib.Path, mock_fetch, install_mockery): +def test_cdash_configure_warning( + tmp_path: pathlib.Path, mock_fetch, install_mockery, installer_variant +): with fs.working_dir(str(tmp_path)): # Test would fail if install raised an error. @@ -934,7 +950,7 @@ def test_install_fails_no_args_suggests_env_activation(tmp_path: pathlib.Path): # Unit tests should not be affected by the user's managed environments @pytest.mark.not_on_windows("Environment views not supported on windows. Revisit after #34701") def test_install_env_with_tests_all( - mutable_mock_env_path, mock_packages, mock_fetch, install_mockery + mutable_mock_env_path, mock_packages, mock_fetch, install_mockery, installer_variant ): env("create", "test") with ev.read("test"): @@ -947,7 +963,7 @@ def test_install_env_with_tests_all( # Unit tests should not be affected by the user's managed environments @pytest.mark.not_on_windows("Environment views not supported on windows. Revisit after #34701") def test_install_env_with_tests_root( - mutable_mock_env_path, mock_packages, mock_fetch, install_mockery + mutable_mock_env_path, mock_packages, mock_fetch, install_mockery, installer_variant ): env("create", "test") with ev.read("test"): @@ -959,7 +975,9 @@ def test_install_env_with_tests_root( # Unit tests should not be affected by the user's managed environments @pytest.mark.not_on_windows("Environment views not supported on windows. Revisit after #34701") -def test_install_empty_env(mutable_mock_env_path, mock_packages, mock_fetch, install_mockery): +def test_install_empty_env( + mutable_mock_env_path, mock_packages, mock_fetch, install_mockery, installer_variant +): env_name = "empty" env("create", env_name) with ev.read(env_name): diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index 3829e2b9203a32..84358b4063b228 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -76,7 +76,6 @@ join_path, mkdirp, remove_linked_tree, - touchp, working_dir, ) from spack.main import SpackCommand @@ -703,8 +702,6 @@ def mock_packages_repo(): def _pkg_install_fn(pkg, spec, prefix): # sanity_check_prefix requires something in the install directory mkdirp(prefix.bin) - if not os.path.exists(spec.package.install_log_path): - touchp(spec.package.install_log_path) @pytest.fixture From 3cae9d190144795686a1ad5e418c7516f67b39ed Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 19 Mar 2026 19:34:26 +0100 Subject: [PATCH 167/337] detection: deduplicate specs at different prefixes (#52100) When the same spec (e.g. gcc@8.4.0) is discovered at two different prefixes, only the first entry is kept. A warning is printed to users for any successive duplicate found in other prefixes. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/detection/path.py | 11 ++++---- lib/spack/spack/test/cmd/compiler.py | 11 +++++--- lib/spack/spack/test/detection.py | 38 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/lib/spack/spack/detection/path.py b/lib/spack/spack/detection/path.py index 183cb3d1fbba57..6e088061f3c920 100644 --- a/lib/spack/spack/detection/path.py +++ b/lib/spack/spack/detection/path.py @@ -268,6 +268,7 @@ def detect_specs( return [] result = [] + resolved_specs: Dict[spack.spec.Spec, str] = {} # spec -> prefix of first detection for candidate_path, items_in_prefix in _group_by_prefix( spack.llnl.util.lang.dedupe(paths) ).items(): @@ -297,21 +298,19 @@ def detect_specs( f"part of the package {pkg.name}: {files}" ) - resolved_specs: Dict[spack.spec.Spec, str] = {} # spec -> exe found for the spec for spec in specs: prefix = self.prefix_from_path(path=candidate_path) if not prefix: continue if spec in resolved_specs: - prior_prefix = ", ".join(_convert_to_iterable(resolved_specs[spec])) - spack.llnl.util.tty.debug( - f"Files in {candidate_path} and {prior_prefix} are both associated" - f" with the same spec {str(spec)}" + prior_prefix = resolved_specs[spec] + warnings.warn( + f'"{spec}" detected in "{prefix}" was already detected in "{prior_prefix}"' ) continue - resolved_specs[spec] = candidate_path + resolved_specs[spec] = prefix try: # Validate the spec calling a package specific method pkg_cls = repo_path.get_pkg_class(spec.name) diff --git a/lib/spack/spack/test/cmd/compiler.py b/lib/spack/spack/test/cmd/compiler.py index deeec78e16595c..361a1a1fcbc112 100644 --- a/lib/spack/spack/test/cmd/compiler.py +++ b/lib/spack/spack/test/cmd/compiler.py @@ -169,7 +169,9 @@ def test_compiler_find_prefer_no_suffix(no_packages_yaml, working_env, compilers @pytest.mark.not_on_windows("Cannot execute bash script on Windows") def test_compiler_find_path_order(no_packages_yaml, working_env, compilers_dir): - """Ensure that we look for compilers in the same order as PATH, when there are duplicates""" + """When the same compiler version is found in two PATH directories, only the first + entry in PATH is kept and a warning is emitted for the duplicate. + """ new_dir = compilers_dir / "first_in_path" new_dir.mkdir() for name in ("gcc-8", "g++-8", "gfortran-8"): @@ -177,13 +179,14 @@ def test_compiler_find_path_order(no_packages_yaml, working_env, compilers_dir): # Set PATH to have the new folder searched first os.environ["PATH"] = f"{str(new_dir)}:{str(compilers_dir)}" - compiler("find", "--scope=site") + with pytest.warns(UserWarning, match="gcc@"): + compiler("find", "--scope=site") compilers = spack.compilers.config.all_compilers(scope="site") gcc = [x for x in compilers if x.satisfies("gcc@8.4")] - # Ensure we found both duplicates - assert len(gcc) == 2 + # Duplicate is dropped. Only the first entry in PATH is kept + assert len(gcc) == 1 assert gcc[0].extra_attributes["compilers"] == { "c": str(new_dir / "gcc-8"), "cxx": str(new_dir / "g++-8"), diff --git a/lib/spack/spack/test/detection.py b/lib/spack/spack/test/detection.py index b501acb448fb7c..1218907d7dc656 100644 --- a/lib/spack/spack/test/detection.py +++ b/lib/spack/spack/test/detection.py @@ -4,10 +4,13 @@ import collections import pathlib +import pytest + import spack.config import spack.detection import spack.detection.common import spack.detection.path +import spack.repo import spack.spec @@ -52,3 +55,38 @@ def test_dedupe_paths(tmp_path: pathlib.Path): assert spack.detection.path.dedupe_paths([str(x), str(y), str(z)]) == [str(x), str(y)] assert spack.detection.path.dedupe_paths([str(z), str(y), str(x)]) == [str(x), str(y)] assert spack.detection.path.dedupe_paths([str(y), str(z), str(x)]) == [str(y), str(x)] + + +@pytest.mark.usefixtures("mock_packages") +def test_detect_specs_deduplicates_across_prefixes(tmp_path, monkeypatch): + """Tests that the same spec detected at two different prefixes should yield only one result. + + Returning both causes duplicate externals in packages.yaml and non-deterministic hashes + during concretization. + """ + # Create two independent bin/ directories, each containing the same executable name. + prefix_a = tmp_path / "prefix_a" + prefix_b = tmp_path / "prefix_b" + (prefix_a / "bin").mkdir(parents=True) + (prefix_b / "bin").mkdir(parents=True) + exe_a = prefix_a / "bin" / "cmake" + exe_b = prefix_b / "bin" / "cmake" + exe_a.touch() + exe_b.touch() + + cmake_cls = spack.repo.PATH.get_pkg_class("cmake") + + # Patch determine_spec_details to always return the same spec, regardless of prefix. + @classmethod + def _same_spec(cls, prefix, exes_in_prefix): + return spack.spec.Spec("cmake@3.17.1") + + monkeypatch.setattr(cmake_cls, "determine_spec_details", _same_spec) + + finder = spack.detection.path.ExecutablesFinder() + detected = finder.detect_specs( + pkg=cmake_cls, paths=[str(exe_a), str(exe_b)], repo_path=spack.repo.PATH + ) + + # Both prefixes produce cmake@3.17.1; only the first should be kept. + assert len(detected) == 1 From 1dd8c8167ae7af42b4400dc5220358cba8f9f392 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 19 Mar 2026 19:36:13 +0100 Subject: [PATCH 168/337] solver: prefer best provider above one with no penalty on variants (#52070) Before this commit there were cases where a default compiler was not selected because it had penalty on variants. Instead, the second-best provider was selected. Here we tweak priorities to ensure that the compiler choice is above variant penalty. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 75 ++++++++----------- lib/spack/spack/test/concretization/core.py | 59 +++++++++++++++ .../builtin_mock/packages/gcc/package.py | 3 + 3 files changed, 94 insertions(+), 43 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 0f7ffefe721e2f..883ae4f1a59f0b 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -2158,16 +2158,6 @@ opt_criterion(65, "variant penalty (roots)"). build_priority(PackageNode, Priority) }. -opt_criterion(60, "preferred providers for roots"). -#minimize{ 0@260: #true }. -#minimize{ 0@60: #true }. -#minimize{ - Weight@60+Priority,ProviderNode,X,Virtual - : provider_weight(ProviderNode, node(X, Virtual), Weight), - attr("root", ProviderNode), not language(Virtual), not language_runtime(Virtual), - build_priority(ProviderNode, Priority) -}. - opt_criterion(55, "default values of variants not being used (roots)"). #minimize{ 0@255: #true }. #minimize{ 0@55: #true }. @@ -2178,49 +2168,26 @@ opt_criterion(55, "default values of variants not being used (roots)"). build_priority(PackageNode, Priority) }. -% Try to use default variants or variants that have been set -opt_criterion(50, "variant penalty (non-roots)"). -#minimize{ 0@250: #true }. -#minimize{ 0@50: #true }. -#minimize { - Penalty@50+Priority,PackageNode,Variant,Value - : variant_penalty(PackageNode, Variant, Value, Penalty), - not attr("root", PackageNode), - build_priority(PackageNode, Priority) -}. - -% Minimize the weights of the providers, i.e. use as much as -% possible the most preferred providers -opt_criterion(48, "preferred providers (non-roots)"). -#minimize{ 0@248: #true }. -#minimize{ 0@48: #true }. -#minimize{ - Weight@48+Priority,ProviderNode,X,Virtual - : provider_weight(ProviderNode, node(X, Virtual), Weight), - not attr("root", ProviderNode), not language(Virtual), not language_runtime(Virtual), - build_priority(ProviderNode, Priority) -}. - % Minimize the number of compilers used on nodes - compiler_penalty(PackageNode, C-1) :- C = #count { CompilerNode : node_compiler(PackageNode, CompilerNode) }, node_compiler(PackageNode, _), C > 0. -opt_criterion(46, "number of compilers used on the same node"). -#minimize{ 0@246: #true }. -#minimize{ 0@46: #true }. +opt_criterion(48, "number of compilers used on the same node"). +#minimize{ 0@248: #true }. +#minimize{ 0@48: #true }. #minimize{ - Penalty@46+Priority,PackageNode + Penalty@48+Priority,PackageNode : compiler_penalty(PackageNode, Penalty), build_priority(PackageNode, Priority) }. - -opt_criterion(40, "preferred compilers"). -#minimize{ 0@240: #true }. -#minimize{ 0@40: #true }. +% Choose the preferred compiler before penalizing variants, to avoid that a variant penalty +% on e.g. gcc causes clingo to pick another compiler e.g. llvm +opt_criterion(46, "preferred compilers"). +#minimize{ 0@246: #true }. +#minimize{ 0@46: #true }. #minimize{ - Weight@40+Priority,ProviderNode,X,Virtual + Weight@46+Priority,ProviderNode,X,Virtual : provider_weight(ProviderNode, node(X, Virtual), Weight), language(Virtual), build_priority(ProviderNode, Priority) @@ -2231,6 +2198,28 @@ opt_criterion(41, "compiler penalty from reuse"). #minimize{ 0@41: #true }. #minimize{1@41,Hash : compiler_penalty_from_reuse(Hash)}. +% Try to use default variants or variants that have been set +opt_criterion(40, "variant penalty (non-roots)"). +#minimize{ 0@240: #true }. +#minimize{ 0@40: #true }. +#minimize { + Penalty@40+Priority,PackageNode,Variant,Value + : variant_penalty(PackageNode, Variant, Value, Penalty), + not attr("root", PackageNode), + build_priority(PackageNode, Priority) +}. + +% Minimize the weights of all the other providers (mpi, lapack, etc.) +opt_criterion(38, "preferred providers (excluded compilers and language runtimes)"). +#minimize{ 0@238: #true }. +#minimize{ 0@38: #true }. +#minimize{ + Weight@38+Priority,ProviderNode,X,Virtual + : provider_weight(ProviderNode, node(X, Virtual), Weight), + not language(Virtual), not language_runtime(Virtual), + build_priority(ProviderNode, Priority) +}. + opt_criterion(30, "non-preferred OS's"). #minimize{ 0@230: #true }. #minimize{ 0@30: #true }. diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index d7e5c2bf5d20db..659d83e4da9070 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4980,3 +4980,62 @@ def test_virtual_gets_multiple_dupes(mock_packages, config): assert len(selected_lines) == 1 max_dupes_c = selected_lines[0] assert 'max_dupes("c",2).' == max_dupes_c, f"should have max_dupes=2, but got: {max_dupes_c}" + + +def test_compiler_selection_when_external_has_variant_penalty(mutable_config, mock_packages): + """Tests that a compiler that should be preferred is not swapped with a less preferred + compiler because of penalties on variants. + """ + packages_yaml = syaml.load_config( + """ +packages: + gcc:: + externals: + - spec: "gcc@15.2.0 languages='c,c++' ~binutils" + prefix: /path + extra_attributes: + compilers: + c: /path/bin/gcc + cxx: /path/bin/g++ + llvm:: + buildable: false + externals: + - spec: "llvm@20 +clang" + prefix: /path + extra_attributes: + compilers: + c: /path/bin/gcc + cxx: /path/bin/g++ +""" + ) + mutable_config.set("packages", packages_yaml["packages"]) + + concrete = spack.concretize.concretize_one("libdwarf") + + # GCC is the preferred provider, but has a penalty on its variants + assert concrete.satisfies("%gcc@15.2.0 ~binutils"), concrete.tree() + # LLVM is the second provider choice, with no penalty on variants + assert not concrete.satisfies("%llvm@20 +clang") + + +def test_mpi_selection_when_external_has_variant_penalty(mutable_config, mock_packages): + """Tests that conflicting with a default provider doesn't cause a variant values to be + flipped to avoid the variant dependency. + """ + packages_yaml = syaml.load_config( + """ +packages: + all: + variants: +mpi + mpich: + buildable: false +""" + ) + mutable_config.set("packages", packages_yaml["packages"]) + + concrete = spack.concretize.concretize_one("transitive-conditional-virtual-dependency") + + # GCC is the preferred provider, but has a penalty on its variants + assert concrete.satisfies("%conditional-virtual-dependency+mpi"), concrete.tree() + # LLVM is the second provider choice, with no penalty on variants + assert concrete.satisfies("^mpi=zmpi") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py index a433f6d403b611..b2374251e006fc 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py @@ -36,6 +36,9 @@ class Gcc(CompilerPackage, Package): description="Compilers and runtime libraries to build", ) + # This variant is here so that we can test having externals using the non-default value + variant("binutils", default=True, description="") + provides("c", "cxx", when="languages=c,c++") provides("c", when="languages=c") provides("cxx", when="languages=c++") From f1cb12f2f1dfe3917676eb6b045f1bd3f28610b8 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 19 Mar 2026 21:52:45 +0100 Subject: [PATCH 169/337] requirements.py: fix toolchain expansion for preferences (#52106) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/requirements.py | 6 +++--- lib/spack/spack/test/env.py | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/solver/requirements.py b/lib/spack/spack/solver/requirements.py index d8117e8fc05ee2..9b2598f5b7c8eb 100644 --- a/lib/spack/spack/solver/requirements.py +++ b/lib/spack/spack/solver/requirements.py @@ -207,11 +207,11 @@ def _rules_from_conflicts( def _parse_prefer_conflict_item(self, item): # The item is either a string or an object with at least a "spec" attribute if isinstance(item, str): - spec = parse_spec_from_yaml_string(item) + spec = self._parse_and_expand(item) condition = spack.spec.Spec() message = None else: - spec = parse_spec_from_yaml_string(item["spec"]) + spec = self._parse_and_expand(item["spec"]) condition = spack.spec.Spec(item.get("when")) message = item.get("message") return spec, condition, message @@ -260,7 +260,7 @@ def _rules_from_requirements( for constraint in constraints ] when_str = requirement.get("when") - when = parse_spec_from_yaml_string(when_str) if when_str else spack.spec.Spec() + when = self._parse_and_expand(when_str) if when_str else spack.spec.Spec() constraints = [ x diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index 4c7d1c096166fa..177b1e08d68620 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -1200,7 +1200,10 @@ def test_toolchains_as_matrix_dimension(unify, tmp_path: pathlib.Path, mutable_c @pytest.mark.parametrize("unify", ["true", "false", "when_possible"]) -def test_using_toolchain_as_requirement(unify, tmp_path: pathlib.Path, mutable_config): +@pytest.mark.parametrize("requirement_type", ["require", "prefer"]) +def test_using_toolchain_as_requirement( + unify, requirement_type, tmp_path: pathlib.Path, mutable_config +): """Tests using a toolchain as a default requirement in an environment""" spack_yaml = f""" spack: @@ -1212,7 +1215,7 @@ def test_using_toolchain_as_requirement(unify, tmp_path: pathlib.Path, mutable_c {MIXED_TOOLCHAIN} packages: all: - require: + {requirement_type}: - "%mixed-toolchain" concretizer: unify: {unify} From 7b378635fa834135e58fec1c313e712945ea212b Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 19 Mar 2026 22:17:16 +0100 Subject: [PATCH 170/337] =?UTF-8?q?Revert=20"solver:=20prefer=20best=20pro?= =?UTF-8?q?vider=20above=20one=20with=20no=20penalty=20on=20variants=20(#?= =?UTF-8?q?=E2=80=A6"=20(#52108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1dd8c8167ae7af42b4400dc5220358cba8f9f392. Signed-off-by: Harmen Stoppels --- lib/spack/spack/solver/concretize.lp | 75 +++++++++++-------- lib/spack/spack/test/concretization/core.py | 59 --------------- .../builtin_mock/packages/gcc/package.py | 3 - 3 files changed, 43 insertions(+), 94 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 883ae4f1a59f0b..0f7ffefe721e2f 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -2158,6 +2158,16 @@ opt_criterion(65, "variant penalty (roots)"). build_priority(PackageNode, Priority) }. +opt_criterion(60, "preferred providers for roots"). +#minimize{ 0@260: #true }. +#minimize{ 0@60: #true }. +#minimize{ + Weight@60+Priority,ProviderNode,X,Virtual + : provider_weight(ProviderNode, node(X, Virtual), Weight), + attr("root", ProviderNode), not language(Virtual), not language_runtime(Virtual), + build_priority(ProviderNode, Priority) +}. + opt_criterion(55, "default values of variants not being used (roots)"). #minimize{ 0@255: #true }. #minimize{ 0@55: #true }. @@ -2168,26 +2178,49 @@ opt_criterion(55, "default values of variants not being used (roots)"). build_priority(PackageNode, Priority) }. +% Try to use default variants or variants that have been set +opt_criterion(50, "variant penalty (non-roots)"). +#minimize{ 0@250: #true }. +#minimize{ 0@50: #true }. +#minimize { + Penalty@50+Priority,PackageNode,Variant,Value + : variant_penalty(PackageNode, Variant, Value, Penalty), + not attr("root", PackageNode), + build_priority(PackageNode, Priority) +}. + +% Minimize the weights of the providers, i.e. use as much as +% possible the most preferred providers +opt_criterion(48, "preferred providers (non-roots)"). +#minimize{ 0@248: #true }. +#minimize{ 0@48: #true }. +#minimize{ + Weight@48+Priority,ProviderNode,X,Virtual + : provider_weight(ProviderNode, node(X, Virtual), Weight), + not attr("root", ProviderNode), not language(Virtual), not language_runtime(Virtual), + build_priority(ProviderNode, Priority) +}. + % Minimize the number of compilers used on nodes + compiler_penalty(PackageNode, C-1) :- C = #count { CompilerNode : node_compiler(PackageNode, CompilerNode) }, node_compiler(PackageNode, _), C > 0. -opt_criterion(48, "number of compilers used on the same node"). -#minimize{ 0@248: #true }. -#minimize{ 0@48: #true }. +opt_criterion(46, "number of compilers used on the same node"). +#minimize{ 0@246: #true }. +#minimize{ 0@46: #true }. #minimize{ - Penalty@48+Priority,PackageNode + Penalty@46+Priority,PackageNode : compiler_penalty(PackageNode, Penalty), build_priority(PackageNode, Priority) }. -% Choose the preferred compiler before penalizing variants, to avoid that a variant penalty -% on e.g. gcc causes clingo to pick another compiler e.g. llvm -opt_criterion(46, "preferred compilers"). -#minimize{ 0@246: #true }. -#minimize{ 0@46: #true }. + +opt_criterion(40, "preferred compilers"). +#minimize{ 0@240: #true }. +#minimize{ 0@40: #true }. #minimize{ - Weight@46+Priority,ProviderNode,X,Virtual + Weight@40+Priority,ProviderNode,X,Virtual : provider_weight(ProviderNode, node(X, Virtual), Weight), language(Virtual), build_priority(ProviderNode, Priority) @@ -2198,28 +2231,6 @@ opt_criterion(41, "compiler penalty from reuse"). #minimize{ 0@41: #true }. #minimize{1@41,Hash : compiler_penalty_from_reuse(Hash)}. -% Try to use default variants or variants that have been set -opt_criterion(40, "variant penalty (non-roots)"). -#minimize{ 0@240: #true }. -#minimize{ 0@40: #true }. -#minimize { - Penalty@40+Priority,PackageNode,Variant,Value - : variant_penalty(PackageNode, Variant, Value, Penalty), - not attr("root", PackageNode), - build_priority(PackageNode, Priority) -}. - -% Minimize the weights of all the other providers (mpi, lapack, etc.) -opt_criterion(38, "preferred providers (excluded compilers and language runtimes)"). -#minimize{ 0@238: #true }. -#minimize{ 0@38: #true }. -#minimize{ - Weight@38+Priority,ProviderNode,X,Virtual - : provider_weight(ProviderNode, node(X, Virtual), Weight), - not language(Virtual), not language_runtime(Virtual), - build_priority(ProviderNode, Priority) -}. - opt_criterion(30, "non-preferred OS's"). #minimize{ 0@230: #true }. #minimize{ 0@30: #true }. diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 659d83e4da9070..d7e5c2bf5d20db 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4980,62 +4980,3 @@ def test_virtual_gets_multiple_dupes(mock_packages, config): assert len(selected_lines) == 1 max_dupes_c = selected_lines[0] assert 'max_dupes("c",2).' == max_dupes_c, f"should have max_dupes=2, but got: {max_dupes_c}" - - -def test_compiler_selection_when_external_has_variant_penalty(mutable_config, mock_packages): - """Tests that a compiler that should be preferred is not swapped with a less preferred - compiler because of penalties on variants. - """ - packages_yaml = syaml.load_config( - """ -packages: - gcc:: - externals: - - spec: "gcc@15.2.0 languages='c,c++' ~binutils" - prefix: /path - extra_attributes: - compilers: - c: /path/bin/gcc - cxx: /path/bin/g++ - llvm:: - buildable: false - externals: - - spec: "llvm@20 +clang" - prefix: /path - extra_attributes: - compilers: - c: /path/bin/gcc - cxx: /path/bin/g++ -""" - ) - mutable_config.set("packages", packages_yaml["packages"]) - - concrete = spack.concretize.concretize_one("libdwarf") - - # GCC is the preferred provider, but has a penalty on its variants - assert concrete.satisfies("%gcc@15.2.0 ~binutils"), concrete.tree() - # LLVM is the second provider choice, with no penalty on variants - assert not concrete.satisfies("%llvm@20 +clang") - - -def test_mpi_selection_when_external_has_variant_penalty(mutable_config, mock_packages): - """Tests that conflicting with a default provider doesn't cause a variant values to be - flipped to avoid the variant dependency. - """ - packages_yaml = syaml.load_config( - """ -packages: - all: - variants: +mpi - mpich: - buildable: false -""" - ) - mutable_config.set("packages", packages_yaml["packages"]) - - concrete = spack.concretize.concretize_one("transitive-conditional-virtual-dependency") - - # GCC is the preferred provider, but has a penalty on its variants - assert concrete.satisfies("%conditional-virtual-dependency+mpi"), concrete.tree() - # LLVM is the second provider choice, with no penalty on variants - assert concrete.satisfies("^mpi=zmpi") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py index b2374251e006fc..a433f6d403b611 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py @@ -36,9 +36,6 @@ class Gcc(CompilerPackage, Package): description="Compilers and runtime libraries to build", ) - # This variant is here so that we can test having externals using the non-default value - variant("binutils", default=True, description="") - provides("c", "cxx", when="languages=c,c++") provides("c", when="languages=c") provides("cxx", when="languages=c++") From 8153569addca9e562adf2bb45069b391148aa59c Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Fri, 20 Mar 2026 10:16:29 +0100 Subject: [PATCH 171/337] Add Changelog to Package API section (#52110) Signed-off-by: Massimiliano Culpo --- lib/spack/docs/package_api.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/lib/spack/docs/package_api.rst b/lib/spack/docs/package_api.rst index 53fd0b10d6c1bb..9d18100304b88f 100644 --- a/lib/spack/docs/package_api.rst +++ b/lib/spack/docs/package_api.rst @@ -34,6 +34,33 @@ Compatibility between Spack and :doc:`package repositories ` is ma Spack version |spack_version| supports package repositories with a Package API version between |min_package_api_version| and |package_api_version|, inclusive. +Changelog +--------- + +**v2.4** *(Spack v1.0.3)* + +* The ``%%`` operator can be used on input specs to set propagated preferences, which is particularly useful for ``unify: false`` environments. + +**v2.3** *(Spack v1.0.3)* + +* The :func:`~spack.package.version` directive now supports the ``git_sparse_paths`` parameter, allowing sparse checkouts when fetching from git repositories. + +**v2.2** *(Spack v1.0.0)* + +* Renamed implicit builder attributes with backward compatibility: + + * ``legacy_buildsystem`` to ``default_buildsystem``, + * ``legacy_methods`` to ``package_methods``, + * ``legacy_attributes`` to ``package_attributes``, + * ``legacy_long_methods`` to ``package_long_methods``. + +* Exported :class:`~spack.package.GenericBuilder`, :class:`~spack.package.Package`, and :class:`~spack.package.BuilderWithDefaults` from :mod:`spack.package`. +* Exported numerous utility functions and classes for file operations, library/header search, macOS/Windows support, compiler detection, and build system helpers. + +**v2.1** *(Spack v1.0.0)* + +* Exported :class:`~spack.package.CompilerError` and :class:`~spack.package.SpackError` from :mod:`spack.package`. + Spack Package API Reference --------------------------- From e88dce167277519bd696fd74b48a5929e5cc8ef5 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Fri, 20 Mar 2026 14:57:25 +0100 Subject: [PATCH 172/337] audit: catch propagation in directives (#52113) Variant and dependency propagation shouldn't be used in package.py files. This PR adds checks to catch such uses and block them in CI. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/audit.py | 143 +++++++++++++++++++++++++++++---------- 1 file changed, 109 insertions(+), 34 deletions(-) diff --git a/lib/spack/spack/audit.py b/lib/spack/spack/audit.py index b928e7bad31b8b..2393b4571ed69b 100644 --- a/lib/spack/spack/audit.py +++ b/lib/spack/spack/audit.py @@ -51,6 +51,7 @@ def _search_duplicate_compilers(error_cls): import spack.builder import spack.config +import spack.enums import spack.fetch_strategy import spack.llnl.util.lang import spack.patch @@ -901,61 +902,91 @@ def _linting_package_file(pkgs, error_cls): @package_directives -def _unknown_variants_in_directives(pkgs, error_cls): - """Report unknown or wrong variants in directives for this package""" +def _variant_issues_in_directives(pkgs, error_cls): + """Report unknown, wrong, or propagating variants in directives for this package""" errors = [] for pkg_name in pkgs: pkg_cls = spack.repo.PATH.get_pkg_class(pkg_name) + filename = spack.repo.PATH.filename_for_package_name(pkg_name) - # Check "conflicts" directive + # Check the "conflicts" directive for trigger, conflicts in pkg_cls.conflicts.items(): + errors.extend( + _issues_in_directive_constraint( + pkg_cls, + spack.spec.Spec(trigger), + directive="conflicts", + error_cls=error_cls, + filename=filename, + requestor=pkg_name, + ) + ) for conflict, _ in conflicts: - vrn = spack.spec.Spec(conflict) - try: - vrn.constrain(trigger) - except Exception: - # If one of the conflict/trigger includes a platform and the other - # includes an os or target, the constraint will fail if the current - # platform is not the plataform in the conflict/trigger. Audit the - # conflict and trigger separately in that case. - # When os and target constraints can be created independently of - # the platform, TODO change this back to add an error. - errors.extend( - _analyze_variants_in_directive( - pkg_cls, - spack.spec.Spec(trigger), - directive="conflicts", - error_cls=error_cls, - ) - ) errors.extend( - _analyze_variants_in_directive( - pkg_cls, vrn, directive="conflicts", error_cls=error_cls + _issues_in_directive_constraint( + pkg_cls, + spack.spec.Spec(conflict), + directive="conflicts", + error_cls=error_cls, + filename=filename, + requestor=pkg_name, ) ) # Check "depends_on" directive - for trigger in pkg_cls.dependencies: + for trigger, deps_by_name in pkg_cls.dependencies.items(): vrn = spack.spec.Spec(trigger) errors.extend( - _analyze_variants_in_directive( - pkg_cls, vrn, directive="depends_on", error_cls=error_cls + _issues_in_directive_constraint( + pkg_cls, + vrn, + directive="depends_on", + error_cls=error_cls, + filename=filename, + requestor=pkg_name, ) ) + for dep_name, dep in deps_by_name.items(): + if spack.repo.PATH.is_virtual(dep_name): + continue + try: + dep_pkg_cls = spack.repo.PATH.get_pkg_class(dep_name) + except spack.repo.UnknownPackageError: + continue + errors.extend( + _issues_in_directive_constraint( + dep_pkg_cls, + dep.spec, + directive="depends_on", + error_cls=error_cls, + filename=filename, + requestor=pkg_name, + ) + ) # Check "provides" directive for when_spec in pkg_cls.provided: errors.extend( - _analyze_variants_in_directive( - pkg_cls, when_spec, directive="provides", error_cls=error_cls + _issues_in_directive_constraint( + pkg_cls, + when_spec, + directive="provides", + error_cls=error_cls, + filename=filename, + requestor=pkg_name, ) ) # Check "resource" directive for vrn in pkg_cls.resources: errors.extend( - _analyze_variants_in_directive( - pkg_cls, vrn, directive="resource", error_cls=error_cls + _issues_in_directive_constraint( + pkg_cls, + vrn, + directive="resource", + error_cls=error_cls, + filename=filename, + requestor=pkg_name, ) ) @@ -1156,18 +1187,50 @@ def _version_constraints_are_satisfiable_by_some_version_in_repo(pkgs, error_cls return errors -def _analyze_variants_in_directive(pkg, constraint, directive, error_cls): +def _issues_in_directive_constraint(pkg, constraint, *, directive, error_cls, filename, requestor): errors = [] - variant_names = pkg.variant_names() - summary = f"{pkg.name}: wrong variant in '{directive}' directive" - filename = spack.repo.PATH.filename_for_package_name(pkg.name) + errors.extend( + _analyze_variants_in_directive( + pkg, + constraint, + directive=directive, + error_cls=error_cls, + filename=filename, + requestor=requestor, + ) + ) + errors.extend( + _analize_propagated_deps_in_directive( + pkg, + constraint, + directive=directive, + error_cls=error_cls, + filename=filename, + requestor=requestor, + ) + ) + return errors + +def _analyze_variants_in_directive(pkg, constraint, *, directive, error_cls, filename, requestor): + errors = [] + variant_names = pkg.variant_names() + summary = f"{requestor}: wrong variant in '{directive}' directive" for name, v in constraint.variants.items(): + if name == "commit": + # Automatic variant + continue + if name not in variant_names: msg = f"variant {name} does not exist in {pkg.name}" errors.append(error_cls(summary=summary, details=[msg, f"in {filename}"])) continue + if v.propagate: + propagation_summary = f"{requestor}: propagating variant in '{directive}' directive" + msg = f"using {constraint} in a directive, which propagates the '{name}' variant" + errors.append(error_cls(summary=propagation_summary, details=[msg, f"in {filename}"])) + try: spack.variant.prevalidate_variant_value(pkg, v, constraint, strict=True) except ( @@ -1181,6 +1244,18 @@ def _analyze_variants_in_directive(pkg, constraint, directive, error_cls): return errors +def _analize_propagated_deps_in_directive( + pkg, constraint, *, directive, error_cls, filename, requestor +): + errors = [] + summary = f"{requestor}: dependency propagation ('%%') in '{directive}' directive" + for edge in constraint.traverse_edges(): + if edge.propagation != spack.enums.PropagationPolicy.NONE: + msg = f"'{edge.spec}' contains a propagated dependency" + errors.append(error_cls(summary=summary, details=[msg, f"in {filename}"])) + return errors + + @package_directives def _named_specs_in_when_arguments(pkgs, error_cls): """Reports named specs in the 'when=' attribute of a directive. From edfe62a371e837f6a619be30725f92c42d97592e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 20 Mar 2026 16:42:25 +0100 Subject: [PATCH 173/337] urlopen: improved retry on transient error (#52088) * Use single retry-on-transient-error mechanism throughout. * Add exponential back-off also to `url_exists` in case of transient errors * Widen retry mechanism exception `OSError -> Exception`, because botocore/urllib3 exceptions do not subtype `OSError` but `Exception` directly --- lib/spack/docs/conf.py | 2 + lib/spack/spack/fetch_strategy.py | 2 +- lib/spack/spack/oci/opener.py | 20 +------ lib/spack/spack/test/web.py | 61 ++++++++++++++++++++ lib/spack/spack/util/web.py | 92 +++++++++++++++++++++++++------ 5 files changed, 139 insertions(+), 38 deletions(-) diff --git a/lib/spack/docs/conf.py b/lib/spack/docs/conf.py index 0c1546b10c5088..e25aa27b39f1cd 100644 --- a/lib/spack/docs/conf.py +++ b/lib/spack/docs/conf.py @@ -379,6 +379,8 @@ def setup(sphinx): ("py:obj", "spack.llnl.util.lang.KT"), ("py:obj", "spack.llnl.util.lang.V"), ("py:obj", "spack.llnl.util.lang.VT"), + ("py:class", "_P"), + ("py:class", "spack.util.web._R"), ] # The reST default role (used for this markup: `text`) to use for all documents. diff --git a/lib/spack/spack/fetch_strategy.py b/lib/spack/spack/fetch_strategy.py index 76f222b0ca59c1..ca98fb9b5e2264 100644 --- a/lib/spack/spack/fetch_strategy.py +++ b/lib/spack/spack/fetch_strategy.py @@ -458,7 +458,7 @@ def _fetch_urllib(self, url, chunk_size=65536, retries=5): response_headers_str = str(response.headers) os.replace(part_file, save_file) break # success: exit retry loop - except OSError as e: + except Exception as e: # clean up archive on failure. if self.archive_file: os.remove(self.archive_file) diff --git a/lib/spack/spack/oci/opener.py b/lib/spack/spack/oci/opener.py index affe65b151f0a2..1e983d8c6f72a3 100644 --- a/lib/spack/spack/oci/opener.py +++ b/lib/spack/spack/oci/opener.py @@ -7,7 +7,6 @@ import base64 import json import re -import time import urllib.error import urllib.parse import urllib.request @@ -432,21 +431,4 @@ def ensure_status(request: urllib.request.Request, response: HTTPResponse, statu ) -def default_retry(f, retries: int = 5, sleep=None): - sleep = sleep or time.sleep - - def wrapper(*args, **kwargs): - for i in range(retries): - try: - return f(*args, **kwargs) - except OSError as e: - # Retry on internal server errors, rate limits, and timeouts. - # Potentially this could take into account the Retry-After header - # if registries support it - if i + 1 != retries and spack.util.web.is_transient_error(e): - # Exponential backoff - sleep(2**i) - continue - raise - - return wrapper +default_retry = spack.util.web.retry_on_transient_error diff --git a/lib/spack/spack/test/web.py b/lib/spack/spack/test/web.py index 3a5d91b6f9a2ea..5f6c6d6345e201 100644 --- a/lib/spack/spack/test/web.py +++ b/lib/spack/spack/test/web.py @@ -450,3 +450,64 @@ def test_ssl_curl_cert_file( assert dump_env["CURL_CA_BUNDLE"] == mock_cert else: assert "CURL_CA_BUNDLE" not in dump_env + + +@pytest.mark.parametrize( + "error_code,num_errors,max_retries,expect_failure", + [ + (500, 2, 5, False), # transient, enough retries + (500, 2, 2, True), # transient, not enough retries + (429, 2, 5, False), # rate limit, enough retries + (404, 1, 5, True), # not transient, never retried + ], +) +def test_retry_on_transient_error(error_code, num_errors, max_retries, expect_failure): + import urllib.error + + call_count = 0 + sleep_times = [] + + def flaky_func(): + nonlocal call_count + call_count += 1 + if call_count <= num_errors: + raise urllib.error.HTTPError( + url="https://example.com", code=error_code, msg="err", hdrs={}, fp=None + ) + return "ok" + + retrying = spack.util.web.retry_on_transient_error( + flaky_func, retries=max_retries, sleep=sleep_times.append + ) + + if expect_failure: + with pytest.raises(urllib.error.HTTPError): + retrying() + else: + assert retrying() == "ok" + assert sleep_times == [2**i for i in range(num_errors)] + + +def test_retry_on_transient_error_non_oserror(): + """Non-OSError exceptions with transient names (e.g. botocore) should be retried.""" + + class ResponseStreamingError(Exception): + pass + + call_count = 0 + sleep_times = [] + + def flaky_func(): + nonlocal call_count + call_count += 1 + if call_count <= 2: + raise ResponseStreamingError("IncompleteRead") + return "ok" + + retrying = spack.util.web.retry_on_transient_error( + flaky_func, retries=5, sleep=sleep_times.append + ) + + assert retrying() == "ok" + assert call_count == 3 + assert sleep_times == [1, 2] diff --git a/lib/spack/spack/util/web.py b/lib/spack/spack/util/web.py index 3b0bf459fc8cd7..14d5b8384a2e6d 100644 --- a/lib/spack/spack/util/web.py +++ b/lib/spack/spack/util/web.py @@ -4,6 +4,7 @@ import email.message import errno +import functools import io import json import os @@ -13,14 +14,18 @@ import ssl import stat import sys +import time import traceback import urllib.parse from html.parser import HTMLParser +from http.client import IncompleteRead from pathlib import Path, PurePosixPath -from typing import IO, Dict, Iterable, List, Optional, Set, Tuple, Union +from typing import IO, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar, Union from urllib.error import HTTPError, URLError from urllib.request import HTTPDefaultErrorHandler, HTTPSHandler, Request, build_opener +from spack.vendor.typing_extensions import ParamSpec + import spack import spack.config import spack.error @@ -44,14 +49,45 @@ def is_transient_error(e: Exception) -> bool: return True if isinstance(e, URLError) and isinstance(e.reason, socket.timeout): return True - if isinstance(e, socket.timeout): + if isinstance(e, (socket.timeout, IncompleteRead)): return True - # botocore.exceptions.ResponseStreamingError (IncompleteRead mid-stream) - if type(e).__name__ == "ResponseStreamingError": + # exceptions not inherited from the above used in urllib3 and botocore. + if type(e).__name__ in ( + "ConnectionClosedError", + "IncompleteReadError", + "ProtocolError", + "ReadTimeoutError", + "ResponseStreamingError", + ): return True return False +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def retry_on_transient_error( + f: Callable[_P, _R], retries: int = 5, sleep: Optional[Callable[[float], None]] = None +) -> Callable[_P, _R]: + """Retry a function on transient HTTP/network errors with exponential backoff.""" + sleep = sleep or time.sleep + + @functools.wraps(f) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + for i in range(retries): + try: + return f(*args, **kwargs) + except Exception as e: + if i + 1 != retries and is_transient_error(e): + sleep(2**i) # type: ignore[misc] # mypy still thinks it's possibly None. + continue + raise + raise AssertionError("unreachable") + + return wrapper + + class DetailedHTTPError(HTTPError): def __init__( self, req: Request, code: int, msg: str, hdrs: email.message.Message, fp: Optional[IO] @@ -268,23 +304,35 @@ def read_from_url(url, accept_content_type=None): return response.url, response.headers, response +def _read_text(url: str) -> str: + request = Request(url, headers={"User-Agent": SPACK_USER_AGENT}) + with urlopen(request) as response: + return io.TextIOWrapper(response, encoding="utf-8").read() + + +def _read_json(url: str): + request = Request(url, headers={"User-Agent": SPACK_USER_AGENT}) + with urlopen(request) as response: + return json.load(response) + + +_read_text_with_retry = retry_on_transient_error(_read_text) +_read_json_with_retry = retry_on_transient_error(_read_json) + + def read_text(url: str) -> str: """Fetch url and return the response body decoded as UTF-8 text.""" - request = Request(url, headers={"User-Agent": SPACK_USER_AGENT}) try: - with urlopen(request) as response: - return io.TextIOWrapper(response, encoding="utf-8").read() - except OSError as e: + return _read_text_with_retry(url) + except Exception as e: raise SpackWebError(f"Download of {url} failed: {e.__class__.__name__}: {e}") def read_json(url: str): """Fetch url and return the response body parsed as JSON.""" - request = Request(url, headers={"User-Agent": SPACK_USER_AGENT}) try: - with urlopen(request) as response: - return json.load(response) - except OSError as e: + return _read_json_with_retry(url) + except Exception as e: raise SpackWebError(f"Download of {url} failed: {e.__class__.__name__}: {e}") @@ -475,6 +523,17 @@ def fetch_url_text(url, curl: Optional[Executable] = None, dest_dir="."): return None +def _url_exists_urllib_impl(url): + with urlopen( + Request(url, method="HEAD", headers={"User-Agent": SPACK_USER_AGENT}), + timeout=spack.config.get("config:connect_timeout", 10), + ) as _: + pass + + +_url_exists_urllib = retry_on_transient_error(_url_exists_urllib_impl) + + def url_exists(url, curl=None): """Determines whether url exists. @@ -508,12 +567,9 @@ def url_exists(url, curl=None): # Otherwise use urllib. try: - with urlopen( - Request(url, method="HEAD", headers={"User-Agent": SPACK_USER_AGENT}), - timeout=spack.config.get("config:connect_timeout", 10), - ) as _: - return True - except OSError as e: + _url_exists_urllib(url) + return True + except Exception as e: tty.debug(f"Failure reading {url}: {e}") return False From 436c22ba75c2b323f54ea82fedbb715d2c597e23 Mon Sep 17 00:00:00 2001 From: Todd Gamblin Date: Fri, 20 Mar 2026 10:46:50 -0500 Subject: [PATCH 174/337] solver: rename `NodeArgument` to `NodeId`. (#52116) `NodeArgument` is used as an argument when reconstructing specs, but I think it's clearer to call it a `NodeId`, since its real function is to disambiguate "dupes", which is what we call different configurations of the same package. - [x] Rename `NodeArgument` to `NodeId` in `core.py` and `asp.py` - [x] Call `NodeID` `ID` in `concretize.lp` for consistency with `asp.py` Signed-off-by: Todd Gamblin --- lib/spack/spack/solver/asp.py | 26 ++++++++--------- lib/spack/spack/solver/concretize.lp | 42 ++++++++++++++-------------- lib/spack/spack/solver/core.py | 8 +++--- 3 files changed, 36 insertions(+), 40 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 5c03f1aa80003c..6b1d21721399db 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -71,7 +71,7 @@ from .core import ( AspFunction, AspVar, - NodeArgument, + NodeId, SourceContext, clingo, extract_args, @@ -472,7 +472,7 @@ def from_dict(obj: dict): def _dict_to_node_argument(dict): id = dict["id"] pkg = dict["pkg"] - return NodeArgument(id=id, pkg=pkg) + return NodeId(id=id, pkg=pkg) def _str_to_spec(spec_str): return spack.spec.Spec(spec_str) @@ -3434,7 +3434,7 @@ def possible_compilers(*, configuration) -> Tuple[Set["spack.spec.Spec"], Set["s return result, rejected -FunctionTupleT = Tuple[str, Tuple[Union[str, NodeArgument], ...]] +FunctionTupleT = Tuple[str, Tuple[Union[str, NodeId], ...]] class SpecBuilder: @@ -3461,23 +3461,23 @@ class SpecBuilder: ) @staticmethod - def make_node(*, pkg: str) -> NodeArgument: + def make_node(*, pkg: str) -> NodeId: """Given a package name, returns the string representation of the "min_dupe_id" node in the ASP encoding. Args: pkg: name of a package """ - return NodeArgument(id="0", pkg=pkg) + return NodeId(id="0", pkg=pkg) def __init__(self, specs, hash_lookup=None): - self._specs: Dict[NodeArgument, spack.spec.Spec] = {} + self._specs: Dict[NodeId, spack.spec.Spec] = {} # Matches parent nodes to splice node self._splices: Dict[spack.spec.Spec, List[spack.solver.splicing.Splice]] = {} self._result = None self._command_line_specs = specs - self._flag_sources: Dict[Tuple[NodeArgument, str], Set[str]] = collections.defaultdict( + self._flag_sources: Dict[Tuple[NodeId, str], Set[str]] = collections.defaultdict( lambda: set() ) @@ -3656,15 +3656,11 @@ def _order_index(flag_group): spec.compiler_flags.update({flag_type: ordered_flags}) - def deprecated(self, node: NodeArgument, version: str) -> None: + def deprecated(self, node: NodeId, version: str) -> None: tty.warn(f'using "{node.pkg}@{version}" which is a deprecated version') def splice_at_hash( - self, - parent_node: NodeArgument, - splice_node: NodeArgument, - child_name: str, - child_hash: str, + self, parent_node: NodeId, splice_node: NodeId, child_name: str, child_hash: str ): parent_spec = self._specs[parent_node] splice_spec = self._specs[splice_node] @@ -3712,7 +3708,7 @@ def build_specs(self, function_tuples: List[FunctionTupleT]) -> List[spack.spec. # predicates on virtual packages. if name != "error": node = args[0] - assert isinstance(node, NodeArgument), ( + assert isinstance(node, NodeId), ( f"internal solver error: expected a node, but got a {type(args[0])}. " "Please report a bug at https://github.com/spack/spack/issues" ) @@ -3820,7 +3816,7 @@ def execute_explicit_splices(self): if not replacement.concrete: replacement.replace_hash() current_spec = current_spec.splice(replacement, transitive) - new_key = NodeArgument(id=key.id, pkg=current_spec.name) + new_key = NodeId(id=key.id, pkg=current_spec.name) specs[new_key] = current_spec return specs diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 0f7ffefe721e2f..164ad2b945b2aa 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -588,11 +588,11 @@ imposed_packages(ID, A1) :- imposed_constraint(ID, _, A1, _, _). imposed_packages(ID, A1) :- imposed_constraint(ID, _, A1, _, _, _). imposed_packages(ID, A1) :- imposed_constraint(ID, "depends_on", _, A1, _). -imposed_nodes(node(NodeID, Package), node(X, A1)) - :- condition_set(node(NodeID, Package), node(X, A1)), +imposed_nodes(node(ID, Package), node(X, A1)) + :- condition_set(node(ID, Package), node(X, A1)), % We don't want to add build requirements to imposed nodes, to avoid % unsat problems when we deal with self-dependencies: gcc@14 %gcc@10 - not self_build_requirement(node(NodeID, Package), node(X, A1)). + not self_build_requirement(node(ID, Package), node(X, A1)). self_build_requirement(node(X, Package), node(Y, Package)) :- build_requirement(node(X, Package), node(Y, Package)). @@ -1336,14 +1336,14 @@ error(50000, Message) :- % Variant definitions come from package facts in two ways: % 1. unconditional variants are always defined on all nodes for a given package -variant_definition(node(NodeID, Package), Name, VariantID) :- +variant_definition(node(ID, Package), Name, VariantID) :- pkg_fact(Package, variant_definition(Name, VariantID)), - attr("node", node(NodeID, Package)). + attr("node", node(ID, Package)). % 2. conditional variants are only defined if the conditions hold for the node -variant_definition(node(NodeID, Package), Name, VariantID) :- +variant_definition(node(ID, Package), Name, VariantID) :- pkg_fact(Package, variant_condition(Name, VariantID, ConditionID)), - condition_holds(ConditionID, node(NodeID, Package)). + condition_holds(ConditionID, node(ID, Package)). % If there are any definitions for a variant on a node, the variant is "defined". variant_defined(PackageNode, Name) :- variant_definition(PackageNode, Name, _). @@ -1366,19 +1366,19 @@ node_has_variant(PackageNode, Name, SelectedVariantID) :- % packages.yaml and CLI are associated with just the variant name. % Also, settings specified on the CLI apply to all duplicates, but always have % `min_dupe_id` as their node id. -variant_default_value(node(NodeID, Package), VariantName, Value) :- - node_has_variant(node(NodeID, Package), VariantName, VariantID), +variant_default_value(node(ID, Package), VariantName, Value) :- + node_has_variant(node(ID, Package), VariantName, VariantID), pkg_fact(Package, variant_default_value_from_package_py(VariantID, Value)), not variant_default_value_from_packages_yaml(Package, VariantName, _), not attr("variant_default_value_from_cli", node(min_dupe_id, Package), VariantName, _). -variant_default_value(node(NodeID, Package), VariantName, Value) :- - node_has_variant(node(NodeID, Package), VariantName, _), +variant_default_value(node(ID, Package), VariantName, Value) :- + node_has_variant(node(ID, Package), VariantName, _), variant_default_value_from_packages_yaml(Package, VariantName, Value), not attr("variant_default_value_from_cli", node(min_dupe_id, Package), VariantName, _). -variant_default_value(node(NodeID, Package), VariantName, Value) :- - node_has_variant(node(NodeID, Package), VariantName, _), +variant_default_value(node(ID, Package), VariantName, Value) :- + node_has_variant(node(ID, Package), VariantName, _), attr("variant_default_value_from_cli", node(min_dupe_id, Package), VariantName, Value). % Penalty from the variant definition @@ -1401,21 +1401,21 @@ variant_penalty(node(NodeID, Package), Variant, Value, Penalty) :- not propagate(node(NodeID, Package), variant_value(Variant, Value, _)). % -- Associate the definition's possible values with the node -variant_possible_value(node(NodeID, Package), VariantName, Value) :- - node_has_variant(node(NodeID, Package), VariantName, VariantID), +variant_possible_value(node(ID, Package), VariantName, Value) :- + node_has_variant(node(ID, Package), VariantName, VariantID), pkg_fact(Package, variant_possible_value(VariantID, Value)). -variant_possible_value(node(NodeID, Package), VariantName, Value) :- - node_has_variant(node(NodeID, Package), VariantName, VariantID), +variant_possible_value(node(ID, Package), VariantName, Value) :- + node_has_variant(node(ID, Package), VariantName, VariantID), pkg_fact(Package, variant_penalty(VariantID, Value, _)). -variant_value_from_disjoint_sets(node(NodeID, Package), VariantName, Value1, Set1) :- - node_has_variant(node(NodeID, Package), VariantName, VariantID), +variant_value_from_disjoint_sets(node(ID, Package), VariantName, Value1, Set1) :- + node_has_variant(node(ID, Package), VariantName, VariantID), pkg_fact(Package, variant_value_from_disjoint_sets(VariantID, Value1, Set1)). % -- Associate definition's arity with the node -variant_single_value(node(NodeID, Package), VariantName) :- - node_has_variant(node(NodeID, Package), VariantName, VariantID), +variant_single_value(node(ID, Package), VariantName) :- + node_has_variant(node(ID, Package), VariantName, VariantID), variant_type(VariantID, VariantType), VariantType != "multi". diff --git a/lib/spack/spack/solver/core.py b/lib/spack/spack/solver/core.py index f31ad7c37c887c..6d1a9787bab3fe 100644 --- a/lib/spack/spack/solver/core.py +++ b/lib/spack/spack/solver/core.py @@ -213,7 +213,7 @@ def parse_term(*args, **kwargs): return clingo().parse_term(*args, **kwargs) -class NodeArgument(NamedTuple): +class NodeId(NamedTuple): """Represents a node in the DAG""" id: str @@ -230,10 +230,10 @@ class NodeFlag(NamedTuple): def intermediate_repr(sym): """Returns an intermediate representation of clingo models for Spack's spec builder. - Currently, transforms symbols from clingo models either to strings or to NodeArgument objects. + Currently, transforms symbols from clingo models either to strings or to NodeId objects. Returns: - This will turn a ``clingo.Symbol`` into a string or NodeArgument, or a sequence of + This will turn a ``clingo.Symbol`` into a string or NodeId, or a sequence of ``clingo.Symbol`` objects into a tuple of those objects. """ # TODO: simplify this when we no longer have to support older clingo versions. @@ -242,7 +242,7 @@ def intermediate_repr(sym): try: if sym.name == "node": - return NodeArgument( + return NodeId( id=intermediate_repr(sym.arguments[0]), pkg=intermediate_repr(sym.arguments[1]) ) elif sym.name == "node_flag": From 7a17cfcb7affbff3dd84620ee57ead9a0510ff16 Mon Sep 17 00:00:00 2001 From: Todd Gamblin Date: Fri, 20 Mar 2026 12:01:43 -0500 Subject: [PATCH 175/337] `spack_json`: add `pretty` option to `to_json()` (#52118) Comparing JSON specs in tests is not useful unless `pytest` can show us a diff. - [x] Introduce a `pretty=True` option to Spack JSON dumps that prints with newlines and indentation. - [x] Modify tests to show a detailed diff using the new json output. Signed-off-by: Todd Gamblin --- lib/spack/spack/spec.py | 4 ++-- lib/spack/spack/test/concretization/core.py | 5 ++++- lib/spack/spack/util/spack_json.py | 8 +++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index b6820ff93947b1..81c431acc01fb5 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -2550,8 +2550,8 @@ def node_dict_with_hashes(self, hash: ht.SpecHashDescriptor = ht.dag_hash) -> Di def to_yaml(self, stream=None, hash=ht.dag_hash): return syaml.dump(self.to_dict(hash), stream=stream, default_flow_style=False) - def to_json(self, stream=None, hash=ht.dag_hash): - return sjson.dump(self.to_dict(hash), stream) + def to_json(self, stream=None, *, hash=ht.dag_hash, pretty=False): + return sjson.dump(self.to_dict(hash), stream=stream, pretty=pretty) @staticmethod def from_specfile(path): diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index d7e5c2bf5d20db..e115dcbf59280a 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4465,7 +4465,10 @@ def _ensure_cache_hits(self, problem: str): # ensure subsequent concretizations of the same spec produce the same spec # object for _ in range(5): - assert h == spack.concretize.concretize_one("hdf5") + hdf5 = spack.concretize.concretize_one("hdf5") + + assert h.to_json(pretty=True) == hdf5.to_json(pretty=True) + assert h == hdf5 def test_concretization_cache_roundtrip_result(use_concretization_cache): diff --git a/lib/spack/spack/util/spack_json.py b/lib/spack/spack/util/spack_json.py index 40bd816c353bf1..8656bc256fb08d 100644 --- a/lib/spack/spack/util/spack_json.py +++ b/lib/spack/spack/util/spack_json.py @@ -11,6 +11,7 @@ __all__ = ["load", "dump", "SpackJSONError"] _json_dump_args = {"indent": None, "separators": (",", ":")} +_pretty_dump_args = {"indent": " ", "separators": (", ", ": ")} def load(stream: Any) -> Dict: @@ -20,11 +21,12 @@ def load(stream: Any) -> Dict: return json.load(stream) -def dump(data: Dict, stream: Optional[Any] = None) -> Optional[str]: +def dump(data: Dict, stream: Optional[Any] = None, pretty: bool = False) -> Optional[str]: """Dump JSON with a reasonable amount of indentation and separation.""" + dump_args = _pretty_dump_args if pretty else _json_dump_args if stream is None: - return json.dumps(data, **_json_dump_args) # type: ignore[arg-type] - json.dump(data, stream, **_json_dump_args) # type: ignore[arg-type] + return json.dumps(data, **dump_args) # type: ignore[arg-type] + json.dump(data, stream, **dump_args) # type: ignore[arg-type] return None From 041eeb25c3e7e5860db18dadd3a7bd854a2bd7c9 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 20 Mar 2026 19:24:01 +0100 Subject: [PATCH 176/337] executable.py: refactor to improve static type analysis (#52111) * The use of TextIO in Executable.__call__ was a bug, only BinaryIO was allowed. * input= doesn't support str or str.split values. Signed-off-by: Harmen Stoppels --- lib/spack/spack/build_environment.py | 16 +-- lib/spack/spack/test/util/executable.py | 14 +++ lib/spack/spack/util/executable.py | 149 +++++++++++++----------- 3 files changed, 101 insertions(+), 78 deletions(-) diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py index b41412e6f281c8..0d1ad28139d0e4 100644 --- a/lib/spack/spack/build_environment.py +++ b/lib/spack/spack/build_environment.py @@ -48,13 +48,13 @@ from multiprocessing.connection import Connection from typing import ( Any, + BinaryIO, Callable, Dict, List, Optional, Sequence, Set, - TextIO, Tuple, Type, Union, @@ -199,9 +199,9 @@ def __call__( timeout: Optional[int] = ..., env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., extra_env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., - input: Optional[TextIO] = ..., - output: Union[Optional[TextIO], str] = ..., - error: Union[Optional[TextIO], str] = ..., + input: Optional[BinaryIO] = ..., + output: Union[Optional[BinaryIO], str] = ..., + error: Union[Optional[BinaryIO], str] = ..., _dump_env: Optional[Dict[str, str]] = ..., ) -> None: ... @@ -218,9 +218,9 @@ def __call__( timeout: Optional[int] = ..., env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., extra_env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., - input: Optional[TextIO] = ..., + input: Optional[BinaryIO] = ..., output: Union[Type[str], Callable] = ..., - error: Union[Optional[TextIO], str, Type[str], Callable] = ..., + error: spack.util.executable.OutType = ..., _dump_env: Optional[Dict[str, str]] = ..., ) -> str: ... @@ -237,8 +237,8 @@ def __call__( timeout: Optional[int] = ..., env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., extra_env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., - input: Optional[TextIO] = ..., - output: Union[Optional[TextIO], str, Type[str], Callable] = ..., + input: Optional[BinaryIO] = ..., + output: spack.util.executable.OutType = ..., error: Union[Type[str], Callable] = ..., _dump_env: Optional[Dict[str, str]] = ..., ) -> str: ... diff --git a/lib/spack/spack/test/util/executable.py b/lib/spack/spack/test/util/executable.py index afd666bec27113..a3aba7bac39d7a 100644 --- a/lib/spack/spack/test/util/executable.py +++ b/lib/spack/spack/test/util/executable.py @@ -163,3 +163,17 @@ def test_construct_from_pathlib(mock_executable): path = mock_executable("hello", output=f"echo {expected}\n") hello = ex.Executable(path) assert expected in hello(output=str) + + +def test_exe_disallows_str_split_as_input(mock_executable): + path = mock_executable("hello", output="echo hi\n") + hello = ex.Executable(path) + with pytest.raises(ValueError): + hello(input=str.split) + + +def test_exe_disallows_callable_as_output(mock_executable): + path = mock_executable("hello", output="echo hi\n") + hello = ex.Executable(path) + with pytest.raises(ValueError): + hello(output=lambda line: line) diff --git a/lib/spack/spack/util/executable.py b/lib/spack/spack/util/executable.py index f42417275647dc..817bb0563cb471 100644 --- a/lib/spack/spack/util/executable.py +++ b/lib/spack/spack/util/executable.py @@ -2,12 +2,14 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) +import io import os import re +import shlex import subprocess import sys from pathlib import Path, PurePath -from typing import Callable, Dict, List, Optional, Sequence, TextIO, Type, Union, overload +from typing import BinaryIO, Callable, Dict, List, Optional, Sequence, Tuple, Type, Union, overload from spack.vendor.typing_extensions import Literal @@ -17,6 +19,43 @@ __all__ = ["Executable", "which", "which_string", "ProcessError"] +OutType = Union[Optional[BinaryIO], str, Type[str], Callable] + + +def _process_cmd_output( + out: bytes, + err: bytes, + output: OutType, + error: OutType, + encoding: str = "ISO-8859-1" if sys.platform == "win32" else "utf-8", +) -> Optional[str]: + if output is str or output is str.split or error is str or error is str.split: + result = "" + if output is str or output is str.split: + outstr = out.decode(encoding) + result += outstr + if output is str.split: + sys.stdout.write(outstr) + if error is str or error is str.split: + errstr = err.decode(encoding) + result += errstr + if error is str.split: + sys.stderr.write(errstr) + return result + else: + return None + + +def _streamify_output(arg: OutType, name: str) -> Tuple[Union[int, BinaryIO, None], bool]: + if isinstance(arg, str): + return open(arg, "wb"), True + elif arg is str or arg is str.split: + return subprocess.PIPE, False + elif callable(arg): + raise ValueError(f"`{name}` must be a stream, a filename, or `str`/`str.split`") + else: + return arg, False + class Executable: """ @@ -107,9 +146,9 @@ def __call__( timeout: Optional[int] = ..., env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., extra_env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., - input: Optional[TextIO] = ..., - output: Union[Optional[TextIO], str] = ..., - error: Union[Optional[TextIO], str] = ..., + input: Optional[BinaryIO] = ..., + output: Union[Optional[BinaryIO], str] = ..., + error: Union[Optional[BinaryIO], str] = ..., _dump_env: Optional[Dict[str, str]] = ..., ) -> None: ... @@ -123,9 +162,9 @@ def __call__( timeout: Optional[int] = ..., env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., extra_env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., - input: Optional[TextIO] = ..., - output: Union[Type[str], Callable], - error: Union[Optional[TextIO], str, Type[str], Callable] = ..., + input: Optional[BinaryIO] = ..., + output: Union[Type[str], Callable], # str or str.split + error: OutType = ..., _dump_env: Optional[Dict[str, str]] = ..., ) -> str: ... @@ -139,9 +178,9 @@ def __call__( timeout: Optional[int] = ..., env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., extra_env: Optional[Union[Dict[str, str], EnvironmentModifications]] = ..., - input: Optional[TextIO] = ..., - output: Union[Optional[TextIO], str, Type[str], Callable] = ..., - error: Union[Type[str], Callable], + input: Optional[BinaryIO] = ..., + output: OutType = ..., + error: Union[Type[str], Callable], # str or str.split _dump_env: Optional[Dict[str, str]] = ..., ) -> str: ... @@ -154,9 +193,9 @@ def __call__( timeout: Optional[int] = None, env: Optional[Union[Dict[str, str], EnvironmentModifications]] = None, extra_env: Optional[Union[Dict[str, str], EnvironmentModifications]] = None, - input: Optional[TextIO] = None, - output: Union[Optional[TextIO], str, Type[str], Callable] = None, - error: Union[Optional[TextIO], str, Type[str], Callable] = None, + input: Optional[BinaryIO] = None, + output: OutType = None, + error: OutType = None, _dump_env: Optional[Dict[str, str]] = None, ) -> Optional[str]: """Runs this executable in a subprocess. @@ -195,29 +234,6 @@ def __call__( By default, the subprocess inherits the parent's file descriptors. """ - - def process_cmd_output(out, err): - result = None - if output in (str, str.split) or error in (str, str.split): - result = "" - if output in (str, str.split): - if sys.platform == "win32": - outstr = str(out.decode("ISO-8859-1")) - else: - outstr = str(out.decode("utf-8")) - result += outstr - if output is str.split: - sys.stdout.write(outstr) - if error in (str, str.split): - if sys.platform == "win32": - errstr = str(err.decode("ISO-8859-1")) - else: - errstr = str(err.decode("utf-8")) - result += errstr - if error is str.split: - sys.stderr.write(errstr) - return result - # Setup default environment current_environment = os.environ.copy() if env is None else {} self._default_envmod.apply_modifications(current_environment) @@ -246,37 +262,31 @@ def process_cmd_output(out, err): if isinstance(ignore_errors, int): ignore_errors = (ignore_errors,) - if input is str: - raise ValueError("Cannot use `str` as input stream.") + if input is str or input is str.split: + raise ValueError("Cannot use `str` or `str.split` as input stream.") + elif isinstance(input, str): + istream, close_istream = open(input, "rb"), True + else: + istream, close_istream = input, False - def streamify(arg, mode): - if isinstance(arg, str): - return open(arg, mode), True # pylint: disable=unspecified-encoding - elif arg in (str, str.split): - return subprocess.PIPE, False - else: - return arg, False - - ostream, close_ostream = streamify(output, "wb") - estream, close_estream = streamify(error, "wb") - istream, close_istream = streamify(input, "rb") + ostream, close_ostream = _streamify_output(output, "output") + estream, close_estream = _streamify_output(error, "error") if not ignore_quotes: quoted_args = [arg for arg in args if re.search(r'^".*"$|^\'.*\'$', arg)] if quoted_args: tty.warn( - "Quotes in command arguments can confuse scripts like" " configure.", + "Quotes in command arguments can confuse scripts like configure.", "The following arguments may cause problems when executed:", str("\n".join([" " + arg for arg in quoted_args])), "Quotes aren't needed because spack doesn't use a shell. " "Consider removing them.", - "If multiple levels of quotation are required, use " "`ignore_quotes=True`.", + "If multiple levels of quotation are required, use `ignore_quotes=True`.", ) cmd = self.exe + list(args) - escaped_cmd = ["'%s'" % arg.replace("'", "'\"'\"'") for arg in cmd] - cmd_line_string = " ".join(escaped_cmd) + cmd_line_string = " ".join(shlex.quote(arg) for arg in cmd) tty.debug(cmd_line_string) result = None @@ -289,9 +299,16 @@ def streamify(arg, mode): env=current_environment, close_fds=False, ) - out, err = proc.communicate(timeout=timeout) + except OSError as e: + message = "Command: " + cmd_line_string + if " " in self.exe[0]: + message += "\nDid you mean to add a space to the command?" - result = process_cmd_output(out, err) + raise ProcessError(f"{self.exe[0]}: {e.strerror}", message) + + try: + out, err = proc.communicate(timeout=timeout) + result = _process_cmd_output(out, err, output, error) rc = self.returncode = proc.returncode if fail_on_error and rc != 0 and (rc not in ignore_errors): long_msg = cmd_line_string @@ -302,18 +319,12 @@ def streamify(arg, mode): # stdout/stderr (e.g. if 'output' is not specified) long_msg += "\n" + result - raise ProcessError("Command exited with status %d:" % proc.returncode, long_msg) - except OSError as e: - message = "Command: " + cmd_line_string - if " " in self.exe[0]: - message += "\nDid you mean to add a space to the command?" - - raise ProcessError("%s: %s" % (self.exe[0], e.strerror), message) + raise ProcessError(f"Command exited with status {proc.returncode}:", long_msg) except subprocess.TimeoutExpired as te: proc.kill() out, err = proc.communicate() - result = process_cmd_output(out, err) + result = _process_cmd_output(out, err, output, error) long_msg = cmd_line_string + f"\n{result}" if fail_on_error: raise ProcessTimeoutError( @@ -324,11 +335,12 @@ def streamify(arg, mode): ) from te finally: - if close_ostream: + # The isinstance checks are only needed for type checking. + if close_ostream and isinstance(ostream, io.IOBase): ostream.close() - if close_estream: + if close_estream and isinstance(estream, io.IOBase): estream.close() - if close_istream: + if close_istream and isinstance(istream, io.IOBase): istream.close() return result @@ -336,14 +348,11 @@ def streamify(arg, mode): def __eq__(self, other): return hasattr(other, "exe") and self.exe == other.exe - def __neq__(self, other): - return not (self == other) - def __hash__(self): return hash((type(self),) + tuple(self.exe)) def __repr__(self): - return "" % self.exe + return f"" def __str__(self): return " ".join(self.exe) From c11fda331bf0e42c07f9fe94c09a3ebe3ce4224a Mon Sep 17 00:00:00 2001 From: Todd Gamblin Date: Sat, 21 Mar 2026 12:39:16 -0700 Subject: [PATCH 177/337] `.gitignore`: ignore coding agents state directories (#52125) Claude, Gemini, and Codex all leave a hidden top-level directory in the project for their own saved state. Ignore these for now. If there are helpful things in here that make sense to check in, we can modify this to allow them. For now, just prevent people from inadvertently checking these directories in. Signed-off-by: Todd Gamblin --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 33acfae49a1a69..2ea116c2540b19 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,13 @@ CLAUDE.md !/etc/spack/defaults !/etc/spack/site/README.md +########################### +# Coding agent state +########################### +.claude/ +.gemini/ +.codex/ + ########################### # Python-specific ignores # ########################### From b742ce993763d5dc68b26a04d55217b9c2178983 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sun, 22 Mar 2026 20:05:58 +0100 Subject: [PATCH 178/337] new_installer.py: support --source (#52122) --- lib/spack/spack/new_installer.py | 16 +++++++++++++--- lib/spack/spack/test/cmd/install.py | 4 +++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 7b084327c3cf69..3cfa8da986e124 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -402,6 +402,7 @@ def worker_function( keep_prefix: bool, skip_patch: bool, fake: bool, + install_source: bool, run_tests: bool, state: Connection, parent: Connection, @@ -493,6 +494,7 @@ def handle_sigterm(signum, frame): restage, skip_patch, fake, + install_source, state_stream, log_path, spack.store.STORE, @@ -609,6 +611,7 @@ def _install( restage: bool, skip_patch: bool, fake: bool, + install_source: bool, state_stream: io.TextIOWrapper, log_path: str, store: spack.store.Store = spack.store.STORE, @@ -682,6 +685,10 @@ def _install( os.chdir(stage.source_path) + if install_source and os.path.isdir(stage.source_path): + src_target = os.path.join(spec.prefix, "share", spec.name, "src") + fs.install_tree(stage.source_path, src_target) + spack.hooks.pre_install(spec) for phase in spack.builder.create(pkg): @@ -811,6 +818,7 @@ def start_build( keep_prefix: bool, skip_patch: bool, fake: bool, + install_source: bool, run_tests: bool, jobserver: JobServer, ) -> ChildInfo: @@ -851,6 +859,7 @@ def start_build( keep_prefix, skip_patch, fake, + install_source, run_tests, state_w_conn, output_w_conn, @@ -1814,9 +1823,9 @@ def __init__( ) -> None: assert install_package or install_deps, "Must install package, dependencies or both" - if install_source: - raise NotImplementedError("Installing sources is not implemented") - elif stop_at is not None: + self.install_source = install_source + + if stop_at is not None: raise NotImplementedError("Stopping at an install phase is not implemented") elif stop_before is not None: raise NotImplementedError("Stopping before an install phase is not implemented") @@ -2241,6 +2250,7 @@ def _start( keep_prefix=self.keep_prefix, skip_patch=self.skip_patch, fake=self.fake, + install_source=self.install_source, run_tests=run_tests, jobserver=jobserver, ) diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index 93c1f7e79961df..8252859fde5940 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -194,7 +194,9 @@ def test_install_output_on_python_error(mock_packages, mock_archive, mock_fetch, @pytest.mark.disable_clean_stage_check -def test_install_with_source(mock_packages, mock_archive, mock_fetch, install_mockery): +def test_install_with_source( + mock_packages, mock_archive, mock_fetch, install_mockery, installer_variant +): """Verify that source has been copied into place.""" install("--source", "--keep-stage", "trivial-install-test-package") spec = spack.concretize.concretize_one("trivial-install-test-package") From cd9f9d3d396f9413467afe48630e748f511557f4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sun, 22 Mar 2026 20:09:18 +0100 Subject: [PATCH 179/337] new_installer.py: dynamic --jobs (#51997) Makes the number of build jobs (-j) dynamic with `+` and `-` keyboard input, provided that we are the owner of the jobserver. * On `+`: write a byte to `jobserver.w` * On `-`: eventually read a byte from `jobserver.r` So, `+` applies immediately, while `-` happens at some point in the event loop. If the target number of jobs is less than the effective number of jobs because the Spack process didn't get to read from the jobserver pipe, the terminal UI renders it as `8=>4` meaning it's in the process of decreasing from 8 to 4 jobs. There are two ways in which parallelism is reduced: 1. A parallel build finishes: do not write back the Spack-acquired token 2. The `jobserver.r` becomes readable: read at most `num_jobs - target_jobs` bytes. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 87 ++++++++++++++-- lib/spack/spack/test/installer_tui.py | 43 ++++++++ lib/spack/spack/test/jobserver.py | 144 ++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 3cfa8da986e124..f10d2cff4c910d 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -712,7 +712,14 @@ class JobServer: def __init__(self, num_jobs: int) -> None: #: Keep track of how many tokens Spack itself has acquired, which is used to release them. self.tokens_acquired = 0 + #: The number of jobs to run concurrently. This translates to `num_jobs - 1` tokens in the + #: jobserver. self.num_jobs = num_jobs + #: The target number of jobs to run concurrently, which may differ from num_jobs if the + #: user has requested a decrease in parallelism, but we haven't consumed enough tokens to + #: reflect that yet. This value is used in the UI. The invariant is that self.target_jobs + #: can only be modified if self.created is True. + self.target_jobs = num_jobs self.fifo_path: Optional[str] = None self.created = False self._setup() @@ -755,6 +762,35 @@ def makeflags(self, gmake: Optional[spack.spec.Spec]) -> str: else: return f" -j{self.num_jobs} --jobserver-fds={self.r},{self.w}" + def has_target_parallelism(self) -> bool: + return self.num_jobs == self.target_jobs + + def increase_parallelism(self) -> None: + """Add one token to the jobserver to increase parallelism; this should always work.""" + if not self.created: + return + os.write(self.w, b"+") + self.target_jobs += 1 + self.num_jobs += 1 + + def decrease_parallelism(self) -> None: + """Request an eventual concurrency decrease by 1.""" + if not self.created or self.target_jobs <= 1: + return + self.target_jobs -= 1 + self.maybe_discard_tokens() + + def maybe_discard_tokens(self) -> None: + """Try to get reduce parallelism by discarding tokens.""" + to_discard = self.num_jobs - self.target_jobs + if to_discard <= 0: + return + try: + # The read may return zero or just fewer bytes than requested; we'll try again later. + self.num_jobs -= len(os.read(self.r, to_discard)) + except BlockingIOError: + pass + def acquire(self, jobs: int) -> int: """Try and acquire at most 'jobs' tokens from the jobserver. Returns the number of tokens actually acquired (may be less than requested, or zero).""" @@ -770,8 +806,12 @@ def release(self) -> None: # The last job to quit has an implicit token, so don't release if we have none. if self.tokens_acquired == 0: return - os.write(self.w, b"+") self.tokens_acquired -= 1 + if self.target_jobs < self.num_jobs: + # If a decrease in parallelism is requested, discard a token instead of releasing it. + self.num_jobs -= 1 + else: + os.write(self.w, b"+") def close(self) -> None: if self.created and self.num_jobs > 1: @@ -1030,6 +1070,8 @@ def __init__( self.search_term = "" self.search_mode = False self.log_ends_with_newline = True + self.actual_jobs: int = 0 + self.target_jobs: int = 0 self.stdout = stdout self.get_terminal_size = get_terminal_size @@ -1186,6 +1228,14 @@ def next(self, direction: int = 1) -> None: except (KeyError, OSError): pass + def set_jobs(self, actual: int, target: int) -> None: + """Set the actual and target number of jobs to run concurrently.""" + if actual == self.actual_jobs and target == self.target_jobs: + return + self.actual_jobs = actual + self.target_jobs = target + self.dirty = True + def update_state(self, build_id: str, state: str) -> None: """Update the state of a package and mark the display as dirty.""" build_info = self.builds[build_id] @@ -1294,13 +1344,20 @@ def update(self, finalize: bool = False) -> None: else: bold = reset = cyan = "" + if self.actual_jobs != self.target_jobs: + jobs_str = f"{self.actual_jobs}=>{self.target_jobs}" + else: + jobs_str = str(self.target_jobs) long_header_len = len( - f"Progress: {self.completed}/{self.total} /: filter v: logs n/p: next/prev" + f"Progress: {self.completed}/{self.total} +/-: {jobs_str} jobs" + " /: filter v: logs n/p: next/prev" ) if long_header_len < max_width: self._println( buffer, f"{bold}Progress:{reset} {self.completed}/{self.total}" + f" {cyan}+{reset}/{cyan}-{reset}: " + f"{jobs_str} jobs" f" {cyan}/{reset}: filter {cyan}v{reset}: logs" f" {cyan}n{reset}/{cyan}p{reset}: next/prev", ) @@ -1895,6 +1952,8 @@ def __init__( filter_padding=spack.store.STORE.has_padding(), ) self.jobs = spack.config.determine_number_of_jobs(parallel=True) + self.build_status.actual_jobs = self.jobs + self.build_status.target_jobs = self.jobs if concurrent_packages is None: concurrent_packages_config = spack.config.get("config:concurrent_packages", 0) # The value 0 in config means no limit (other than self.jobs) @@ -1955,11 +2014,17 @@ def _handle_sigwinch(signum: int, frame: object) -> None: while self.pending_builds or self.running_builds or finished_builds: # Monitor the jobserver when we have pending builds, capacity, and at least one - # spec is not locked by another process. - can_schedule_more = self.pending_builds and self.capacity and not blocked - if can_schedule_more and jobserver.r not in selector.get_map(): + # spec is not locked by another process. Also listen if the target parallelism is + # reduced. + wake_on_jobserver = ( + self.pending_builds + and self.capacity + and not blocked + or not jobserver.has_target_parallelism() + ) + if wake_on_jobserver and jobserver.r not in selector.get_map(): selector.register(jobserver.r, selectors.EVENT_READ, "jobserver") - elif not can_schedule_more and jobserver.r in selector.get_map(): + elif not wake_on_jobserver and jobserver.r in selector.get_map(): selector.unregister(jobserver.r) stdin_ready = False @@ -1985,6 +2050,9 @@ def _handle_sigwinch(signum: int, frame: object) -> None: elif data == "sigwinch": os.read(sigwinch_r, 64) # drain the pipe self.build_status.on_resize() + elif data == "jobserver" and not jobserver.has_target_parallelism(): + jobserver.maybe_discard_tokens() + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) current_time = time.monotonic() for pid in finished_pids: @@ -1993,6 +2061,7 @@ def _handle_sigwinch(signum: int, frame: object) -> None: jobserver.release() self._drain_child_output(build) self.state_buffers.pop(build.state_r_conn.fileno(), None) + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) build.cleanup(selector) exitcode = build.proc.exitcode assert exitcode is not None, "Finished build should have exit code set" @@ -2036,6 +2105,12 @@ def _handle_sigwinch(signum: int, frame: object) -> None: self.build_status.next(1) elif char == "p" or char == "N": self.build_status.next(-1) + elif char == "+": + jobserver.increase_parallelism() + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) + elif char == "-": + jobserver.decrease_parallelism() + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) # Insert into the database if we have any finished builds, and either the delay # interval has passed, or we're done with all builds. The database save is not diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 33cc0c974db13a..49a99dec88a264 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -1370,3 +1370,46 @@ def test_non_tty_finished_color_false_no_ansi(self): status.add_build(spec, explicit=True) status.update_state(spec.dag_hash(), "finished") assert "\033[" not in stdout.getvalue() + + +class TestTargetJobs: + """Test set_jobs and its effect on the header.""" + + def test_set_jobs_marks_dirty(self): + """set_jobs with a new value should update target_jobs and mark dirty.""" + status, _, _ = create_build_status() + status.dirty = False + status.set_jobs(3, 2) + assert status.actual_jobs == 3 + assert status.target_jobs == 2 + assert status.dirty is True + status.set_jobs(2, 2) + assert status.actual_jobs == 2 + assert status.target_jobs == 2 + + def test_set_jobs_same_value_no_dirty(self): + """set_jobs with the same value should not mark dirty.""" + status, _, _ = create_build_status() + status.set_jobs(5, 5) + status.dirty = False + status.set_jobs(5, 5) + assert status.dirty is False + + def test_header_shows_target_jobs(self): + """The rendered header should contain the target_jobs count and the word 'jobs'.""" + status, _, fake_stdout = create_build_status(total=1) + add_mock_builds(status, 1) + status.set_jobs(4, 4) + status.update() + output = fake_stdout.getvalue() + assert "4" in output + assert "jobs" in output + + def test_header_shows_arrow_when_pending(self): + """When actual != target, the header should show 'actual=>target jobs'.""" + status, _, fake_stdout = create_build_status(total=1) + add_mock_builds(status, 1) + status.set_jobs(4, 2) + status.update() + output = fake_stdout.getvalue() + assert "4=>2" in output diff --git a/lib/spack/spack/test/jobserver.py b/lib/spack/spack/test/jobserver.py index d7764c4ae9baab..fcabd2ddf3ddb3 100644 --- a/lib/spack/spack/test/jobserver.py +++ b/lib/spack/spack/test/jobserver.py @@ -127,6 +127,10 @@ def test_returns_none_for_missing_fifo(self, tmp_path: pathlib.Path): assert result is None +#: Constant that's larger than the number of jobs used in tests. +ALL_TOKENS = 100 + + class TestJobServer: """Test JobServer class functionality.""" @@ -294,3 +298,143 @@ def test_close_warns_when_subprocess_holds_tokens(self): os.read(js2.r, 2) # A subprocess acquires two tokens without releasing them with pytest.warns(UserWarning, match="2 jobserver tokens were not released"): js2.close() + + def test_has_target_parallelism(self): + """has_target_parallelism() should be True initially.""" + js = JobServer(4) + try: + assert js.has_target_parallelism() is True + js.target_jobs = js.num_jobs - 1 + assert js.has_target_parallelism() is False + finally: + js.close() + + def test_increase_parallelism_not_created(self): + """increase_parallelism() should be a no-op when not self.created.""" + # Simulate an externally attached jobserver by patching created after construction. + js = JobServer(3) + try: + original_num = js.num_jobs + original_target = js.target_jobs + js.created = False + js.increase_parallelism() + assert js.num_jobs == original_num + assert js.target_jobs == original_target + js.decrease_parallelism() + assert js.num_jobs == original_num + assert js.target_jobs == original_target + finally: + js.created = True # restore so close() works + js.close() + + def test_increase_parallelism(self): + """increase_parallelism() should increment num_jobs and target_jobs and add a token.""" + js = JobServer(3) + try: + original_num = js.num_jobs + original_target = js.target_jobs + js.increase_parallelism() + assert js.num_jobs == original_num + 1 + assert js.target_jobs == original_target + 1 + # Verify the "js.num_jobs - 1 tokens in the pipe" invariant. + assert js.acquire(ALL_TOKENS) + 1 == js.num_jobs + finally: + js.close() + + def test_decrease_parallelism_at_floor(self): + """decrease_parallelism() should not go below target_jobs == 1.""" + js = JobServer(1) + try: + # target_jobs starts at 1 + assert js.target_jobs == 1 + js.decrease_parallelism() + assert js.target_jobs == 1 + finally: + js.close() + + def test_decrease_parallelism_token_available(self): + """When pipe has tokens, decrease_parallelism discards one immediately.""" + js = JobServer(3) + try: + # 3-job server starts with 2 tokens in the pipe. + original_num = js.num_jobs + js.decrease_parallelism() + assert js.target_jobs == original_num - 1 + assert js.num_jobs == original_num - 1 + assert js.acquire(ALL_TOKENS) + 1 == js.num_jobs + finally: + js.close() + + def test_decrease_parallelism_no_token_available(self): + """When all tokens are held, decrease_parallelism defers the discard.""" + js = JobServer(3) + try: + # Drain the pipe so no tokens are available for immediate discard. + assert js.acquire(ALL_TOKENS) == js.num_jobs - 1 + original_num = js.num_jobs + js.decrease_parallelism() + # target_jobs decremented but num_jobs unchanged (no token to discard yet). + assert js.target_jobs == original_num - 1 + assert js.num_jobs == original_num + finally: + js.close() + + def test_maybe_discard_tokens_noop_at_target(self): + """maybe_discard_tokens() should be a no-op when num_jobs == target_jobs.""" + js = JobServer(3) + try: + original_num = js.num_jobs + js.maybe_discard_tokens() # to_discard == 0 + assert js.num_jobs == original_num + finally: + js.close() + + def test_maybe_discard_tokens_discards_when_available(self): + """maybe_discard_tokens() should consume tokens from the pipe.""" + js = JobServer(4) + try: + # Manually set target lower to create a discard requirement. + js.target_jobs = js.num_jobs - 2 + original_num = js.num_jobs + js.maybe_discard_tokens() + assert js.num_jobs < original_num + finally: + js.close() + + def test_maybe_discard_tokens_noop_on_blocking(self): + """maybe_discard_tokens() should not raise when pipe is empty.""" + js = JobServer(3) + try: + # Drain all tokens from the pipe (simulates subprocesses holding them). + assert js.acquire(ALL_TOKENS) == js.num_jobs - 1 + original_num = js.num_jobs + # Artificially lower target so a discard is requested, but pipe is empty. + js.target_jobs = js.num_jobs - 1 + js.maybe_discard_tokens() # Should not raise; num_jobs unchanged. + assert js.num_jobs == original_num + finally: + js.close() + + def test_release_discards_token_when_target_below_num(self): + """release() should discard a token (not return it) when target_jobs < num_jobs.""" + js = JobServer(4) + try: + # Acquire a token. + assert js.acquire(1) == 1 + assert js.tokens_acquired == 1 + # Manually lower target to simulate a pending decrease. + js.target_jobs = js.num_jobs - 1 + original_num = js.num_jobs + # Drain the free tokens from the pipe so we can count them after. + drained = os.read(js.r, ALL_TOKENS) + # Release should discard the token (decrement num_jobs) instead of writing to pipe. + js.release() + assert js.tokens_acquired == 0 + assert js.num_jobs == original_num - 1 + # Pipe should remain empty (nothing written back). + with pytest.raises(BlockingIOError): + os.read(js.r, 1) + finally: + # Restore drained tokens so close() can clean up cleanly. + os.write(js.w, drained) + js.close() From 0714f2f90645d167742f6401e534b06c3f559ae0 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 23 Mar 2026 11:39:02 +0100 Subject: [PATCH 180/337] lock.py: add non-blocking api (#52126) Add `Lock.try_acquire_write()` and `Lock.try_acquire_read()` to simplify the code in the new installer which only uses non-blocking lock calls in the event loop. Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/lock.py | 36 ++++++++++++ lib/spack/spack/new_installer.py | 26 +++------ lib/spack/spack/test/llnl/util/lock.py | 77 ++++++++++++++++++++++++++ lib/spack/spack/test/new_installer.py | 31 +++-------- lib/spack/spack/util/lock.py | 5 ++ 5 files changed, 134 insertions(+), 41 deletions(-) diff --git a/lib/spack/spack/llnl/util/lock.py b/lib/spack/spack/llnl/util/lock.py index cd2ec4a3b733a3..a94993155c4c93 100644 --- a/lib/spack/spack/llnl/util/lock.py +++ b/lib/spack/spack/llnl/util/lock.py @@ -528,6 +528,42 @@ def _reaffirm_lock(self) -> None: if not self._poll_lock(op): raise LockTimeoutError(op, self.path, time=0, attempts=1) + def try_acquire_read(self) -> bool: + """Non-blocking attempt to acquire a shared read lock. + + Returns True if the lock was acquired, False if it would block. + """ + if self._reads == 0 and self._writes == 0: + self._ensure_valid_handle() + if not self._poll_lock(LockType.READ): + return False + self._reads += 1 + self._log_acquired("READ LOCK", 0, 1) + return True + else: + self._reaffirm_lock() + self._reads += 1 + return True + + def try_acquire_write(self) -> bool: + """Non-blocking attempt to acquire an exclusive write lock. + + Returns True if the lock was acquired, False if it would block. + """ + if self._writes == 0: + fh = self._ensure_valid_handle() + if LockType.to_module(LockType.WRITE) == fcntl.LOCK_EX and fh.mode == "rb": + raise LockROFileError(self.path) + if not self._poll_lock(LockType.WRITE): + return False + self._writes += 1 + self._log_acquired("WRITE LOCK", 0, 1) + return True + else: + self._reaffirm_lock() + self._writes += 1 + return True + def is_write_locked(self) -> bool: """Returns ``True`` if the path is write locked, otherwise, ``False``""" try: diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index f10d2cff4c910d..348b8c8f465780 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1682,9 +1682,7 @@ def schedule_builds( # Acquire the DB read lock non-blocking; hold it throughout the loop so the in-memory snapshot # stays consistent while we acquire per-spec prefix locks. - try: - db.lock.acquire_read(timeout=1e-9) - except spack.util.lock.LockTimeoutError: + if not db.lock.try_acquire_read(): return ScheduleResult(blocked, to_start, newly_installed) try: @@ -1696,19 +1694,14 @@ def schedule_builds( spec = build_graph.nodes[dag_hash] lock = prefix_locker.lock(spec) - try: - lock.acquire_write(timeout=1e-9) + if lock.try_acquire_write(): blocked = False have_write = True - except spack.util.lock.LockTimeoutError: - # Write lock failed: either another process is actively building, or it - # finished and downgraded to a read lock. Try a read lock to find out. - try: - lock.acquire_read(timeout=1e-9) - except spack.util.lock.LockTimeoutError: - idx += 1 - continue # active build in progress; try the next spec + elif lock.try_acquire_read(): have_write = False + else: + idx += 1 + continue # Check installed status under the DB read lock and prefix lock. upstream, record = db.query_by_spec_hash(dag_hash) @@ -2229,13 +2222,10 @@ def _handle_sigwinch(signum: int, frame: object) -> None: def _save_to_db( self, finished_builds: List[ChildInfo], retained_read_locks: List[spack.util.lock.Lock] ) -> bool: - try: - # Only try to get the lock once (non-blocking). If it fails, try it next time. - if self.db.lock.acquire_write(timeout=1e-9): - self.db._read() - except spack.util.lock.LockTimeoutError: + if not self.db.lock.try_acquire_write(): return False try: + self.db._read() for build in finished_builds: self.db._add(build.spec, explicit=build.explicit) finally: diff --git a/lib/spack/spack/test/llnl/util/lock.py b/lib/spack/spack/test/llnl/util/lock.py index a01591afa511b1..06fb68cf98bf5d 100644 --- a/lib/spack/spack/test/llnl/util/lock.py +++ b/lib/spack/spack/test/llnl/util/lock.py @@ -1395,6 +1395,83 @@ def child(): lock.release_write() +def _child_try_acquire_write(lock_path: str, result_queue): + lock = lk.Lock(lock_path) + result_queue.put(lock.try_acquire_write()) + + +def _child_try_acquire_read(lock_path: str, result_queue): + lock = lk.Lock(lock_path) + result_queue.put(lock.try_acquire_read()) + + +def test_try_acquire_read(tmp_path: pathlib.Path): + """Test non-blocking try_acquire_read.""" + lock = lk.Lock(str(tmp_path / "lockfile")) + + # Succeeds on unlocked lock + assert lock.try_acquire_read() is True + assert lock._reads == 1 + + # Succeeds again (nested) + assert lock.try_acquire_read() is True + assert lock._reads == 2 + + lock.release_read() + lock.release_read() + ctx = multiprocessing.get_context() + + # Fails when another process holds an exclusive write lock + lock.acquire_write() + try: + q = ctx.Queue() + p = ctx.Process(target=_child_try_acquire_read, args=(str(tmp_path / "lockfile"), q)) + p.start() + p.join() + assert q.get() is False + finally: + lock.release_write() + + +def test_try_acquire_write(tmp_path: pathlib.Path): + """Test non-blocking try_acquire_write.""" + lock = lk.Lock(str(tmp_path / "lockfile")) + ctx = multiprocessing.get_context() + + # Succeeds on unlocked lock + assert lock.try_acquire_write() is True + assert lock._writes == 1 + + # Succeeds again (nested) + assert lock.try_acquire_write() is True + assert lock._writes == 2 + + lock.release_write() + lock.release_write() + + # Fails when another process holds a write lock + lock.acquire_write() + try: + q = ctx.Queue() + p = ctx.Process(target=_child_try_acquire_write, args=(str(tmp_path / "lockfile"), q)) + p.start() + p.join() + assert q.get() is False + finally: + lock.release_write() + + # Fails when another process holds a read lock + lock.acquire_read() + try: + q = ctx.Queue() + p = ctx.Process(target=_child_try_acquire_write, args=(str(tmp_path / "lockfile"), q)) + p.start() + p.join() + assert q.get() is False + finally: + lock.release_read() + + def _child_fails_to_acquire_read(_lock: lk.Lock): try: _lock.acquire_read(timeout=1e-9) diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index 958dcbe7506479..8114735babfd48 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -13,7 +13,6 @@ pytest.skip("No Windows support", allow_module_level=True) import spack.spec -import spack.util.lock from spack.new_installer import ( OVERWRITE_GARBAGE_SUFFIX, JobServer, @@ -377,13 +376,10 @@ def test_all_locked_returns_blocked(self, temporary_store, mock_packages, monkey pending = [spec.dag_hash()] bg = _FakeBuildGraph([spec]) jobserver = JobServer(num_jobs=2) - # Pre-register the lock in the prefix_locker cache, then patch acquire_write to fail. + # Pre-register the lock in the prefix_locker cache, then patch try_acquire to fail. lock = temporary_store.prefix_locker.lock(spec) - - def always_timeout(timeout=None): - raise spack.util.lock.LockTimeoutError("write", lock.path, 0, 1) - - monkeypatch.setattr(lock, "acquire_write", always_timeout) + monkeypatch.setattr(lock, "try_acquire_write", lambda: False) + monkeypatch.setattr(lock, "try_acquire_read", lambda: False) try: blocked, to_start, newly_installed = schedule_builds( pending, @@ -438,13 +434,10 @@ def test_mixed_locked_unlocked(self, temporary_store, mock_packages, monkeypatch pending = [spec_a.dag_hash(), spec_b.dag_hash()] bg = _FakeBuildGraph([spec_a, spec_b]) jobserver = JobServer(num_jobs=4) - # Patch spec_a's lock to always time out, simulating an external write lock. + # Patch spec_a's lock to always fail, simulating an external write lock. lock_a = temporary_store.prefix_locker.lock(spec_a) - - def always_timeout(timeout=None): - raise spack.util.lock.LockTimeoutError("write", lock_a.path, 0, 1) - - monkeypatch.setattr(lock_a, "acquire_write", always_timeout) + monkeypatch.setattr(lock_a, "try_acquire_write", lambda: False) + monkeypatch.setattr(lock_a, "try_acquire_read", lambda: False) try: blocked, to_start, newly_installed = schedule_builds( pending, @@ -482,11 +475,7 @@ def test_write_locked_read_locked_installed_yields_newly_installed( bg = _FakeBuildGraph([spec]) jobserver = JobServer(num_jobs=2) lock = temporary_store.prefix_locker.lock(spec) - - def write_timeout(timeout=None): - raise spack.util.lock.LockTimeoutError("write", lock.path, 0, 1) - - monkeypatch.setattr(lock, "acquire_write", write_timeout) + monkeypatch.setattr(lock, "try_acquire_write", lambda: False) try: blocked, to_start, newly_installed = schedule_builds( pending, @@ -524,11 +513,7 @@ def test_write_locked_read_locked_not_installed_still_blocked( bg = _FakeBuildGraph([spec]) jobserver = JobServer(num_jobs=2) lock = temporary_store.prefix_locker.lock(spec) - - def write_timeout(timeout=None): - raise spack.util.lock.LockTimeoutError("write", lock.path, 0, 1) - - monkeypatch.setattr(lock, "acquire_write", write_timeout) + monkeypatch.setattr(lock, "try_acquire_write", lambda: False) try: blocked, to_start, newly_installed = schedule_builds( pending, diff --git a/lib/spack/spack/util/lock.py b/lib/spack/spack/util/lock.py index 060005347b49b9..8de73be068f9be 100644 --- a/lib/spack/spack/util/lock.py +++ b/lib/spack/spack/util/lock.py @@ -57,6 +57,11 @@ def _lock(self, op: int, timeout: Optional[float] = 0.0) -> Tuple[float, int]: return super()._lock(op, timeout) return 0.0, 0 + def _poll_lock(self, op: int) -> bool: + if self._enable: + return super()._poll_lock(op) + return True + def _unlock(self) -> None: """Unlock call that always succeeds.""" if self._enable: From 3b6064d31bf343f5a9d790c05d1a440e85712949 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 23 Mar 2026 16:17:13 +0100 Subject: [PATCH 181/337] new_installer.py: improve SIGTERM handler of build (#52102) The previous implementation of SIGTERM was somewhat brittle. The assumption was that if we're stuck in `Executable("make")(...)` and receive SIGTERM, we can just forward the signal and then return to waiting for `make` to exit with nonzero exite code, which would trigger an exception and ultimately build failure. Apart from the fact that the build *could* run `Executable("make")(fail_on_error=False)`, it could also just be running Python code, which would only continue. So, instead of assuming we're stuck in `waitpid`, do an explicit `waitpid` until all children have exited, and then raise `KeyboardInterrupt`. This ensures we hit an exception in all cases mentioned above. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 348b8c8f465780..efcb288bcc406a 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -449,15 +449,21 @@ def worker_function( os.setsid() def handle_sigterm(signum, frame): - # This SIGTERM handler forwards the signal to child processes, and - # then resets the handler to default. It does not raise an exception, - # because the assumption is we're stuck in waitpid, and we want to - # let child processes finish with SIGTERM before we run the cleanup - # code in finally blocks and __exit__ functions and exit. If we exit - # too early, the child process may still write to the prefix or stage. + # This SIGTERM handler forwards the signal to child processes (cmake, make, etc). We wait + # for all child processes to exit before raising KeyboardInterrupt. This ensures all + # __exit__ and finally blocks run after the child processes have stopped, meaning that we + # get to clean up the prefix without risking that the child process writes to it + # afterwards. signal.signal(signal.SIGTERM, signal.SIG_IGN) os.killpg(0, signal.SIGTERM) - signal.signal(signal.SIGTERM, signal.SIG_DFL) + + try: + while True: + os.waitpid(-1, 0) + except ChildProcessError: + pass + + raise KeyboardInterrupt("Installation interrupted") signal.signal(signal.SIGTERM, handle_sigterm) @@ -500,7 +506,7 @@ def handle_sigterm(signum, frame): spack.store.STORE, run_tests, ) - except Exception: + except BaseException: traceback.print_exc() # log the traceback to the log file exit_code = 1 finally: From 618dd722b496be0bdec12016f44c5509681aa7d5 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Mon, 23 Mar 2026 17:10:07 +0100 Subject: [PATCH 182/337] solver: prefer best compiler above one with no penalty on variants (take 2) (#52109) Before this commit there were cases where a default compiler was not selected because it had penalty on variants. Instead, the second-best provider was selected. Here we tweak priorities to ensure that the compiler choice is above variant penalty. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 72 ++++++-------- lib/spack/spack/test/concretization/core.py | 94 +++++++++++++++++++ .../builtin_mock/packages/gcc/package.py | 3 + 3 files changed, 128 insertions(+), 41 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index 164ad2b945b2aa..ddd16a2f84a77e 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -2158,16 +2158,6 @@ opt_criterion(65, "variant penalty (roots)"). build_priority(PackageNode, Priority) }. -opt_criterion(60, "preferred providers for roots"). -#minimize{ 0@260: #true }. -#minimize{ 0@60: #true }. -#minimize{ - Weight@60+Priority,ProviderNode,X,Virtual - : provider_weight(ProviderNode, node(X, Virtual), Weight), - attr("root", ProviderNode), not language(Virtual), not language_runtime(Virtual), - build_priority(ProviderNode, Priority) -}. - opt_criterion(55, "default values of variants not being used (roots)"). #minimize{ 0@255: #true }. #minimize{ 0@55: #true }. @@ -2178,59 +2168,59 @@ opt_criterion(55, "default values of variants not being used (roots)"). build_priority(PackageNode, Priority) }. +% Choose the preferred compiler before penalizing variants, to avoid that a variant penalty +% on e.g. gcc causes clingo to pick another compiler e.g. llvm +opt_criterion(48, "preferred compilers"). +#minimize{ 0@248: #true }. +#minimize{ 0@48: #true }. +#minimize{ + Weight@48+Priority,ProviderNode,X,Virtual + : provider_weight(ProviderNode, node(X, Virtual), Weight), + language(Virtual), + build_priority(ProviderNode, Priority) +}. + +opt_criterion(41, "compiler penalty from reuse"). +#minimize{ 0@241: #true }. +#minimize{ 0@41: #true }. +#minimize{1@41,Hash : compiler_penalty_from_reuse(Hash)}. + % Try to use default variants or variants that have been set -opt_criterion(50, "variant penalty (non-roots)"). -#minimize{ 0@250: #true }. -#minimize{ 0@50: #true }. +opt_criterion(40, "variant penalty (non-roots)"). +#minimize{ 0@240: #true }. +#minimize{ 0@40: #true }. #minimize { - Penalty@50+Priority,PackageNode,Variant,Value + Penalty@40+Priority,PackageNode,Variant,Value : variant_penalty(PackageNode, Variant, Value, Penalty), not attr("root", PackageNode), build_priority(PackageNode, Priority) }. -% Minimize the weights of the providers, i.e. use as much as -% possible the most preferred providers -opt_criterion(48, "preferred providers (non-roots)"). -#minimize{ 0@248: #true }. -#minimize{ 0@48: #true }. +% Minimize the weights of all the other providers (mpi, lapack, etc.) +opt_criterion(38, "preferred providers (excluded compilers and language runtimes)"). +#minimize{ 0@238: #true }. +#minimize{ 0@38: #true }. #minimize{ - Weight@48+Priority,ProviderNode,X,Virtual + Weight@38+Priority,ProviderNode,X,Virtual : provider_weight(ProviderNode, node(X, Virtual), Weight), - not attr("root", ProviderNode), not language(Virtual), not language_runtime(Virtual), + not language(Virtual), not language_runtime(Virtual), build_priority(ProviderNode, Priority) }. % Minimize the number of compilers used on nodes - compiler_penalty(PackageNode, C-1) :- C = #count { CompilerNode : node_compiler(PackageNode, CompilerNode) }, node_compiler(PackageNode, _), C > 0. -opt_criterion(46, "number of compilers used on the same node"). -#minimize{ 0@246: #true }. -#minimize{ 0@46: #true }. +opt_criterion(36, "number of compilers used on the same node"). +#minimize{ 0@236: #true }. +#minimize{ 0@36: #true }. #minimize{ - Penalty@46+Priority,PackageNode + Penalty@36+Priority,PackageNode : compiler_penalty(PackageNode, Penalty), build_priority(PackageNode, Priority) }. -opt_criterion(40, "preferred compilers"). -#minimize{ 0@240: #true }. -#minimize{ 0@40: #true }. -#minimize{ - Weight@40+Priority,ProviderNode,X,Virtual - : provider_weight(ProviderNode, node(X, Virtual), Weight), - language(Virtual), - build_priority(ProviderNode, Priority) -}. - -opt_criterion(41, "compiler penalty from reuse"). -#minimize{ 0@241: #true }. -#minimize{ 0@41: #true }. -#minimize{1@41,Hash : compiler_penalty_from_reuse(Hash)}. - opt_criterion(30, "non-preferred OS's"). #minimize{ 0@230: #true }. #minimize{ 0@30: #true }. diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index e115dcbf59280a..69780bd02a6bc8 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -4983,3 +4983,97 @@ def test_virtual_gets_multiple_dupes(mock_packages, config): assert len(selected_lines) == 1 max_dupes_c = selected_lines[0] assert 'max_dupes("c",2).' == max_dupes_c, f"should have max_dupes=2, but got: {max_dupes_c}" + + +def test_compiler_selection_when_external_has_variant_penalty(mutable_config, mock_packages): + """Tests that a compiler that should be preferred is not swapped with a less preferred + compiler because of penalties on variants. + """ + packages_yaml = syaml.load_config( + """ +packages: + gcc:: + externals: + - spec: "gcc@15.2.0 languages='c,c++' ~binutils" + prefix: /path + extra_attributes: + compilers: + c: /path/bin/gcc + cxx: /path/bin/g++ + llvm:: + buildable: false + externals: + - spec: "llvm@20 +clang" + prefix: /path + extra_attributes: + compilers: + c: /path/bin/gcc + cxx: /path/bin/g++ +""" + ) + mutable_config.set("packages", packages_yaml["packages"]) + + concrete = spack.concretize.concretize_one("libdwarf") + + # GCC is the preferred provider, but has a penalty on its variants + assert concrete.satisfies("%gcc@15.2.0 ~binutils"), concrete.tree() + # LLVM is the second provider choice, with no penalty on variants + assert not concrete.satisfies("%llvm@20 +clang") + + +def test_mpi_selection_when_external_has_variant_penalty(mutable_config, mock_packages): + """Tests that conflicting with a default provider doesn't cause a variant values to be + flipped to avoid the variant dependency. + """ + packages_yaml = syaml.load_config( + """ +packages: + all: + variants: +mpi + mpich: + buildable: false +""" + ) + mutable_config.set("packages", packages_yaml["packages"]) + + concrete = spack.concretize.concretize_one("transitive-conditional-virtual-dependency") + + # GCC is the preferred provider, but has a penalty on its variants + assert concrete.satisfies("%conditional-virtual-dependency+mpi"), concrete.tree() + # LLVM is the second provider choice, with no penalty on variants + assert concrete.satisfies("^mpi=zmpi") + + +def test_preferring_different_compilers_for_different_languages(mutable_config, mock_packages): + """Tests that in a case where we prefer different compilers for different languages, steering + towards using a unique toolchain is lower priority with respect to flipping variants to turn + off a language, or selecting a non-default provider. + """ + packages_yaml = syaml.load_config( + """ +packages: + all: + providers: + c:: [llvm, gcc] + cxx:: [llvm, gcc] + fortran:: [gcc] + c: + prefer: + - llvm + cxx: + prefer: + - llvm + fortran: + prefer: + - gcc + mpileaks: + variants: +fortran +""" + ) + mutable_config.set("packages", packages_yaml["packages"]) + + mpileaks = spack.concretize.concretize_one("mpileaks") + + assert mpileaks.satisfies("%c,cxx=llvm %fortran=gcc"), mpileaks.tree() + assert mpileaks.satisfies("%mpi=mpich") + assert mpileaks["mpich"].satisfies("%c,cxx=llvm %fortran=gcc") diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py index a433f6d403b611..b2374251e006fc 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/gcc/package.py @@ -36,6 +36,9 @@ class Gcc(CompilerPackage, Package): description="Compilers and runtime libraries to build", ) + # This variant is here so that we can test having externals using the non-default value + variant("binutils", default=True, description="") + provides("c", "cxx", when="languages=c,c++") provides("c", when="languages=c") provides("cxx", when="languages=c++") From f66589c1e844e919ee12d2d085f3131c39b4cfdc Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 23 Mar 2026 17:14:57 +0100 Subject: [PATCH 183/337] installer: improve spawn/forkserver (#52127) * In the forkserver case, preload a few modules needed in the installer * Avoid the 8k stat calls in all build processes Signed-off-by: Harmen Stoppels --- lib/spack/spack/main.py | 4 ++++ lib/spack/spack/new_installer.py | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index 25825b2141656b..130a7a9df6eec7 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -10,6 +10,7 @@ import argparse import gc import inspect +import multiprocessing import operator import os import pstats @@ -1120,6 +1121,9 @@ def main(argv=None): the executable name. If None, parses from sys.argv. """ + # When using the forkserver start method, preload the following modules to improve startup + # time of child processes. + multiprocessing.set_forkserver_preload(["spack.main", "spack.package", "spack.new_installer"]) try: g0, g1, g2 = gc.get_threshold() gc.set_threshold(50 * g0, g1, g2) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index efcb288bcc406a..09512d6f5f0d0d 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -70,6 +70,7 @@ import spack.llnl.util.tty import spack.llnl.util.tty.color import spack.paths +import spack.repo import spack.report import spack.spec import spack.stage @@ -290,7 +291,7 @@ class GlobalState: but excludes the Spack environment, which is slow to serialize and should not be needed during the build.""" - __slots__ = ("store", "config", "monkey_patches", "spack_working_dir") + __slots__ = ("store", "config", "monkey_patches", "spack_working_dir", "repo_cache") def __init__(self): if multiprocessing.get_start_method() == "fork": @@ -299,6 +300,10 @@ def __init__(self): self.store = spack.store.STORE self.monkey_patches = spack.subprocess_context.TestPatches.create() self.spack_working_dir = spack.paths.spack_working_dir + # Avoid 8k stat calls in build process. The downside of this is the additional startup + # cost that blocks the parent process in `proc.start()`, but we avoid filesystem pressure. + # TODO: we don't need to send this if Spec.satisfies(...) etc does not depend on the repo. + self.repo_cache = spack.repo.FastPackageChecker._paths_cache def restore(self): if multiprocessing.get_start_method() == "fork": @@ -315,6 +320,7 @@ def restore(self): spack.config.CONFIG = self.config self.monkey_patches.restore() spack.paths.spack_working_dir = self.spack_working_dir + spack.repo.FastPackageChecker._paths_cache = self.repo_cache class PrefixPivoter: From 84721f049ecc24f3c029d05dcfad65624874fee2 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 24 Mar 2026 18:06:00 +0100 Subject: [PATCH 184/337] solver: remove unused `asp` attribute from `Result` (#52136) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 6b1d21721399db..c101688becc669 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -323,8 +323,7 @@ def check_packages_exist(specs): class Result: """Result of an ASP solve.""" - def __init__(self, specs, asp=None): - self.asp = asp + def __init__(self, specs): self.satisfiable = None self.optimal = None self.warnings = None @@ -443,7 +442,6 @@ def to_dict(self) -> dict: lambda node_dict: f"""{{"id": "{node_dict.id}", "pkg": "{node_dict.pkg}"}}""" ) ret = dict() - ret["asp"] = self.asp ret["criteria"] = self.criteria ret["optimal"] = self.optimal ret["warnings"] = self.warnings @@ -483,13 +481,12 @@ def _dict_to_spec(spec_dict): spack.spec.Spec.ensure_no_deprecated(loaded_spec) return loaded_spec - asp = obj.get("asp") spec_list = obj.get("abstract_specs") if not spec_list: raise RuntimeError("Invalid json for concretization Result object") if spec_list: spec_list = [_str_to_spec(x) for x in spec_list] - result = Result(spec_list, asp) + result = Result(spec_list) criteria = obj.get("criteria") result.criteria = ( @@ -518,7 +515,6 @@ def _dict_to_spec(spec_dict): def __eq__(self, other): eq = ( - self.asp == other.asp, self.satisfiable == other.satisfiable, self.optimal == other.optimal, self.warnings == other.warnings, From 9ff2ef8e76a31ac2c78c5987dc2b2c02e64c5ae4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 25 Mar 2026 09:40:14 +0100 Subject: [PATCH 185/337] debug.py: avoid pdb import, simplify (#52139) Importing pdb is problematic cause it pulls in readline, which makes it impossible to run `spack install &` in the background on macOS, as it queries stdin and gets immediately suspended as a result. Signed-off-by: Harmen Stoppels --- lib/spack/spack/util/debug.py | 78 ++++++----------------------------- 1 file changed, 12 insertions(+), 66 deletions(-) diff --git a/lib/spack/spack/util/debug.py b/lib/spack/spack/util/debug.py index 814779070caedd..aa277c880a8517 100644 --- a/lib/spack/spack/util/debug.py +++ b/lib/spack/spack/util/debug.py @@ -2,79 +2,25 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) -"""Debug signal handler: prints a stack trace and enters interpreter. - -``register_interrupt_handler()`` enables a ctrl-C handler that prints -a stack trace and drops the user into an interpreter. - -""" -import code -import io +"""Debug signal handler: enters pdb on ctrl-C.""" import os -import pdb import signal -import sys -import traceback + +_DEBUG_PID = None def debug_handler(sig, frame): - """Interrupt running process, and provide a python prompt for - interactive debugging.""" - d = {"_frame": frame} # Allow access to frame object. - d.update(frame.f_globals) # Unless shadowed by global - d.update(frame.f_locals) + """Signal handler for SIGINT. Enters pdb if the signal is sent to this process.""" + if os.getpid() != _DEBUG_PID: + raise KeyboardInterrupt - i = code.InteractiveConsole(d) - message = "Signal received : entering python shell.\nTraceback:\n" - message += "".join(traceback.format_stack(frame)) - i.interact(message) - os._exit(1) # Use os._exit to avoid test harness. + import pdb + + pdb.Pdb().set_trace(frame) def register_interrupt_handler(): - """Print traceback and enter an interpreter on Ctrl-C""" + """Register the debug handler for SIGINT.""" + global _DEBUG_PID + _DEBUG_PID = os.getpid() signal.signal(signal.SIGINT, debug_handler) - - -# Subclass of the debugger to keep readline working. See -# https://stackoverflow.com/questions/4716533/how-to-attach-debugger-to-a-python-subproccess/23654936 -class ForkablePdb(pdb.Pdb): - """ - This class allows the python debugger to follow forked processes - and can set tracepoints allowing the Python Debugger Pdb to be used - from a python multiprocessing child process. - - This is used the same way one would normally use Pdb, simply import this - class and use as a drop in for Pdb, although the syntax here is slightly different, - requiring the instantiton of this class, i.e. ForkablePdb().set_trace(). - - This should be used when attempting to call a debugger from a - child process spawned by the python multiprocessing such as during - the run of Spack.install, or any where else Spack spawns a child process. - """ - - try: - _original_stdin_fd = sys.stdin.fileno() - except io.UnsupportedOperation: - _original_stdin_fd = None - _original_stdin = None - - def __init__(self, stdout_fd=None, stderr_fd=None): - pdb.Pdb.__init__(self, nosigint=True) - self._stdout_fd = stdout_fd - self._stderr_fd = stderr_fd - - def _cmdloop(self): - current_stdin = sys.stdin - try: - if not self._original_stdin: - self._original_stdin = os.fdopen(self._original_stdin_fd) - sys.stdin = self._original_stdin - if self._stdout_fd is not None: - os.dup2(self._stdout_fd, sys.stdout.fileno()) - os.dup2(self._stdout_fd, self.stdout.fileno()) - if self._stderr_fd is not None: - os.dup2(self._stderr_fd, sys.stderr.fileno()) - self.cmdloop() - finally: - sys.stdin = current_stdin From 33d863b55e0572c0934d8e1661fd432c68464820 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 25 Mar 2026 11:37:09 +0100 Subject: [PATCH 186/337] new_installer: mark explicit if already installed but implicit in db (#52120) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 145 ++++++++++++------ lib/spack/spack/test/cmd/env.py | 2 +- lib/spack/spack/test/installer_build_graph.py | 23 +++ lib/spack/spack/test/new_installer.py | 134 ++++++++++------ 4 files changed, 207 insertions(+), 97 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 09512d6f5f0d0d..65b9f475780c1e 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -108,19 +108,35 @@ OVERWRITE_GARBAGE_SUFFIX = ".garbage" -class ChildInfo: +class DatabaseAction: + """Base class for objects that need to be persisted to the database.""" + + __slots__ = ("spec", "prefix_lock") + + spec: "spack.spec.Spec" + prefix_lock: Optional[spack.util.lock.Lock] + + def save_to_db(self, db: spack.database.Database) -> None: ... + + +class MarkExplicitAction(DatabaseAction): + """Action to mark an already installed spec as explicitly installed. Similar to ChildInfo, but + used when no build process was needed.""" + + __slots__ = () + + def __init__(self, spec: "spack.spec.Spec") -> None: + self.spec = spec + self.prefix_lock = None + + def save_to_db(self, db: spack.database.Database) -> None: + db._mark(self.spec, "explicit", True) + + +class ChildInfo(DatabaseAction): """Information about a child process.""" - __slots__ = ( - "proc", - "spec", - "output_r_conn", - "state_r_conn", - "control_w_conn", - "explicit", - "prefix_lock", - "log_path", - ) + __slots__ = ("proc", "output_r_conn", "state_r_conn", "control_w_conn", "explicit", "log_path") def __init__( self, @@ -141,6 +157,9 @@ def __init__( self.explicit = explicit self.prefix_lock: Optional[spack.util.lock.Lock] = None + def save_to_db(self, db: spack.database.Database) -> None: + return db._add(self.spec, explicit=self.explicit) + def cleanup(self, selector: selectors.BaseSelector) -> None: """Unregister and close file descriptors, and join the child process.""" try: @@ -1525,6 +1544,7 @@ def __init__( database: spack.database.Database, overwrite_set: Optional[Set[str]] = None, tests: Union[bool, List[str], Set[str]] = False, + explicit_set: Optional[Set[str]] = None, ): """Construct a build graph from the given specs. This includes only packages that need to be installed. Installed packages are pruned from the graph, and build dependencies are only @@ -1534,6 +1554,7 @@ def __init__( self.parent_to_child: Dict[str, Set[str]] = {} self.child_to_parent: Dict[str, Set[str]] = {} overwrite_set = overwrite_set or set() + explicit_set = explicit_set or set() self.pruned: Set[str] = set() stack: List[Tuple[spack.spec.Spec, InstallPolicy]] = [ (s, root_policy) for s in self.nodes.values() @@ -1554,10 +1575,14 @@ def __init__( key = spec.dag_hash() _, record = database.query_by_spec_hash(key) - # Conditionally include build dependencies + # Conditionally include build dependencies. Don't prune installed specs + # that need to be marked explicit so they flow through the DB write path. if record and record.installed and key not in overwrite_set: - self.pruned.add(key) + # Installed spec only needs link/run deps traversed. dependencies = spec.dependencies(deptype=dt.LINK | dt.RUN) + # If it needs to be marked explicit, keep it in the graph (don't prune). + if not (key in explicit_set and not record.explicit): + self.pruned.add(key) elif install_policy == "cache_only" and not include_build_deps: dependencies = spec.dependencies(deptype=dt.LINK | dt.RUN) else: @@ -1646,6 +1671,8 @@ class ScheduleResult(NamedTuple): #: ``(dag_hash, spec, lock)`` triples found already installed by another process; the read lock #: is held and the caller must add it to retained_read_locks. newly_installed: List[Tuple[str, spack.spec.Spec, spack.util.lock.Lock]] + #: Actions to mark already installed specs explicit in the DB. + to_mark_explicit: List[MarkExplicitAction] def schedule_builds( @@ -1658,6 +1685,7 @@ def schedule_builds( capacity: int, needs_jobserver_token: bool, jobserver: JobServer, + explicit: Set[str], ) -> ScheduleResult: """Try to schedule as many pending builds as possible. @@ -1683,6 +1711,7 @@ def schedule_builds( capacity: Maximum number of new builds to add to to_start in this call. needs_jobserver_token: True if a jobserver token is required for the first new build. jobserver: Jobserver for acquiring tokens. + explicit: Set of dag hashes to mark explicit in the DB if found already installed. Returns: A :class:`ScheduleResult` with ``blocked``, ``to_start``, and ``newly_installed`` @@ -1690,12 +1719,13 @@ def schedule_builds( """ to_start: List[Tuple[str, spack.util.lock.Lock]] = [] newly_installed: List[Tuple[str, spack.spec.Spec, spack.util.lock.Lock]] = [] + to_mark_explicit: List[MarkExplicitAction] = [] blocked = True # Acquire the DB read lock non-blocking; hold it throughout the loop so the in-memory snapshot # stays consistent while we acquire per-spec prefix locks. if not db.lock.try_acquire_read(): - return ScheduleResult(blocked, to_start, newly_installed) + return ScheduleResult(blocked, to_start, newly_installed, to_mark_explicit) try: db._read() # refresh in-memory snapshot under the read lock @@ -1731,6 +1761,9 @@ def schedule_builds( # keep the read lock (either downgraded or already a read lock) del pending[idx] newly_installed.append((dag_hash, spec, lock)) + # It's already installed, but needs to be marked as explicitly installed in the DB. + if dag_hash in explicit and not record.explicit: + to_mark_explicit.append(MarkExplicitAction(spec)) build_graph.enqueue_parents(dag_hash, pending) continue @@ -1760,7 +1793,7 @@ def schedule_builds( finally: db.lock.release_read() - return ScheduleResult(blocked, to_start, newly_installed) + return ScheduleResult(blocked, to_start, newly_installed, to_mark_explicit) def _node_to_roots(roots: List[spack.spec.Spec]) -> Dict[str, FrozenSet[str]]: @@ -1857,6 +1890,8 @@ def finalize( class PackageInstaller: + explicit: Set[str] + def __init__( self, packages: List["spack.package_base.PackageBase"], @@ -1910,6 +1945,13 @@ def __init__( # Buffer for incoming, partially received state data from child processes self.state_buffers: Dict[int, str] = {} + if explicit is True: + self.explicit = {spec.dag_hash() for spec in specs} + elif explicit is False: + self.explicit = set() + else: + self.explicit = explicit + # Build the dependency graph self.build_graph = BuildGraph( specs, @@ -1921,6 +1963,7 @@ def __init__( self.db, self.overwrite, tests, + self.explicit, ) #: check what specs we could fetch from binaries (checks against cache, not remotely) @@ -1941,13 +1984,6 @@ def __init__( parent for parent, children in self.build_graph.parent_to_child.items() if not children ] - if explicit is True: - self.explicit = {spec.dag_hash() for spec in specs} - elif explicit is False: - self.explicit = set() - else: - self.explicit = explicit - self.verbose = verbose self.running_builds: Dict[int, ChildInfo] = {} self.log_paths: Dict[str, str] = {} @@ -2006,7 +2042,7 @@ def _handle_sigwinch(signum: int, frame: object) -> None: selector.register(sigwinch_r, selectors.EVENT_READ, "sigwinch") # Finished builds that have not yet been written to the database. - finished_builds: List[ChildInfo] = [] + database_actions: List[DatabaseAction] = [] # Prefix read locks retained after DB flush (downgraded from write locks in _save_to_db). retained_read_locks: List[spack.util.lock.Lock] = [] next_database_write = 0.0 @@ -2015,9 +2051,11 @@ def _handle_sigwinch(signum: int, frame: object) -> None: try: # Try to schedule builds immediately. The first job does not require a token. - blocked = self._schedule_builds(selector, jobserver, retained_read_locks) + blocked = self._schedule_builds( + selector, jobserver, retained_read_locks, database_actions + ) - while self.pending_builds or self.running_builds or finished_builds: + while self.pending_builds or self.running_builds or database_actions: # Monitor the jobserver when we have pending builds, capacity, and at least one # spec is not locked by another process. Also listen if the target parallelism is # reduced. @@ -2073,7 +2111,7 @@ def _handle_sigwinch(signum: int, frame: object) -> None: self.report_data.finish_record(build.spec, exitcode) if exitcode == 0: # Add successful builds for database insertion (after a short delay) - finished_builds.append(build) + database_actions.append(build) self.build_graph.enqueue_parents( build.spec.dag_hash(), self.pending_builds ) @@ -2122,18 +2160,20 @@ def _handle_sigwinch(signum: int, frame: object) -> None: # guaranteed; it fails if another process holds the lock. We'll try again next # iteration of the event loop in that case. if ( - finished_builds + database_actions and ( current_time >= next_database_write or not (self.pending_builds or self.running_builds) ) - and self._save_to_db(finished_builds, retained_read_locks) + and self._save_to_db(database_actions, retained_read_locks) ): - finished_builds.clear() + database_actions.clear() # Try to schedule more builds, acquiring per-spec locks and jobserver tokens. if self.capacity and self.pending_builds: - blocked = self._schedule_builds(selector, jobserver, retained_read_locks) + blocked = self._schedule_builds( + selector, jobserver, retained_read_locks, database_actions + ) # Finally update the UI self.build_status.update() @@ -2143,8 +2183,8 @@ def _handle_sigwinch(signum: int, frame: object) -> None: db_exc = None try: with self.db.write_transaction(): - for build in finished_builds: - self.db._add(build.spec, explicit=build.explicit) + for action in database_actions: + action.save_to_db(self.db) except Exception as e: db_exc = e @@ -2180,11 +2220,11 @@ def _handle_sigwinch(signum: int, frame: object) -> None: lock.release_read() except Exception: pass - for build in finished_builds: + for action in database_actions: try: - if build.prefix_lock is not None: - build.prefix_lock.release_write() - build.prefix_lock = None + if action.prefix_lock is not None: + action.prefix_lock.release_write() + action.prefix_lock = None except Exception: pass @@ -2232,29 +2272,31 @@ def _handle_sigwinch(signum: int, frame: object) -> None: ) def _save_to_db( - self, finished_builds: List[ChildInfo], retained_read_locks: List[spack.util.lock.Lock] + self, + database_actions: List[DatabaseAction], + retained_read_locks: List[spack.util.lock.Lock], ) -> bool: if not self.db.lock.try_acquire_write(): return False try: self.db._read() - for build in finished_builds: - self.db._add(build.spec, explicit=build.explicit) + for action in database_actions: + action.save_to_db(self.db) finally: self.db.lock.release_write(self.db._write) # DB has been written and flushed; downgrade per-spec prefix write locks to read locks so # other processes can see the specs are installed, while preventing concurrent uninstalls. - for build in finished_builds: - if build.prefix_lock is not None: + for action in database_actions: + if action.prefix_lock is not None: try: - build.prefix_lock.downgrade_write_to_read() - retained_read_locks.append(build.prefix_lock) + action.prefix_lock.downgrade_write_to_read() + retained_read_locks.append(action.prefix_lock) except Exception: - build.prefix_lock.release_write() + action.prefix_lock.release_write() raise finally: - build.prefix_lock = None + action.prefix_lock = None return True @@ -2263,6 +2305,7 @@ def _schedule_builds( selector: selectors.BaseSelector, jobserver: JobServer, retained_read_locks: List[spack.util.lock.Lock], + database_actions: List[DatabaseAction], ) -> bool: """Try to schedule as many pending builds as possible. @@ -2276,7 +2319,7 @@ def _schedule_builds( processes. In that case we should not monitor the jobserver for new tokens, since we'd end up in a busy wait loop until the locks are released. """ - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending=self.pending_builds, build_graph=self.build_graph, db=self.db, @@ -2286,14 +2329,18 @@ def _schedule_builds( capacity=self.capacity, needs_jobserver_token=bool(self.running_builds), jobserver=jobserver, + explicit=self.explicit, ) + blocked = result.blocked + database_actions.extend(result.to_mark_explicit) # Specs installed by another process. - for dag_hash, spec, lock in newly_installed: + for dag_hash, spec, lock in result.newly_installed: retained_read_locks.append(lock) - self.build_status.add_build(spec, explicit=dag_hash in self.explicit) + explicit = dag_hash in self.explicit + self.build_status.add_build(spec, explicit=explicit) self.build_status.update_state(dag_hash, "finished") # Specs we can start building ourselves. - for dag_hash, lock in to_start: + for dag_hash, lock in result.to_start: self._start(selector, jobserver, dag_hash, lock) return blocked diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 445a84e9ff005b..f33944dd5b9f1a 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -570,7 +570,7 @@ def test_env_install_include_concrete_env( assert mpileaks["libelf"].dag_hash() in test2_user_spec_hashes -def test_env_roots_marked_explicit(install_mockery, mock_fetch): +def test_env_roots_marked_explicit(install_mockery, mock_fetch, installer_variant): install = SpackCommand("install") install("--fake", "dependent-install") diff --git a/lib/spack/spack/test/installer_build_graph.py b/lib/spack/spack/test/installer_build_graph.py index a06a2539ead8f3..d3cfb33cf9c1d1 100644 --- a/lib/spack/spack/test/installer_build_graph.py +++ b/lib/spack/spack/test/installer_build_graph.py @@ -664,3 +664,26 @@ def test_tests_all_includes_test_deps_for_all( assert specs_with_test_deps["dep"].dag_hash() in graph.nodes assert specs_with_test_deps["test_dep"].dag_hash() in graph.nodes assert specs_with_test_deps["dep_test_dep"].dag_hash() in graph.nodes + + def test_mark_explicit_spec_excludes_build_only_deps( + self, specs_with_build_deps: Dict[str, Spec], temporary_store: Store + ): + """An installed-implicit spec in explicit_set should only traverse link/run deps, + not build-only deps.""" + root = specs_with_build_deps["root"] + install_spec_in_db(root, temporary_store) + assert temporary_store.db._data[root.dag_hash()].explicit is False + graph = BuildGraph( + specs=[root], + root_policy="auto", + dependencies_policy="auto", + include_build_deps=True, + install_package=True, + install_deps=True, + database=temporary_store.db, + explicit_set={root.dag_hash()}, + ) + # root should be in graph (not pruned) because it needs to be marked explicit. + assert root.dag_hash() in graph.nodes + # build-only dep should NOT be pulled in since root is already installed. + assert specs_with_build_deps["build_dep"].dag_hash() not in graph.nodes diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index 8114735babfd48..e8d286fa251b24 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -294,7 +294,7 @@ def test_not_installed_no_running_starts_build(self, temporary_store, mock_packa bg = _FakeBuildGraph([spec]) jobserver = JobServer(num_jobs=2) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -304,14 +304,15 @@ def test_not_installed_no_running_starts_build(self, temporary_store, mock_packa capacity=1, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert not blocked - assert len(to_start) == 1 - assert to_start[0][0] == spec.dag_hash() - assert not newly_installed + assert not result.blocked + assert len(result.to_start) == 1 + assert result.to_start[0][0] == spec.dag_hash() + assert not result.newly_installed assert not pending # removed from the pending list finally: - for _, lock in to_start: + for _, lock in result.to_start: lock.release_write() jobserver.close() @@ -323,7 +324,7 @@ def test_already_installed_yields_newly_installed(self, temporary_store, mock_pa bg = _FakeBuildGraph([spec]) jobserver = JobServer(num_jobs=2) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -333,14 +334,15 @@ def test_already_installed_yields_newly_installed(self, temporary_store, mock_pa capacity=1, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert not blocked - assert not to_start - assert len(newly_installed) == 1 - assert newly_installed[0][0] == spec.dag_hash() + assert not result.blocked + assert not result.to_start + assert len(result.newly_installed) == 1 + assert result.newly_installed[0][0] == spec.dag_hash() assert not pending # removed from the pending list finally: - for _, _, lock in newly_installed: + for _, _, lock in result.newly_installed: lock.release_read() jobserver.close() @@ -352,7 +354,7 @@ def test_no_jobserver_token_returns_empty(self, temporary_store, mock_packages): # num_jobs=1 writes 0 tokens to the FIFO. Only the implicit token exists. jobserver = JobServer(num_jobs=1) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -362,10 +364,11 @@ def test_no_jobserver_token_returns_empty(self, temporary_store, mock_packages): capacity=2, needs_jobserver_token=True, jobserver=jobserver, + explicit=set(), ) - assert not blocked - assert not to_start - assert not newly_installed + assert not result.blocked + assert not result.to_start + assert not result.newly_installed assert len(pending) == 1 finally: jobserver.close() @@ -381,7 +384,7 @@ def test_all_locked_returns_blocked(self, temporary_store, mock_packages, monkey monkeypatch.setattr(lock, "try_acquire_write", lambda: False) monkeypatch.setattr(lock, "try_acquire_read", lambda: False) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -391,10 +394,11 @@ def test_all_locked_returns_blocked(self, temporary_store, mock_packages, monkey capacity=2, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert blocked - assert not to_start - assert not newly_installed + assert result.blocked + assert not result.to_start + assert not result.newly_installed assert len(pending) == 1 finally: jobserver.close() @@ -407,7 +411,7 @@ def test_overwrite_installed_spec_is_started(self, temporary_store, mock_package bg = _FakeBuildGraph([spec]) jobserver = JobServer(num_jobs=2) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -417,13 +421,14 @@ def test_overwrite_installed_spec_is_started(self, temporary_store, mock_package capacity=1, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert not blocked - assert len(to_start) == 1 - assert to_start[0][0] == spec.dag_hash() - assert not newly_installed + assert not result.blocked + assert len(result.to_start) == 1 + assert result.to_start[0][0] == spec.dag_hash() + assert not result.newly_installed finally: - for _, lock in to_start: + for _, lock in result.to_start: lock.release_write() jobserver.close() @@ -439,7 +444,7 @@ def test_mixed_locked_unlocked(self, temporary_store, mock_packages, monkeypatch monkeypatch.setattr(lock_a, "try_acquire_write", lambda: False) monkeypatch.setattr(lock_a, "try_acquire_read", lambda: False) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -449,14 +454,15 @@ def test_mixed_locked_unlocked(self, temporary_store, mock_packages, monkeypatch capacity=2, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert not blocked # spec_b was schedulable - started_hashes = {h for h, _ in to_start} + assert not result.blocked # spec_b was schedulable + started_hashes = {h for h, _ in result.to_start} assert spec_b.dag_hash() in started_hashes assert spec_a.dag_hash() not in started_hashes - assert not newly_installed + assert not result.newly_installed finally: - for _, lock in to_start: + for _, lock in result.to_start: lock.release_write() jobserver.close() @@ -477,7 +483,7 @@ def test_write_locked_read_locked_installed_yields_newly_installed( lock = temporary_store.prefix_locker.lock(spec) monkeypatch.setattr(lock, "try_acquire_write", lambda: False) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -487,16 +493,17 @@ def test_write_locked_read_locked_installed_yields_newly_installed( capacity=2, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert blocked # no write lock was obtained; jobserver should not fire - assert not to_start - assert len(newly_installed) == 1 - dag_hash, installed_spec, lock = newly_installed[0] + assert result.blocked # no write lock was obtained; jobserver should not fire + assert not result.to_start + assert len(result.newly_installed) == 1 + dag_hash, installed_spec, lock = result.newly_installed[0] assert dag_hash == spec.dag_hash() assert installed_spec == spec assert not pending # spec was removed from pending finally: - for _, _, lock in newly_installed: + for _, _, lock in result.newly_installed: lock.release_read() jobserver.close() @@ -515,7 +522,7 @@ def test_write_locked_read_locked_not_installed_still_blocked( lock = temporary_store.prefix_locker.lock(spec) monkeypatch.setattr(lock, "try_acquire_write", lambda: False) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -525,10 +532,11 @@ def test_write_locked_read_locked_not_installed_still_blocked( capacity=2, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert blocked - assert not to_start - assert not newly_installed + assert result.blocked + assert not result.to_start + assert not result.newly_installed assert pending == [spec.dag_hash()] # spec stays in pending for retry finally: jobserver.close() @@ -541,7 +549,7 @@ def test_overwrite_handled_by_concurrent_process(self, temporary_store, mock_pac bg = _FakeBuildGraph([spec]) jobserver = JobServer(num_jobs=2) try: - blocked, to_start, newly_installed = schedule_builds( + result = schedule_builds( pending, bg, temporary_store.db, @@ -551,13 +559,45 @@ def test_overwrite_handled_by_concurrent_process(self, temporary_store, mock_pac capacity=1, needs_jobserver_token=False, jobserver=jobserver, + explicit=set(), ) - assert not blocked - assert not to_start - assert len(newly_installed) == 1 - assert newly_installed[0][0] == spec.dag_hash() + assert not result.blocked + assert not result.to_start + assert len(result.newly_installed) == 1 + assert result.newly_installed[0][0] == spec.dag_hash() finally: - for _, _, lock in newly_installed: + for _, _, lock in result.newly_installed: + lock.release_read() + jobserver.close() + + def test_installed_implicit_explicit_set_produces_db_update( + self, temporary_store, mock_packages + ): + """An installed-implicit spec in explicit set produces a DbUpdate.""" + spec = self._make_spec("trivial-install-test-package") + temporary_store.layout.create_install_directory(spec) + temporary_store.db.add(spec, explicit=False) + pending = [spec.dag_hash()] + bg = _FakeBuildGraph([spec]) + jobserver = JobServer(num_jobs=2) + try: + result = schedule_builds( + pending, + bg, + temporary_store.db, + temporary_store.prefix_locker, + overwrite=set(), + overwrite_time=0.0, + capacity=1, + needs_jobserver_token=False, + jobserver=jobserver, + explicit={spec.dag_hash()}, + ) + assert len(result.to_mark_explicit) == 1 + assert result.to_mark_explicit[0].spec is spec + assert len(result.newly_installed) == 1 + finally: + for _, _, lock in result.newly_installed: lock.release_read() jobserver.close() From 30883e60a31e41f5783d832fac79045b0d8bc22c Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 25 Mar 2026 12:11:19 +0100 Subject: [PATCH 187/337] solver: switch version_constraints from set of tuples to dictionary (#52140) Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 74 ++++++++++++++++-------------- lib/spack/spack/solver/runtimes.py | 6 +-- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index c101688becc669..80ef87d3b0f070 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -1362,7 +1362,7 @@ def __init__(self, tests: spack.concretize.TestsType = False): self.rejected_compilers: Set[spack.spec.Spec] = set() self.possible_oses: Set = set() self.variant_values_from_specs: Set = set() - self.version_constraints: Set = set() + self.version_constraints: Dict[str, Set] = collections.defaultdict(set) self.target_constraints: Set = set() self.default_targets: List = [] self.variant_ids_by_def_id: Dict[int, int] = {} @@ -1406,7 +1406,10 @@ def pkg_version_rules(self, pkg: Type[spack.package_base.PackageBase]) -> None: # Set the deprecation penalty, according to the package. This should be enough to move the # first version last if deprecated. - self.gen.fact(fn.pkg_fact(pkg.name, fn.version_deprecation_penalty(len(ordered_versions)))) + if ordered_versions: + self.gen.fact( + fn.pkg_fact(pkg.name, fn.version_deprecation_penalty(len(ordered_versions))) + ) for weight, declared_version in enumerate(ordered_versions): self.gen.fact(fn.pkg_fact(pkg.name, fn.version_declared(declared_version, weight))) @@ -1439,7 +1442,7 @@ def spec_versions( return [] # record all version constraints for later - self.version_constraints.add((name, spec.versions)) + self.version_constraints[name].add(spec.versions) return [fn.attr("node_version_satisfies", name, spec.versions)] def target_ranges( @@ -1898,8 +1901,8 @@ def package_splice_rules(self, pkg): for i, (cond, (spec_to_splice, match_variants)) in enumerate( sorted(pkg.splice_specs.items()) ): - self.version_constraints.add((pkg.name, cond.versions)) - self.version_constraints.add((spec_to_splice.name, spec_to_splice.versions)) + self.version_constraints[pkg.name].add(cond.versions) + self.version_constraints[spec_to_splice.name].add(spec_to_splice.versions) hash_var = AspVar("Hash") splice_node = fn.node(AspVar("NID"), pkg.name) when_spec_attrs = [ @@ -2678,24 +2681,27 @@ def define_version_constraints(self): self.gen.newline() self.gen.newline() - for pkg_name, versions in self.version_constraints: + for pkg_name, set_of_versions in sorted(self.version_constraints.items()): possible_versions = sorted_versions.get(pkg_name) if possible_versions is None: continue - # Look for contiguous ranges of versions that satisfy the constraint - start_idx = None - for current_idx, v in enumerate(possible_versions): - if v.satisfies(versions): - if start_idx is None: - start_idx = current_idx - elif start_idx is not None: - # End of a contiguous satisfying range found - version_range = fn.version_range(versions, start_idx, current_idx - 1) + for versions in sorted(set_of_versions): + # Look for contiguous ranges of versions that satisfy the constraint + start_idx = None + for current_idx, v in enumerate(possible_versions): + if v.satisfies(versions): + if start_idx is None: + start_idx = current_idx + elif start_idx is not None: + # End of a contiguous satisfying range found + version_range = fn.version_range(versions, start_idx, current_idx - 1) + self.gen.fact(fn.pkg_fact(pkg_name, version_range)) + start_idx = None + if start_idx is not None: + version_range = fn.version_range( + versions, start_idx, len(possible_versions) - 1 + ) self.gen.fact(fn.pkg_fact(pkg_name, version_range)) - start_idx = None - if start_idx is not None: - version_range = fn.version_range(versions, start_idx, len(possible_versions) - 1) - self.gen.fact(fn.pkg_fact(pkg_name, version_range)) self.gen.newline() def collect_virtual_constraints(self): @@ -2703,30 +2709,30 @@ def collect_virtual_constraints(self): Must be called before define_version_constraints(). """ - # aggregate constraints into per-virtual sets - constraint_map = collections.defaultdict(lambda: set()) - for pkg_name, versions in self.version_constraints: - if not spack.repo.PATH.is_virtual(pkg_name): - continue - constraint_map[pkg_name].add(versions) # extract all the real versions mentioned in version ranges def versions_for(v): if isinstance(v, vn.StandardVersion): - return [v] + yield v elif isinstance(v, vn.ClosedOpenRange): - return [v.lo, vn._prev_version(v.hi)] + yield v.lo + yield vn._prev_version(v.hi) elif isinstance(v, vn.VersionList): - return sum((versions_for(e) for e in v), []) + for e in v: + yield from versions_for(e) else: raise TypeError(f"expected version type, found: {type(v)}") - # define a set of synthetic possible versions for virtuals, so - # that `version_satisfies(Package, Constraint, Version)` has the - # same semantics for virtuals as for regular packages. - for pkg_name, versions in sorted(constraint_map.items()): - possible_versions = set(sum([versions_for(v) for v in versions], [])) - for version in sorted(possible_versions): + # Define a set of synthetic possible versions for virtuals that don't define versions in a + # package.py file. This ensures that `version_satisfies(Package, Constraint, Version)` has + # the same semantics for virtuals as for regular packages. + for pkg_name, versions in self.version_constraints.items(): + # Not a virtual package + if pkg_name not in self.possible_virtuals: + continue + + possible_versions = {pv for v in versions for pv in versions_for(v)} + for version in possible_versions: self.possible_versions[pkg_name][version].append(Provenance.VIRTUAL_CONSTRAINT) def define_target_constraints(self): diff --git a/lib/spack/spack/solver/runtimes.py b/lib/spack/spack/solver/runtimes.py index 0755b15b427adf..a7523b6a2f87b8 100644 --- a/lib/spack/spack/solver/runtimes.py +++ b/lib/spack/spack/solver/runtimes.py @@ -79,7 +79,7 @@ def depends_on(self, dependency_str: str, *, when: str, type: str, description: dependency_spec = spack.spec.Spec(dependency_str) if dependency_spec.versions != spack.version.any_version: - self._setup.version_constraints.add((dependency_spec.name, dependency_spec.versions)) + self._setup.version_constraints[dependency_spec.name].add(dependency_spec.versions) self.injected_dependencies.add(dependency_spec) body_str, node_variable = self.rule_body_from(when_spec) @@ -195,9 +195,7 @@ def propagate(self, constraint_str: str, *, when: str): constraint_clauses = self._setup.spec_clauses(constraint_spec, body=False) for clause in constraint_clauses: if clause.args[0] == "node_version_satisfies": - self._setup.version_constraints.add( - (constraint_spec.name, constraint_spec.versions) - ) + self._setup.version_constraints[constraint_spec.name].add(constraint_spec.versions) args = f'"{constraint_spec.name}", "{constraint_spec.versions}"' head_str = f"propagate({node_variable}, node_version_satisfies({args}))" rule = f"{head_str} :-\n{body_str}." From 1b010c58b6ebf7f43972f34d7feae68671c942c6 Mon Sep 17 00:00:00 2001 From: Angelica Date: Wed, 25 Mar 2026 19:29:01 -0600 Subject: [PATCH 188/337] Mirror all skip placeholder packages (#51991) Placeholder (or stub) packages for externals do not have a url or vcs to fetch from. They should not error out for `spack mirror -a` * Skip placeholder packages Signed-off-by: Angelica Loshak * unit tests skipping placeholder pkgs Signed-off-by: Angelica Loshak * Placeholder mock pkg Signed-off-by: Angelica Loshak --------- Signed-off-by: Angelica Loshak --- lib/spack/spack/mirrors/utils.py | 6 +++++ lib/spack/spack/test/cmd/mirror.py | 18 +++++++++++++++ .../packages/placeholder/package.py | 22 +++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/placeholder/package.py diff --git a/lib/spack/spack/mirrors/utils.py b/lib/spack/spack/mirrors/utils.py index ac720a1b96d11f..acb55788af26c2 100644 --- a/lib/spack/spack/mirrors/utils.py +++ b/lib/spack/spack/mirrors/utils.py @@ -15,6 +15,7 @@ from spack.error import MirrorError from spack.llnl.util.filesystem import mkdirp from spack.mirrors.mirror import Mirror, MirrorCollection +from spack.package import InstallError def get_all_versions(specs): @@ -209,6 +210,11 @@ def create_mirror_from_package_object( True if the spec was added successfully, False otherwise """ tty.msg("Adding package {} to mirror".format(pkg_obj.spec.format("{name}{@version}"))) + # Skip placeholder packages + try: + pkg_obj.fetcher + except InstallError: + return False max_retries = 3 for num_retries in range(max_retries): try: diff --git a/lib/spack/spack/test/cmd/mirror.py b/lib/spack/spack/test/cmd/mirror.py index 9e0518ec9f5c05..2afb0df2ca84a1 100644 --- a/lib/spack/spack/test/cmd/mirror.py +++ b/lib/spack/spack/test/cmd/mirror.py @@ -13,6 +13,7 @@ import spack.config import spack.environment as ev import spack.error +import spack.mirrors.utils import spack.package_base import spack.spec import spack.util.git @@ -753,3 +754,20 @@ def test_git_provenance_relative_to_mirror( spec_head = spack.concretize.concretize_one(f"git-test-commit@main commit={head_commit}") assert spec_head.variants["commit"].value == head_commit + + +@pytest.mark.usefixtures("mock_packages") +def test_mirror_skip_placeholder_pkg(tmp_path: pathlib.Path): + """Test a placeholder package which should skip during mirror all""" + from spack.repo import PATH + + spec = spack.spec.Spec("placeholder@1.5") + pkg_cls = PATH.get_pkg_class(spec.name) + pkg_obj = pkg_cls(spec) + mirror_cache = spack.mirrors.utils.get_mirror_cache(str(tmp_path)) + mirror_stats = spack.mirrors.utils.MirrorStatsForOneSpec(spec) + result = spack.mirrors.utils.create_mirror_from_package_object( + pkg_obj, mirror_cache, mirror_stats + ) + assert result is False + assert not mirror_stats.errors diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/placeholder/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/placeholder/package.py new file mode 100644 index 00000000000000..c02c79acbf837f --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/placeholder/package.py @@ -0,0 +1,22 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +from spack_repo.builtin_mock.build_systems.generic import Package + +from spack.package import * + + +class Placeholder(Package): + """Placeholder test package""" + + version("1.5") + + @property + def fetcher(self): + msg = "Placeholder package" + raise InstallError(msg) + + @fetcher.setter + def fetcher(self, value): + _ = self.fetcher From 0875e9be639bc4c8cfafeb74bfe6d7e678d1bbef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 23:08:20 -0700 Subject: [PATCH 189/337] build(deps): bump black in /.github/workflows/requirements/style (#52068) Bumps [black](https://github.com/psf/black) from 25.12.0 to 26.3.1. - [Release notes](https://github.com/psf/black/releases) - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md) - [Commits](https://github.com/psf/black/compare/25.12.0...26.3.1) --- updated-dependencies: - dependency-name: black dependency-version: 26.3.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/requirements/style/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index 102c1461816be3..52f1c086b8ec69 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -1,4 +1,4 @@ -black==25.12.0 +black==26.3.1 clingo==5.8.0 flake8==7.3.0 isort==7.0.0 From 86fccfaa243b0d6f514ff265549715e24767f2ce Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 26 Mar 2026 13:57:07 +0100 Subject: [PATCH 190/337] Revert "build(deps): bump black in /.github/workflows/requirements/style (#52068)" (#52147) This reverts commit 0875e9be639bc4c8cfafeb74bfe6d7e678d1bbef. Signed-off-by: Harmen Stoppels --- .github/workflows/requirements/style/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index 52f1c086b8ec69..102c1461816be3 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -1,4 +1,4 @@ -black==26.3.1 +black==25.12.0 clingo==5.8.0 flake8==7.3.0 isort==7.0.0 From 0517e97284dff7ddf604cd3715c4e5c8cd50f954 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 26 Mar 2026 15:18:58 +0100 Subject: [PATCH 191/337] config.yaml: default to `installer: new` (#52149) Use the new installer by default on Linux and Darwin. Dispatch to old installer when using unsupported features (splicing, --until). Signed-off-by: Harmen Stoppels --- etc/spack/defaults/base/config.yaml | 4 +- etc/spack/defaults/windows/config.yaml | 1 + lib/spack/spack/bootstrap/core.py | 14 +--- lib/spack/spack/cmd/dev_build.py | 4 +- lib/spack/spack/cmd/install.py | 8 +- lib/spack/spack/environment/environment.py | 10 +-- lib/spack/spack/installer_dispatch.py | 87 ++++++++++++++++++++ lib/spack/spack/test/data/config/config.yaml | 1 + lib/spack/spack/test/installer.py | 26 ++++++ 9 files changed, 128 insertions(+), 27 deletions(-) create mode 100644 lib/spack/spack/installer_dispatch.py diff --git a/etc/spack/defaults/base/config.yaml b/etc/spack/defaults/base/config.yaml index 05052f3de0ed59..96050aed47721c 100644 --- a/etc/spack/defaults/base/config.yaml +++ b/etc/spack/defaults/base/config.yaml @@ -165,8 +165,8 @@ config: # Windows. concurrent_packages: 0 - # Which installer to use: "old" or "new". The new installer is experimental. - installer: old + # Which installer to use: "old" or "new". + installer: new # If set to true, Spack will use ccache to cache C compiles. ccache: false diff --git a/etc/spack/defaults/windows/config.yaml b/etc/spack/defaults/windows/config.yaml index f54febe957553e..af50575a3c1a60 100644 --- a/etc/spack/defaults/windows/config.yaml +++ b/etc/spack/defaults/windows/config.yaml @@ -3,3 +3,4 @@ config: build_stage:: - '$user_cache_path/stage' stage_name: '{name}-{version}-{hash:7}' + installer: old diff --git a/lib/spack/spack/bootstrap/core.py b/lib/spack/spack/bootstrap/core.py index 7a788108894f7b..9bf8419cac4523 100644 --- a/lib/spack/spack/bootstrap/core.py +++ b/lib/spack/spack/bootstrap/core.py @@ -34,6 +34,7 @@ import spack.config import spack.detection import spack.error +import spack.installer_dispatch import spack.mirrors.mirror import spack.platforms import spack.spec @@ -290,12 +291,7 @@ def try_import(self, module: str, abstract_spec_str: str) -> bool: # Install the spec that should make the module importable with spack.config.override(self.mirror_scope): - if spack.config.get("config:installer", "old") == "new": - from spack.new_installer import PackageInstaller # type: ignore - else: - from spack.installer import PackageInstaller # type: ignore - - PackageInstaller( + spack.installer_dispatch.create_installer( [concrete_spec.package], fail_fast=True, root_policy="source_only", @@ -323,11 +319,7 @@ def try_search_path(self, executables: Tuple[str], abstract_spec_str: str) -> bo msg = "[BOOTSTRAP] Try installing '{0}' from sources" tty.debug(msg.format(abstract_spec_str)) with spack.config.override(self.mirror_scope): - if spack.config.get("config:installer", "old") == "new": - from spack.new_installer import PackageInstaller # type: ignore - else: - from spack.installer import PackageInstaller # type: ignore - PackageInstaller([concrete_spec.package]).install() + spack.installer_dispatch.create_installer([concrete_spec.package]).install() if _executables_in_store(executables, concrete_spec, query_info=info): self.last_search = info return True diff --git a/lib/spack/spack/cmd/dev_build.py b/lib/spack/spack/cmd/dev_build.py index 26b03324bb99d0..68f676e1c50eed 100644 --- a/lib/spack/spack/cmd/dev_build.py +++ b/lib/spack/spack/cmd/dev_build.py @@ -11,10 +11,10 @@ import spack.cmd.common.arguments import spack.concretize import spack.config +import spack.installer_dispatch import spack.llnl.util.tty as tty import spack.repo from spack.cmd.common import arguments -from spack.installer import PackageInstaller description = "build package from code in current working directory" section = "build" @@ -132,7 +132,7 @@ def dev_build(self, args): elif args.test == "root": tests = [spec.name for spec in specs] - PackageInstaller( + spack.installer_dispatch.create_installer( [spec.package], tests=tests, keep_prefix=args.keep_prefix, diff --git a/lib/spack/spack/cmd/install.py b/lib/spack/spack/cmd/install.py index 10a097f65d63fd..24619f81a926c2 100644 --- a/lib/spack/spack/cmd/install.py +++ b/lib/spack/spack/cmd/install.py @@ -11,6 +11,7 @@ import spack.cmd import spack.config import spack.environment as ev +import spack.installer_dispatch import spack.llnl.util.filesystem as fs import spack.paths import spack.spec @@ -439,13 +440,8 @@ def install_without_active_env(args, install_kwargs, reporter): installs = [s.package for s in concrete_specs] install_kwargs["explicit"] = [s.dag_hash() for s in concrete_specs] - if spack.config.get("config:installer", "old") == "new": - from spack.new_installer import PackageInstaller - else: - from spack.installer import PackageInstaller - try: - builder = PackageInstaller(installs, **install_kwargs) + builder = spack.installer_dispatch.create_installer(installs, **install_kwargs) builder.install() finally: if reporter: diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index bae93c99b36d6e..d05a2a7587cd0c 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -33,6 +33,7 @@ import spack.error import spack.filesystem_view as fsv import spack.hash_types as ht +import spack.installer_dispatch import spack.llnl.util.filesystem as fs import spack.llnl.util.tty as tty import spack.llnl.util.tty.color as clr @@ -1975,12 +1976,9 @@ def install_specs(self, specs: Optional[List[Spec]] = None, **install_args): *(s.dag_hash() for s in roots), } - if spack.config.get("config:installer", "old") == "new": - from spack.new_installer import PackageInstaller - else: - from spack.installer import PackageInstaller # type: ignore[assignment] - - builder = PackageInstaller([spec.package for spec in specs], **install_args) + builder = spack.installer_dispatch.create_installer( + [spec.package for spec in specs], **install_args + ) try: builder.install() diff --git a/lib/spack/spack/installer_dispatch.py b/lib/spack/spack/installer_dispatch.py new file mode 100644 index 00000000000000..1db1034251dd72 --- /dev/null +++ b/lib/spack/spack/installer_dispatch.py @@ -0,0 +1,87 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +import sys +from typing import TYPE_CHECKING, List, Optional, Set, Union + +from spack.vendor.typing_extensions import Literal + +import spack.config +import spack.traverse + +if TYPE_CHECKING: + import spack.installer + import spack.new_installer + import spack.package_base + + +def create_installer( + packages: List["spack.package_base.PackageBase"], + *, + dirty: bool = False, + explicit: Union[Set[str], bool] = False, + overwrite: Optional[Union[List[str], Set[str]]] = None, + fail_fast: bool = False, + fake: bool = False, + include_build_deps: bool = False, + install_deps: bool = True, + install_package: bool = True, + install_source: bool = False, + keep_prefix: bool = False, + keep_stage: bool = False, + restage: bool = True, + skip_patch: bool = False, + stop_at: Optional[str] = None, + stop_before: Optional[str] = None, + tests: Union[bool, List[str], Set[str]] = False, + unsigned: Optional[bool] = None, + verbose: bool = False, + concurrent_packages: Optional[int] = None, + root_policy: Literal["auto", "cache_only", "source_only"] = "auto", + dependencies_policy: Literal["auto", "cache_only", "source_only"] = "auto", +) -> Union["spack.installer.PackageInstaller", "spack.new_installer.PackageInstaller"]: + """Create an installer based on the current configuration and feature support.""" + use_old_installer = ( + sys.platform == "win32" + or spack.config.get("config:installer", "new") == "old" + or stop_at is not None + or stop_before is not None + ) + + # Use the old installer if splicing is used. + if not use_old_installer: + specs = [pkg.spec for pkg in packages] + for s in spack.traverse.traverse_nodes(specs): + if s.build_spec is not s: + use_old_installer = True + break + if use_old_installer: + from spack.installer import PackageInstaller # type: ignore + else: + from spack.new_installer import PackageInstaller # type: ignore + + return PackageInstaller( + packages, + dirty=dirty, + explicit=explicit, + overwrite=overwrite, + fail_fast=fail_fast, + fake=fake, + include_build_deps=include_build_deps, + install_deps=install_deps, + install_package=install_package, + install_source=install_source, + keep_prefix=keep_prefix, + keep_stage=keep_stage, + restage=restage, + skip_patch=skip_patch, + stop_at=stop_at, + stop_before=stop_before, + tests=tests, + unsigned=unsigned, + verbose=verbose, + concurrent_packages=concurrent_packages, + root_policy=root_policy, + dependencies_policy=dependencies_policy, + ) diff --git a/lib/spack/spack/test/data/config/config.yaml b/lib/spack/spack/test/data/config/config.yaml index e6867adb3db9b2..20bcd2a7e1af88 100644 --- a/lib/spack/spack/test/data/config/config.yaml +++ b/lib/spack/spack/test/data/config/config.yaml @@ -12,5 +12,6 @@ config: verify_ssl: true ssl_certs: $SSL_CERT_FILE checksum: true + installer: old # many tests are based on stdout from old installer dirty: false locks: {1} diff --git a/lib/spack/spack/test/installer.py b/lib/spack/spack/test/installer.py index 99696edb15a6b0..decc32ef1091df 100644 --- a/lib/spack/spack/test/installer.py +++ b/lib/spack/spack/test/installer.py @@ -19,6 +19,7 @@ import spack.error import spack.hooks import spack.installer as inst +import spack.installer_dispatch import spack.llnl.util.filesystem as fs import spack.llnl.util.lock as ulk import spack.llnl.util.tty as tty @@ -1389,3 +1390,28 @@ def test_print_install_test_log_failures( inst.print_install_test_log(pkg) out = capfd.readouterr()[0] assert "See test results at" in out + + +def test_fallback_to_old_installer_for_splicing(monkeypatch, mock_packages, mutable_config): + """Test that the old installer is used for spliced specs (unsupported in the new installer)""" + mutable_config.set("config:installer", "new") + spec = spack.concretize.concretize_one("splice-t") + dep = spack.concretize.concretize_one("splice-h+foo") + out = spec.splice(dep) + assert isinstance( + spack.installer_dispatch.create_installer([out.package]), inst.PackageInstaller + ) + + +def test_fallback_to_old_installer_for_until(monkeypatch, mock_packages, mutable_config): + """Test that the old installer is used if --until is used (unsupported in the new installer)""" + mutable_config.set("config:installer", "new") + spec = spack.concretize.concretize_one("trivial-install-test-package") + assert isinstance( + spack.installer_dispatch.create_installer([spec.package], stop_at="build"), + inst.PackageInstaller, + ) + assert isinstance( + spack.installer_dispatch.create_installer([spec.package], stop_before="build"), + inst.PackageInstaller, + ) From 625e9f5dc210d6cc2ea453fc082b88fd140ae4b7 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 26 Mar 2026 18:33:02 +0100 Subject: [PATCH 192/337] new_installer.py: support --until (#52123) Signed-off-by: Harmen Stoppels --- lib/spack/spack/installer_dispatch.py | 5 +- lib/spack/spack/new_installer.py | 75 +++++++++++++++++++-------- lib/spack/spack/test/cmd/dev_build.py | 12 +++-- lib/spack/spack/test/installer.py | 14 ----- 4 files changed, 60 insertions(+), 46 deletions(-) diff --git a/lib/spack/spack/installer_dispatch.py b/lib/spack/spack/installer_dispatch.py index 1db1034251dd72..dee36ec513702d 100644 --- a/lib/spack/spack/installer_dispatch.py +++ b/lib/spack/spack/installer_dispatch.py @@ -43,10 +43,7 @@ def create_installer( ) -> Union["spack.installer.PackageInstaller", "spack.new_installer.PackageInstaller"]: """Create an installer based on the current configuration and feature support.""" use_old_installer = ( - sys.platform == "win32" - or spack.config.get("config:installer", "new") == "old" - or stop_at is not None - or stop_before is not None + sys.platform == "win32" or spack.config.get("config:installer", "new") == "old" ) # Use the old installer if splicing is used. diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 65b9f475780c1e..daac1953bef03c 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -107,6 +107,9 @@ #: Suffix for temporary cleanup during failed install OVERWRITE_GARBAGE_SUFFIX = ".garbage" +#: Exit code used by the child process to signal that the build was stopped at a phase boundary +EXIT_STOPPED_AT_PHASE = 3 + class DatabaseAction: """Base class for objects that need to be persisted to the database.""" @@ -437,6 +440,8 @@ def worker_function( js2: Optional[Connection], log_path: str, global_state: GlobalState, + stop_before: Optional[str] = None, + stop_at: Optional[str] = None, ): """ Function run in the build child process. Installs the specified spec, sending state updates @@ -530,7 +535,11 @@ def handle_sigterm(signum, frame): log_path, spack.store.STORE, run_tests, + stop_before, + stop_at, ) + except spack.error.StopPhase: + exit_code = EXIT_STOPPED_AT_PHASE except BaseException: traceback.print_exc() # log the traceback to the log file exit_code = 1 @@ -647,6 +656,8 @@ def _install( log_path: str, store: spack.store.Store = spack.store.STORE, run_tests: bool = False, + stop_before: Optional[str] = None, + stop_at: Optional[str] = None, ) -> None: """Install a spec from build cache or source.""" @@ -722,7 +733,16 @@ def _install( spack.hooks.pre_install(spec) - for phase in spack.builder.create(pkg): + builder = spack.builder.create(pkg) + if stop_before is not None and stop_before not in builder.phases: + raise spack.error.InstallError(f"'{stop_before}' is not a valid phase for {pkg.name}") + if stop_at is not None and stop_at not in builder.phases: + raise spack.error.InstallError(f"'{stop_at}' is not a valid phase for {pkg.name}") + + for phase in builder: + if stop_before is not None and phase.name == stop_before: + send_state(f"stopped before {stop_before}", state_stream) + raise spack.error.StopPhase(f"Stopping before '{stop_before}'") send_state(phase.name, state_stream) spack.llnl.util.tty.msg(f"{pkg.name}: Executing phase: '{phase.name}'") # Run the install phase with debug output enabled. @@ -732,6 +752,9 @@ def _install( phase.execute() finally: spack.llnl.util.tty.set_debug(old_debug) + if stop_at is not None and phase.name == stop_at: + send_state(f"stopped after {stop_at}", state_stream) + raise spack.error.StopPhase(f"Stopping at '{stop_at}'") _archive_build_metadata(pkg) spack.hooks.post_install(spec, explicit) @@ -892,6 +915,8 @@ def start_build( install_source: bool, run_tests: bool, jobserver: JobServer, + stop_before: Optional[str] = None, + stop_at: Optional[str] = None, ) -> ChildInfo: """Start a new build.""" # Create pipes for the child's output, state reporting, and control. @@ -940,6 +965,8 @@ def start_build( None if fifo else jobserver.w_conn, log_path, GlobalState(), + stop_before, + stop_at, ), ) proc.start() @@ -1921,11 +1948,8 @@ def __init__( assert install_package or install_deps, "Must install package, dependencies or both" self.install_source = install_source - - if stop_at is not None: - raise NotImplementedError("Stopping at an install phase is not implemented") - elif stop_before is not None: - raise NotImplementedError("Stopping before an install phase is not implemented") + self.stop_at = stop_at + self.stop_before = stop_before self.tests: Union[bool, List[str], Set[str]] = tests self.db = spack.store.STORE.db @@ -2102,9 +2126,9 @@ def _handle_sigwinch(signum: int, frame: object) -> None: build = self.running_builds.pop(pid) self.capacity += 1 jobserver.release() - self._drain_child_output(build) - self.state_buffers.pop(build.state_r_conn.fileno(), None) self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) + self._drain_child_output(build, selector) + self._drain_child_state(build, selector) build.cleanup(selector) exitcode = build.proc.exitcode assert exitcode is not None, "Finished build should have exit code set" @@ -2117,6 +2141,13 @@ def _handle_sigwinch(signum: int, frame: object) -> None: ) next_database_write = current_time + DATABASE_WRITE_INTERVAL self.build_status.update_state(build.spec.dag_hash(), "finished") + elif exitcode == EXIT_STOPPED_AT_PHASE: + # Partial build: neither failure nor success. Should not be persisted in + # the database, but also not treated as a failure in the UI. Just release + # locks and move on. + if build.prefix_lock is not None: + build.prefix_lock.release_write() + build.prefix_lock = None elif not self.fail_fast or not failures: # In fail-fast mode, only record the first failure. Subsequent failures may # be a consequence of us terminating other builds, and should not be @@ -2357,16 +2388,13 @@ def _start( is_develop = spec.is_develop tests = self.tests run_tests = tests is True or bool(tests and spec.name in tests) + is_root = dag_hash in self.build_graph.roots child_info = start_build( spec, explicit=explicit, mirrors=self.binary_cache_for_spec[dag_hash], unsigned=self.unsigned, - install_policy=( - self.root_policy - if dag_hash in self.build_graph.roots - else self.dependencies_policy - ), + install_policy=self.root_policy if is_root else self.dependencies_policy, dirty=self.dirty, # keep_stage/restage logic taken from installer.py keep_stage=self.keep_stage or is_develop, @@ -2377,6 +2405,8 @@ def _start( install_source=self.install_source, run_tests=run_tests, jobserver=jobserver, + stop_before=self.stop_before if is_root else None, + stop_at=self.stop_at if is_root else None, ) self.log_paths[dag_hash] = child_info.log_path child_info.prefix_lock = prefix_lock @@ -2420,18 +2450,17 @@ def _handle_child_logs( self.build_status.print_logs(child_info.spec.dag_hash(), data) - def _drain_child_output(self, child_info: ChildInfo) -> None: + def _drain_child_output(self, child_info: ChildInfo, selector: selectors.BaseSelector) -> None: """Read and print any remaining output from a finished child's pipe.""" - dag_hash = child_info.spec.dag_hash() r_fd = child_info.output_r_conn.fileno() - try: - while True: - data = os.read(r_fd, OUTPUT_BUFFER_SIZE) - if not data: - break - self.build_status.print_logs(dag_hash, data) - except OSError: - pass + while r_fd in selector.get_map(): + self._handle_child_logs(r_fd, child_info, selector) + + def _drain_child_state(self, child_info: ChildInfo, selector: selectors.BaseSelector) -> None: + """Read and process any remaining state messages from a finished child's pipe.""" + r_fd = child_info.state_r_conn.fileno() + while r_fd in selector.get_map(): + self._handle_child_state(r_fd, child_info, selector) def _handle_child_state( self, r_fd: int, child_info: ChildInfo, selector: selectors.BaseSelector diff --git a/lib/spack/spack/test/cmd/dev_build.py b/lib/spack/spack/test/cmd/dev_build.py index 3659917b75862a..18e35dd13e869c 100644 --- a/lib/spack/spack/test/cmd/dev_build.py +++ b/lib/spack/spack/test/cmd/dev_build.py @@ -44,7 +44,7 @@ def test_dev_build_basics(tmp_path: pathlib.Path, install_mockery): assert os.path.exists(str(tmp_path)) -def test_dev_build_before(tmp_path: pathlib.Path, install_mockery): +def test_dev_build_before(tmp_path: pathlib.Path, install_mockery, installer_variant): spec = spack.concretize.concretize_one( spack.spec.Spec(f"dev-build-test-install@0.0.0 dev_path={tmp_path}") ) @@ -62,7 +62,7 @@ def test_dev_build_before(tmp_path: pathlib.Path, install_mockery): assert not os.path.exists(spec.prefix) -def test_dev_build_until(tmp_path: pathlib.Path, install_mockery): +def test_dev_build_until(tmp_path: pathlib.Path, install_mockery, installer_variant): spec = spack.concretize.concretize_one( spack.spec.Spec(f"dev-build-test-install@0.0.0 dev_path={tmp_path}") ) @@ -102,7 +102,7 @@ def test_dev_build_until_last_phase(tmp_path: pathlib.Path, install_mockery): assert os.path.exists(str(tmp_path)) -def test_dev_build_before_until(tmp_path: pathlib.Path, install_mockery): +def test_dev_build_before_until(tmp_path: pathlib.Path, install_mockery, installer_variant): spec = spack.concretize.concretize_one( spack.spec.Spec(f"dev-build-test-install@0.0.0 dev_path={tmp_path}") ) @@ -120,12 +120,14 @@ def test_dev_build_before_until(tmp_path: pathlib.Path, install_mockery): out = dev_build("-u", bad_phase, "dev-build-test-install@0.0.0", fail_on_error=False) assert bad_phase in out assert not_allowed in out - assert not_installed in out + if installer_variant == "old": + assert not_installed in out out = dev_build("-b", bad_phase, "dev-build-test-install@0.0.0", fail_on_error=False) assert bad_phase in out assert not_allowed in out - assert not_installed in out + if installer_variant == "old": + assert not_installed in out def _print_spack_short_spec(*args): diff --git a/lib/spack/spack/test/installer.py b/lib/spack/spack/test/installer.py index decc32ef1091df..40027c44d35e75 100644 --- a/lib/spack/spack/test/installer.py +++ b/lib/spack/spack/test/installer.py @@ -1401,17 +1401,3 @@ def test_fallback_to_old_installer_for_splicing(monkeypatch, mock_packages, muta assert isinstance( spack.installer_dispatch.create_installer([out.package]), inst.PackageInstaller ) - - -def test_fallback_to_old_installer_for_until(monkeypatch, mock_packages, mutable_config): - """Test that the old installer is used if --until is used (unsupported in the new installer)""" - mutable_config.set("config:installer", "new") - spec = spack.concretize.concretize_one("trivial-install-test-package") - assert isinstance( - spack.installer_dispatch.create_installer([spec.package], stop_at="build"), - inst.PackageInstaller, - ) - assert isinstance( - spack.installer_dispatch.create_installer([spec.package], stop_before="build"), - inst.PackageInstaller, - ) From bf1637a793b507a76bdc06e1169a373c83a5e535 Mon Sep 17 00:00:00 2001 From: Tamara Dahlgren <35777542+tldahlgren@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:08:43 -0700 Subject: [PATCH 193/337] remote include path: cache under enclosing scope ({spack|include}.yaml) path or tempdir (from #50207) (#51896) * remote include path: cache under enclosing scope Cache remote include paths under the enclosing scope's path. If it has no path, the file will be cached in tmpdir * Update include yaml docs * cleanup and add unit tests for include's destination directory * config: add extra subdirectory to support multiple remote includes subdir naming order: name, git repo name, or hash --------- Signed-off-by: tldahlgren --- lib/spack/docs/include_yaml.rst | 51 ++++--- lib/spack/spack/config.py | 153 ++++++++++++++++----- lib/spack/spack/environment/environment.py | 2 +- lib/spack/spack/test/cmd/env.py | 12 +- lib/spack/spack/test/config.py | 95 ++++++++++--- lib/spack/spack/test/conftest.py | 6 - lib/spack/spack/util/remote_file_cache.py | 2 +- 7 files changed, 229 insertions(+), 92 deletions(-) diff --git a/lib/spack/docs/include_yaml.rst b/lib/spack/docs/include_yaml.rst index 4e19dbb19d3b4a..65b8078c68a381 100644 --- a/lib/spack/docs/include_yaml.rst +++ b/lib/spack/docs/include_yaml.rst @@ -32,7 +32,7 @@ You can include a single configuration file or an entire configuration *scope* l - path: /path/to/os-specific/config-dir when: os == "ventura" -Included paths may be absolute, relative (to the configuration file), specified as URLs, or provided in an environment variable (e.g., ``$MY_SPECIAL_CONFIG_FILE``). +Included paths may be absolute, relative (to the configuration file), specified as URLs, or provided in environment variables (e.g., ``$MY_SPECIAL_CONFIG_FILE``). * ``optional``: Spack will raise an error when an included configuration file does not exist, *unless* it is explicitly made ``optional: true``, like the second path above. * ``when``: Configuration scopes can also be included *conditionally* with ``when``. @@ -44,24 +44,31 @@ The same conditions and variables in :ref:`Spec List References `_ or `GitLab `_). + If the directory containing the ``include.yaml`` file is not writable when the remote file is downloaded, then the destination will be a temporary directory. + + ``git`` repository files ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -72,12 +79,12 @@ Inclusion of the repository (and its paths) can be optional or conditional. If you want to control the :ref:`name of the configuration scope `, you can provide a ``name``. For example, suppose we only want to include the ``config.yaml`` and ``packages.yaml`` files from the `spack/spack-configs `_ repository's ``USC/config`` directory when using the ``centos7`` operating system. -And we want the configuration scope name to start ``USC``. -We would then configure the ``include.yaml`` file as follows:: +And we want the configuration scope name to start ``common``. +We could then configure the include in, for example, the user scope include file (i.e., ``$HOME/.spack/include.yaml`` by default), as follows:: include: - - name: USC - git: https://github.com/spack/spack-configs + - name: common + git: https://github.com/spack/spack-configs.git branch: main when: os == "centos7" paths: @@ -86,24 +93,24 @@ We would then configure the ``include.yaml`` file as follows:: .. note:: - The git URL can be specified through an environment variable (e.g., ``$MY_USC_CONFIG_URL``). + The git URL could be specified through an environment variable (e.g., ``$MY_USC_CONFIG_URL``). -If the condition is satisfied, then the ``main`` branch of the repository will be cloned when the configuration scopes are initially created. +If the condition is satisfied, then the ``main`` branch of the repository will be cloned -- under ``$HOME/.spack/includes`` -- when configuration scopes are initially created. Once cloned, the settings for the two files under the ``USC/config`` directory will be integrated into Spack's configuration. -In this example, the new scopes can be seen by running:: +In this example, the new scopes and their paths can be seen by running:: $ spack config scopes -p Scope Path command_line - spack /Users/username/spack/etc/spack/ - user /Users/username/.spack/ - USC:config.yaml /Users/username/.spack/includes/nncrh7v/USC/config/config.yaml - USC:packages.yaml /Users/username/.spack/includes/nncrh7v/USC/config/packages.yaml - site /Users/username/spack/etc/spack/site/ - system /etc/spack/ - defaults /Users/username/spack/etc/spack/defaults/ - defaults:darwin /Users/username/spack/etc/spack/defaults/darwin/ - defaults:base /Users/username/spack/etc/spack/defaults/base/ + spack /Users/username/spack/etc/spack/ + user /Users/username/.spack/ + common:USC/config/config.yaml /Users/username/.spack/includes/common/USC/config/config.yaml + common:USC/config/packages.yaml /Users/username/.spack/includes/common/USC/config/packages.yaml + site /Users/username/spack/etc/spack/site/ + system /etc/spack/ + defaults /Users/username/spack/etc/spack/defaults/ + defaults:darwin /Users/username/spack/etc/spack/defaults/darwin/ + defaults:base /Users/username/spack/etc/spack/defaults/base/ _builtin Since there are two unique paths, each results in a separate configuration scope. diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 2ad0d417d5210e..bb812e749b8f87 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -137,11 +137,6 @@ MAX_RECURSIVE_INCLUDES = 100 -def _include_cache_location(): - """Location to cache included configuration files.""" - return os.path.join(spack.paths.user_cache_path, "includes") - - class ConfigScope: def __init__(self, name: str, included: bool = False) -> None: self.name = name @@ -150,7 +145,7 @@ def __init__(self, name: str, included: bool = False) -> None: self.prefer_modify = False self.included = included - #: names of any included scopes + #: included configuration scopes self._included_scopes: Optional[List["ConfigScope"]] = None @property @@ -555,7 +550,7 @@ def push_scope_incremental( # TODO: includes AND ensure properly sorted such that the order included # TODO: at the highest level is reflected in the value of an option that # TODO: is set in multiple included files. - # before pushing the scope itself, push any included scopes recursively, at same priority + # before pushing the scope itself, push included scopes recursively, at the same priority for included_scope in reversed(scope.included_scopes): if _depth + 1 > MAX_RECURSIVE_INCLUDES: # make sure we're not recursing endlessly mark = "" @@ -1028,6 +1023,7 @@ class OptionalInclude: when: str optional: bool prefer_modify: bool + remote: bool _scopes: List[ConfigScope] def __init__(self, entry: dict): @@ -1035,8 +1031,78 @@ def __init__(self, entry: dict): self.when = entry.get("when", "") self.optional = entry.get("optional", False) self.prefer_modify = entry.get("prefer_modify", False) + self.remote = False self._scopes = [] + @staticmethod + def _parent_scope_directory(parent_scope: Optional[ConfigScope]) -> Optional[str]: + """Return the directory of the parent scope, or ``None`` if unavailable. + + Normalizes ``SingleFileScope`` to its containing directory. + """ + path = getattr(parent_scope, "path", "") if parent_scope else "" + if not path: + return None + return os.path.dirname(path) if os.path.isfile(path) else path + + def base_directory( + self, path_or_url: str, parent_scope: Optional[ConfigScope] = None + ) -> Optional[str]: + """Return the local directory to use for this include. + + For remote includes this is the cache destination directory. + For local relative includes this is the working directory from which to resolve the path. + + Args: + path_or_url: path or URL of the include + parent_scope: including scope + + Returns: ``None`` for a local include without an enclosing parent scope; + an appropriate subdirectory of the enclosing (parent) scope's writable + directory (when available); otherwise a stable temporary directory. + """ + scope_dir = self._parent_scope_directory(parent_scope) + if not self.remote: + return scope_dir + + def _subdir(): + # Prefer the provided include name over the git repository name. + # If neither, use a hash of the url or path for uniqueness. + if self.name: + return self.name + + match = re.search(r"/([^/]+?)(\.git)?$", path_or_url) + if match: + if not os.path.splitext(match.group(1))[1]: + return match.group(1) + + return spack.util.hash.b32_hash(path_or_url)[-7:] + + # For remote includes, prefer a writable subdirectory of the parent scope. + if scope_dir and filesystem.can_write_to_dir(scope_dir): + assert parent_scope is not None + subdir = os.path.join("includes", _subdir()) + if parent_scope.name.startswith("env:"): + subdir = os.path.join(".spack-env", subdir) + return os.path.join(scope_dir, subdir) + + # Fall back to a stable, unique, temporary directory, logging the reason. + tmpdir = tempfile.gettempdir() + if path_or_url: + pre = self.name or getattr(parent_scope, "name", "") + subdir = f"{pre}:{path_or_url}" if pre else path_or_url + tmpdir = os.path.join(tmpdir, spack.util.hash.b32_hash(subdir)[-7:]) + + if not scope_dir: + tty.debug(f"No parent scope directory for include ({self}). Using {tmpdir}.") + else: + assert parent_scope is not None + tty.debug( + f"Parent scope {parent_scope.name}'s directory ({scope_dir}) is not writable. " + f"Using {tmpdir}." + ) + return tmpdir + def _scope( self, path: str, config_path: str, parent_scope: ConfigScope ) -> Optional[ConfigScope]: @@ -1095,7 +1161,7 @@ def _scope( exists = os.path.exists(config_path) if not exists and not self.optional: - dest = f" at ({config_path})" if config_path != path else "" + dest = f" at ({config_path})" if config_path != os.path.normpath(path) else "" raise ValueError(f"Required path ({path}) does not exist{dest}") if (exists and not is_dir) or ext_is_yaml: @@ -1131,6 +1197,10 @@ def _validate_parent_scope(self, parent_scope: ConfigScope): assert parent_scope.name.strip(), "Parent scope of an include must have a name" def evaluate_condition(self) -> bool: + """Evaluate the include condition: + + Returns: ``True`` if the include condition is satisfied; else ``False``. + """ # circular dependencies import spack.spec @@ -1142,8 +1212,8 @@ def scopes(self, parent_scope: ConfigScope) -> List[ConfigScope]: Args: parent_scope: including scope - Returns: configuration scopes IF the when condition is satisfied; - otherwise, an empty list. + Returns: configuration scopes for configuration files IF the when + condition is satisfied; otherwise, an empty list. Raises: ValueError: the required configuration path does not exist @@ -1175,6 +1245,7 @@ def __init__(self, entry: dict): self.path = spack.util.path.substitute_path_variables(path) self.sha256 = entry.get("sha256", "") + self.remote = "sha256" in entry self.destination = None def __repr__(self): @@ -1205,18 +1276,18 @@ def scopes(self, parent_scope: ConfigScope) -> List[ConfigScope]: tty.debug(f"Using existing scopes: {[s.name for s in self._scopes]}") return self._scopes - # Make sure to use the proper (default) working directory when obtaining - # the local path for a local file. - def work_dir(): - if not os.path.isabs(self.path) and hasattr(parent_scope, "path"): - if os.path.isfile(parent_scope.path): - return os.path.dirname(parent_scope.path) - if os.path.isdir(parent_scope.path): - return parent_scope.path - return os.getcwd() - - with filesystem.working_dir(work_dir()): - config_path = rfc_util.local_path(self.path, self.sha256, _include_cache_location()) + # An absolute path does not need a local base directory. + if os.path.isabs(self.path): + tty.debug(f"The included path ({self}) is absolute so needs no base directory") + base = None + else: + base = self.base_directory(self.path, parent_scope) + + # Make sure to use a proper working directory when obtaining the local + # path for a local (or remote) file. + tty.debug(f"Local base directory for {self.path} is {base}") + + config_path = rfc_util.local_path(self.path, self.sha256, base) assert config_path self.destination = config_path @@ -1247,6 +1318,7 @@ def __init__(self, entry: dict): super().__init__(entry) self.git = spack.util.path.substitute_path_variables(entry.get("git", "")) + self.branch = entry.get("branch", "") self.commit = entry.get("commit", "") self.tag = entry.get("tag", "") @@ -1254,6 +1326,7 @@ def __init__(self, entry: dict): spack.util.path.substitute_path_variables(path) for path in entry.get("paths", []) ] self.destination = None + self.remote = True if not self.branch and not self.commit and not self.tag: raise spack.error.ConfigError( @@ -1272,24 +1345,31 @@ def __repr__(self): identifier = f"commit={self.commit}, tag={self.tag}" return ( - f"GitIncludePaths({self.git}, paths={self.paths}, " + f"GitIncludePaths('{self.name}', {self.git}, paths={self._paths}, " f"{identifier}, when='{self.when}', optional={self.optional})" ) - def _destination(self): - dir_name = spack.util.hash.b32_hash(self.git)[-7:] - return os.path.join(_include_cache_location(), dir_name) + def _clone(self, parent_scope: ConfigScope) -> Optional[str]: + """Clone the repository. + + Args: + parent_scope: enclosing scope - def _clone(self) -> Optional[str]: - """Clone the repository.""" + Returns: destination path if cloned or ``None`` + """ if self.fetched(): tty.debug(f"Repository ({self.git}) already cloned to {self.destination}") return self.destination - destination = self._destination() + # environment includes should be located under the environment + destination = self.base_directory(self.git, parent_scope) + assert destination, f"{self} requires a local cache directory" + tty.debug(f"Cloning {self.git} into {destination}") + with filesystem.working_dir(destination, create=True): if not os.path.exists(".git"): try: + tty.debug("Initializing the git repository") spack.util.git.init_git_repo(self.git) except spack.util.executable.ProcessError as e: raise spack.error.ConfigError( @@ -1298,12 +1378,15 @@ def _clone(self) -> Optional[str]: try: if self.commit: + tty.debug(f"Pulling commit {self.commit}") spack.util.git.pull_checkout_commit(self.commit) elif self.tag: + tty.debug(f"Pulling tag {self.tag}") spack.util.git.pull_checkout_tag(self.tag) elif self.branch: # if the branch already exists we should use the # previously configured remote + tty.debug(f"Pulling branch {self.branch}") try: git = spack.util.git.git(required=True) output = git("config", f"branch.{self.branch}.remote", output=str) @@ -1323,8 +1406,10 @@ def _clone(self) -> Optional[str]: self.destination = destination return self.destination - def fetched(self): - return self.destination is not None and os.path.join(self.destination, ".git") + def fetched(self) -> bool: + return bool(self.destination) and os.path.exists( + os.path.join(self.destination, ".git") # type: ignore[arg-type] + ) def scopes(self, parent_scope: ConfigScope) -> List[ConfigScope]: """Instantiate configuration scopes for the included paths. @@ -1348,13 +1433,13 @@ def scopes(self, parent_scope: ConfigScope) -> List[ConfigScope]: tty.debug(f"Using existing scopes: {[s.name for s in self._scopes]}") return self._scopes - destination = self._clone() - if destination is None: + destination = self._clone(parent_scope) + if not destination: raise spack.error.ConfigError(f"Unable to cache the include: {self}") scopes: List[ConfigScope] = [] for path in self.paths: - config_path = os.path.join(destination, path) + config_path = str(pathlib.Path(destination) / path) scope = self._scope(path, config_path, parent_scope) if scope is not None: scopes.append(scope) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index d05a2a7587cd0c..19be16c49295af 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -1220,7 +1220,7 @@ def _process_included_lockfiles(self): if isinstance(include, spack.config.GitIncludePaths): # Git includes must be cloned first; paths are relative to the # clone destination, not to the manifest directory. - destination = include._clone() + destination = include._clone(self.manifest.env_config_scope) if destination is None: continue resolved = [os.path.join(destination, p) for p in include.paths] diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index f33944dd5b9f1a..6a74393ae2fe89 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -4374,14 +4374,8 @@ def test_unify_when_possible_works_around_conflicts(mutable_config): assert len([x for x in e.all_specs() if x.satisfies("mpich")]) == 1 -# Using mock_include_cache to ensure the "remote" file is cached in a temporary -# location and not polluting the user cache. def test_env_include_packages_url( - tmp_path: pathlib.Path, - mutable_empty_config, - mock_fetch_url_text, - mock_curl_configs, - mock_include_cache, + tmp_path: pathlib.Path, mutable_empty_config, mock_fetch_url_text, mock_curl_configs ): """Test inclusion of a (GitHub) URL.""" develop_url = "https://github.com/fake/fake/blob/develop/" @@ -4885,7 +4879,9 @@ def test_env_include_concrete_git_lockfile(tmp_path, mock_packages, mutable_conf shutil.copy(os.path.join(e.path, ev.manifest_name), lock_in_clone.parent) # Prevent actual git operations; return the pre-built clone destination. - monkeypatch.setattr(spack.config.GitIncludePaths, "_clone", lambda self: str(clone_dest)) + monkeypatch.setattr( + spack.config.GitIncludePaths, "_clone", lambda self, parent_scope: str(clone_dest) + ) main_dir = tmp_path / "main_env" main_dir.mkdir() diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index 46d6c6a529500f..b5cb98ef1b0617 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -1844,19 +1844,11 @@ def test_included_path_git_substitutions(): def test_included_path_git( tmp_path: pathlib.Path, mock_low_high_config, ensure_debug, monkeypatch, key, value, capfd ): - monkeypatch.setattr(spack.paths, "user_cache_path", str(tmp_path)) - - class MockIncludeGit(spack.util.executable.Executable): - def __init__(self, required: bool): - pass - - def __call__(self, *args, **kwargs) -> str: # type: ignore - action = args[0] - - if action == "config": - return "origin" + """Check git includes for branch, commit, and tag using relative paths. - return "" + Note the mock config fixture does NOT create the scope path so a temporary + directory will be used for caching the files. + """ # Specifying two relative paths, one explicit, one implicit paths = ["./config.yaml", "packages.yaml"] @@ -1871,18 +1863,30 @@ def __call__(self, *args, **kwargs) -> str: # type: ignore assert isinstance(include, spack.config.GitIncludePaths) assert not include.optional and include.evaluate_condition() - destination = include._destination() - assert not os.path.exists(destination) - # set up minimal git and repository operations + class MockIncludeGit(spack.util.executable.Executable): + def __init__(self, required: bool): + pass + + def __call__(self, *args, **kwargs) -> str: # type: ignore + action = args[0] + + if action == "config": + return "origin" + + return "" + monkeypatch.setattr(spack.util.git, "git", MockIncludeGit) def _init_repo(*args, **kwargs): - fs.mkdirp(fs.join_path(destination, ".git")) + # Make sure the directory exists, where assuming called from within + # the working directory. + fs.mkdirp(fs.join_path(os.getcwd(), ".git")) def _checkout(*args, **kwargs): - # Make sure the files exist at the clone destination - with fs.working_dir(destination): + # Make sure the files exist at the clone destination, where assuming + # called from within the working directory. + with fs.working_dir(os.getcwd()): for p in paths: fs.touch(p) @@ -1892,7 +1896,7 @@ def _checkout(*args, **kwargs): # First successful pass builds the scope parent_scope = mock_low_high_config.scopes["low"] scopes = include.scopes(parent_scope) - assert scopes and len(scopes) == len(paths) + assert len(scopes) == len(paths) base_paths = [os.path.basename(p) for p in paths] for scope in scopes: @@ -1911,11 +1915,62 @@ def _checkout(*args, **kwargs): # A direct clone now returns already cloned destination and debug message. # Again only need to run this test once. if key == "tag": - assert include._clone() == include.destination + assert include._clone(parent_scope) == include.destination captured = capfd.readouterr()[1] assert "already cloned" in captured +@pytest.mark.parametrize("path", ["./config.yaml", "/path/to/my/special/package.yaml"]) +def test_included_path_local_no_dest(path): + """Confirm that local paths have no cache destination.""" + entry = {"path": path} + include = spack.config.included_path(entry) + destination = include.base_directory(entry["path"]) + assert not destination, f"Expected local include ({include}) to NOT have a cache destination" + + +def test_included_path_url_temp_dest(mock_low_high_config): + """Check that remote (raw) path under different scopes end up with temporary + cache destinations.""" + entry = { + "path": "https://github.com/path/to/raw/config/config.yaml", + "sha256": "26e871804a92cd07bb3d611b31b4156ae93d35b6a6d6e0ef3a67871fcb1d258b", + } + include = spack.config.included_path(entry) + + parent_scope = mock_low_high_config.scopes["low"] + parent_scope.path = "" + pre = f"Expected temporary cache destination for raw include path ({include}) for " + + for scope in [None, parent_scope]: + rest = "parent scope with no path" if scope else "no parent scope" + destination = include.base_directory(entry["path"], parent_scope=scope) + dest_dir = str(pathlib.Path(destination).parent) + temp_dir = tempfile.gettempdir() + assert dest_dir == temp_dir, pre + rest + + +def test_included_path_git_temp_dest(mock_low_high_config): + """Check a remote (relative) path with different parent scope options that + result in a temporary cache destination.""" + entry = { + "git": "https://example.com/linux/configs.git", + "branch": "develop", + "paths": ["config.yaml"], + } + include = spack.config.included_path(entry) + parent_scope = mock_low_high_config.scopes["low"] + parent_scope.path = "" + pre = f"Expected temporary cache destination for git include path ({include}) for " + + for scope in [None, parent_scope]: + rest = "parent scope with no path" if scope else "no parent scope" + destination = include.base_directory(entry["git"], parent_scope=scope) + dest_dir = str(pathlib.Path(destination).parent) + temp_dir = tempfile.gettempdir() + assert dest_dir == temp_dir, pre + rest + + def test_included_path_git_errs(tmp_path: pathlib.Path, mock_low_high_config, monkeypatch): monkeypatch.setattr(spack.paths, "user_cache_path", str(tmp_path)) diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index 84358b4063b228..8afedd2a833e97 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -2485,12 +2485,6 @@ def _include_cache_root(): return join_path(str(tempfile.mkdtemp()), "user_cache", "includes") -@pytest.fixture() -def mock_include_cache(monkeypatch): - """Override the include cache directory so tests don't pollute user cache.""" - monkeypatch.setattr(spack.config, "_include_cache_location", _include_cache_root) - - @pytest.fixture() def wrapper_dir(install_mockery): """Installs the compiler wrapper and returns the prefix where the script is installed.""" diff --git a/lib/spack/spack/util/remote_file_cache.py b/lib/spack/spack/util/remote_file_cache.py index 6624da8ce9a1af..a2330808d3c439 100644 --- a/lib/spack/spack/util/remote_file_cache.py +++ b/lib/spack/spack/util/remote_file_cache.py @@ -80,7 +80,7 @@ def local_path(raw_path: str, sha256: str, dest: Optional[str] = None) -> str: # Allow paths (and URLs) to contain spack config/environment variables, # etc. - path = canonicalize_path(raw_path) + path = canonicalize_path(raw_path, dest) # Save off the Windows drive of the canonicalized path (since now absolute) # to ensure recognized by URL parsing as a valid file "scheme". From a5b4056c9019d27d7d38885d37f3be4446b48dd8 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 27 Mar 2026 10:27:33 +0100 Subject: [PATCH 194/337] languages: add hip-lang, cuda-lang (#52145) Signed-off-by: Harmen Stoppels --- etc/spack/defaults/base/packages.yaml | 2 ++ lib/spack/spack/solver/concretize.lp | 2 ++ 2 files changed, 4 insertions(+) diff --git a/etc/spack/defaults/base/packages.yaml b/etc/spack/defaults/base/packages.yaml index 57bc3e8fd6574d..ad86568c4e894b 100644 --- a/etc/spack/defaults/base/packages.yaml +++ b/etc/spack/defaults/base/packages.yaml @@ -20,6 +20,7 @@ packages: armci: [armcimpi] blas: [openblas] c: [gcc, llvm, intel-oneapi-compilers] + cuda-lang: [cuda] cxx: [gcc, llvm, intel-oneapi-compilers] daal: [intel-oneapi-daal] elf: [elfutils] @@ -32,6 +33,7 @@ packages: glu: [mesa-glu, openglu] golang: [go, gcc] go-or-gccgo-bootstrap: [go-bootstrap, gcc] + hip-lang: [llvm-amdgpu] iconv: [libiconv] ipp: [intel-oneapi-ipp] java: [openjdk, jdk] diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index ddd16a2f84a77e..d31f3269fb90d2 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -1865,8 +1865,10 @@ compiler(Compiler) :- target_supported(Compiler, _, _). % Can't use targets on node if the compiler for the node doesn't support them language("c"). +language("cuda-lang"). language("cxx"). language("fortran"). +language("hip-lang"). language_runtime("fortran-rt"). error(10, "Only external, or concrete, compilers are allowed for the {0} language", Language) From 2e0cf4de3459216053b41620606d8e689f24cc4a Mon Sep 17 00:00:00 2001 From: "Seth R. Johnson" Date: Mon, 30 Mar 2026 10:37:03 -0400 Subject: [PATCH 195/337] Document correct practices for specifying dependency version ranges (#52022) * Document version range practice Signed-off-by: Seth R Johnson * Tweak verbiage and examples Signed-off-by: Seth R Johnson * Fix typo Signed-off-by: Seth R Johnson * Incorporate feedback Signed-off-by: Seth R Johnson * Incorporate comments Signed-off-by: Seth R Johnson --------- Signed-off-by: Seth R Johnson --- lib/spack/docs/package_review_guide.rst | 2 +- lib/spack/docs/packaging_guide_creation.rst | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/spack/docs/package_review_guide.rst b/lib/spack/docs/package_review_guide.rst index 18806398558fd3..585251bb31cf5a 100644 --- a/lib/spack/docs/package_review_guide.rst +++ b/lib/spack/docs/package_review_guide.rst @@ -300,7 +300,7 @@ They only need to be checked in a review when versions are being added or remove Dependencies affected by such changes should be confirmed, when possible, and *at least* when the Contributor is not a Maintainer of the package. **Solutions.** -In some cases, the needed change may be as simple as ensuring the version range and or variant options in the dependency are accurate. +In some cases, the needed change may be as simple as ensuring the version ranges (see :ref:`version_compatibility`) and/or variant options in the dependency are accurate. In others, one or more of the dependencies needed by new versions are missing and need to be added. Or there may be dependencies that are no longer relevant when versions requiring them are removed, meaning the dependencies should be removed as well. diff --git a/lib/spack/docs/packaging_guide_creation.rst b/lib/spack/docs/packaging_guide_creation.rst index b2556b85db8299..95f5af4fd21d83 100644 --- a/lib/spack/docs/packaging_guide_creation.rst +++ b/lib/spack/docs/packaging_guide_creation.rst @@ -1703,7 +1703,7 @@ Spack allows you to specify this in the ``depends_on`` directive using version r depends_on("python@3.10:") -In this case, the package requires Python 3.10 or newer. +In this case, the package requires Python 3.10 or newer, as specified in the project's :file:`pyproject.toml`. Commonly, packages drop support for older versions of a dependency as they release new versions. In Spack you can conveniently add every backward compatibility rule as a separate line: @@ -1770,6 +1770,22 @@ For example, if you need Boost 1.59.0 or newer, but there are known issues with depends_on("boost@1.59.0:1.63,1.65.1,1.67.0:") +or, if those particular versions are excluded due to bugs rather than removed and reintroduced features: + +.. code-block:: python + + depends_on("boost@1.59.0:") + conflicts("^boost@1.64.0,1.65.0,1.66.0") + +Always specify version ranges with an open-world assumption: + +- all "ground truths" about exclusions and inclusions (e.g., versions with features added or removed) must satisfy the range, and +- no potential but unknown versions are excluded from the range. + +This practice avoids overconstraining version ranges, which can lead to concretization errors, and ensures that every version in a package is *meaningful* and not just *incidental* (i.e., based on the version you happened to test). +In the above example, the project has presumably documented (with pyproject.toml, CMakeLists.txt, or release notes) that ``@:1.58`` are incompatible, and it is known from testing that ``@1.67`` is compatible. +It is *not* known whether future versions ``@1.68:`` are incompatible, so they must be included by the range. +If and when future versions are known incompatible, the version range should be constrained with an upper bound. .. _dependency-types: From 6901e4add84e926b0f37eaff77ef3e3e55a101fd Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 30 Mar 2026 17:23:09 +0200 Subject: [PATCH 196/337] new_installer.py: suspend, background, foreground (#52101) * new_installer.py: Ctrl+Z, bg, and fg When the user presses Ctrl+Z, the parent process was stopped by SIGTSTP but its build subprocesses kept running, consuming CPU and filling log pipes until they blocked on write. Fix this by broadcasting SIGSTOP to every child process group just before the parent suspends, and SIGCONT when it resumes. Because children call os.setsid(), one killpg() per child stops the entire tree of compiler and build-system subprocesses. The changes are structured as follows: * Add TerminalState: a new class that owns all terminal-related setup and teardown: cbreak mode, stdin selector registration, SIGWINCH self-pipe, and the SIGTSTP handler. Two optional hooks, on_suspend and on_resume, let callers register side-effects without coupling TerminalState to child process management. The installer wires these up to _signal_children(). * Add headless mode to the UI class: when True, update(), print_logs(), and the non-TTY path of update_state() are all suppressed. TerminalState sets this flag on suspend and clears it on resume; it also cleared when the process transitions from background to foreground. When in headless mode, the event loop fires only once a second since there are no spinners to update; but we *do* have to check every now and then whether we went to the foreground, which I think is only possible through polling. So, once a second we detect whether we go from headless back to ... headful? * Reset SIGTSTP to SIG_DFL in worker_function so child processes do not inherit the parent's handler after forking. * Add TestHeadlessMode unit tests covering the three suppression paths. Signed-off-by: Harmen Stoppels * fix for zsh: output stdout = suspend, so do not print Signed-off-by: Harmen Stoppels --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 234 +++++++++++++++++++++----- lib/spack/spack/test/installer_tui.py | 46 +++++ 2 files changed, 238 insertions(+), 42 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index daac1953bef03c..891e41e4091a30 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -81,6 +81,7 @@ import spack.util.environment import spack.util.lock from spack.installer import _do_fake_install, dump_packages +from spack.llnl.util.tty.log import _is_background_tty, ignore_signal from spack.util.path import padding_filter, padding_filter_bytes if TYPE_CHECKING: @@ -92,6 +93,9 @@ #: How often to update a spinner in seconds SPINNER_INTERVAL = 0.1 +#: How often to wake up in headless mode to check for background->foreground transition (seconds) +HEADLESS_WAKE_INTERVAL = 1.0 + #: How long to display finished packages before graying them out CLEANUP_TIMEOUT = 2.0 @@ -478,6 +482,9 @@ def worker_function( # Start a new session, so our SIGTERM handler can kill all child processes. os.setsid() + # Reset SIGTSTP to default in case the parent had a custom handler. + signal.signal(signal.SIGTSTP, signal.SIG_DFL) + def handle_sigterm(signum, frame): # This SIGTERM handler forwards the signal to child processes (cmake, make, etc). We wait # for all child processes to exit before raising KeyboardInterrupt. This ensures all @@ -1144,6 +1151,9 @@ def __init__( #: Verbose mode only applies to non-TTY where we want to track a single build log. self.verbose = verbose and not self.is_tty self.filter_padding = filter_padding + #: When True, suppress all terminal output (process is in background). + #: Controlling code is responsible for modifying this variable based on process state + self.headless = False def on_resize(self) -> None: """Refresh cached terminal size and trigger a redraw.""" @@ -1314,7 +1324,7 @@ def update_state(self, build_id: str, state: str) -> None: self.dirty = True # For non-TTY output, print state changes immediately - if not self.is_tty: + if not self.is_tty and not self.headless: line = "".join(self._generate_line_components(build_info, static=True)) self.stdout.write(line + "\n") self.stdout.flush() @@ -1342,7 +1352,7 @@ def update_progress(self, build_id: str, current: int, total: int) -> None: def update(self, finalize: bool = False) -> None: """Redraw the interactive display.""" - if not self.is_tty or not self.overview_mode: + if self.headless or not self.is_tty or not self.overview_mode: return now = self.get_time() @@ -1467,6 +1477,8 @@ def _println(self, buffer: io.StringIO, line: str = "") -> None: buffer.write("\033[0m\033[K\033[1E") # reset, clear to EOL, move down 1 line def print_logs(self, build_id: str, data: bytes) -> None: + if self.headless: + return # Discard logs we are not following. Generally this should not happen as we tell the child # to only send logs when we are following it. It could maybe happen while transitioning # between builds. @@ -1915,6 +1927,154 @@ def finalize( reports[root_hash].append_record(record) +class TerminalState: + """Manages terminal settings, stdin selector registration, and suspend/resume signals. + + Installs a SIGTSTP handler that restores the terminal before suspending and re-applies it + on resume. After waking up it checks whether the process is in the foreground or background + and enables or suppresses interactive output accordingly. + + Optional ``on_suspend`` / ``on_resume`` hooks are called just before the process suspends + and just after it wakes, allowing callers to pause and resume child processes.""" + + def __init__( + self, + selector: selectors.BaseSelector, + build_status: BuildStatus, + on_suspend: Optional[Callable[[], None]] = None, + on_resume: Optional[Callable[[], None]] = None, + ) -> None: + self.selector = selector + self.build_status = build_status + self.on_suspend = on_suspend + self.on_resume = on_resume + self.old_stdin_settings = termios.tcgetattr(sys.stdin) + self.sigwinch_r = -1 + self.sigwinch_w = -1 + + def setup(self) -> None: + """Set cbreak mode, register stdin and signal pipes in the selector.""" + + # SIGWINCH self-pipe (stdout must be a tty too) + if sys.stdout.isatty(): + self.sigwinch_r, self.sigwinch_w = os.pipe() + os.set_blocking(self.sigwinch_r, False) + os.set_blocking(self.sigwinch_w, False) + self.selector.register(self.sigwinch_r, selectors.EVENT_READ, "sigwinch") + self.old_sigwinch = signal.signal(signal.SIGWINCH, self._handle_sigwinch) + else: + self.old_sigwinch = None + + self.old_sigtstp = signal.signal(signal.SIGTSTP, self._handle_sigtstp) + + # Start correctly depending on whether we're foregrounded or backgrounded + self.build_status.headless = True + if not _is_background_tty(sys.stdin): + self.enter_foreground() + + def teardown(self) -> None: + """Restore terminal settings and signal handlers, close pipes.""" + with ignore_signal(signal.SIGTTOU): + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old_stdin_settings) + + for sig, old in ((signal.SIGTSTP, self.old_sigtstp), (signal.SIGWINCH, self.old_sigwinch)): + if old is not None: + try: + signal.signal(sig, old) + except Exception as e: + spack.llnl.util.tty.debug(f"Failed to restore signal handler for {sig}: {e}") + + if sys.stdin.fileno() in self.selector.get_map(): + self.selector.unregister(sys.stdin.fileno()) + + for fd in (self.sigwinch_r, self.sigwinch_w): + if fd < 0: + continue + if fd in self.selector.get_map(): + self.selector.unregister(fd) + try: + os.close(fd) + except Exception as e: + spack.llnl.util.tty.debug(f"Failed to close sigwinch pipe {fd}: {e}") + + def _handle_sigtstp(self, signum: int, frame: object) -> None: + """Restore terminal before suspending, then re-install handler after resume.""" + + # Reset so the first redraw after resume doesn't overwrite the shell's + # prompt / "$ fg" line. + self.build_status.active_area_rows = 0 + + # Restore terminal so the user's shell works normally while we're stopped. + with ignore_signal(signal.SIGTTOU): + termios.tcsetattr(sys.stdin, termios.TCSANOW, self.old_stdin_settings) + + # Force headless mode before suspending so that enter_foreground() doesn't + # exit early when we resume, ensuring terminal settings are re-applied. + self.build_status.headless = True + + # Actually suspend: reset to default handler then re-send SIGTSTP. + if self.on_suspend is not None: + self.on_suspend() + signal.signal(signal.SIGTSTP, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGTSTP) + + # Execution resumes here after SIGCONT. Re-install our handler. + signal.signal(signal.SIGTSTP, self._handle_sigtstp) + + if self.on_resume is not None: + self.on_resume() + self.handle_continue() + + def _handle_sigwinch(self, signum: int, frame: object) -> None: + try: + os.write(self.sigwinch_w, b"\x00") + except OSError: + pass + + def enter_foreground(self) -> None: + """Restore interactive terminal mode.""" + if not self.build_status.headless: + return + + # We save old settings right before applying cbreak. + # If we started in the background, bash may have had the terminal in its own + # readline (raw) mode when __init__ ran. Waiting until we are foregrounded + # ensures we capture the shell's exported 'sane' configuration for this job. + self.old_stdin_settings = termios.tcgetattr(sys.stdin) + + with ignore_signal(signal.SIGTTOU): + tty.setcbreak(sys.stdin.fileno()) + + if sys.stdin.fileno() not in self.selector.get_map(): + self.selector.register(sys.stdin.fileno(), selectors.EVENT_READ, "stdin") + self.build_status.headless = False + self.build_status.dirty = True + + def enter_background(self) -> None: + """Suppress output and stop reading stdin to avoid SIGTTIN/SIGTTOU.""" + if sys.stdin.fileno() in self.selector.get_map(): + self.selector.unregister(sys.stdin.fileno()) + self.build_status.headless = True + + def handle_continue(self) -> None: + """Detect whether the process is in the foreground or background and adjust accordingly.""" + if _is_background_tty(sys.stdin): + self.enter_background() + else: + self.enter_foreground() + + +def _signal_children(running_builds: Dict[int, ChildInfo], sig: signal.Signals) -> None: + """Send a signal to the process group of each running build.""" + for child in running_builds.values(): + try: + pid = child.proc.pid + if pid is not None: + os.killpg(pid, sig) + except OSError: + pass + + class PackageInstaller: explicit: Set[str] @@ -2040,30 +2200,17 @@ def install(self) -> None: def _installer(self) -> None: jobserver = JobServer(self.jobs) selector = selectors.DefaultSelector() - sigwinch_r = sigwinch_w = -1 - # Set stdin to non-blocking for key press detection + # Set up terminal handling (cbreak, signals, stdin registration) + terminal: Optional[TerminalState] = None if sys.stdin.isatty(): - old_stdin_settings = termios.tcgetattr(sys.stdin) - tty.setcbreak(sys.stdin.fileno()) - selector.register(sys.stdin.fileno(), selectors.EVENT_READ, "stdin") - else: - old_stdin_settings = None - - if sys.stdout.isatty(): - # Listen to terminal resizing events with self-pipe trick. - sigwinch_r, sigwinch_w = os.pipe() - os.set_blocking(sigwinch_r, False) - os.set_blocking(sigwinch_w, False) - - def _handle_sigwinch(signum: int, frame: object) -> None: - try: - os.write(sigwinch_w, b"\x00") - except OSError: - pass - - signal.signal(signal.SIGWINCH, _handle_sigwinch) - selector.register(sigwinch_r, selectors.EVENT_READ, "sigwinch") + terminal = TerminalState( + selector, + self.build_status, + on_suspend=lambda: _signal_children(self.running_builds, signal.SIGSTOP), + on_resume=lambda: _signal_children(self.running_builds, signal.SIGCONT), + ) + terminal.setup() # Finished builds that have not yet been written to the database. database_actions: List[DatabaseAction] = [] @@ -2096,11 +2243,25 @@ def _handle_sigwinch(signum: int, frame: object) -> None: stdin_ready = False - timeout = SPINNER_INTERVAL if self.build_status.is_tty else DATABASE_WRITE_INTERVAL + if self.build_status.headless: + # no UI to update, but check background to foreground transition periodically + timeout = HEADLESS_WAKE_INTERVAL + elif self.build_status.is_tty: + timeout = SPINNER_INTERVAL + else: + # when not in interactive mode, wake least often (no spinner/terminal updates) + timeout = DATABASE_WRITE_INTERVAL events = selector.select(timeout=timeout) finished_pids = [] + # The transition "suspended to foreground/background" is handled in the signal + # handler, but there's no SIGCONT event in the transition of background to + # foreground, so we conditionally poll for that here (headless case). In the + # headless case the event loop only fires once per second, so this is cheap enough. + if terminal and self.build_status.headless and not _is_background_tty(sys.stdin): + terminal.enter_foreground() + for key, _ in events: data = key.data if isinstance(data, FdInfo): @@ -2115,7 +2276,8 @@ def _handle_sigwinch(signum: int, frame: object) -> None: elif data == "stdin": stdin_ready = True elif data == "sigwinch": - os.read(sigwinch_r, 64) # drain the pipe + assert terminal is not None + os.read(terminal.sigwinch_r, 64) # drain the pipe self.build_status.on_resize() elif data == "jobserver" and not jobserver.has_target_parallelism(): jobserver.maybe_discard_tokens() @@ -2209,6 +2371,10 @@ def _handle_sigwinch(signum: int, frame: object) -> None: # Finally update the UI self.build_status.update() finally: + # First ensure that the user's terminal state is restored. + if terminal is not None: + terminal.teardown() + # Flush any not-yet-written successful builds to the DB; save the exception on error # to be re-raised after best-effort cleanup. db_exc = None @@ -2259,22 +2425,6 @@ def _handle_sigwinch(signum: int, frame: object) -> None: except Exception: pass - # Terminal related cleanup - if old_stdin_settings: - try: - termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_stdin_settings) - except Exception: - pass - - if sigwinch_r >= 0: - try: - signal.signal(signal.SIGWINCH, signal.SIG_DFL) - selector.unregister(sigwinch_r) - os.close(sigwinch_r) - os.close(sigwinch_w) - except Exception: - pass - try: self.build_status.overview_mode = True self.build_status.update(finalize=True) diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 49a99dec88a264..6607436d5b4f04 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -1413,3 +1413,49 @@ def test_header_shows_arrow_when_pending(self): status.update() output = fake_stdout.getvalue() assert "4=>2" in output + + +class TestHeadlessMode: + """Test that headless mode suppresses terminal output.""" + + def test_update_suppressed_when_headless(self): + """update() should not write anything when headless is True.""" + status, time_values, stdout = create_build_status(is_tty=True, total=1) + add_mock_builds(status, 1) + status.headless = True + time_values.append(10.0) + status.update() + assert stdout.getvalue() == "" + + def test_print_logs_suppressed_when_headless(self): + """print_logs() should discard data when headless is True.""" + status, _, stdout = create_build_status(is_tty=True, total=1) + specs = add_mock_builds(status, 1) + status.tracked_build_id = specs[0].dag_hash() + status.headless = True + status.print_logs(specs[0].dag_hash(), b"hello world\n") + assert stdout.getvalue() == "" + + def test_update_state_non_tty_suppressed_when_headless(self): + """update_state() non-TTY output should be suppressed when headless.""" + status, _, stdout = create_build_status(is_tty=False, total=1) + spec = MockSpec("pkg", "1.0") + status.add_build(spec, explicit=True) + status.headless = True + stdout.clear() + status.update_state(spec.dag_hash(), "finished") + assert stdout.getvalue() == "" + + def test_update_works_after_headless_cleared(self): + """update() should work normally once headless is cleared.""" + status, time_values, stdout = create_build_status(is_tty=True, total=1, color=False) + add_mock_builds(status, 1) + status.headless = True + time_values.append(10.0) + status.update() + assert stdout.getvalue() == "" + # Clear headless and verify output resumes + status.headless = False + status.dirty = True + status.update() + assert "[/] pkg0 pkg0@0.0 starting" in stdout.getvalue() From 87dd2ba313d1d85238749068ee2b321719c9b90f Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 30 Mar 2026 20:28:34 +0200 Subject: [PATCH 197/337] conf.py: do not document re-export of spack.package classes (#52169) Signed-off-by: Harmen Stoppels --- lib/spack/docs/conf.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/spack/docs/conf.py b/lib/spack/docs/conf.py index e25aa27b39f1cd..8470c8cd719679 100644 --- a/lib/spack/docs/conf.py +++ b/lib/spack/docs/conf.py @@ -104,6 +104,7 @@ "--implicit-namespaces", ".spack/spack-packages/repos/spack_repo", ".spack/spack-packages/repos/spack_repo/builtin/packages", + ".spack/spack-packages/repos/spack_repo/builtin/build_systems/generic.py", ] ) From 60bdc99558ec7d91d0407f311a5df2f9fdc0f007 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 30 Mar 2026 20:54:01 +0200 Subject: [PATCH 198/337] new_installer.py: elapsed time (#52163) * new_installer.py: print full line on finish If a build finishes, we can print the full status line instead of truncating it so it fits the terminal width. Signed-off-by: Harmen Stoppels * new_installer.py: duration Print ``` [+] gzo2zcw pkgconf@2.5.1 /tmp/x/darwin-m4/pkgconf-2.5.1-gzo2zcwlb4xijoa55hvqdllvwp6l4z6n (5s) [+] 4b4piwd berkeley-db@18.1.40 /tmp/x/darwin-m4/berkeley-db-18.1.40-4b4piwdndd6dqu35duc2lipafivfhfvy (23s) [+] uybprz4 libiconv@1.18 /tmp/x/darwin-m4/libiconv-1.18-uybprz4tzlcwzxeebqzk2nh5znuagvth (24s) [+] koh6kwu ncurses@6.6 /tmp/x/darwin-m4/ncurses-6.6-koh6kwuju3yay4r47ol6abfy7lpc434a (1m04s) [+] fr3f5o6 readline@8.3 /tmp/x/darwin-m4/readline-8.3-fr3f5o6i2ubrkpnw4ia64qpi2uqnvtsz (11s) [+] lu4rfdj diffutils@3.12 /tmp/x/darwin-m4/diffutils-3.12-lu4rfdjlepbltqqouzjmfyzd4nuyt36p (1m02s) [+] 6thd5gj bzip2@1.0.8 /tmp/x/darwin-m4/bzip2-1.0.8-6thd5gjm42tbtq5mqbuqspmgfrdsstia (2s) [+] juwuxzu gdbm@1.26 /tmp/x/darwin-m4/gdbm-1.26-juwuxzudiwkbuvdj2im6uzff2qr3jjl3 (11s) [+] jm5dx2t nghttp2@1.67.1 /tmp/x/darwin-m4/nghttp2-1.67.1-jm5dx2tciwjf7gey23cbqouypgmlz7n4 (14s) [+] 4tnhqsm perl@5.42.0 /tmp/x/darwin-m4/perl-5.42.0-4tnhqsmre6iamiwcg6hn6nbowvse6332 (1m32s) [+] ku62zd5 openssl@3.6.1 /tmp/x/darwin-m4/openssl-3.6.1-ku62zd5nsontauvl5vw7o27cfofzp42a (39s) [+] lkzwnfg curl@8.18.0 /tmp/x/darwin-m4/curl-8.18.0-lkzwnfgfvjff6ve6lpfc2w6laff42zjm (49s) ``` Signed-off-by: Harmen Stoppels --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/lang.py | 13 ++++++++ lib/spack/spack/new_installer.py | 45 ++++++++++++++++++++------ lib/spack/spack/test/llnl/util/lang.py | 9 ++++++ 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/lib/spack/spack/llnl/util/lang.py b/lib/spack/spack/llnl/util/lang.py index d6155b795106a9..555a7ae6d1754a 100644 --- a/lib/spack/spack/llnl/util/lang.py +++ b/lib/spack/spack/llnl/util/lang.py @@ -670,6 +670,19 @@ def pretty_seconds(seconds): return pretty_seconds_formatter(seconds)(seconds) +def pretty_duration(seconds: float) -> str: + """Format a duration in seconds as a compact human-readable string (e.g. "1h02m", "3m05s", + "45s").""" + s = int(seconds) + if s < 60: + return f"{s}s" + m, s = divmod(s, 60) + if m < 60: + return f"{m}m{s:02d}s" + h, m = divmod(m, 60) + return f"{h}h{m:02d}m" + + class ObjectWrapper: """Base class that wraps an object. Derived classes can add new behavior while staying undercover. diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 891e41e4091a30..9174e6354d8805 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -81,6 +81,7 @@ import spack.util.environment import spack.util.lock from spack.installer import _do_fake_install, dump_packages +from spack.llnl.util.lang import pretty_duration from spack.llnl.util.tty.log import _is_background_tty, ignore_signal from spack.util.path import padding_filter, padding_filter_bytes @@ -1077,6 +1078,8 @@ class BuildInfo: "external", "prefix", "finished_time", + "start_time", + "duration", "progress_percent", "control_w_conn", "log_path", @@ -1089,6 +1092,7 @@ def __init__( explicit: bool, control_w_conn: Optional[Connection], log_path: Optional[str] = None, + start_time: float = 0.0, ) -> None: self.state: str = "starting" self.explicit: bool = explicit @@ -1098,6 +1102,8 @@ def __init__( self.external: bool = spec.external self.prefix: str = spec.prefix self.finished_time: Optional[float] = None + self.start_time: float = start_time + self.duration: Optional[float] = None self.progress_percent: Optional[int] = None self.control_w_conn = control_w_conn self.log_path: Optional[str] = log_path @@ -1168,7 +1174,8 @@ def add_build( log_path: Optional[str] = None, ) -> None: """Add a new build to the display and mark the display as dirty.""" - self.builds[spec.dag_hash()] = BuildInfo(spec, explicit, control_w_conn, log_path) + build_info = BuildInfo(spec, explicit, control_w_conn, log_path, int(self.get_time())) + self.builds[spec.dag_hash()] = build_info self.dirty = True # Track the new build's logs when we're not already following another build. This applies # only in non-TTY verbose mode. @@ -1312,7 +1319,9 @@ def update_state(self, build_id: str, state: str) -> None: if state in ("finished", "failed"): self.completed += 1 - build_info.finished_time = self.get_time() + CLEANUP_TIMEOUT + now = self.get_time() + build_info.duration = now - build_info.start_time + build_info.finished_time = now + CLEANUP_TIMEOUT # Stop tracking the finished build's logs. if build_id == self.tracked_build_id: @@ -1325,7 +1334,9 @@ def update_state(self, build_id: str, state: str) -> None: # For non-TTY output, print state changes immediately if not self.is_tty and not self.headless: - line = "".join(self._generate_line_components(build_info, static=True)) + line = "".join( + self._generate_line_components(build_info, static=True, now=self.get_time()) + ) self.stdout.write(line + "\n") self.stdout.flush() @@ -1399,7 +1410,7 @@ def update(self, finalize: bool = False) -> None: # First flush the finished builds. These are "persisted" in terminal history. for build in self.finished_builds: - self._render_build(build, buffer, max_width) + self._render_build(build, buffer, now=now) self.finished_builds.clear() # Then a header followed by the active builds. This is the "mutable" part of the display. @@ -1447,7 +1458,7 @@ def update(self, finalize: bool = False) -> None: if i > truncate_at: self._println(buffer, f"{len_builds - i + 1} more...") break - self._render_build(build, buffer, max_width) + self._render_build(build, buffer, max_width, now=now) if self.search_mode: buffer.write(f"filter> {self.search_term}\033[K") @@ -1490,11 +1501,14 @@ def print_logs(self, build_id: str, data: bytes) -> None: self.stdout.flush() self.log_ends_with_newline = data.endswith(b"\n") - def _render_build(self, build_info: BuildInfo, buffer: io.StringIO, max_width: int) -> None: + def _render_build( + self, build_info: BuildInfo, buffer: io.StringIO, max_width: int = 0, now: float = 0.0 + ) -> None: + """Print a single build line to the buffer, truncating to max_width (if > 0).""" line_width = 0 - for component in self._generate_line_components(build_info): + for component in self._generate_line_components(build_info, now=now): # ANSI escape sequence(s), does not contribute to width - if not component.startswith("\033"): + if not component.startswith("\033") and max_width > 0: line_width += len(component) if line_width > max_width: break @@ -1502,7 +1516,7 @@ def _render_build(self, build_info: BuildInfo, buffer: io.StringIO, max_width: i self._println(buffer) def _generate_line_components( - self, build_info: BuildInfo, static: bool = False + self, build_info: BuildInfo, static: bool = False, now: float = 0.0 ) -> Generator[str, None, None]: """Yield formatted line components for a package. Escape sequences are yielded as separate strings so they do not contribute to the line width.""" @@ -1564,6 +1578,19 @@ def _generate_line_components( else: yield f" {build_info.state}" + # Duration + elapsed = ( + build_info.duration + if build_info.duration is not None + else (now - build_info.start_time) + ) + if elapsed > 0: + if self.color: + yield "\033[0;90m" # dark gray + yield f" ({pretty_duration(elapsed)})" + if self.color: + yield "\033[0m" + Nodes = Dict[str, spack.spec.Spec] Edges = Dict[str, Set[str]] diff --git a/lib/spack/spack/test/llnl/util/lang.py b/lib/spack/spack/test/llnl/util/lang.py index 6bcadf17beccf2..d75c7c70dd8657 100644 --- a/lib/spack/spack/test/llnl/util/lang.py +++ b/lib/spack/spack/test/llnl/util/lang.py @@ -133,6 +133,15 @@ def test_pretty_seconds(): assert spack.llnl.util.lang.pretty_seconds(2.1 / 1000 / 1000 / 1000 / 10) == "0.210ns" +def test_pretty_duration(): + assert spack.llnl.util.lang.pretty_duration(0) == "0s" + assert spack.llnl.util.lang.pretty_duration(45) == "45s" + assert spack.llnl.util.lang.pretty_duration(60) == "1m00s" + assert spack.llnl.util.lang.pretty_duration(125) == "2m05s" + assert spack.llnl.util.lang.pretty_duration(3600) == "1h00m" + assert spack.llnl.util.lang.pretty_duration(3661) == "1h01m" + + def test_match_predicate(): matcher = match_predicate(lambda x: True) assert matcher("foo") From e2e28b8a8b661073f45e459f7e08f5fbe0937a43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 22:06:00 +0200 Subject: [PATCH 199/337] docs: pygments v2.20.0 (#52171) --- lib/spack/docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/docs/requirements.txt b/lib/spack/docs/requirements.txt index 3e6dcb275eed5b..3286b63ae285e2 100644 --- a/lib/spack/docs/requirements.txt +++ b/lib/spack/docs/requirements.txt @@ -6,4 +6,4 @@ sphinx-last-updated-by-git==0.3.8 sphinx-sitemap==2.9.0 furo==2025.12.19 docutils==0.22.4 -pygments==2.19.2 +pygments==2.20.0 From c974ccb46fd0460253e10b7c5b56fd0cd11b0dbd Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 30 Mar 2026 23:13:30 +0200 Subject: [PATCH 200/337] new_installer.py: fix cursor movement compatibility (#52173) `\033[A` (Cursor Up) and `\033[B` (Cursor Down) have been part of the original VT100 spec since 1978, so every terminal emulator should support them, including JuiceSSH. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 4 ++-- lib/spack/spack/test/installer_tui.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 9174e6354d8805..c2a994e7111994 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1398,7 +1398,7 @@ def update(self, finalize: bool = False) -> None: # Move cursor up to the start of the display area if self.active_area_rows > 0: - buffer.write(f"\033[{self.active_area_rows}F") + buffer.write(f"\033[{self.active_area_rows}A\r") if self.terminal_size_changed: self.terminal_size = self.get_terminal_size() @@ -1485,7 +1485,7 @@ def _println(self, buffer: io.StringIO, line: str = "") -> None: if self.total_lines > self.active_area_rows: buffer.write("\033[0m\033[K\n") # reset, clear to EOL, newline else: - buffer.write("\033[0m\033[K\033[1E") # reset, clear to EOL, move down 1 line + buffer.write("\033[0m\033[K\033[1B\r") # reset, clear to EOL, move to next line def print_logs(self, build_id: str, data: bytes) -> None: if self.headless: diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 6607436d5b4f04..2e33d4929ace01 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -335,9 +335,9 @@ def test_cursor_movement_vs_newlines(self): status.update() output1 = fake_stdout.getvalue() - # Count newlines (\n) and cursor movements (\033[1E = move down 1 line) + # Count newlines (\n) and cursor movements (\033[1B\r = move down 1 line) newlines1 = output1.count("\n") - cursor_moves1 = output1.count("\033[1E") + cursor_moves1 = output1.count("\033[1B\r") # Initially all lines should be newlines (nothing in history yet) assert newlines1 > 0 @@ -359,7 +359,7 @@ def test_cursor_movement_vs_newlines(self): output2 = fake_stdout.getvalue() newlines2 = output2.count("\n") - cursor_moves2 = output2.count("\033[1E") + cursor_moves2 = output2.count("\033[1B\r") # Should have newlines for the 2 finished builds persisted to history # and cursor movements for the active area (header + 3 active builds) From a1dbb85ab9167c533c0c624ed374204aaf6b3f9c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 31 Mar 2026 21:16:50 +0200 Subject: [PATCH 201/337] new_installer.py: docs (#52164) Signed-off-by: Harmen Stoppels --- lib/spack/docs/advanced_topics.rst | 2 + lib/spack/docs/index.rst | 1 + lib/spack/docs/installing.rst | 148 +++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 lib/spack/docs/installing.rst diff --git a/lib/spack/docs/advanced_topics.rst b/lib/spack/docs/advanced_topics.rst index 1058c99ba03646..61b9cb6188cdbb 100644 --- a/lib/spack/docs/advanced_topics.rst +++ b/lib/spack/docs/advanced_topics.rst @@ -70,6 +70,8 @@ This typically indicates that a package was linked against a system library inst This verification can also be enabled as a post-install hook by setting ``config:shared_linking:missing_library_policy`` to ``error`` or ``warn`` in :ref:`config.yaml `. +.. _filesystem-requirements: + Filesystem Requirements ======================= diff --git a/lib/spack/docs/index.rst b/lib/spack/docs/index.rst index 2aaa4c6d0aaba4..97c8a0200d61af 100644 --- a/lib/spack/docs/index.rst +++ b/lib/spack/docs/index.rst @@ -54,6 +54,7 @@ If you're new to Spack and want to start using it, see :doc:`getting_started`, o :caption: Basic Usage package_fundamentals + installing configuring_compilers environments_basics frequently_asked_questions diff --git a/lib/spack/docs/installing.rst b/lib/spack/docs/installing.rst new file mode 100644 index 00000000000000..bceae733e22024 --- /dev/null +++ b/lib/spack/docs/installing.rst @@ -0,0 +1,148 @@ +.. + Copyright Spack Project Developers. See COPYRIGHT file for details. + + SPDX-License-Identifier: (Apache-2.0 OR MIT) + +.. meta:: + :description lang=en: + Learn how Spack installs packages: the interactive terminal UI, parallelism + via a POSIX jobserver, multi-process installs, background execution, and + handling build failures. + +.. _installing: + +Installing Packages +=================== + +This page covers the ``spack install`` experience in detail, including the interactive terminal UI (TUI), parallelism, background execution, and handling build failures. + +Before diving in, ensure you are familiar with :doc:`package_fundamentals` for basic usage and spec syntax. + +.. versionadded:: 1.2 + The TUI and POSIX jobserver are new in Spack 1.2 and require a Unix-like platform. + + +Interactive terminal UI +----------------------- + +By default, ``spack install`` shows live progress inline in the terminal. +Completed packages scroll into terminal history, while active builds update dynamically below the progress header. + +Every package in the install plan is shown with its current status: + +.. code-block:: text + + $ spack install -j16 python + [+] abc1234 zlib@1.3.1 /home/user/spack/opt/spack/... (4s) + [+] def5678 pkgconf@2.2.0 /home/user/spack/opt/spack/... (6s) + [+] 9ab0123 ncurses@6.5 /home/user/spack/opt/spack/... (23s) + Progress: 3/7 +/-: 4 jobs /: filter v: logs n/p: next/prev + [/] cde4567 readline@8.2 configure (11s) + [/] fgh8901 openssl@3.4.1 build (18s) + +Status indicators: + +* ``[+]`` finished successfully +* ``[x]`` failed +* ``[/]``, ``[-]``, ``[\]``, ``[|]`` building (rotating spinner) +* ``[e]`` external + +**Log-following mode**: press ``v`` to switch from the overview to a live view of build output. +Press ``v``, ``q``, or ``Esc`` to return to the overview. + +While in log-following mode, press ``n`` / ``p`` to cycle to the next or previous build. +Press ``/``, type a pattern, and press ``Enter`` to jump to a matching build (``Esc`` cancels the filter). + +When a build fails, press ``v`` to see a parsed error summary and the path to the full log. + + +Parallelism +----------- + +Spack controls parallelism at two levels: the number of build jobs shared across all packages (``-j``), and the number of packages building concurrently (``-p``). + +Build-level parallelism (``-j``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``-j`` flag controls the **total** number of concurrent build jobs via a POSIX jobserver. +All build processes (``make``, ``cmake``, ``ninja``, etc.) share the same jobserver, so ``-j16`` means at most 16 build jobs across *all* packages combined. +This is the primary concurrency knob. + +.. code-block:: console + + $ spack install -j16 python + +Spack creates a POSIX jobserver compatible with GNU Make's jobserver protocol. +Child build systems automatically respect it through ``MAKEFLAGS``, so total CPU usage stays bounded regardless of how many packages are building concurrently. + +.. note:: + + If an external jobserver is already present in ``MAKEFLAGS``, for example when Spack itself is invoked from inside a larger ``make`` build, Spack attaches to the existing jobserver instead of creating its own. + +Package-level parallelism (``-p``) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The ``-p`` / ``--concurrent-packages`` flag limits how many packages can be in the build queue simultaneously. +By default there is no limit, and packages are started as jobserver tokens become available. + +.. code-block:: console + + $ spack install -j16 -p4 python + +This builds with 16 total make-jobs but never more than 4 packages at once. + +Dynamic adjustment +^^^^^^^^^^^^^^^^^^ + +You can adjust parallelism while a build is running: + +* Press ``+`` to add a job (increases ``-j`` by 1) +* Press ``-`` to remove a job (decreases ``-j`` by 1) + +When reducing parallelism, Spack waits for currently running jobs to finish before the new limit takes effect; it does not kill active processes. +The progress header shows the adjustment in progress, e.g. ``+/-: 4=>2 jobs``, until the actual count reaches the target. + + +Multi-process and multi-node installs +-------------------------------------- + +Multiple ``spack install`` processes can safely run concurrently, whether on the same machine or across multiple nodes in a cluster with a shared filesystem. +Spack coordinates through :ref:`per-prefix filesystem locks `: before building a package, the process acquires an exclusive lock on its install prefix. +If another process already holds the lock, Spack waits rather than building a second copy. +When a process encounters a prefix that was already installed, it simply skips it and moves on to the next install. + +For best results on a cluster, it's recommended to limit per-process package-level parallelism (e.g., ``spack install -p2``) for better load balancing. + + +Non-interactive mode +-------------------- + +When the controlling process is not a tty, such as in CI pipelines, when redirecting output to a file, or when running in the background, Spack skips the TUI and prints simple line-based status updates instead. +Use ``spack install -v`` to also print build output. + +You can also background builds: + +* **Suspend and resume**: press ``Ctrl-Z`` to suspend the install, then ``bg`` to let it continue in the background or ``fg`` to bring it back. + Child builds are paused while suspended, and resumed when continued in the background or foreground. + The TUI is suppressed while backgrounded and restored on ``fg``. +* **Start in the background**: run ``spack install ... &`` to skip the TUI entirely and build in the background from the start. + +.. tip:: + + You don't need a new terminal or SSH session to keep a build running — just suspend it with ``Ctrl-Z`` and ``bg``, then continue working. + + +Handling failures +----------------- + +By default, Spack continues building other packages when one fails (best-effort). +Use ``--fail-fast`` to stop immediately on the first failure. + +.. code-block:: console + + $ spack install --fail-fast python + +Failed builds show ``[x]`` in the overview. +Navigate to a failed build and press ``v`` to see a parsed error summary and the path to the full log. + +See :ref:`spack install ` for the full set of flags related to debugging and controlling build behavior. From 91849fe2c9a98519e48241f16785b415d461f449 Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Tue, 31 Mar 2026 13:24:30 -0700 Subject: [PATCH 202/337] ci: print spec in each rebuild job (#49021) Signed-off-by: Gregory Becker --- lib/spack/spack/ci/common.py | 1 + lib/spack/spack/test/cmd/ci.py | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/spack/spack/ci/common.py b/lib/spack/spack/ci/common.py index 53e7ad2bb1d953..ee3eadacbb3b7a 100644 --- a/lib/spack/spack/ci/common.py +++ b/lib/spack/spack/ci/common.py @@ -582,6 +582,7 @@ def generate_ir(self): "script": [ "cd {env_dir}", "spack env activate --without-view .", + "spack spec /$SPACK_JOB_SPEC_DAG_HASH", "spack ci rebuild", ] } diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index 7fad8e48de44b6..53edde5b46443b 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -339,6 +339,7 @@ def test_ci_generate_with_custom_settings( "spack -d ci rebuild", "cd ENV", "spack env activate --without-view .", + "spack spec /$SPACK_JOB_SPEC_DAG_HASH", "spack ci rebuild", ] assert ci_obj["after_script"] == ["rm -rf /some/path/spack"] From abd879b45ae0d2f1bf08b9009b244d881c4adbe4 Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:56:37 -0400 Subject: [PATCH 203/337] Update Completion: turn off argparse color for 3.14 and newer (#52174) Argparse color adds of color control characters in Python >= 3.14, which breaks "spack commands --update-completion". Add logic to check whether output is a terminal and turn off color when it is not. --------- Signed-off-by: John Parent --- lib/spack/spack/cmd/commands.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/spack/spack/cmd/commands.py b/lib/spack/spack/cmd/commands.py index e88fb359749f31..d6a5f577a8aae2 100644 --- a/lib/spack/spack/cmd/commands.py +++ b/lib/spack/spack/cmd/commands.py @@ -19,7 +19,7 @@ import spack.platforms from spack.llnl.util.argparsewriter import ArgparseRstWriter, ArgparseWriter, Command from spack.llnl.util.tty.colify import colify -from spack.main import section_descriptions +from spack.main import SpackArgumentParser, section_descriptions description = "list available spack commands" section = "config" @@ -688,8 +688,7 @@ def subcommands(args: Namespace, out: IO) -> None: args: Command-line arguments. out: File object to write to. """ - parser = spack.main.make_argument_parser() - spack.main.add_all_commands(parser) + parser = get_all_spack_commands(out) writer = SubcommandWriter(parser.prog, out, args.aliases) writer.write(parser) @@ -735,8 +734,7 @@ def rst(args: Namespace, out: IO) -> None: out: File object to write to. """ # create a parser with all commands - parser = spack.main.make_argument_parser() - spack.main.add_all_commands(parser) + parser = get_all_spack_commands(out) # extract cross-refs of the form `_cmd-spack-:` from rst files documented_commands: Set[str] = set() @@ -774,6 +772,20 @@ def names(args: Namespace, out: IO) -> None: colify(commands, output=out) +def get_all_spack_commands(out: IO) -> SpackArgumentParser: + is_tty = hasattr(out, "isatty") and out.isatty() + # Argparse python 3.14 adds a default color argument that + # adds color control characters to argparse output + # that breaks expected output format from spack formatters + # when written to non tty IO + # If 3.14 and newer and not tty, disable color + parser = spack.main.make_argument_parser( + **({"color": False} if sys.version_info[:2] >= (3, 14) and not is_tty else {}) + ) + spack.main.add_all_commands(parser) + return parser + + @formatter def bash(args: Namespace, out: IO) -> None: """Bash tab-completion script. @@ -782,9 +794,7 @@ def bash(args: Namespace, out: IO) -> None: args: Command-line arguments. out: File object to write to. """ - parser = spack.main.make_argument_parser() - spack.main.add_all_commands(parser) - + parser = get_all_spack_commands(out) aliases_config = spack.config.get("config:aliases") if aliases_config: aliases = ";".join(f"{key}:{val}" for key, val in aliases_config.items()) @@ -796,9 +806,7 @@ def bash(args: Namespace, out: IO) -> None: @formatter def fish(args, out): - parser = spack.main.make_argument_parser() - spack.main.add_all_commands(parser) - + parser = get_all_spack_commands(out) writer = FishCompletionWriter(parser.prog, out, args.aliases) writer.write(parser) From b1f8182ceec046a32bb21e3f38a7f262a062e82f Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 1 Apr 2026 08:40:16 +0200 Subject: [PATCH 204/337] docs: minor modification on the example (#52180) --- lib/spack/docs/configuring_compilers.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/docs/configuring_compilers.rst b/lib/spack/docs/configuring_compilers.rst index 3dabcddee98689..73cf088ae257c4 100644 --- a/lib/spack/docs/configuring_compilers.rst +++ b/lib/spack/docs/configuring_compilers.rst @@ -269,7 +269,7 @@ For example: .. code-block:: spec - $ spack install gcc@14+binutils + $ spack install gcc@14 Once the compiler is installed, you can start using it without additional configuration: From fd7e3d64498650448a497c2fcf1760c69986641c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 1 Apr 2026 10:15:56 +0200 Subject: [PATCH 205/337] repo: improve patch lookup speed (#52157) This commit avoids hitting the 8k stat call penalty when looking up patch files in most cases. Previously the strategy was: * check cache validity (8k stat calls) * lookup the patch by shasum in cache With this commit we add an initial "optimistic" lookup in possibly stale cache. It's expected that we hit this code path a lot as patches directives are rarely changed. * lookup the patch by shasum in possibly stale cache * validate the entry by comparing with package class metadata * early return if the same * otherwise check ache validity (8k stat calls) * lookup the patch by shasum in cache This helps with the installer in the forkserver and spawn during staging of patches, which can now be done without validating freshness of the patch index. Signed-off-by: Harmen Stoppels --- lib/spack/spack/cmd/resource.py | 4 +- lib/spack/spack/patch.py | 28 +++++++- lib/spack/spack/repo.py | 121 ++++++++++++++++++++++++-------- lib/spack/spack/spec.py | 23 +++--- lib/spack/spack/test/patch.py | 27 +++++++ 5 files changed, 157 insertions(+), 46 deletions(-) diff --git a/lib/spack/spack/cmd/resource.py b/lib/spack/spack/cmd/resource.py index 1792b7e3e9f99b..657d1789a80cf4 100644 --- a/lib/spack/spack/cmd/resource.py +++ b/lib/spack/spack/cmd/resource.py @@ -28,7 +28,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def _show_patch(sha256): """Show a record from the patch index.""" - patches = spack.repo.PATH.patch_index.index + patches = spack.repo.PATH.get_patch_index().index data = patches.get(sha256) if not data: @@ -59,7 +59,7 @@ def _show_patch(sha256): def resource_list(args): """list all resources known to spack (currently just patches)""" - patches = spack.repo.PATH.patch_index.index + patches = spack.repo.PATH.get_patch_index().index for sha256 in patches: if args.only_hashes: print(sha256) diff --git a/lib/spack/spack/patch.py b/lib/spack/spack/patch.py index f482841638e6f3..4893c9a0c28223 100644 --- a/lib/spack/spack/patch.py +++ b/lib/spack/spack/patch.py @@ -420,7 +420,9 @@ def to_json(self, stream: Any) -> None: """ sjson.dump({"patches": self.index}, stream) - def patch_for_package(self, sha256: str, pkg: "spack.package_base.PackageBase") -> Patch: + def patch_for_package( + self, sha256: str, pkg: Type["spack.package_base.PackageBase"], *, validate: bool = False + ) -> Patch: """Look up a patch in the index and build a patch object for it. We build patch objects lazily because building them requires that @@ -428,7 +430,9 @@ def patch_for_package(self, sha256: str, pkg: "spack.package_base.PackageBase") Args: sha256: sha256 hash to look up - pkg: Package object to get patch for. + pkg: Package class to get patch for. + validate: if True, validate the cached entry against the owner's current package + class and raise ``PatchLookupError`` if the entry is missing or stale. Returns: The patch object. @@ -449,6 +453,26 @@ def patch_for_package(self, sha256: str, pkg: "spack.package_base.PackageBase") f"Couldn't find patch for package {pkg.fullname} with sha256: {sha256}" ) + if validate: + # Validate the cached entry against the owner's current package class + owner = patch_dict.get("owner") + if not owner: + raise spack.error.PatchLookupError( + f"Patch for {pkg.fullname} with sha256 {sha256} has no owner in cache" + ) + try: + owner_pkg_cls = self.repository.get_pkg_class(owner) + current_index = PatchCache._index_patches(owner_pkg_cls, self.repository) + except Exception as e: + raise spack.error.PatchLookupError( + f"Could not validate patch cache for {pkg.fullname}: {e}" + ) from e + current_sha_index = current_index.get(sha256) + if not current_sha_index or current_sha_index.get(fullname) != patch_dict: + raise spack.error.PatchLookupError( + f"Stale patch cache entry for {pkg.fullname} with sha256: {sha256}" + ) + # add the sha256 back (we take it out on write to save space, # because it's the index key) patch_dict = dict(patch_dict) diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index 65b4f50205e801..a0aca95bc3ff03 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -590,12 +590,14 @@ class RepoIndex: def __init__( self, - package_checker: FastPackageChecker, + packages_path: str, + package_checker: "Callable[[], FastPackageChecker]", namespace: str, cache: spack.util.file_cache.FileCache, ): - self.checker = package_checker - self.packages_path = self.checker.packages_path + self._get_checker = package_checker + self._checker: Optional[FastPackageChecker] = None + self.packages_path = packages_path if sys.platform == "win32": self.packages_path = spack.llnl.path.convert_to_posix_path(self.packages_path) self.namespace = namespace @@ -604,6 +606,15 @@ def __init__( self.indexes: Dict[str, Any] = {} self.cache = cache + #: Whether the indexes are up to date with the package repository. + self.is_fresh = False + + @property + def checker(self) -> FastPackageChecker: + if self._checker is None: + self._checker = self._get_checker() + return self._checker + def add_indexer(self, name: str, indexer: Indexer): """Add an indexer to the repo index. @@ -613,17 +624,22 @@ def add_indexer(self, name: str, indexer: Indexer): self.indexers[name] = indexer def __getitem__(self, name): - """Get the index with the specified name, reindexing if needed.""" + """Get an up-to-date index with the specified name.""" + return self.get_index(name, allow_stale=False) + + def get_index(self, name, allow_stale: bool = False): + """Get the index with the specified name. The index will be updated if it is stale, unless + allow_stale is True, in which case its contents are not validated against the package + repository. When no cache is available, the index will be updated regardless of the value + of allow_stale.""" indexer = self.indexers.get(name) if not indexer: raise KeyError("no such index: %s" % name) - - if name not in self.indexes: - self._build_all_indexes() - + if name not in self.indexes or (not allow_stale and not self.is_fresh): + self._build_all_indexes(allow_stale=allow_stale) return self.indexes[name] - def _build_all_indexes(self): + def _build_all_indexes(self, allow_stale: bool = False) -> None: """Build all the indexes at once. We regenerate *all* indexes whenever *any* index needs an update, @@ -631,11 +647,14 @@ def _build_all_indexes(self): can take tens of seconds to regenerate sequentially, and we'd rather only pay that cost once rather than on several invocations.""" + is_fresh = True for name, indexer in self.indexers.items(): - self.indexes[name] = self._build_index(name, indexer) + is_fresh &= self._update_index(name, indexer, allow_stale=allow_stale) + self.is_fresh = is_fresh - def _build_index(self, name: str, indexer: Indexer): - """Determine which packages need an update, and update indexes.""" + def _update_index(self, name: str, indexer: Indexer, allow_stale: bool = False) -> bool: + """Determine which packages need an update, and update indexes. Returns true if the + index is fresh.""" # Filename of the provider index cache (we assume they're all json) from spack.spec import SPECFILE_FORMAT_VERSION @@ -644,13 +663,21 @@ def _build_index(self, name: str, indexer: Indexer): # Compute which packages needs to be updated in the cache index_mtime = self.cache.mtime(cache_filename) - needs_update = self.checker.modified_since(index_mtime) index_existed = self.cache.init_entry(cache_filename) + if index_existed and allow_stale: + with self.cache.read_transaction(cache_filename) as f: + indexer.read(f) + self.indexes[name] = indexer.index + return False + + needs_update = self.checker.modified_since(index_mtime) if index_existed and not needs_update: # If the index exists and doesn't need an update, read it with self.cache.read_transaction(cache_filename) as f: indexer.read(f) + self.indexes[name] = indexer.index + return True else: # Otherwise update it and rewrite the cache file @@ -666,7 +693,8 @@ def _build_index(self, name: str, indexer: Indexer): indexer.update({f"{self.namespace}.{pkg_name}" for pkg_name in needs_update}) indexer.write(new) - return indexer.index + self.indexes[name] = indexer.index + return True class RepoPath: @@ -682,6 +710,7 @@ def __init__(self, *repos: "Repo") -> None: self.by_namespace = nm.NamespaceTrie() self._provider_index: Optional[spack.provider_index.ProviderIndex] = None self._patch_index: Optional[spack.patch.PatchCache] = None + self._index_is_fresh: bool = False self._tag_index: Optional[spack.tag.TagIndex] = None for repo in repos: @@ -828,17 +857,49 @@ def tag_index(self) -> spack.tag.TagIndex: self._tag_index.merge(repo.tag_index) return self._tag_index - @property - def patch_index(self) -> spack.patch.PatchCache: - """Merged PatchIndex from all Repos in the RepoPath.""" - if self._patch_index is None: - from spack.patch import PatchCache + def get_patch_index(self, allow_stale: bool = False) -> spack.patch.PatchCache: + """Return the merged patch index for all repos in this path. - self._patch_index = PatchCache(repository=self) - for repo in reversed(self.repos): - self._patch_index.update(repo.patch_index) + Args: + allow_stale: if True, return a possibly out-of-date index from cache files, + avoiding filesystem calls to check whether the index is up to date. + """ + if self._patch_index is not None and (self._index_is_fresh or allow_stale): + return self._patch_index + + index = spack.patch.PatchCache(repository=self) + for repo in reversed(self.repos): + index.update(repo.get_patch_index(allow_stale=allow_stale)) + self._patch_index = index + self._index_is_fresh = not allow_stale return self._patch_index + def get_patches_for_package( + self, sha256s: List[str], pkg_cls: Type["spack.package_base.PackageBase"] + ) -> List["spack.patch.Patch"]: + """Look up patches by sha256, trying stale cache first to avoid stat calls. + + Args: + sha256s: ordered list of patch sha256 hashes + pkg_cls: package class the patches belong to + + Returns: + List of Patch objects in the same order as sha256s. + + Raises: + spack.error.PatchLookupError: if a sha256 cannot be found even after a full rebuild. + """ + stale_index = self.get_patch_index(allow_stale=True) + try: + return [ + stale_index.patch_for_package(sha256, pkg_cls, validate=True) for sha256 in sha256s + ] + except spack.error.PatchLookupError: + pass + + current_index = self.get_patch_index(allow_stale=False) + return [current_index.patch_for_package(sha256, pkg_cls) for sha256 in sha256s] + def providers_for(self, virtual: Union[str, "spack.spec.Spec"]) -> List["spack.spec.Spec"]: all_packages = self._all_package_names_set(include_virtuals=False) providers = [ @@ -1285,7 +1346,9 @@ def dump_provenance(self, spec: "spack.spec.Spec", path: str) -> None: def index(self) -> RepoIndex: """Construct the index for this repo lazily.""" if self._repo_index is None: - self._repo_index = RepoIndex(self._pkg_checker, self.namespace, cache=self._cache) + self._repo_index = RepoIndex( + self.packages_path, lambda: self._pkg_checker, self.namespace, cache=self._cache + ) self._repo_index.add_indexer("providers", ProviderIndexer(self)) self._repo_index.add_indexer("tags", TagIndexer(self)) self._repo_index.add_indexer("patches", PatchIndexer(self)) @@ -1293,18 +1356,18 @@ def index(self) -> RepoIndex: @property def provider_index(self) -> spack.provider_index.ProviderIndex: - """A provider index with names *specific* to this repo.""" + """A fresh provider index with names *specific* to this repo.""" return self.index["providers"] @property def tag_index(self) -> spack.tag.TagIndex: - """Index of tags and which packages they're defined on.""" + """Fresh index of tags and which packages they're defined on.""" return self.index["tags"] - @property - def patch_index(self) -> spack.patch.PatchCache: - """Index of patches and packages they're defined on.""" - return self.index["patches"] + def get_patch_index(self, allow_stale: bool = False) -> spack.patch.PatchCache: + """Index of patches and packages they're defined on. Set allow_stale is True to bypass + cache validation and return a potentially stale index.""" + return self.index.get_index("patches", allow_stale=allow_stale) def providers_for(self, virtual: Union[str, "spack.spec.Spec"]) -> List["spack.spec.Spec"]: providers = self.provider_index.providers_for(virtual) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 81c431acc01fb5..af602bb64ea8e3 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -3659,19 +3659,16 @@ def patches(self): # translate patch sha256sums to patch objects by consulting the index if self._patches_assigned(): - for sha256 in self.variants["patches"]._patches_in_order_of_appearance: - index = spack.repo.PATH.patch_index - pkg_cls = spack.repo.PATH.get_pkg_class(self.name) - try: - patch = index.patch_for_package(sha256, pkg_cls) - except spack.error.PatchLookupError as e: - raise spack.error.SpecError( - f"{e}. This usually means the patch was modified or removed. " - "To fix this, either reconcretize or use the original package " - "repository" - ) from e - - self._patches.append(patch) + sha256s = list(self.variants["patches"]._patches_in_order_of_appearance) + pkg_cls = spack.repo.PATH.get_pkg_class(self.name) + try: + self._patches = spack.repo.PATH.get_patches_for_package(sha256s, pkg_cls) + except spack.error.PatchLookupError as e: + raise spack.error.SpecError( + f"{e}. This usually means the patch was modified or removed. " + "To fix this, either reconcretize or use the original package " + "repository" + ) from e return self._patches diff --git a/lib/spack/spack/test/patch.py b/lib/spack/spack/test/patch.py index 3a8b61b29b43a7..6fa20952af0898 100644 --- a/lib/spack/spack/test/patch.py +++ b/lib/spack/spack/test/patch.py @@ -174,6 +174,33 @@ def test_patch_in_spec(mock_packages, config): ) +def test_stale_patch_cache_falls_back_to_fresh(mock_packages, config): + """spec.patches returns correct patches even when the stale in-memory cache is wrong.""" + spec = spack.concretize.concretize_one("patch@=1.0") + pkg_cls = spack.repo.PATH.get_pkg_class("patch") + + # Inject a stale PatchCache: foo_sha256 points to a non-existent patch file + stale_cache = spack.patch.PatchCache(repository=spack.repo.PATH) + stale_cache.index = { + foo_sha256: { + pkg_cls.fullname: { + "owner": pkg_cls.fullname, + "relative_path": "stale_wrong.patch", + "level": 1, + "working_dir": ".", + "reverse": False, + } + } + } + spack.repo.PATH._patch_index = stale_cache + spack.repo.PATH._index_is_fresh = False + + patches = spec.patches + + assert len(patches) == 2 + assert {p.relative_path for p in patches} == {"foo.patch", "baz.patch"} + + def test_patch_mixed_versions_subset_constraint(mock_packages, config): """If we have a package with mixed x.y and x.y.z versions, make sure that a patch applied to a version range of x.y.z versions is not applied to From 3451e95a445ea3a5a7ca5396184c8c1f0587bd26 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 1 Apr 2026 10:19:08 +0200 Subject: [PATCH 206/337] spec.py: avoid cache validation in satisfies (#52176) The goal is to avoid consulting the virtual provider lookup during `Spec.satisfies`, in particular in the common case of concrete lhs. This is accomplished as follows: 1. The left-hand side provides edge attributes to which the right-hand side nodes can be matched. 2. If the right hand side is a known virtual and merely mentions a name `%mpi`, exit early 3. The only virtual attribute to (very rarely) check for satisfaction is the version `mpi@3`; here lookup the package metadata instead of the provider cache in case the left-hand side is concrete. On top of that, various code paths are unified that were incorrectly considered "exceptions to the rule". There is no branching on concrete/abstract and direct/transitive deps. The assumption in this PR is that in the case of right-hand side abstract, its size is O(1) so that it's worst case linear time (early return). The previous implementation was best case linear time in the `^pkg` case. The behavior of `satisfies(%possible-provider, ^virtual)` is defined in a test. That statement is false since you can depend on a package that could provide a virtual without depending on the virtual. A bug in a test was fixed, which asserted `s.satisfies("%c,cxx,fortran=gcc")`, even though `s` did not depend on `fortran`. Signed-off-by: Massimiliano Culpo Signed-off-by: Harmen Stoppels Co-authored-by: Massimiliano Culpo --- lib/spack/spack/spec.py | 323 ++++++++++++------------- lib/spack/spack/test/env.py | 2 +- lib/spack/spack/test/spec_semantics.py | 79 ++++++ 3 files changed, 233 insertions(+), 171 deletions(-) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index af602bb64ea8e3..20134dcc810d8a 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -100,6 +100,7 @@ import spack.util.spack_json as sjson import spack.util.spack_yaml as syaml import spack.variant as vt +import spack.version import spack.version as vn import spack.version.git_ref_lookup @@ -122,7 +123,6 @@ "SpecDeprecatedError", ] - SPEC_FORMAT_RE = re.compile( r"(?:" # this is one big or, with matches ordered by priority # OPTION 1: escaped character (needs to be first to catch opening \{) @@ -1477,6 +1477,83 @@ def _anonymous_star(dep: DependencySpec, dep_format: str) -> str: return "*" if dep.spec.architecture else "" +def _get_satisfying_edge( + lhs_node: "Spec", rhs_edge: DependencySpec, *, resolve_virtuals: bool +) -> Optional[DependencySpec]: + """Search for an edge in ``lhs_node`` that satisfies ``rhs_edge``.""" + # First check direct deps of all types. + for lhs_edge in lhs_node.edges_to_dependencies(): + if _satisfies_edge(lhs_edge, rhs_edge, resolve_virtuals): + return lhs_edge + + # Include the historical compiler node if available as an ad-hoc edge. + compiler_spec = lhs_node.annotations.compiler_node_attribute + if compiler_spec is not None: + compiler_edge = DependencySpec( + lhs_node, + compiler_spec, + depflag=dt.BUILD, + virtuals=("c", "cxx", "fortran"), + direct=True, + ) + if _satisfies_edge(compiler_edge, rhs_edge, resolve_virtuals): + return compiler_edge + + if rhs_edge.direct: + return None + + # BFS through link/run transitive deps (skip depth 1, already checked). + depflag = dt.LINK | dt.RUN + queue = collections.deque(lhs_node.edges_to_dependencies(depflag=depflag)) + seen = {id(lhs_edge.spec) for lhs_edge in queue} + while queue: + lhs_edge = queue.popleft() + + if _satisfies_edge(lhs_edge, rhs_edge, resolve_virtuals): + return lhs_edge + + for lhs_edge in lhs_edge.spec.edges_to_dependencies(depflag=depflag): + if id(lhs_edge.spec) not in seen: + seen.add(id(lhs_edge.spec)) + queue.append(lhs_edge) + + return None + + +def _satisfies_edge(lhs: "DependencySpec", rhs: "DependencySpec", resolve_virtuals: bool) -> bool: + """Helper function for satisfaction tests, which checks edge attributes and the target node. + It skips verification of the parent node.""" + name_mismatch = rhs.spec.name and lhs.spec.name != rhs.spec.name + if name_mismatch and rhs.spec.name not in lhs.virtuals: + return False + + if not rhs.when._satisfies(lhs.when, resolve_virtuals=resolve_virtuals): + return False + + # Subset semantics for virtuals + for v in rhs.virtuals: + if v not in lhs.virtuals: + return False + + # Subset semantics for dependency types + if (lhs.depflag & rhs.depflag) != rhs.depflag: + return False + + if not name_mismatch: + return lhs.spec._satisfies_node(rhs.spec, resolve_virtuals=resolve_virtuals) + + # Right-hand side is virtual provided by left-hand side. The only node attribute supported is + # the version of the virtual. Avoid expensive lookups for provider metadata if there's no + # version constraint to check. + if rhs.spec.versions == spack.version.any_version: + return True + + if not resolve_virtuals: + return False + + return lhs.spec._provides_virtual(rhs.spec) + + @lang.lazy_lexicographic_ordering(set_hash=False) class Spec: compiler = DeprecatedCompilerSpec() @@ -3334,6 +3411,42 @@ def satisfies(self, other: Union[str, "Spec"], deps: bool = True) -> bool: """ return self._satisfies(other=other, deps=deps, resolve_virtuals=True) + def _provides_virtual(self, virtual_spec: "Spec") -> bool: + """Return True if this spec provides the given virtual spec. + + Args: + virtual_spec: abstract virtual spec (e.g. ``"mpi"`` or ``"mpi@3:"``) + """ + if not virtual_spec.name: + return False + + # Get the package instance + if self.concrete: + try: + pkg = self.package + except spack.repo.UnknownPackageError: + return False + else: + try: + pkg_cls = spack.repo.PATH.get_pkg_class(self.fullname) + pkg = pkg_cls(self) + except spack.repo.UnknownEntityError: + # If we can't get package info on this spec, don't treat + # it as a provider of this vdep. + return False + + for when_spec, provided in pkg.provided.items(): + # Don't use satisfies for virtuals, because an abstract vs. abstract spec may use the + # repo index + if self.satisfies(when_spec, deps=False) and any( + provided_virtual.name == virtual_spec.name + and provided_virtual.versions.intersects(virtual_spec.versions) + for provided_virtual in provided + ): + return True + + return False + def _satisfies( self, other: Union[str, "Spec"], deps: bool = True, resolve_virtuals: bool = True ) -> bool: @@ -3347,14 +3460,53 @@ def _satisfies( """ if other is EMPTY_SPEC: return True + other = self._autospec(other) + if not self._satisfies_node(other, resolve_virtuals=resolve_virtuals): + return False + + # If there are no dependencies on the rhs, or we don't recurse, they are satisfied. + if not deps or not other._dependencies: + return True + + stack = [(self, other)] + + while stack: + lhs, rhs = stack.pop() + + for rhs_edge in rhs.edges_to_dependencies(): + # Skip rhs edges whose when condition doesn't apply to the lhs node. + if rhs_edge.when is not EMPTY_SPEC and not lhs._intersects( + rhs_edge.when, resolve_virtuals=resolve_virtuals + ): + continue + + lhs_edge = _get_satisfying_edge(lhs, rhs_edge, resolve_virtuals=resolve_virtuals) + + if not lhs_edge: + return False + + # Recursive case: `^zlib %gcc` + if not rhs_edge.spec.concrete and rhs_edge.spec._dependencies: + stack.append((lhs_edge.spec, rhs_edge.spec)) + + return True + + def _satisfies_node(self, other: "Spec", resolve_virtuals: bool) -> bool: + """Compares self and other without looking at dependencies""" if other.concrete: # The left-hand side must be the same singleton with identical hash. Notice that # package hashes can be different for otherwise indistinguishable concrete Spec # objects. return self.concrete and self.dag_hash() == other.dag_hash() + if self.name != other.name and self.name and other.name: + # Name mismatch can still be satisfiable if lhs provides the virtual mentioned by rhs. + if not resolve_virtuals: + return False + return self._provides_virtual(other) + # If the right-hand side has an abstract hash, make sure it's a prefix of the # left-hand side's (abstract) hash. if other.abstract_hash: @@ -3362,28 +3514,6 @@ def _satisfies( if not compare_hash or not compare_hash.startswith(other.abstract_hash): return False - # If the names are different, we need to consider virtuals - if self.name != other.name and self.name and other.name and resolve_virtuals: - # A concrete provider can satisfy a virtual dependency. - if not spack.repo.PATH.is_virtual(self.name) and spack.repo.PATH.is_virtual( - other.name - ): - try: - # Here we might get an abstract spec - pkg_cls = spack.repo.PATH.get_pkg_class(self.fullname) - pkg = pkg_cls(self) - except spack.repo.UnknownEntityError: - # If we can't get package info on this spec, don't treat - # it as a provider of this vdep. - return False - - if pkg.provides(other.name): - for when_spec, provided in pkg.provided.items(): - if self.satisfies(when_spec, deps=False): - if any(vpkg.intersects(other) for vpkg in provided): - return True - return False - # namespaces either match, or other doesn't require one. if ( other.namespace is not None @@ -3407,153 +3537,6 @@ def _satisfies( if not self.compiler_flags.satisfies(other.compiler_flags): return False - # If we need to descend into dependencies, do it, otherwise we're done. - if not deps: - return True - - # If there are no constraints to satisfy, we're done. - if not other._dependencies: - return True - - # If we arrived here, the lhs root node satisfies the rhs root node. Now we need to check - # all the edges that have an abstract parent, and verify that they match some edge in the - # lhs. - # - # It might happen that the rhs brings in concrete sub-DAGs. For those we don't need to - # verify the edge properties, cause everything is encoded in the hash of the nodes that - # will be verified later. - lhs_edges: Dict[str, Set[DependencySpec]] = collections.defaultdict(set) - for rhs_edge in other.traverse_edges(root=False, cover="edges"): - # Check satisfaction of the dependency only if its when condition can apply - if not rhs_edge.parent.name or rhs_edge.parent.name == self.name: - test_spec = self - elif rhs_edge.parent.name in self: - test_spec = self[rhs_edge.parent.name] - else: - test_spec = None - if test_spec and not test_spec._intersects( - rhs_edge.when, resolve_virtuals=resolve_virtuals - ): - continue - - # If we are checking for ^mpi we need to verify if there is any edge - if resolve_virtuals and spack.repo.PATH.is_virtual(rhs_edge.spec.name): - # Don't mutate objects in memory that may be referred elsewhere - rhs_edge = rhs_edge.copy() - rhs_edge.update_virtuals(virtuals=(rhs_edge.spec.name,)) - - if rhs_edge.direct: - # Note: this relies on abstract specs from string not being deeper than 2 levels - # e.g. in foo %fee ^bar %baz we cannot go deeper than "baz" and e.g. specify its - # dependencies too. - # - # We also need to account for cases like gcc@ %gcc@ where the parent - # name is the same as the child name - # - # The same assumptions hold on Spec.constrain, and Spec.intersect - current_node = self - if rhs_edge.parent.name and rhs_edge.parent.name != rhs_edge.spec.name: - try: - current_node = self[rhs_edge.parent.name] - except KeyError: - return False - - # If the branch is % or ^, check if we have a corresponding - # branch in the lhs - candidate_edges = [] - if resolve_virtuals and spack.repo.PATH.is_virtual(rhs_edge.spec.name): - candidate_edges = current_node.edges_to_dependencies(name=rhs_edge.spec.name) - - name = ( - None - if resolve_virtuals and spack.repo.PATH.is_virtual(rhs_edge.spec.name) - else rhs_edge.spec.name - ) - candidate_edges.extend( - current_node.edges_to_dependencies( - name=name, virtuals=rhs_edge.virtuals or None - ) - ) - - # Select at least the deptypes on the rhs_edge, and conditional edges that - # constrain a bigger portion of the search space (so it's rhs.when <= lhs.when) - candidates = [ - lhs_edge.spec - for lhs_edge in candidate_edges - if ((lhs_edge.depflag & rhs_edge.depflag) ^ rhs_edge.depflag) == 0 - and rhs_edge.when._satisfies(lhs_edge.when, resolve_virtuals=resolve_virtuals) - ] - - # For old specs, consider compiler dependencies from annotations - if current_node.original_spec_format() < 5: - compiler_spec = current_node.annotations.compiler_node_attribute - if compiler_spec is not None: - candidates.append(compiler_spec) - - if not candidates or not any( - x._satisfies(rhs_edge.spec, resolve_virtuals=resolve_virtuals) - for x in candidates - ): - return False - - continue - - # Skip edges from a concrete sub-DAG - if rhs_edge.parent.concrete: - continue - - if not lhs_edges: - # Construct a map of the link/run subDAG + direct "build" edges, - # keyed by dependency name - for lhs_edge in self.traverse_edges( - root=False, cover="edges", deptype=("link", "run") - ): - lhs_edges[lhs_edge.spec.name].add(lhs_edge) - for virtual_name in lhs_edge.virtuals: - lhs_edges[virtual_name].add(lhs_edge) - - build_edges = self.edges_to_dependencies(depflag=dt.BUILD) - for lhs_edge in build_edges: - lhs_edges[lhs_edge.spec.name].add(lhs_edge) - for virtual_name in lhs_edge.virtuals: - lhs_edges[virtual_name].add(lhs_edge) - - # We don't have edges to this dependency - current_dependency_name = rhs_edge.spec.name - if current_dependency_name and current_dependency_name not in lhs_edges: - return False - - if not current_dependency_name: - # Here we have an anonymous spec e.g. ^ dev_path=* - candidate_edges = list(itertools.chain(*lhs_edges.values())) - - else: - candidate_edges = [ - lhs_edge - for lhs_edge in lhs_edges[current_dependency_name] - if rhs_edge.when._satisfies(lhs_edge.when, resolve_virtuals=resolve_virtuals) - ] - - if not candidate_edges: - return False - - for virtual in rhs_edge.virtuals: - # Check the name because ^mpi has the "mpi" virtual - has_virtual = any( - virtual in edge.virtuals or virtual == edge.spec.name - for edge in candidate_edges - ) - if not has_virtual: - return False - - for lhs_edge in candidate_edges: - if lhs_edge.spec._satisfies( - rhs_edge.spec, deps=False, resolve_virtuals=resolve_virtuals - ): - break - else: - return False - return True def _satisfies_variants(self, other: "Spec") -> bool: diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index 177b1e08d68620..1fedd238bc0449 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -1935,7 +1935,7 @@ def test_overriding_concretization_properties_per_group(self, create_temporary_m gcc = next(x for _, x in e.concretized_specs_by(group="compiler")) assert gcc.satisfies("gcc@14") and not gcc.external - assert gcc.satisfies("%c,cxx,fortran=gcc") + assert gcc.satisfies("%c,cxx=gcc") gcc_hash = gcc.dag_hash() assert len(list(e.concretized_specs_by(group="scalapacks"))) == 4 diff --git a/lib/spack/spack/test/spec_semantics.py b/lib/spack/spack/test/spec_semantics.py index 38c73fed4fb772..ae782ff6e0a710 100644 --- a/lib/spack/spack/test/spec_semantics.py +++ b/lib/spack/spack/test/spec_semantics.py @@ -12,6 +12,7 @@ import spack.llnl.util.lang import spack.package_base import spack.paths +import spack.repo import spack.solver.asp import spack.spec import spack.spec_parser @@ -622,6 +623,84 @@ def test_basic_satisfies_conditional_dep(self, default_mock_concretization): assert concrete.satisfies("^[when='^notapackage'] zmpi") assert not concrete.satisfies("^[when='^mpi'] zmpi") + def test_concrete_satisfies_does_not_consult_repo( + self, default_mock_concretization, monkeypatch + ): + """Tests that `satisfies()` on a concrete lhs doesn't need the provider index, when the rhs + contains a virtual name. + """ + concrete = default_mock_concretization("mpileaks ^mpich") + + # Reset the index, will raise if the `_provider_index` is ever removed as an attribute + monkeypatch.setattr(spack.repo.PATH, "_provider_index", None) + + # Basic match and mismatch cases. + assert concrete.satisfies("mpileaks") + assert not concrete.satisfies("zlib") + + # Virtuals on a direct edge + assert concrete.satisfies("%mpi") + assert concrete.satisfies("%mpi@3") + assert not concrete.satisfies("%mpi@5") + assert concrete.satisfies("%mpi=mpich") + assert not concrete.satisfies("%lapack") + + # Virtuals on a transitive edge + assert concrete.satisfies("^mpi") + assert concrete.satisfies("^mpi=mpich") + assert not concrete.satisfies("^lapack") + + # Concrete spec asking about one of its concrete deps. + mpich = concrete["mpich"] + assert mpich.satisfies("mpich") + assert mpich.satisfies("mpi") + + # We should not create again the index + assert spack.repo.PATH._provider_index is None + + def test_concrete_contains_does_not_consult_repo( + self, default_mock_concretization, monkeypatch + ): + """Tests that `foo in spec` on a concrete spec doesn't need the provider index, when the + item contains a virtual name. + """ + concrete = default_mock_concretization("mpileaks ^mpich") + + # Reset the index, will raise if the `_provider_index` is ever removed as an attribute + monkeypatch.setattr(spack.repo.PATH, "_provider_index", None) + + assert "mpi" in concrete + assert "c" in concrete + + # We should not create again the index + assert spack.repo.PATH._provider_index is None + + def test_abstract_satisfies_with_lhs_provider_rhs_virtual(self): + """If the left-hand side mentions a provider among dependencies and the right-hand side + mentions a virtual among its deps, we only have satisfaction if the edge attribute + specifies this virtual is provided.""" + assert not Spec("mpileaks ^mpich").satisfies("mpileaks ^mpi") + assert not Spec("mpileaks %mpich").satisfies("mpileaks %mpi") + assert Spec("mpileaks ^[virtuals=mpi] mpich").satisfies("mpileaks ^mpi") + assert Spec("mpileaks %[virtuals=mpi] mpich").satisfies("mpileaks ^mpi") + assert Spec("mpileaks %[virtuals=mpi] mpich").satisfies("mpileaks %mpi") + + def test_concrete_checks_on_virtual_names_dont_need_repo( + self, default_mock_concretization, monkeypatch + ): + """Tests that ``%mpi`` or similar on a concrete spec doesn't need the repo""" + concrete = default_mock_concretization("mpileaks ^mpich") + + # We don't need the repo + monkeypatch.setattr(spack.repo, "PATH", None) + + assert concrete.satisfies("%mpi") + assert concrete.satisfies("%c") + assert concrete.satisfies("%c=gcc") + assert concrete.satisfies("%mpi=mpich") + + assert not concrete.satisfies("%c,mpi=mpich") + def test_satisfies_single_valued_variant(self): """Tests that the case reported in https://github.com/spack/spack/pull/2386#issuecomment-282147639 From ed06dcee94d912a9713815b1371740e45bf19fd2 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 1 Apr 2026 11:43:41 +0200 Subject: [PATCH 207/337] tags.py: fix integration test (#52184) Signed-off-by: Harmen Stoppels --- lib/spack/spack/test/cmd/tags.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/test/cmd/tags.py b/lib/spack/spack/test/cmd/tags.py index 150f9112d4239f..f005034c4d51f3 100644 --- a/lib/spack/spack/test/cmd/tags.py +++ b/lib/spack/spack/test/cmd/tags.py @@ -6,12 +6,11 @@ import spack.main import spack.repo from spack.installer import PackageInstaller -from spack.tag import TagIndex tags = spack.main.SpackCommand("tags") -def test_tags_bad_options(): +def test_tags_bad_options(mock_packages): out = tags("-a", "tag1", fail_on_error=False) assert "option OR provide" in out @@ -38,9 +37,10 @@ def test_tags_all_mock_tag_packages(mock_packages): assert pkg in out -def test_tags_no_tags(monkeypatch): - monkeypatch.setattr(spack.repo.PATH, "tag_index", TagIndex()) - out = tags() +def test_tags_no_tags(repo_builder): + repo_builder.add_package("pkg-a") + with spack.repo.use_repositories(repo_builder.root): + out = tags() assert "No tagged" in out From e1d7af8f259bc7e1440d761335f49d3d083562e8 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 1 Apr 2026 13:40:22 +0200 Subject: [PATCH 208/337] environment.py: use tag from pkg for view regen (#52182) The logic to exclude packages tagged "runtime" from views triggers cache validation, resulting in 8k stat calls. Avoid this by querying the package classes. This is needed to make `spack install` not trigger many stat calls on package.py files for a concrete environment. Signed-off-by: Harmen Stoppels --- lib/spack/spack/environment/environment.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 19be16c49295af..a055587096165e 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -975,15 +975,17 @@ def regenerate(self, env: "Environment") -> None: msg += str(e) tty.warn(msg) - def _exclude_duplicate_runtimes(self, nodes): - all_runtimes = spack.repo.PATH.packages_with_tags("runtime") - runtimes_by_name = {} - for s in nodes: - if s.name not in all_runtimes: + def _exclude_duplicate_runtimes(self, specs: List[Spec]) -> List[Spec]: + """Stably filter out duplicates of "runtime" tagged packages, keeping only latest.""" + # Maps packages tagged "runtime" to the spec with latest version. + latest: Dict[str, Spec] = {} + for s in specs: + if "runtime" not in getattr(s.package, "tags", ()): continue - current_runtime = runtimes_by_name.get(s.name, s) - runtimes_by_name[s.name] = max(current_runtime, s, key=lambda x: x.version) - return [x for x in nodes if x.name not in all_runtimes or runtimes_by_name[x.name] == x] + elif s.name not in latest or latest[s.name].version < s.version: + latest[s.name] = s + + return [x for x in specs if x.name not in latest or latest[x.name] is x] def env_subdir_path(manifest_dir: Union[str, pathlib.Path]) -> str: From 10f6348e9385f448729cf61d21bece8b32d9e4f3 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 1 Apr 2026 13:48:38 +0200 Subject: [PATCH 209/337] new_installer.py: remove pkg->mtime dict ipc (#52183) With recent changes there is no need anymore to communicate the package to mtime dictionary to build subprocesses. Remove this to reduce subprocess startup latency by ~15ms per process. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index c2a994e7111994..667982fefa8b12 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -70,7 +70,6 @@ import spack.llnl.util.tty import spack.llnl.util.tty.color import spack.paths -import spack.repo import spack.report import spack.spec import spack.stage @@ -327,10 +326,6 @@ def __init__(self): self.store = spack.store.STORE self.monkey_patches = spack.subprocess_context.TestPatches.create() self.spack_working_dir = spack.paths.spack_working_dir - # Avoid 8k stat calls in build process. The downside of this is the additional startup - # cost that blocks the parent process in `proc.start()`, but we avoid filesystem pressure. - # TODO: we don't need to send this if Spec.satisfies(...) etc does not depend on the repo. - self.repo_cache = spack.repo.FastPackageChecker._paths_cache def restore(self): if multiprocessing.get_start_method() == "fork": @@ -347,7 +342,6 @@ def restore(self): spack.config.CONFIG = self.config self.monkey_patches.restore() spack.paths.spack_working_dir = self.spack_working_dir - spack.repo.FastPackageChecker._paths_cache = self.repo_cache class PrefixPivoter: From 61627c0ceba84e6b7c1689047de1723b9f0143ed Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 1 Apr 2026 13:50:21 +0200 Subject: [PATCH 210/337] main.py: cache spack commit (#52185) Signed-off-by: Harmen Stoppels --- lib/spack/spack/__init__.py | 2 ++ lib/spack/spack/test/main.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/lib/spack/spack/__init__.py b/lib/spack/spack/__init__.py index 0c25558b512a8b..da762a5f909190 100644 --- a/lib/spack/spack/__init__.py +++ b/lib/spack/spack/__init__.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) +import functools import os import re from typing import Optional @@ -37,6 +38,7 @@ def __try_int(v): spack_version_info = tuple([__try_int(v) for v in __version__.split(".")]) +@functools.lru_cache(maxsize=None) def get_spack_commit() -> Optional[str]: """Get the Spack git commit sha. diff --git a/lib/spack/spack/test/main.py b/lib/spack/spack/test/main.py index d31a8a442b2cae..0ee7c3c05da636 100644 --- a/lib/spack/spack/test/main.py +++ b/lib/spack/spack/test/main.py @@ -26,6 +26,11 @@ ) +@pytest.fixture(autouse=True) +def _clear_commit_cache(): + spack.get_spack_commit.cache_clear() + + def test_version_git_nonsense_output(tmp_path: pathlib.Path, working_env, monkeypatch): git = tmp_path / "git" with open(git, "w", encoding="utf-8") as f: From 41351d3df9634b3f96d37682310510e55a6d995b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:10:46 +0200 Subject: [PATCH 211/337] build(deps): bump mypy in /.github/workflows/requirements/style (#52178) Bumps [mypy](https://github.com/python/mypy) from 1.19.1 to 1.20.0. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.19.1...v1.20.0) --- updated-dependencies: - dependency-name: mypy dependency-version: 1.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/requirements/style/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index 102c1461816be3..6fe81760bd0819 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -2,7 +2,7 @@ black==25.12.0 clingo==5.8.0 flake8==7.3.0 isort==7.0.0 -mypy==1.19.1 +mypy==1.20.0 types-six==1.17.0.20251009 vermin==1.8.0 pylint==4.0.5 From af1edd44f13ed3fb53c7f647c7e25280f8bc817e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 2 Apr 2026 13:12:28 +0200 Subject: [PATCH 212/337] new_installer.py: new process group instead of session (#52192) In build subprocesses, create a new process group instead of a new session. The benefit is that tools like pstree neatly show the spack install process with all its concurrent builds as a tree, making it easier to verify whether the jobserver is working correctly: -j N <--> N leaf nodes. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 667982fefa8b12..1fb0686f3b9f2e 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -474,8 +474,9 @@ def worker_function( global_state.restore() - # Start a new session, so our SIGTERM handler can kill all child processes. - os.setsid() + # Isolate the process group to shield against Ctrl+C and enable safe killpg() cleanup. In + # constrast to setsid(), this keeps a neat process group hierarchy for utils like pstree. + os.setpgid(0, 0) # Reset SIGTSTP to default in case the parent had a custom handler. signal.signal(signal.SIGTSTP, signal.SIG_DFL) From 36a712616a5314d88edc789bc9c69bd6dda17a22 Mon Sep 17 00:00:00 2001 From: Victor Brunini Date: Mon, 6 Apr 2026 09:01:21 -0400 Subject: [PATCH 213/337] new_installer: make build cache index optional (#52188) Signed-off-by: Victor Brunini --- lib/spack/spack/new_installer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 1fb0686f3b9f2e..ba8a57b11706eb 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2173,7 +2173,11 @@ def __init__( ) #: check what specs we could fetch from binaries (checks against cache, not remotely) - spack.binary_distribution.BINARY_INDEX.update() + try: + spack.binary_distribution.BINARY_INDEX.update() + except spack.binary_distribution.FetchCacheError: + pass + self.binary_cache_for_spec = { s.dag_hash(): spack.binary_distribution.BINARY_INDEX.find_by_hash(s.dag_hash()) for s in self.build_graph.nodes.values() From d42d88d68f99437cbd1a7c20c966dc34f09d7cd2 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 7 Apr 2026 10:00:53 +0200 Subject: [PATCH 214/337] schema: reduce schema size with $ref and definitions (#52206) Use jsonschema $ref / definitions to deduplicate repeated sub-schemas (env modifications, projections, module file configuration, CI job attributes) across standalone and merged schemas. This reduces the size as JSON by a factor 3 to 4. Signed-off-by: Harmen Stoppels --- lib/spack/spack/schema/ci.py | 19 +++++++----- lib/spack/spack/schema/compilers.py | 3 +- lib/spack/spack/schema/config.py | 3 +- lib/spack/spack/schema/env.py | 5 +-- lib/spack/spack/schema/env_vars.py | 3 +- lib/spack/spack/schema/environment.py | 5 ++- lib/spack/spack/schema/merged.py | 23 ++++++++++++-- lib/spack/spack/schema/modules.py | 24 +++++++-------- lib/spack/spack/schema/packages.py | 3 +- lib/spack/spack/schema/projections.py | 44 ++++++++++++++------------- lib/spack/spack/schema/view.py | 4 +-- 11 files changed, 85 insertions(+), 51 deletions(-) diff --git a/lib/spack/spack/schema/ci.py b/lib/spack/spack/schema/ci.py index 4c694c8927250f..97c88700a6c767 100644 --- a/lib/spack/spack/schema/ci.py +++ b/lib/spack/spack/schema/ci.py @@ -31,10 +31,9 @@ ] } -# Additional attributes are allow -# and will be forwarded directly to the -# CI target YAML for each job. -attributes_schema = { +# Additional attributes are allowed and will be forwarded directly to the CI target YAML for each +# job. +ci_job_attributes = { "type": "object", "additionalProperties": True, "properties": { @@ -50,6 +49,8 @@ }, } +ref_ci_job_attributes = {"$ref": "#/definitions/ci_job_attributes"} + submapping_schema = { "type": "object", "additionalProperties": False, @@ -64,8 +65,8 @@ "required": ["match"], "properties": { "match": {"type": "array", "items": {"type": "string"}}, - "build-job": attributes_schema, - "build-job-remove": attributes_schema, + "build-job": ref_ci_job_attributes, + "build-job-remove": ref_ci_job_attributes, }, }, }, @@ -101,7 +102,10 @@ def job_schema(name: str): return { "type": "object", "additionalProperties": False, - "properties": {f"{name}-job": attributes_schema, f"{name}-job-remove": attributes_schema}, + "properties": { + f"{name}-job": ref_ci_job_attributes, + f"{name}-job-remove": ref_ci_job_attributes, + }, } @@ -142,5 +146,6 @@ def job_schema(name: str): "title": "Spack CI configuration file schema", "type": "object", "additionalProperties": False, + "definitions": {"ci_job_attributes": ci_job_attributes}, "properties": properties, } diff --git a/lib/spack/spack/schema/compilers.py b/lib/spack/spack/schema/compilers.py index 8d91505fce0d4e..df62d27040e187 100644 --- a/lib/spack/spack/schema/compilers.py +++ b/lib/spack/spack/schema/compilers.py @@ -92,7 +92,7 @@ ] }, "implicit_rpaths": implicit_rpaths, - "environment": spack.schema.environment.definition, + "environment": spack.schema.environment.ref_env_modifications, "extra_rpaths": extra_rpaths, }, } @@ -109,4 +109,5 @@ "type": "object", "additionalProperties": False, "properties": properties, + "definitions": {"env_modifications": spack.schema.environment.env_modifications}, } diff --git a/lib/spack/spack/schema/config.py b/lib/spack/spack/schema/config.py index b0b3d5151c7c69..b9391481c0668c 100644 --- a/lib/spack/spack/schema/config.py +++ b/lib/spack/spack/schema/config.py @@ -71,7 +71,7 @@ "relocation of binaries (true for max length, integer for specific " "length)", }, - **spack.schema.projections.properties, + **spack.schema.projections.ref_properties, }, }, "install_hash_length": { @@ -243,6 +243,7 @@ "type": "object", "additionalProperties": False, "properties": properties, + "definitions": {"projections": spack.schema.projections.projections}, } diff --git a/lib/spack/spack/schema/env.py b/lib/spack/spack/schema/env.py index 30f0da737ca3b9..c20f68947f52b8 100644 --- a/lib/spack/spack/schema/env.py +++ b/lib/spack/spack/schema/env.py @@ -39,7 +39,7 @@ "type": "object", "description": "Top-most configuration scope for this group of specs", "additionalProperties": False, - "properties": {**spack.schema.merged.properties}, + "properties": {**spack.schema.merged.ref_sections}, }, } @@ -53,7 +53,7 @@ "additionalProperties": False, "properties": { # merged configuration scope schemas - **spack.schema.merged.properties, + **spack.schema.merged.ref_sections, # extra environment schema properties "specs": { "type": "array", @@ -98,6 +98,7 @@ "type": "object", "additionalProperties": False, "properties": properties, + "definitions": spack.schema.merged.defs, } diff --git a/lib/spack/spack/schema/env_vars.py b/lib/spack/spack/schema/env_vars.py index 009961cf5a063c..4f7a4337c08b14 100644 --- a/lib/spack/spack/schema/env_vars.py +++ b/lib/spack/spack/schema/env_vars.py @@ -10,7 +10,7 @@ import spack.schema.environment -properties: Dict[str, Any] = {"env_vars": spack.schema.environment.definition} +properties: Dict[str, Any] = {"env_vars": spack.schema.environment.ref_env_modifications} #: Full schema with metadata schema = { @@ -19,4 +19,5 @@ "type": "object", "additionalProperties": False, "properties": properties, + "definitions": {"env_modifications": spack.schema.environment.env_modifications}, } diff --git a/lib/spack/spack/schema/environment.py b/lib/spack/spack/schema/environment.py index 0f0de85d5bbfc5..815fced752a5b7 100644 --- a/lib/spack/spack/schema/environment.py +++ b/lib/spack/spack/schema/environment.py @@ -12,7 +12,7 @@ "additionalProperties": {"anyOf": [{"type": "string"}, {"type": "number"}]}, } -definition: Dict[str, Any] = { +env_modifications: Dict[str, Any] = { "type": "object", "description": "Environment variable modifications to apply at runtime", "default": {}, @@ -45,6 +45,9 @@ }, } +#: $ref pointer for use in merged schema +ref_env_modifications = {"$ref": "#/definitions/env_modifications"} + def parse(config_obj): """Returns an EnvironmentModifications object containing the modifications diff --git a/lib/spack/spack/schema/merged.py b/lib/spack/spack/schema/merged.py index 11aefa21cb3947..f414e8b65f15ab 100644 --- a/lib/spack/spack/schema/merged.py +++ b/lib/spack/spack/schema/merged.py @@ -19,17 +19,19 @@ import spack.schema.definitions import spack.schema.develop import spack.schema.env_vars +import spack.schema.environment import spack.schema.include import spack.schema.mirrors import spack.schema.modules import spack.schema.packages +import spack.schema.projections import spack.schema.repos import spack.schema.toolchains import spack.schema.upstreams import spack.schema.view #: Properties for inclusion in other schemas -properties: Dict[str, Any] = { +sections: Dict[str, Any] = { **spack.schema.bootstrap.properties, **spack.schema.cdash.properties, **spack.schema.compilers.properties, @@ -50,11 +52,28 @@ **spack.schema.view.properties, } +#: Canonical definitions for JSON Schema $ref +defs: Dict[str, Any] = { + # Section schemas, prefixed to avoid collisions with sub-schema definitions + **{f"section_{name}": schema for name, schema in sections.items()}, + # Sub-schema definitions hoisted for $ref resolution in env.py + "ci_job_attributes": spack.schema.ci.ci_job_attributes, + "env_modifications": spack.schema.environment.env_modifications, + "module_file_configuration": spack.schema.modules.module_file_configuration, + "projections": spack.schema.projections.projections, +} + +#: Properties using $ref pointers into $defs +ref_sections: Dict[str, Any] = { + name: {"$ref": f"#/definitions/section_{name}"} for name in sections +} + #: Full schema with metadata schema = { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Spack merged configuration file schema", "type": "object", "additionalProperties": False, - "properties": properties, + "properties": ref_sections, + "definitions": defs, } diff --git a/lib/spack/spack/schema/modules.py b/lib/spack/spack/schema/modules.py index 16f85672ae1bd5..9cceba5f792576 100644 --- a/lib/spack/spack/schema/modules.py +++ b/lib/spack/spack/schema/modules.py @@ -67,15 +67,13 @@ "additionalKeysAreSpecs": True, "additionalProperties": {"type": "string"}, # key }, - "environment": { - **spack.schema.environment.definition, - "description": "Custom environment variable modifications to apply in this module " - "file", - }, + "environment": spack.schema.environment.ref_env_modifications, }, } -projections_scheme = spack.schema.projections.properties["projections"] +ref_module_file_configuration = {"$ref": "#/definitions/module_file_configuration"} + +projections_scheme = {"$ref": "#/definitions/projections"} common_props = { "verbose": { @@ -125,10 +123,7 @@ "description": "Custom directory structure and naming convention for module files using " "projection format", }, - "all": { - **module_file_configuration, - "description": "Default configuration applied to all module files in this module set", - }, + "all": ref_module_file_configuration, } tcl_configuration = { @@ -138,7 +133,7 @@ "Lmod", "additionalKeysAreSpecs": True, "properties": {**common_props}, - "additionalProperties": module_file_configuration, + "additionalProperties": ref_module_file_configuration, } lmod_configuration = { @@ -172,7 +167,7 @@ "additionalProperties": array_of_strings, }, }, - "additionalProperties": module_file_configuration, + "additionalProperties": ref_module_file_configuration, } module_config_properties = { @@ -259,5 +254,10 @@ "title": "Spack module file configuration file schema", "type": "object", "additionalProperties": False, + "definitions": { + "module_file_configuration": module_file_configuration, + "projections": spack.schema.projections.projections, + "env_modifications": spack.schema.environment.env_modifications, + }, "properties": properties, } diff --git a/lib/spack/spack/schema/packages.py b/lib/spack/spack/schema/packages.py index a583c543f6c044..e8c429cc9c7200 100644 --- a/lib/spack/spack/schema/packages.py +++ b/lib/spack/spack/schema/packages.py @@ -289,7 +289,7 @@ "patternProperties": {r"^\w": {"type": "string"}}, "additionalProperties": False, }, - "environment": spack.schema.environment.definition, + "environment": spack.schema.environment.ref_env_modifications, "extra_rpaths": extra_rpaths, "implicit_rpaths": implicit_rpaths, "flags": flags, @@ -360,6 +360,7 @@ "type": "object", "additionalProperties": False, "properties": properties, + "definitions": {"env_modifications": spack.schema.environment.env_modifications}, } diff --git a/lib/spack/spack/schema/projections.py b/lib/spack/spack/schema/projections.py index 3704ec3bf30b11..aebb9404052e16 100644 --- a/lib/spack/spack/schema/projections.py +++ b/lib/spack/spack/schema/projections.py @@ -10,35 +10,37 @@ from typing import Any, Dict #: Properties for inclusion in other schemas -properties: Dict[str, Any] = { - "projections": { - "type": "object", - "description": "Customize directory structure and naming schemes by mapping specs to " - "format strings.", - "properties": { - "all": { - "type": "string", - "description": "Default projection format string used as fallback for all specs " - "that do not match other entries. Uses spec format syntax like " - '"{name}/{version}/{hash:16}".', - } - }, - "additionalKeysAreSpecs": True, - "additionalProperties": { +projections: Dict[str, Any] = { + "type": "object", + "description": "Customize directory structure and naming schemes by mapping specs to " + "format strings.", + "properties": { + "all": { "type": "string", - "description": "Projection format string for specs matching this key. Uses spec " - "format syntax supporting tokens like {name}, {version}, {compiler.name}, " - "{^dependency.name}, etc.", - }, - } + "description": "Default projection format string used as fallback for all specs " + "that do not match other entries. Uses spec format syntax like " + '"{name}/{version}/{hash:16}".', + } + }, + "additionalKeysAreSpecs": True, + "additionalProperties": { + "type": "string", + "description": "Projection format string for specs matching this key. Uses spec " + "format syntax supporting tokens like {name}, {version}, {compiler.name}, " + "{^dependency.name}, etc.", + }, } +#: $ref pointer for use in merged schema +ref_properties: Dict[str, Any] = {"projections": {"$ref": "#/definitions/projections"}} + #: Full schema with metadata schema = { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Spack view projection configuration file schema", "type": "object", "additionalProperties": False, - "properties": properties, + "properties": ref_properties, + "definitions": {"projections": projections}, } diff --git a/lib/spack/spack/schema/view.py b/lib/spack/spack/schema/view.py index 3d86e4611e8196..9ee55937d95133 100644 --- a/lib/spack/spack/schema/view.py +++ b/lib/spack/spack/schema/view.py @@ -9,7 +9,6 @@ """ from typing import Any, Dict -import spack.schema import spack.schema.projections #: Properties for inclusion in other schemas @@ -75,7 +74,7 @@ "description": "List of specs to exclude from the view " "(default: exclude nothing)", }, - **spack.schema.projections.properties, + **spack.schema.projections.ref_properties, }, }, }, @@ -90,4 +89,5 @@ "type": "object", "additionalProperties": False, "properties": properties, + "definitions": {"projections": spack.schema.projections.projections}, } From fe5946871051770a76dc5720b5091fea2027db53 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 7 Apr 2026 17:01:47 +0200 Subject: [PATCH 215/337] environment.py: minor comment fix (#52213) Remove em-dash from comment Signed-off-by: Harmen Stoppels --- lib/spack/spack/environment/environment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index a055587096165e..55cdaa6d6ddf2c 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -2724,7 +2724,7 @@ def _order_groups(self) -> List[str]: if all(d in done for d in deps): ready.append(current) - # Check we can progress — if nothing is ready, there is a cycle + # Check we can progress - if nothing is ready, there is a cycle if not ready: raise SpackEnvironmentConfigError( f"cyclic dependency detected among groups: {', '.join(sorted(remaining))}", From dee44d7dff1f302a6adcd5afe7ae753797c73d1b Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 7 Apr 2026 17:03:46 +0200 Subject: [PATCH 216/337] docs: cleanup installer output (#52204) Signed-off-by: Harmen Stoppels --- lib/spack/docs/bootstrapping.rst | 8 ---- lib/spack/docs/environments.rst | 10 +---- lib/spack/docs/getting_started.rst | 58 ++++--------------------- lib/spack/docs/package_fundamentals.rst | 11 +---- 4 files changed, 12 insertions(+), 75 deletions(-) diff --git a/lib/spack/docs/bootstrapping.rst b/lib/spack/docs/bootstrapping.rst index dde4cfbe0f56a9..4ef67b5303a807 100644 --- a/lib/spack/docs/bootstrapping.rst +++ b/lib/spack/docs/bootstrapping.rst @@ -47,8 +47,6 @@ Running a command that concretizes a spec, like: .. code-block:: console % spack solve zlib - ==> Bootstrapping clingo from pre-built binaries - ==> Fetching https://mirror.spack.io/bootstrap/github-actions/v0.1/build_cache/darwin-catalina-x86_64/apple-clang-12.0.0/clingo-bootstrap-spack/darwin-catalina-x86_64-apple-clang-12.0.0-clingo-bootstrap-spack-p5on7i4hejl775ezndzfdkhvwra3hatn.spack ==> Installing "clingo-bootstrap@spack%apple-clang@12.0.0~docs~ipo+python build_type=Release arch=darwin-catalina-x86_64" from a buildcache [ ... ] @@ -59,13 +57,7 @@ Users can also bootstrap all Spack's dependencies in a single command, which is .. code-block:: console $ spack bootstrap now - ==> Bootstrapping clingo from pre-built binaries - ==> Fetching https://mirror.spack.io/bootstrap/github-actions/v0.3/build_cache/linux-centos7-x86_64-gcc-10.2.1-clingo-bootstrap-spack-shqedxgvjnhiwdcdrvjhbd73jaevv7wt.spec.json - ==> Fetching https://mirror.spack.io/bootstrap/github-actions/v0.3/build_cache/linux-centos7-x86_64/gcc-10.2.1/clingo-bootstrap-spack/linux-centos7-x86_64-gcc-10.2.1-clingo-bootstrap-spack-shqedxgvjnhiwdcdrvjhbd73jaevv7wt.spack ==> Installing "clingo-bootstrap@spack%gcc@10.2.1~docs~ipo+python+static_libstdcpp build_type=Release arch=linux-centos7-x86_64" from a buildcache - ==> Bootstrapping patchelf from pre-built binaries - ==> Fetching https://mirror.spack.io/bootstrap/github-actions/v0.3/build_cache/linux-centos7-x86_64-gcc-10.2.1-patchelf-0.15.0-htk62k7efo2z22kh6kmhaselru7bfkuc.spec.json - ==> Fetching https://mirror.spack.io/bootstrap/github-actions/v0.3/build_cache/linux-centos7-x86_64/gcc-10.2.1/patchelf-0.15.0/linux-centos7-x86_64-gcc-10.2.1-patchelf-0.15.0-htk62k7efo2z22kh6kmhaselru7bfkuc.spack ==> Installing "patchelf@0.15.0%gcc@10.2.1 ldflags="-static-libstdc++ -static-libgcc" arch=linux-centos7-x86_64" from a buildcache .. _cmd-spack-bootstrap-root: diff --git a/lib/spack/docs/environments.rst b/lib/spack/docs/environments.rst index dd7444ece34efa..944e778cd3147f 100644 --- a/lib/spack/docs/environments.rst +++ b/lib/spack/docs/environments.rst @@ -229,10 +229,7 @@ The same rule applies to the ``install`` and ``uninstall`` commands. ==> 0 installed packages $ spack install zlib@1.2.11 - ==> Installing zlib-1.2.11-q6cqrdto4iktfg6qyqcc5u4vmfmwb7iv - ==> No binary for zlib-1.2.11-q6cqrdto4iktfg6qyqcc5u4vmfmwb7iv found: installing from source - ==> zlib: Executing phase: 'install' - [+] ~/spack/opt/spack/linux-rhel7-broadwell/gcc-8.1.0/zlib-1.2.11-q6cqrdto4iktfg6qyqcc5u4vmfmwb7iv + [+] q6cqrdt zlib@1.2.11 ~/spack/opt/spack/linux-rhel7-broadwell/gcc-8.1.0/zlib-1.2.11-q6cqrdto4iktfg6qyqcc5u4vmfmwb7iv (12s) $ spack env activate myenv @@ -242,10 +239,7 @@ The same rule applies to the ``install`` and ``uninstall`` commands. ==> 0 installed packages $ spack install zlib@1.2.8 - ==> Installing zlib-1.2.8-yfc7epf57nsfn2gn4notccaiyxha6z7x - ==> No binary for zlib-1.2.8-yfc7epf57nsfn2gn4notccaiyxha6z7x found: installing from source - ==> zlib: Executing phase: 'install' - [+] ~/spack/opt/spack/linux-rhel7-broadwell/gcc-8.1.0/zlib-1.2.8-yfc7epf57nsfn2gn4notccaiyxha6z7x + [+] yfc7epf zlib@1.2.8 ~/spack/opt/spack/linux-rhel7-broadwell/gcc-8.1.0/zlib-1.2.8-yfc7epf57nsfn2gn4notccaiyxha6z7x (12s) ==> Updating view at ~/spack/var/spack/environments/myenv/.spack-env/view $ spack find diff --git a/lib/spack/docs/getting_started.rst b/lib/spack/docs/getting_started.rst index 0a15432c47afe7..9320f4135b4b49 100644 --- a/lib/spack/docs/getting_started.rst +++ b/lib/spack/docs/getting_started.rst @@ -101,54 +101,14 @@ The output of this command should look similar to the following: .. code-block:: text - [+] /usr (external gcc-10.5.0-zmjbkxxgltryn6hxwzan35qxxw4skbgl) - ==> No binary for compiler-wrapper-1.0-lrmjw5qy3pjeynmxlyfkyzktarvnycfx found: installing from source - ==> Installing compiler-wrapper-1.0-lrmjw5qy3pjeynmxlyfkyzktarvnycfx [2/7] - [+] /usr (external glibc-2.31-rawvy4pmq4nwhk6ipqnesomvstwyopxq) - ==> No binary for gcc-runtime-10.5.0-vchaib2njqlk2cud4a2n33tabq526qjj found: installing from source - ==> Using cached archive: /tmp/try/spack/var/spack/cache/_source-cache/archive/c6/c65a9d2b2d4eef67ab5cb0684d706bb9f005bb2be94f53d82683d7055bdb837c - ==> No patches needed for compiler-wrapper - ==> Installing gcc-runtime-10.5.0-vchaib2njqlk2cud4a2n33tabq526qjj [4/7] - ==> compiler-wrapper: Executing phase: 'install' - ==> No patches needed for gcc-runtime - ==> compiler-wrapper: Successfully installed compiler-wrapper-1.0-lrmjw5qy3pjeynmxlyfkyzktarvnycfx - Stage: 0.00s. Install: 0.00s. Post-install: 0.01s. Total: 0.07s - [+] /home/spack/.local/spack/opt/linux-icelake/compiler-wrapper-1.0-lrmjw5qy3pjeynmxlyfkyzktarvnycfx - ==> gcc-runtime: Executing phase: 'install' - ==> gcc-runtime: Successfully installed gcc-runtime-10.5.0-vchaib2njqlk2cud4a2n33tabq526qjj - Stage: 0.00s. Install: 0.04s. Post-install: 0.05s. Total: 0.14s - [+] /home/spack/.local/spack/opt/linux-icelake/gcc-runtime-10.5.0-vchaib2njqlk2cud4a2n33tabq526qjj - ==> No binary for gmake-4.4.1-ifn6em7abtw6ozpog5ezy565vu66gsrm found: installing from source - ==> Installing gmake-4.4.1-ifn6em7abtw6ozpog5ezy565vu66gsrm [5/7] - ==> Using cached archive: /tmp/try/spack/var/spack/cache/_source-cache/archive/dd/dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3.tar.gz - ==> No patches needed for gmake - ==> gmake: Executing phase: 'install' - ==> gmake: Successfully installed gmake-4.4.1-ifn6em7abtw6ozpog5ezy565vu66gsrm - Stage: 0.05s. Install: 15.91s. Post-install: 0.01s. Total: 16.00s - [+] /home/spack/.local/spack/opt/linux-icelake/gmake-4.4.1-ifn6em7abtw6ozpog5ezy565vu66gsrm - ==> No binary for zlib-ng-2.2.4-j5ddfaq7nyykn2bovorx73gykhjcl5nz found: installing from source - ==> Installing zlib-ng-2.2.4-j5ddfaq7nyykn2bovorx73gykhjcl5nz [6/7] - ==> Using cached archive: /tmp/try/spack/var/spack/cache/_source-cache/archive/a7/a73343c3093e5cdc50d9377997c3815b878fd110bf6511c2c7759f2afb90f5a3.tar.gz - ==> No patches needed for zlib-ng - ==> zlib-ng: Executing phase: 'autoreconf' - ==> zlib-ng: Executing phase: 'configure' - ==> zlib-ng: Executing phase: 'build' - ==> zlib-ng: Executing phase: 'install' - ==> zlib-ng: Successfully installed zlib-ng-2.2.4-j5ddfaq7nyykn2bovorx73gykhjcl5nz - Stage: 0.03s. Autoreconf: 0.00s. Configure: 3.63s. Build: 2.52s. Install: 0.09s. Post-install: 0.02s. Total: 6.49s - [+] /home/spack/.local/spack/opt/linux-icelake/zlib-ng-2.2.4-j5ddfaq7nyykn2bovorx73gykhjcl5nz - ==> No binary for tcl-8.6.12-6vo5hxeqw5plzd6gvzm74wlfz5stnzcv found: installing from source - ==> Installing tcl-8.6.12-6vo5hxeqw5plzd6gvzm74wlfz5stnzcv [7/7] - ==> Fetching https://mirror.spack.io/_source-cache/archive/26/26c995dd0f167e48b11961d891ee555f680c175f7173ff8cb829f4ebcde4c1a6.tar.gz - [100%] 10.35 MB @ 48.5 MB/s - ==> No patches needed for tcl - ==> tcl: Executing phase: 'autoreconf' - ==> tcl: Executing phase: 'configure' - ==> tcl: Executing phase: 'build' - ==> tcl: Executing phase: 'install' - ==> tcl: Successfully installed tcl-8.6.12-6vo5hxeqw5plzd6gvzm74wlfz5stnzcv - Stage: 0.46s. Autoreconf: 0.00s. Configure: 9.25s. Build: 1m 8.71s. Install: 3.32s. Post-install: 0.68s. Total: 1m 22.61s - [+] /home/spack/.local/spack/opt/linux-icelake/tcl-8.6.12-6vo5hxeqw5plzd6gvzm74wlfz5stnzcv + [e] zmjbkxx gcc@10.5.0 /usr (0s) + [e] rawvy4p glibc@2.31 /usr (0s) + [+] 5qfbgng compiler-wrapper@1.0 /home/spack/.local/spack/opt/linux-icelake/compiler-wrapper-1.0-5qfbgngzoqcjfbwrjn2vh75fr3g25c35 (0s) + [+] vchaib2 gcc-runtime@10.5.0 /home/spack/.local/spack/opt/linux-icelake/gcc-runtime-10.5.0-vchaib2njqlk2cud4a2n33tabq526qjj (0s) + [+] vzazvty gmake@4.4.1 /home/spack/.local/spack/opt/linux-icelake/gmake-4.4.1-vzazvtyn5cjdmg3vkkuau35x7hzu7pyl (12s) + [+] soedrhb zlib-ng@2.3.3 /home/spack/.local/spack/opt/linux-icelake/zlib-ng-2.3.3-soedrhbnpeordiixaib6utcple6tpgya (3s) + [+] u6nztpk tcl@8.6.17 /home/spack/.local/spack/opt/linux-icelake/tcl-8.6.17-u6nztpkhzbga4ul665qqhxucxqk3cins (49s) + Congratulations! You just installed your first package with Spack! @@ -160,7 +120,7 @@ Once you have installed ``tcl``, you can immediately use it by starting the ``tc .. code-block:: console - $ /home/spack/.local/spack/opt/linux-icelake/tcl-8.6.12-6vo5hxeqw5plzd6gvzm74wlfz5stnzcv/bin/tclsh + $ /home/spack/.local/spack/opt/linux-icelake/tcl-8.6.17-u6nztpkhzbga4ul665qqhxucxqk3cins/bin/tclsh >% echo "Hello world!" Hello world! diff --git a/lib/spack/docs/package_fundamentals.rst b/lib/spack/docs/package_fundamentals.rst index 8817c316354710..48fa1458811fa8 100644 --- a/lib/spack/docs/package_fundamentals.rst +++ b/lib/spack/docs/package_fundamentals.rst @@ -133,24 +133,15 @@ For example, to install the latest version of the ``mpileaks`` package, you migh If ``mpileaks`` depends on other packages, Spack will install the dependencies first. It then fetches the ``mpileaks`` tarball, expands it, verifies that it was downloaded without errors, builds it, and installs it in its own directory under ``$SPACK_ROOT/opt``. -You'll see a number of messages from Spack, a lot of build output, and a message that the package is installed. .. code-block:: spec $ spack install mpileaks ... dependency build output ... - ==> Installing mpileaks-1.0-ph7pbnhl334wuhogmugriohcwempqry2 - ==> No binary for mpileaks-1.0-ph7pbnhl334wuhogmugriohcwempqry2 found: installing from source - ==> mpileaks: Executing phase: 'autoreconf' - ==> mpileaks: Executing phase: 'configure' - ==> mpileaks: Executing phase: 'build' - ==> mpileaks: Executing phase: 'install' - [+] ~/spack/opt/linux-rhel7-broadwell/gcc-8.1.0/mpileaks-1.0-ph7pbnhl334wuhogmugriohcwempqry2 + [+] ph7pbnh mpileaks@1.0 ~/spack/opt/linux-rhel7-broadwell/gcc-8.1.0/mpileaks-1.0-ph7pbnhl334wuhogmugriohcwempqry2 (5s) The last line, with the ``[+]``, indicates where the package is installed. -Add the Spack debug option (one or more times) -- ``spack -d install mpileaks`` -- to get additional (and even more verbose) output. - Building a specific version ^^^^^^^^^^^^^^^^^^^^^^^^^^^ From e0e272ce5c7668caaf7f8545592770007de3d9d6 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 7 Apr 2026 19:32:53 +0200 Subject: [PATCH 217/337] new_installer.py: defensive prefix checks (#52146) * Ensure prefix uniqueness * Ensure no install in upstream Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 37 +++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index ba8a57b11706eb..fc6688ee4f9c30 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1686,6 +1686,14 @@ def __init__( self.child_to_parent.pop(key, None) self.nodes.pop(key, None) + # Check that all prefixes to be created are unique. + prefixes = [s.prefix for s in self.nodes.values() if not s.external] + if len(prefixes) != len(set(prefixes)): + raise spack.error.InstallError( + "Install prefix collision: " + + ", ".join(p for p in prefixes if prefixes.count(p) > 1) + ) + # If we're not installing dependencies, verify that all remaining nodes in the build graph # after pruning are roots. If there are any non-root nodes, it means there are uninstalled # dependencies that we're not supposed to install. @@ -1837,9 +1845,32 @@ def schedule_builds( # Write lock acquired: proceed with scheduling. # Don't schedule builds for specs from upstream databases. - assert not ( - upstream and record and not record.installed - ), f"Cannot install {spec}: it is uninstalled in an upstream database." + if upstream and record and not record.installed: + lock.release_write() + raise spack.error.InstallError( + f"Cannot install {spec}: it is uninstalled in an upstream database." + ) + + # Defensively assert prefix invariants + if not spec.external: + if ( + dag_hash in overwrite + and record + and record.installed + and record.path != spec.prefix + ): + # Cannot do an overwrite install to a different prefix. + lock.release_write() + raise spack.error.InstallError( + f"Prefix mismatch in overwrite of {spec}: expected {record.path}, " + f"got {spec.prefix}" + ) + elif dag_hash not in overwrite and spec.prefix in db._installed_prefixes: + # Prevent install prefix collision with other specs. + lock.release_write() + raise spack.error.InstallError( + f"Cannot install {spec}: prefix {spec.prefix} already exists" + ) # Acquire a jobserver token if needed. The first (implicit) job needs no token. if needs_jobserver_token and not jobserver.acquire(1): From ef7d9bc66a8542ff23d756be252b0be2de00246c Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:43:04 -0400 Subject: [PATCH 218/337] Pin remaining versioned GHAs to SHAs (#52218) * Pin julia actions to shas Signed-off-by: John Parent * Better julia cache pin Signed-off-by: John Parent * Better setup julia pin Signed-off-by: John Parent --------- Signed-off-by: John Parent --- .github/workflows/import-check.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index d282b7ea88133f..daef15204a211c 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -9,10 +9,10 @@ jobs: continue-on-error: true runs-on: ubuntu-latest steps: - - uses: julia-actions/setup-julia@v2 + - uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7 with: version: '1.10' - - uses: julia-actions/cache@v2 + - uses: julia-actions/cache@d10a6fd8f31b12404a54613ebad242900567f2b9 # v2.1 # PR: use the base of the PR as the old commit - name: Checkout PR base commit From d58206bb2e8085f8369967ef8b15b4c61acbc35a Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Tue, 7 Apr 2026 17:18:02 -0700 Subject: [PATCH 219/337] display_specs: display abstract hash if present (#52219) * display_specs: display abstract hash if present Abstract specs may have an abstract hash and no name, even in contexts in which anonymous specs are generally not allowed. This commit updates the default format string for `display_specs` to display the abstract hash if one exists. Otherwise, we pass empty strings to `tty.colify` and get a division by 0 error. --------- Signed-off-by: Gregory Becker --- lib/spack/spack/cmd/__init__.py | 3 ++- lib/spack/spack/test/cmd/find.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/cmd/__init__.py b/lib/spack/spack/cmd/__init__.py index 989cef3ff16ce4..e2f5340a5c3429 100644 --- a/lib/spack/spack/cmd/__init__.py +++ b/lib/spack/spack/cmd/__init__.py @@ -486,7 +486,8 @@ def get_arg(name, default=None): if flags: ffmt += " {compiler_flags}" vfmt = "{variants}" if variants else "" - format_string = nfmt + "{@version}" + vfmt + ffmt + hfmt = "{/abstract_hash}" + format_string = nfmt + "{@version}" + vfmt + ffmt + hfmt if specfile_format: format_string = "[{specfile_version}] " + format_string diff --git a/lib/spack/spack/test/cmd/find.py b/lib/spack/spack/test/cmd/find.py index 56b673b0322cb8..4eefaaae254769 100644 --- a/lib/spack/spack/test/cmd/find.py +++ b/lib/spack/spack/test/cmd/find.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import argparse +import io import json import os import pathlib @@ -16,6 +17,7 @@ import spack.package_base import spack.paths import spack.repo +import spack.spec import spack.store import spack.user_environment as uenv from spack.enums import InstallRecordStatus @@ -215,6 +217,15 @@ def test_display_json_deps(database, capfd): _check_json_output_deps(spec_list) +@pytest.mark.regression("52219") +def test_display_abstract_hash(): + spec = spack.spec.Spec("/foobar") + out = io.StringIO() + + spack.cmd.display_specs([spec], output=out) # errors on failure + assert "/foobar" in out.getvalue() + + @pytest.mark.db def test_find_format(database, config): output = find("--format", "{name}-{^mpi.name}", "mpileaks") From 21167cc6f1dc3aa39a6cefab2a08d7c8c4d76487 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 8 Apr 2026 08:23:55 +0200 Subject: [PATCH 220/337] lock.py: fix release_write when _read > 0 (#52202) Fix a bug where ``` lock.acquire_read() lock.acquire_write() lock.release_write() ``` would hold an exclusive lock afterwards; it should be a shared lock. Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/lock.py | 16 ++++++-------- lib/spack/spack/test/llnl/util/lock.py | 30 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/lib/spack/spack/llnl/util/lock.py b/lib/spack/spack/llnl/util/lock.py index a94993155c4c93..a45a3ef952bd84 100644 --- a/lib/spack/spack/llnl/util/lock.py +++ b/lib/spack/spack/llnl/util/lock.py @@ -665,25 +665,23 @@ def release_write(self, release_fn: ReleaseFnType = None) -> bool: release_fn = release_fn or true_fn locktype = "WRITE LOCK" - if self._writes == 1 and self._reads == 0: + if self._writes == 1: self._log_releasing(locktype) # we need to call release_fn before releasing the lock result = release_fn() - self._unlock() # can raise LockError. + if self._reads > 0: + self._lock(LockType.READ) + else: + self._unlock() # can raise LockError. + self._writes = 0 self._log_released(locktype) return result else: self._writes -= 1 - - # when the last *write* is released, we call release_fn here - # instead of immediately before releasing the lock. - if self._writes == 0: - return release_fn() - else: - return False + return False def cleanup(self) -> None: if self._reads == 0 and self._writes == 0: diff --git a/lib/spack/spack/test/llnl/util/lock.py b/lib/spack/spack/test/llnl/util/lock.py index 06fb68cf98bf5d..bce9a9101cfb53 100644 --- a/lib/spack/spack/test/llnl/util/lock.py +++ b/lib/spack/spack/test/llnl/util/lock.py @@ -648,6 +648,36 @@ def test_upgrade_read_to_write(private_lock_path): assert not lock._file_ref.fh.closed # recycle the file handle for next lock +def test_release_write_downgrades_to_shared(private_lock_path): + """Releasing a write lock while a read lock is held must downgrade the POSIX lock + from exclusive to shared, allowing other processes to acquire read locks.""" + lock = lk.Lock(private_lock_path) + lock.acquire_read() + lock.acquire_write() + lock.release_write() + assert lock._reads == 1 + assert lock._writes == 0 + + ctx = multiprocessing.get_context() + q = ctx.Queue() + + # Another process must be able to acquire a shared read lock concurrently. + p = ctx.Process(target=_child_try_acquire_read, args=(private_lock_path, q)) + p.start() + p.join() + assert q.get() is True + + # But must not be able to acquire an exclusive write lock. + p = ctx.Process(target=_child_try_acquire_write, args=(private_lock_path, q)) + p.start() + p.join() + assert q.get() is False + + lock.release_read() + assert lock._reads == 0 + assert lock._writes == 0 + + @pytest.mark.skipif(getuid() == 0, reason="user is root") def test_upgrade_read_to_write_fails_with_readonly_file(private_lock_path): """Test that read-only file can be read-locked but not write-locked.""" From 0bc681ab1f43ee559fa8f172a56b2f036b15cd0d Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 8 Apr 2026 08:26:02 +0200 Subject: [PATCH 221/337] database.py: fix toctou bugs (#52200) Use try-open-except instead of if-exists-open, etc. Signed-off-by: Harmen Stoppels --- lib/spack/spack/binary_distribution.py | 7 ++-- lib/spack/spack/cmd/buildcache.py | 10 +++--- lib/spack/spack/database.py | 46 +++++++++++++++++--------- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index 287d7e56c98866..f4c3b81f3621a1 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -232,10 +232,9 @@ def _associate_built_specs_with_mirror(self, cache_key, mirror_metadata: MirrorM db = BuildCacheDatabase(tmpdir) try: - self._index_file_cache.init_entry(cache_key) - cache_path = self._index_file_cache.cache_path(cache_key) - with self._index_file_cache.read_transaction(cache_key): - db._read_from_file(pathlib.Path(cache_path)) + with self._index_file_cache.read_transaction(cache_key) as f: + if f is not None: + db._read_from_stream(f) except spack.database.InvalidDatabaseVersionError as e: tty.warn( "you need a newer Spack version to read the buildcache index for the " diff --git a/lib/spack/spack/cmd/buildcache.py b/lib/spack/spack/cmd/buildcache.py index 0a9646c2d033dc..02e4fabe49c569 100644 --- a/lib/spack/spack/cmd/buildcache.py +++ b/lib/spack/spack/cmd/buildcache.py @@ -946,7 +946,9 @@ def update_view( cache_index = BINARY_INDEX._local_index_cache.get(str(mirror_metadata)) if cache_index: cache_key = cache_index["index_path"] - db._read_from_file(BINARY_INDEX._index_file_cache.cache_path(cache_key)) + with BINARY_INDEX._index_file_cache.read_transaction(cache_key) as f: + if f is not None: + db._read_from_stream(f) spack.binary_distribution._url_generate_package_index(url, tmpdir, db, name, filter_fn) @@ -1006,9 +1008,9 @@ def check_index_fn(args): db = spack.binary_distribution.BuildCacheDatabase(tmpdir) cache_entry = BINARY_INDEX._local_index_cache[str(mirror_metadata)] cache_key = cache_entry["index_path"] - cache_path = BINARY_INDEX._index_file_cache.cache_path(cache_key) - with BINARY_INDEX._index_file_cache.read_transaction(cache_key): - db._read_from_file(cache_path) + with BINARY_INDEX._index_file_cache.read_transaction(cache_key) as f: + if f is not None: + db._read_from_stream(f) index_hash_list = set( [ diff --git a/lib/spack/spack/database.py b/lib/spack/spack/database.py index 299a14b373d712..d64d342116a94a 100644 --- a/lib/spack/spack/database.py +++ b/lib/spack/spack/database.py @@ -25,6 +25,7 @@ import time from json import JSONDecoder from typing import ( + IO, Any, Callable, Container, @@ -814,24 +815,31 @@ def _assign_dependencies( def _read_from_file(self, filename: pathlib.Path, *, reindex: bool = False) -> None: """Fill database from file, do not maintain old data. + + Does not do any locking. + """ + with filename.open("r", encoding="utf-8") as f: + self._read_from_stream(f, reindex=reindex) + + def _read_from_stream(self, stream: IO[str], *, reindex: bool = False) -> None: + """Fill database from a text stream, do not maintain old data. Translate the spec portions from node-dict form to spec form. Does not do any locking. """ + source = getattr(stream, "name", None) or self._index_path try: # In the future we may use a stream of JSON objects, hence `raw_decode` for compat. - fdata, _ = JSONDecoder().raw_decode(filename.read_text(encoding="utf-8")) + fdata, _ = JSONDecoder().raw_decode(stream.read()) except Exception as e: - raise CorruptDatabaseError(f"error parsing database at {filename}:", str(e)) from e + raise CorruptDatabaseError(f"error parsing database at {source}:", str(e)) from e if fdata is None: return def check(cond, msg): if not cond: - raise CorruptDatabaseError( - f"Spack database is corrupt: {msg}", str(self._index_path) - ) + raise CorruptDatabaseError(f"Spack database is corrupt: {msg}", str(source)) check("database" in fdata, "no 'database' attribute in JSON DB.") @@ -853,7 +861,7 @@ def invalid_record(hash_key, error): return CorruptDatabaseError( f"Invalid record in Spack database: hash: {hash_key}, cause: " f"{type(error).__name__}: {error}", - str(self._index_path), + str(source), ) # Build up the database in three passes: @@ -963,8 +971,10 @@ def reindex(self): # ignore errors if we need to rebuild a corrupt database. def _read_suppress_error(): try: - if self._index_path.is_file(): - self._read_from_file(self._index_path, reindex=True) + with self._index_path.open("r", encoding="utf-8") as f: + self._read_from_stream(f, reindex=True) + except FileNotFoundError: + pass except (CorruptDatabaseError, DatabaseNotReadableError): self._data = {} self._installed_prefixes = set() @@ -1148,24 +1158,28 @@ def _write(self, type=None, value=None, traceback=None): def _read(self): """Re-read Database from the data in the set location. This does no locking.""" - if self._index_path.is_file(): + try: + index_file = self._index_path.open("r", encoding="utf-8") + except FileNotFoundError: + if self.is_upstream: + tty.warn(f"upstream not found: {self._index_path}") + return + + with index_file as f: current_verifier = "" if _use_uuid: try: - with self._verifier_path.open("r", encoding="utf-8") as f: - current_verifier = f.read() + with self._verifier_path.open("r", encoding="utf-8") as vf: + current_verifier = vf.read() except BaseException: pass if (current_verifier != self.last_seen_verifier) or (current_verifier == ""): self.last_seen_verifier = current_verifier # Read from file if a database exists - self._read_from_file(self._index_path) + self._read_from_stream(f) elif self._state_is_inconsistent: - self._read_from_file(self._index_path) + self._read_from_stream(f) self._state_is_inconsistent = False - return - elif self.is_upstream: - tty.warn(f"upstream not found: {self._index_path}") def _add( self, From 0734189b090855c42d69197cb27d6f7d5a2b59f2 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 8 Apr 2026 08:27:31 +0200 Subject: [PATCH 222/337] file_cache.py: single-file byte-range locking (#52199) Replace the multiple .lock file approach with fcntl byte-range locking on a single global lockfile. Works well for the installer, and reduces the number of open files a bit. Signed-off-by: Harmen Stoppels --- lib/spack/spack/test/util/file_cache.py | 2 +- lib/spack/spack/util/file_cache.py | 32 +++++++++++++++++-------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/lib/spack/spack/test/util/file_cache.py b/lib/spack/spack/test/util/file_cache.py index 47f4cd52961a12..950da277d8f6fc 100644 --- a/lib/spack/spack/test/util/file_cache.py +++ b/lib/spack/spack/test/util/file_cache.py @@ -45,7 +45,7 @@ def test_failed_write_and_read_cache_file(file_cache): raise RuntimeError("foobar") # Cache dir should have exactly one (lock) file - assert os.listdir(file_cache.root) == [".test.yaml.lock"] + assert os.listdir(file_cache.root) == [".lock"] # File does not exist assert not file_cache.init_entry("test.yaml") diff --git a/lib/spack/spack/util/file_cache.py b/lib/spack/spack/util/file_cache.py index 55626da053e5e5..8b9c9e384ebc96 100644 --- a/lib/spack/spack/util/file_cache.py +++ b/lib/spack/spack/util/file_cache.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import errno +import hashlib import math import os import pathlib @@ -87,6 +88,7 @@ def __init__(self, root: Union[str, pathlib.Path], timeout=120): self.root = root self.root.mkdir(parents=True, exist_ok=True) + self.lock_path = self.root / ".lock" self._locks: Dict[Union[pathlib.Path, str], Lock] = {} self.lock_timeout = timeout @@ -102,18 +104,28 @@ def cache_path(self, key: Union[str, pathlib.Path]): """Path to the file in the cache for a particular key.""" return self.root / key - def _lock_path(self, key: Union[str, pathlib.Path]): - """Path to the file in the cache for a particular key.""" - keyfile = os.path.basename(key) - keydir = os.path.dirname(key) - - return self.root / keydir / ("." + keyfile + ".lock") + def _get_lock_offsets(self, key: str) -> Tuple[int, int]: + """Hash function to determine byte-range offsets for a key. Returns (start, length) for + the lock.""" + hasher = hashlib.sha256(key.encode("utf-8")) + hash_int = int.from_bytes(hasher.digest()[:8], "little") + start_offset = hash_int % (2**63 - 1) + return start_offset, 1 def _get_lock(self, key: Union[str, pathlib.Path]): - """Create a lock for a key, if necessary, and return a lock object.""" - if key not in self._locks: - self._locks[key] = Lock(str(self._lock_path(key)), default_timeout=self.lock_timeout) - return self._locks[key] + """Create a lock for a key using byte-range offsets.""" + key_str = str(key) + + if key_str not in self._locks: + start, length = self._get_lock_offsets(key_str) + self._locks[key_str] = Lock( + str(self.lock_path), + start=start, + length=length, + default_timeout=self.lock_timeout, + desc=f"key:{key_str}", + ) + return self._locks[key_str] def init_entry(self, key: Union[str, pathlib.Path]): """Ensure we can access a cache file. Create a lock for it if needed. From 80e8c08e10192f533b1adfb79c2057693c036dff Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 8 Apr 2026 08:28:52 +0200 Subject: [PATCH 223/337] installer.py: avoid chmod when --fake (#52196) Signed-off-by: Harmen Stoppels --- lib/spack/spack/installer.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index 8a941c1f1a3895..b240c1feed3085 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -68,7 +68,6 @@ from spack.llnl.util.tty.log import log_output, preserve_terminal_settings from spack.url_buildcache import BuildcacheEntryError from spack.util.environment import EnvironmentModifications, dump_environment -from spack.util.executable import which if TYPE_CHECKING: import spack.spec @@ -286,10 +285,8 @@ def _do_fake_install(pkg: "spack.package_base.PackageBase") -> None: # Install fake command fs.mkdirp(pkg.prefix.bin) - fs.touch(os.path.join(pkg.prefix.bin, command)) - if sys.platform != "win32": - chmod = which("chmod", required=True) - chmod("+x", os.path.join(pkg.prefix.bin, command)) + executable = lambda path, flags: os.open(path, flags, 0o700) + open(os.path.join(pkg.prefix.bin, command), "wb", opener=executable).close() # Install fake header file fs.mkdirp(pkg.prefix.include) From cf79a2b8e7bbd6b07e18f910db838e8d86874121 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 8 Apr 2026 08:32:20 +0200 Subject: [PATCH 224/337] installer.py: --until (#52195) Currently `--until install` is handled as an exceptional case in the old installer where it simplify continues installation as if no flag was passed. This makes it impossible to run until install without registering the spec in the database, which could be a valid use case. Also makes it hard to troubleshoot post-install hooks. This change removes the exceptional case so that the builds stops after the last phase. It adapts integrations tests accordingly, ensuring the behavior is the same for the old and new installer. Signed-off-by: Harmen Stoppels --- lib/spack/spack/installer.py | 4 ---- lib/spack/spack/test/cmd/dev_build.py | 26 +++----------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index b240c1feed3085..c11837d85b88cb 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -231,10 +231,6 @@ def _check_last_phase(pkg: "spack.package_base.PackageBase") -> None: if pkg.last_phase and pkg.last_phase not in phases: # type: ignore[attr-defined] raise BadInstallPhase(pkg.name, pkg.last_phase) # type: ignore[attr-defined] - # If we got a last_phase, make sure it's not already last - if pkg.last_phase and pkg.last_phase == phases[-1]: # type: ignore[attr-defined] - pkg.last_phase = None # type: ignore[attr-defined] - def _handle_external_and_upstream(pkg: "spack.package_base.PackageBase", explicit: bool) -> bool: """ diff --git a/lib/spack/spack/test/cmd/dev_build.py b/lib/spack/spack/test/cmd/dev_build.py index 18e35dd13e869c..04751f510da597 100644 --- a/lib/spack/spack/test/cmd/dev_build.py +++ b/lib/spack/spack/test/cmd/dev_build.py @@ -62,7 +62,8 @@ def test_dev_build_before(tmp_path: pathlib.Path, install_mockery, installer_var assert not os.path.exists(spec.prefix) -def test_dev_build_until(tmp_path: pathlib.Path, install_mockery, installer_variant): +@pytest.mark.parametrize("last_phase", ["edit", "install"]) +def test_dev_build_until(tmp_path: pathlib.Path, install_mockery, last_phase, installer_variant): spec = spack.concretize.concretize_one( spack.spec.Spec(f"dev-build-test-install@0.0.0 dev_path={tmp_path}") ) @@ -71,7 +72,7 @@ def test_dev_build_until(tmp_path: pathlib.Path, install_mockery, installer_vari with open(spec.package.filename, "w", encoding="utf-8") as f: # type: ignore f.write(spec.package.original_string) # type: ignore - dev_build("-u", "edit", "dev-build-test-install@0.0.0") + dev_build("--until", last_phase, "dev-build-test-install@0.0.0") assert spec.package.filename in os.listdir(os.getcwd()) # type: ignore with open(spec.package.filename, "r", encoding="utf-8") as f: # type: ignore @@ -81,27 +82,6 @@ def test_dev_build_until(tmp_path: pathlib.Path, install_mockery, installer_vari assert not spack.store.STORE.db.query(spec, installed=True) -def test_dev_build_until_last_phase(tmp_path: pathlib.Path, install_mockery): - # Test that we ignore the last_phase argument if it is already last - spec = spack.concretize.concretize_one( - spack.spec.Spec(f"dev-build-test-install@0.0.0 dev_path={tmp_path}") - ) - - with fs.working_dir(str(tmp_path)): - with open(spec.package.filename, "w", encoding="utf-8") as f: - f.write(spec.package.original_string) - - dev_build("-u", "install", "dev-build-test-install@0.0.0") - - assert spec.package.filename in os.listdir(os.getcwd()) - with open(spec.package.filename, "r", encoding="utf-8") as f: - assert f.read() == spec.package.replacement_string - - assert os.path.exists(spec.prefix) - assert spack.store.STORE.db.query(spec, installed=True) - assert os.path.exists(str(tmp_path)) - - def test_dev_build_before_until(tmp_path: pathlib.Path, install_mockery, installer_variant): spec = spack.concretize.concretize_one( spack.spec.Spec(f"dev-build-test-install@0.0.0 dev_path={tmp_path}") From 0a62e2b5a063a0d4f16bd5bc78576724e6a013fc Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 8 Apr 2026 08:37:27 +0200 Subject: [PATCH 225/337] spack env create: do not call regen views (#52190) When creating an empty view, do just that. The view regenerate may trigger an unnecessary database read, which is just overhead. Only generate views if the env is initialized from a spack.lock file. Signed-off-by: Harmen Stoppels --- lib/spack/spack/cmd/env.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py index a1e59b9bf5aea6..f8b3ad722b775c 100644 --- a/lib/spack/spack/cmd/env.py +++ b/lib/spack/spack/cmd/env.py @@ -127,7 +127,8 @@ def env_create(args): ) # Generate views, only really useful for environments created from spack.lock files. - env.regenerate_views() + if args.envfile: + env.regenerate_views() def _env_create( From 5a1576c6dcb5dcdb32452aa959feb740a37fe2c0 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 8 Apr 2026 09:13:14 +0200 Subject: [PATCH 226/337] environment: replace included environment list attributes with ConcretizedRootInfo (#52217) `included_concretized_user_specs` and `included_concretized_order` were two parallel `Dict[str, List[...]]` that had to be kept in sync by hand, resulting in a `zip()` that could silently truncate if they diverged. Replace both with a single `included_concretized_roots: Dict[str, List[ConcretizedRootInfo]]`, mirroring the pattern already used for the main environment's `concretized_roots`. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/environment/environment.py | 92 ++++++++++------------ lib/spack/spack/test/cmd/env.py | 25 +++--- 2 files changed, 55 insertions(+), 62 deletions(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 55cdaa6d6ddf2c..efc59ccf0708e1 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -1027,6 +1027,16 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash((self.root, self.hash, self.new, self.group)) + @staticmethod + def from_info_dict(info_dict: Dict[str, str]) -> "ConcretizedRootInfo": + # Lockfile versions < 7 don't have the "group" attribute + return ConcretizedRootInfo( + root_spec=Spec(info_dict["spec"]), + root_hash=info_dict["hash"], + new=False, + group=info_dict.get("group", DEFAULT_USER_SPEC_GROUP), + ) + class Environment: """A Spack environment, which bundles together configuration and a list of specs.""" @@ -1062,10 +1072,8 @@ def __init__(self, manifest_dir: Union[str, pathlib.Path]) -> None: self.included_concrete_env_root_dirs: List[str] = [] #: First-level included concretized spec data from/to the lockfile. self.included_concrete_spec_data: Dict[str, Dict[str, List[str]]] = {} - #: User specs from included environments from the last concretization - self.included_concretized_user_specs: Dict[str, List[Spec]] = {} - #: Roots from included environments with the last concretization, in order - self.included_concretized_order: Dict[str, List[str]] = {} + #: Roots from included environments from the last concretization, keyed by env path + self.included_concretized_roots: Dict[str, List[ConcretizedRootInfo]] = {} #: Concretized specs by hash from the included environments self.included_specs_by_hash: Dict[str, Dict[str, Spec]] = {} @@ -1322,8 +1330,7 @@ def clear(self): self.specs_by_hash = {} # concretized specs by hash self.included_concrete_spec_data = {} # concretized specs from lockfile of included envs - self.included_concretized_order = {} # root specs of the included envs, keyed by env path - self.included_concretized_user_specs = {} # user specs from last concretize's included env + self.included_concretized_roots = {} # root specs of the included envs, keyed by env path self.included_specs_by_hash = {} # concretized specs by hash from the included envs self.invalidate_repository_cache() @@ -2042,21 +2049,18 @@ def concretized_specs(self): def concretized_specs_from_all_included_environments(self): seen = {(x.root, x.hash) for x in self.concretized_roots} - for included_env in self.included_concretized_user_specs: + for included_env in self.included_concretized_roots: yield from self.concretized_specs_from_included_environment(included_env, _seen=seen) def concretized_specs_from_included_environment( self, included_env: str, *, _seen: Optional[Set[Tuple[spack.spec.Spec, str]]] = None ): _seen = set() if _seen is None else _seen - for s, h in zip( - self.included_concretized_user_specs[included_env], - self.included_concretized_order[included_env], - ): - if (s, h) in _seen: + for x in self.included_concretized_roots[included_env]: + if (x.root, x.hash) in _seen: continue - _seen.add((s, h)) - yield s, self.included_specs_by_hash[included_env][h] + _seen.add((x.root, x.hash)) + yield x.root, self.included_specs_by_hash[included_env][x.hash] def concrete_roots(self): """Same as concretized_specs, except it returns the list of concrete @@ -2256,39 +2260,38 @@ def _read_lockfile(self, file_or_json): self._read_lockfile_dict(lockfile_dict) return lockfile_dict - def set_included_concretized_user_specs( + def _set_included_env_roots( self, env_name: str, env_info: Dict[str, Dict[str, Any]], included_json_specs_by_hash: Dict[str, Dict[str, Any]], ) -> Dict[str, Dict[str, Any]]: - """Sets all of the concretized user specs from included environments - to include those from nested included environments. + """Populates included_concretized_roots from included environment data, + including any transitively nested included environments. Args: - env_name: the name (technically the path) of the included environment + env_name: the path of the included environment env_info: included concrete environment data included_json_specs_by_hash: concrete spec data keyed by hash Returns: updated specs_by_hash """ - self.included_concretized_order[env_name] = [] - self.included_concretized_user_specs[env_name] = [] + self.included_concretized_roots[env_name] = [] def add_specs(name, info, specs_by_hash): # Add specs from the environment as well as any of its nested # environments. for root_info in info["roots"]: - self.included_concretized_order[name].append(root_info["hash"]) - self.included_concretized_user_specs[name].append(Spec(root_info["spec"])) + self.included_concretized_roots[name].append( + ConcretizedRootInfo.from_info_dict(root_info) + ) if "concrete_specs" in info: specs_by_hash.update(info["concrete_specs"]) if lockfile_include_key in info: for included_name, included_info in info[lockfile_include_key].items(): - if included_name not in self.included_concretized_order: - self.included_concretized_order[included_name] = [] - self.included_concretized_user_specs[included_name] = [] + if included_name not in self.included_concretized_roots: + self.included_concretized_roots[included_name] = [] add_specs(included_name, included_info, specs_by_hash) add_specs(env_name, env_info, included_json_specs_by_hash) @@ -2298,22 +2301,10 @@ def _read_lockfile_dict(self, d): """Read a lockfile dictionary into this environment.""" self.specs_by_hash = {} self.included_specs_by_hash = {} - self.included_concretized_user_specs = {} - self.included_concretized_order = {} + self.included_concretized_roots = {} roots = d["roots"] - default_user_specs_group = DEFAULT_USER_SPEC_GROUP - - self.concretized_roots = [ - # Lockfile versions < 7 don't have the "group" attribute - ConcretizedRootInfo( - root_spec=Spec(r["spec"]), - root_hash=r["hash"], - new=False, - group=r.get("group", default_user_specs_group), - ) - for r in roots - ] + self.concretized_roots = [ConcretizedRootInfo.from_info_dict(r) for r in roots] json_specs_by_hash = d["concrete_specs"] included_json_specs_by_hash = {} @@ -2321,9 +2312,7 @@ def _read_lockfile_dict(self, d): if lockfile_include_key in d: for env_name, env_info in d[lockfile_include_key].items(): included_json_specs_by_hash.update( - self.set_included_concretized_user_specs( - env_name, env_info, included_json_specs_by_hash - ) + self._set_included_env_roots(env_name, env_info, included_json_specs_by_hash) ) current_lockfile_format = d["_meta"]["lockfile-version"] @@ -2346,21 +2335,20 @@ def _read_lockfile_dict(self, d): self.concretized_roots[idx].hash = spec_dag_hash self.specs_by_hash[spec_dag_hash] = first_seen[spec_dag_hash] - if any(self.included_concretized_order.values()): + if any(self.included_concretized_roots.values()): first_seen = {} - for env_name, concretized_order in self.included_concretized_order.items(): - filtered_spec, self.included_concretized_order[env_name] = self._filter_specs( - reader, included_json_specs_by_hash, concretized_order + for env_name, roots in self.included_concretized_roots.items(): + order = [x.hash for x in roots] + filtered_spec, new_order = self._filter_specs( + reader, included_json_specs_by_hash, order ) first_seen.update(filtered_spec) + for idx, spec_dag_hash in enumerate(new_order): + roots[idx].hash = spec_dag_hash - for env_path, spec_hashes in self.included_concretized_order.items(): - self.included_specs_by_hash[env_path] = {} - for spec_dag_hash in spec_hashes: - self.included_specs_by_hash[env_path].update( - {spec_dag_hash: first_seen[spec_dag_hash]} - ) + for env_path, roots in self.included_concretized_roots.items(): + self.included_specs_by_hash[env_path] = {x.hash: first_seen[x.hash] for x in roots} def _filter_specs(self, reader, json_specs_by_hash, order_concretized): # Track specs by their lockfile key. Currently, spack uses the finest diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 6a74393ae2fe89..a1092b7a311d20 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -552,13 +552,16 @@ def test_env_install_include_concrete_env( test1_user_spec_hashes = [x.hash for x in test1.concretized_roots] test2_user_spec_hashes = [x.hash for x in test2.concretized_roots] - combined_included_roots = combined.included_concretized_order for spec in combined.all_specs(): assert spec.installed - assert test1_user_spec_hashes == combined_included_roots[test1.path] - assert test2_user_spec_hashes == combined_included_roots[test2.path] + assert test1_user_spec_hashes == [ + x.hash for x in combined.included_concretized_roots[test1.path] + ] + assert test2_user_spec_hashes == [ + x.hash for x in combined.included_concretized_roots[test2.path] + ] mpileaks_hash = combined.concretized_roots[0].hash mpileaks = combined.specs_by_hash[mpileaks_hash] @@ -2322,12 +2325,14 @@ def test_concretize_include_concrete_env(): # Check the test1 environment includes mpileaks, while the combined environment does not assert Spec("mpileaks") in {x.root for x in test1.concretized_roots} - assert Spec("mpileaks") not in combined.included_concretized_user_specs[test1.path] + assert Spec("mpileaks") not in { + x.root for x in combined.included_concretized_roots[test1.path] + } # If we update the combined environment, it will include mpileaks too combined.concretize() combined.write() - assert Spec("mpileaks") in combined.included_concretized_user_specs[test1.path] + assert Spec("mpileaks") in {x.root for x in combined.included_concretized_roots[test1.path]} def test_concretize_nested_include_concrete_envs(): @@ -2357,7 +2362,7 @@ def test_concretize_nested_include_concrete_envs(): in lockfile_as_dict[ev.lockfile_include_key][test2.path][ev.lockfile_include_key] ) - assert Spec("zlib") in test3.included_concretized_user_specs[test1.path] + assert Spec("zlib") in {x.root for x in test3.included_concretized_roots[test1.path]} def test_concretize_nested_included_concrete(): @@ -2378,7 +2383,7 @@ def test_concretize_nested_included_concrete(): test2.concretize() test2.write() - assert Spec("zlib") in test2.included_concretized_user_specs[test1.path] + assert Spec("zlib") in {x.root for x in test2.included_concretized_roots[test1.path]} # Modify/re-concretize test1 to replace zlib with mpileaks with test1: @@ -2395,9 +2400,9 @@ def test_concretize_nested_included_concrete(): test3.concretize() test3.write() - included_specs = test3.included_concretized_user_specs[test1.path] - assert len(included_specs) == 1 - assert Spec("mpileaks") in included_specs + included_roots = test3.included_concretized_roots[test1.path] + assert len(included_roots) == 1 + assert Spec("mpileaks") in {x.root for x in included_roots} # The last concretization of test4's included environments should have test2 # with the original concretized test1 spec and test3 with the re-concretized From b8e51922fb93d3b56ffcee657b817b2e51075f40 Mon Sep 17 00:00:00 2001 From: Peter Scheibel Date: Wed, 8 Apr 2026 01:04:24 -0700 Subject: [PATCH 227/337] Don't drop compiler flags when `packages:all:require` sets a target (#52133) Compiler flags added to a compiler definition were not applied in the following circumstances: - The user sets packages:all:require:[target=x] - The default target for a system does not match x - The compiler definition does not include a target Signed-off-by: Peter Josef Scheibel Signed-off-by: Peter Scheibel --- lib/spack/spack/compilers/config.py | 31 +++++++++++----------- lib/spack/spack/test/cmd/env.py | 40 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/lib/spack/spack/compilers/config.py b/lib/spack/spack/compilers/config.py index f34551180b42c9..a193784f6efd98 100644 --- a/lib/spack/spack/compilers/config.py +++ b/lib/spack/spack/compilers/config.py @@ -20,7 +20,7 @@ import spack.platforms import spack.repo import spack.spec -from spack.externals import ExternalSpecsParser, external_spec +from spack.externals import ExternalSpecsParser, external_spec, extract_dicts_from_configuration from spack.operating_systems import windows_os from spack.util.environment import get_path @@ -259,25 +259,24 @@ def from_packages_yaml( configuration: spack.config.Configuration, *, scope: Optional[str] = None ) -> List[spack.spec.Spec]: """Returns the compiler specs defined in the "packages" section of the configuration""" - externals_dicts = [] compiler_package_names = supported_compilers() - packages_yaml = configuration.get_config("packages", scope=scope) - for name, entry in packages_yaml.items(): - if name not in compiler_package_names: - continue + packages_yaml = configuration.deepcopy_as_builtin("packages", scope=scope) - externals_config = entry.get("externals", None) - if not externals_config: - continue + init_external_dicts = extract_dicts_from_configuration(packages_yaml) + init_external_dicts = list( + x + for x in init_external_dicts + if spack.spec.Spec(x["spec"]).name in compiler_package_names + ) - for current in externals_config: - # If extra_attributes is not there don't use this entry as a compiler. - if _EXTRA_ATTRIBUTES_KEY not in current: - header = f"The external spec '{current['spec']}' cannot be used as a compiler" - tty.debug(f"[{__file__}] {header}: missing the '{_EXTRA_ATTRIBUTES_KEY}' key") - continue + externals_dicts = [] + for current in init_external_dicts: + if _EXTRA_ATTRIBUTES_KEY not in current: + header = f"The external spec '{current['spec']}' cannot be used as a compiler" + tty.debug(f"[{__file__}] {header}: missing the '{_EXTRA_ATTRIBUTES_KEY}' key") + continue - externals_dicts.append(current) + externals_dicts.append(current) external_parser = ExternalSpecsParser(externals_dicts) return external_parser.all_specs() diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index a1092b7a311d20..a9fda3ea88e4c9 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -8,6 +8,7 @@ import os import pathlib import shutil +import sys from argparse import Namespace from typing import Any, Dict, Optional @@ -4905,3 +4906,42 @@ def test_env_include_concrete_git_lockfile(tmp_path, mock_packages, mutable_conf e.write() assert len(e.user_specs) == 0 assert [s for s, _ in e.concretized_specs()] == [Spec("libdwarf")] + + +@pytest.mark.skipif(sys.platform != "linux", reason="Target is linux-specific") +def test_compiler_target_env(mock_packages, environment_from_manifest): + """Tests that Spack doesn't drop flag definitions on compilers + when a target is required in config. + """ + + cflags = "-Wall" + env = environment_from_manifest( + f"""\ +spack: + specs: + - libdwarf %c=gcc@12.100.100 + packages: + all: + require: + - "target=x86_64_v3" + gcc: + externals: + - spec: gcc@12.100.100 languages:=c,c++ + prefix: /fake + extra_attributes: + compilers: + c: /fake/bin/gcc + cxx: /fake/bin/g++ + flags: + cflags: {cflags} + require: "gcc" +""" + ) + + with env: + env.concretize() + libdwarf = env.concrete_roots()[0] + assert libdwarf.satisfies("cflags=-Wall") + # Sanity check: make sure the target we expect was applied to the + # compiler entry + assert libdwarf["c"].satisfies("gcc@12.100.100 languages:=c,c++ target=x86_64_v3") From 45b9069e4997f4b453b2770886b1e8ba790980f4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 8 Apr 2026 14:38:32 +0200 Subject: [PATCH 228/337] new_installer.py: sub_process < /dev/null (#52221) In the Python build subprocess, redirect stdin to /dev/null. This regressed after #52192 as that change attaches the user's stdin. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index fc6688ee4f9c30..187feb2d7b285f 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -512,6 +512,13 @@ def handle_sigterm(signum, frame): sys.stderr.fileno(), "w", buffering=1, encoding=sys.stderr.encoding, closefd=False ) + # Detach stdin from the terminal like `./build < /dev/null`. This would not be necessary if we + # used os.setsid() instead of os.setpgid(), but that would "break" pstree output. + devnull_fd = os.open(os.devnull, os.O_RDONLY) + os.dup2(devnull_fd, 0) + os.close(devnull_fd) + sys.stdin = open(os.devnull, "r", encoding=sys.stdin.encoding) + # Open the log file created by the parent process. log_fd = os.open(log_path, os.O_WRONLY | os.O_TRUNC, 0o644) tee = Tee(echo_control, parent, log_fd) From 0060267f1057c2dabb0fe2a27979af4de202d7c0 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 9 Apr 2026 10:28:04 +0200 Subject: [PATCH 229/337] spack buildcache push: add --group argument (#52224) When an environment is active, --group can be used to push only the specs from that group. The option can be given multiple times. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/cmd/buildcache.py | 24 +++- lib/spack/spack/test/cmd/buildcache.py | 176 +++++++++++++++++++++++-- share/spack/spack-completion.bash | 4 +- share/spack/spack-completion.fish | 8 +- 4 files changed, 195 insertions(+), 17 deletions(-) diff --git a/lib/spack/spack/cmd/buildcache.py b/lib/spack/spack/cmd/buildcache.py index 02e4fabe49c569..48b95987f49410 100644 --- a/lib/spack/spack/cmd/buildcache.py +++ b/lib/spack/spack/cmd/buildcache.py @@ -134,6 +134,15 @@ def setup_parser(subparser: argparse.ArgumentParser): action="store_true", help="for a private mirror, include non-redistributable packages", ) + push.add_argument( + "--group", + action="append", + default=None, + dest="groups", + metavar="GROUP", + help="push only specs from the given environment group " + "(can be specified multiple times, requires an active environment)", + ) arguments.add_common_arguments(push, ["specs", "jobs"]) push.set_defaults(func=push_fn) @@ -446,7 +455,20 @@ def _specs_to_be_packaged( def push_fn(args): """create a binary package and push it to a mirror""" - if args.specs: + if args.specs and args.groups: + tty.die("--group and explicit specs are mutually exclusive") + + if args.groups: + env = spack.cmd.require_active_env(cmd_name="buildcache push") + available_groups = env.manifest.groups() + if any(g not in available_groups for g in args.groups): + tty.die( + f"Some of the groups do not exist in the environment. " + f"Available groups are: {', '.join(sorted(available_groups))}" + ) + + roots = [c for g in args.groups for _, c in env.concretized_specs_by(group=g)] + elif args.specs: roots = _matching_specs(spack.cmd.parse_specs(args.specs)) else: roots = spack.cmd.require_active_env(cmd_name="buildcache push").concrete_roots() diff --git a/lib/spack/spack/test/cmd/buildcache.py b/lib/spack/spack/test/cmd/buildcache.py index d644c7360332e3..776841a6791a85 100644 --- a/lib/spack/spack/test/cmd/buildcache.py +++ b/lib/spack/spack/test/cmd/buildcache.py @@ -360,6 +360,21 @@ def test_buildcache_create_install( cache_entry.destroy() +def _mock_uploader(tmp_path: pathlib.Path): + class DontUpload(spack.binary_distribution.Uploader): + def __init__(self): + super().__init__( + spack.mirrors.mirror.Mirror.from_local_path(str(tmp_path)), False, False + ) + self.pushed = [] + + def push(self, specs: List[spack.spec.Spec]): + self.pushed.extend(s.name for s in specs) + return [], [] + + return DontUpload() + + @pytest.mark.parametrize( "things_to_install,expected", [ @@ -411,18 +426,7 @@ def test_correct_specs_are_pushed( PackageInstaller([spec.package], explicit=True, fake=True).install() slash_hash = f"/{spec.dag_hash()}" - class DontUpload(spack.binary_distribution.Uploader): - def __init__(self): - super().__init__( - spack.mirrors.mirror.Mirror.from_local_path(str(tmp_path)), False, False - ) - self.pushed = [] - - def push(self, specs: List[spack.spec.Spec]): - self.pushed.extend(s.name for s in specs) - return [], [] # nothing skipped, nothing errored - - uploader = DontUpload() + uploader = _mock_uploader(tmp_path) monkeypatch.setattr( spack.binary_distribution, "make_uploader", lambda *args, **kwargs: uploader @@ -1321,3 +1325,151 @@ def test_buildcache_check_index_full( assert "The index blob is missing" in out assert "Unindexed specs: 15" in out assert "Missing blobs: 1" + + +def test_buildcache_push_with_group( + tmp_path: pathlib.Path, monkeypatch, install_mockery, mock_fetch, mutable_mock_env_path +): + """Tests that --group pushes only specs from the requested group.""" + env_dir = tmp_path / "myenv" + env_dir.mkdir() + (env_dir / "spack.yaml").write_text( + """\ +spack: + specs: + - libelf + - group: extra + specs: + - libdwarf + view: false +""" + ) + + mirror_dir = tmp_path / "mirror" + mirror_dir.mkdir() + + uploader = _mock_uploader(mirror_dir) + monkeypatch.setattr( + spack.binary_distribution, "make_uploader", lambda *args, **kwargs: uploader + ) + + with ev.Environment(env_dir) as e: + e.concretize() + e.write() + for _, root in e.concretized_specs(): + PackageInstaller([root.package], explicit=True, fake=True).install() + + buildcache("push", "--unsigned", "--only", "package", "--group", "extra", str(mirror_dir)) + + assert uploader.pushed == ["libdwarf"] + + +def test_buildcache_push_with_multiple_groups( + tmp_path: pathlib.Path, monkeypatch, install_mockery, mock_fetch, mutable_mock_env_path +): + """Tests that --group can be repeated to push specs from multiple groups.""" + env_dir = tmp_path / "myenv" + env_dir.mkdir() + (env_dir / "spack.yaml").write_text( + """\ +spack: + specs: + - libelf + - group: extra + specs: + - libdwarf + view: false +""" + ) + + mirror_dir = tmp_path / "mirror" + mirror_dir.mkdir() + + uploader = _mock_uploader(mirror_dir) + monkeypatch.setattr( + spack.binary_distribution, "make_uploader", lambda *args, **kwargs: uploader + ) + + with ev.Environment(env_dir) as e: + e.concretize() + e.write() + for _, root in e.concretized_specs(): + PackageInstaller([root.package], explicit=True, fake=True).install() + + buildcache( + "push", + "--unsigned", + "--only", + "package", + "--group", + "default", + "--group", + "extra", + str(mirror_dir), + ) + + assert set(uploader.pushed) == {"libelf", "libdwarf"} + assert len(uploader.pushed) == len(set(uploader.pushed)) + + +def test_buildcache_push_group_nonexistent_errors(tmp_path: pathlib.Path, mutable_mock_env_path): + """Tests that --group with a nonexistent group name raises an error.""" + env_dir = tmp_path / "myenv" + env_dir.mkdir() + (env_dir / "spack.yaml").write_text( + """\ +spack: + specs: + - libelf + view: false +""" + ) + + mirror_dir = tmp_path / "mirror" + mirror_dir.mkdir() + + with ev.Environment(env_dir): + with pytest.raises(spack.main.SpackCommandError): + buildcache( + "push", "--unsigned", "--group", "nonexistent", str(mirror_dir), fail_on_error=True + ) + + +def test_buildcache_push_group_and_specs_mutually_exclusive( + tmp_path: pathlib.Path, mutable_mock_env_path +): + """Tests that --group and explicit specs on the command line are mutually exclusive.""" + env_dir = tmp_path / "myenv" + env_dir.mkdir() + (env_dir / "spack.yaml").write_text( + """\ +spack: + specs: + - libelf + view: false +""" + ) + + mirror_dir = tmp_path / "mirror" + mirror_dir.mkdir() + + with ev.Environment(env_dir): + with pytest.raises(spack.main.SpackCommandError): + buildcache( + "push", + "--unsigned", + "--group", + "default", + str(mirror_dir), + "libelf", + fail_on_error=True, + ) + + +def test_buildcache_push_group_requires_active_env(tmp_path: pathlib.Path): + """Tests that ck--group without an active environment produces an error.""" + mirror_dir = tmp_path / "mirror" + mirror_dir.mkdir() + + with pytest.raises(spack.main.SpackCommandError): + buildcache("push", "--unsigned", "--group", "default", str(mirror_dir), fail_on_error=True) diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 5e19d81e070ddc..463639513e064d 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -566,7 +566,7 @@ _spack_buildcache() { _spack_buildcache_push() { if $list_options then - SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --base-image --tag -t --private -j --jobs" + SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --base-image --tag -t --private --group -j --jobs" else _mirrors fi @@ -575,7 +575,7 @@ _spack_buildcache_push() { _spack_buildcache_create() { if $list_options then - SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --base-image --tag -t --private -j --jobs" + SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --base-image --tag -t --private --group -j --jobs" else _mirrors fi diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 17d8693285a4ff..52341d10c81d7e 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -707,7 +707,7 @@ complete -c spack -n '__fish_spack_using_command buildcache' -s h -l help -f -a complete -c spack -n '__fish_spack_using_command buildcache' -s h -l help -d 'show this help message and exit' # spack buildcache push -set -g __fish_spack_optspecs_spack_buildcache_push h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast base-image= t/tag= private j/jobs= +set -g __fish_spack_optspecs_spack_buildcache_push h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast base-image= t/tag= private group= j/jobs= complete -c spack -n '__fish_spack_using_command_pos_remainder 1 buildcache push' -f -k -a '(__fish_spack_specs)' complete -c spack -n '__fish_spack_using_command buildcache push' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command buildcache push' -s h -l help -d 'show this help message and exit' @@ -735,11 +735,13 @@ complete -c spack -n '__fish_spack_using_command buildcache push' -l tag -s t -r complete -c spack -n '__fish_spack_using_command buildcache push' -l tag -s t -r -d 'when pushing to an OCI registry, tag an image containing all root specs and their runtime dependencies' complete -c spack -n '__fish_spack_using_command buildcache push' -l private -f -a private complete -c spack -n '__fish_spack_using_command buildcache push' -l private -d 'for a private mirror, include non-redistributable packages' +complete -c spack -n '__fish_spack_using_command buildcache push' -l group -r -f -a groups +complete -c spack -n '__fish_spack_using_command buildcache push' -l group -r -d 'push only specs from the given environment group (can be specified multiple times, requires an active environment)' complete -c spack -n '__fish_spack_using_command buildcache push' -s j -l jobs -r -f -a jobs complete -c spack -n '__fish_spack_using_command buildcache push' -s j -l jobs -r -d 'explicitly set number of parallel jobs' # spack buildcache create -set -g __fish_spack_optspecs_spack_buildcache_create h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast base-image= t/tag= private j/jobs= +set -g __fish_spack_optspecs_spack_buildcache_create h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast base-image= t/tag= private group= j/jobs= complete -c spack -n '__fish_spack_using_command_pos_remainder 1 buildcache create' -f -k -a '(__fish_spack_specs)' complete -c spack -n '__fish_spack_using_command buildcache create' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command buildcache create' -s h -l help -d 'show this help message and exit' @@ -767,6 +769,8 @@ complete -c spack -n '__fish_spack_using_command buildcache create' -l tag -s t complete -c spack -n '__fish_spack_using_command buildcache create' -l tag -s t -r -d 'when pushing to an OCI registry, tag an image containing all root specs and their runtime dependencies' complete -c spack -n '__fish_spack_using_command buildcache create' -l private -f -a private complete -c spack -n '__fish_spack_using_command buildcache create' -l private -d 'for a private mirror, include non-redistributable packages' +complete -c spack -n '__fish_spack_using_command buildcache create' -l group -r -f -a groups +complete -c spack -n '__fish_spack_using_command buildcache create' -l group -r -d 'push only specs from the given environment group (can be specified multiple times, requires an active environment)' complete -c spack -n '__fish_spack_using_command buildcache create' -s j -l jobs -r -f -a jobs complete -c spack -n '__fish_spack_using_command buildcache create' -s j -l jobs -r -d 'explicitly set number of parallel jobs' From cb9daa26a857a6eaa47fa785f5ed30a336c22306 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 9 Apr 2026 10:32:19 +0200 Subject: [PATCH 230/337] new_installer.py: fix line wrap issues (#52193) In the "active area" of the terminal UI, downwards cursor movement is used instead of `\n` to prevent flickering. The variable `active_area_rows` keeps track on how many lines cursor movement can be used, and any new builds would use `\n` instead (to scroll the terminal when needed). However, after #52163 the finished builds were printed *without* truncating to terminal width, and typically exceed it. This means they can consume multiple lines from the "active area" of running builds, and as a result the new "active area" is actualy fewer lines. To fix this, reset `active_area_rows` to 0, which forces `\n` instead of cursor movement. Use the same trick when the terminal resizes. A narrower/wider terminal can result in line wrapping, so better not to rely on cursor movement as much. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 187feb2d7b285f..ccd733e5c5c985 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1398,24 +1398,32 @@ def update(self, finalize: bool = False) -> None: # Build the overview output in a buffer and print all at once to avoid flickering. buffer = io.StringIO() - # Move cursor up to the start of the display area + # Move cursor up to the start of the display area assuming the same terminal width. If the + # terminal resized, lines may have wrapped, and we should've moved up further. We do not + # try to track that (would require keeping track of each line's width). if self.active_area_rows > 0: buffer.write(f"\033[{self.active_area_rows}A\r") if self.terminal_size_changed: self.terminal_size = self.get_terminal_size() self.terminal_size_changed = False + # After resize, active_area_rows is invalidated due to possible line wrapping. Set to + # 0 to force newlines instead of cursor movement. + self.active_area_rows = 0 max_width, max_height = self.terminal_size - self.total_lines = 0 - total_finished = len(self.finished_builds) - # First flush the finished builds. These are "persisted" in terminal history. - for build in self.finished_builds: - self._render_build(build, buffer, now=now) - self.finished_builds.clear() + if self.finished_builds: + for build in self.finished_builds: + self._render_build(build, buffer, now=now) + self._println(buffer, force_newline=True) # should scroll the terminal + self.finished_builds.clear() + # Finished builds can span multiple lines, overlapping our "active area", invalidating + # active_area_rows. Set to 0 to force newlines instead of cursor movement. + self.active_area_rows = 0 # Then a header followed by the active builds. This is the "mutable" part of the display. + self.total_lines = 0 if not finalize: if self.color: @@ -1461,6 +1469,7 @@ def update(self, finalize: bool = False) -> None: self._println(buffer, f"{len_builds - i + 1} more...") break self._render_build(build, buffer, max_width, now=now) + self._println(buffer) if self.search_mode: buffer.write(f"filter> {self.search_term}\033[K") @@ -1473,18 +1482,18 @@ def update(self, finalize: bool = False) -> None: self.stdout.flush() # Update the number of lines drawn for next time. It reflects the number of active builds. - self.active_area_rows = self.total_lines - total_finished + self.active_area_rows = self.total_lines self.dirty = False # Schedule next UI update self.next_update = now + SPINNER_INTERVAL / 2 - def _println(self, buffer: io.StringIO, line: str = "") -> None: + def _println(self, buffer: io.StringIO, line: str = "", force_newline: bool = False) -> None: """Print a line to the buffer, handling line clearing and cursor movement.""" self.total_lines += 1 if line: buffer.write(line) - if self.total_lines > self.active_area_rows: + if self.total_lines > self.active_area_rows or force_newline: buffer.write("\033[0m\033[K\n") # reset, clear to EOL, newline else: buffer.write("\033[0m\033[K\033[1B\r") # reset, clear to EOL, move to next line @@ -1515,7 +1524,6 @@ def _render_build( if line_width > max_width: break buffer.write(component) - self._println(buffer) def _generate_line_components( self, build_info: BuildInfo, static: bool = False, now: float = 0.0 From 0ed3c47ceee4622135f3f4b096424c374c986ad2 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 10 Apr 2026 08:25:14 +0200 Subject: [PATCH 231/337] file_cache: drop init_entry/mtime and simplify (#52201) Remove `FileCache.init_entry` and `FileCache.mtime` because they were TOCTOU-prone (check existence/permissions, then open later) and required every call site to remember a separate initialization step. Instead, `read_transaction` and `write_transaction` are now `@contextmanager` generators that acquire locks and open files directly: - `read_transaction` yields an open file or `None` if missing. - `write_transaction` yields `(old_file_or_none, new_file)` and creates parent directories on demand. Errors (permission, not-a-file) are raised as `CacheError` at the point of use rather than checked upfront with `os.access`. Call sites in `binary_distribution`, `compilers/libraries`, `repo`, and `git_ref_lookup` are updated to drop `init_entry` calls and handle the `None` case from `read_transaction`. Signed-off-by: Harmen Stoppels --- lib/spack/spack/binary_distribution.py | 29 ++--- lib/spack/spack/compilers/libraries.py | 31 +++--- lib/spack/spack/repo.py | 57 +++++----- lib/spack/spack/test/util/file_cache.py | 50 ++++----- lib/spack/spack/util/file_cache.py | 127 ++++++++++------------ lib/spack/spack/version/git_ref_lookup.py | 7 +- 6 files changed, 138 insertions(+), 163 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index f4c3b81f3621a1..7fb1d29296f93d 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -188,13 +188,9 @@ def __init__(self, cache_root: Optional[str] = None): def _init_local_index_cache(self): if not self._index_file_cache_initialized: cache_key = self._index_contents_key - self._index_file_cache.init_entry(cache_key) - - cache_path = self._index_file_cache.cache_path(cache_key) - self._local_index_cache = {} - if os.path.isfile(cache_path): - with self._index_file_cache.read_transaction(cache_key) as cache_file: + with self._index_file_cache.read_transaction(cache_key) as cache_file: + if cache_file is not None: self._local_index_cache = json.load(cache_file) self._index_file_cache_initialized = True @@ -231,17 +227,17 @@ def _associate_built_specs_with_mirror(self, cache_key, mirror_metadata: MirrorM with tempfile.TemporaryDirectory(dir=spack.stage.get_stage_root()) as tmpdir: db = BuildCacheDatabase(tmpdir) - try: - with self._index_file_cache.read_transaction(cache_key) as f: - if f is not None: + with self._index_file_cache.read_transaction(cache_key) as f: + if f is not None: + try: db._read_from_stream(f) - except spack.database.InvalidDatabaseVersionError as e: - tty.warn( - "you need a newer Spack version to read the buildcache index for the " - f"following v{mirror_metadata.version} mirror: '{mirror_metadata.url}'. " - f"{e.database_version_message}" - ) - return + except spack.database.InvalidDatabaseVersionError as e: + tty.warn( + "you need a newer Spack version to read the buildcache index for the " + f"following v{mirror_metadata.version} mirror: " + f"'{mirror_metadata.url}'. {e.database_version_message}" + ) + return spec_list = [ s @@ -484,7 +480,6 @@ def _fetch_and_cache_index(self, mirror_metadata: MirrorMetadata, cache_entry={} # Persist new index.json url_hash = compute_hash(str(mirror_metadata)) cache_key = "{}_{}.json".format(url_hash[:10], result.hash[:10]) - self._index_file_cache.init_entry(cache_key) with self._index_file_cache.write_transaction(cache_key) as (old, new): new.write(result.data) diff --git a/lib/spack/spack/compilers/libraries.py b/lib/spack/spack/compilers/libraries.py index d0831a602eb674..4be8c06c0b11df 100644 --- a/lib/spack/spack/compilers/libraries.py +++ b/lib/spack/spack/compilers/libraries.py @@ -380,7 +380,6 @@ class FileCompilerCache(CompilerCache): def __init__(self, cache: "FileCache") -> None: self.cache = cache - self.cache.init_entry(self.name) self._data: Dict[str, Dict[str, Optional[str]]] = {} def _get_entry(self, key: str, *, allow_empty: bool) -> Optional[CompilerCacheEntry]: @@ -395,13 +394,16 @@ def _get_entry(self, key: str, *, allow_empty: bool) -> Optional[CompilerCacheEn def get(self, compiler: spack.spec.Spec) -> CompilerCacheEntry: # Cache hit - try: - with self.cache.read_transaction(self.name) as f: - assert f is not None - self._data = json.loads(f.read()) - assert isinstance(self._data, dict) - except (json.JSONDecodeError, AssertionError): - self._data = {} + with self.cache.read_transaction(self.name) as f: + if f is not None: + try: + self._data = json.loads(f.read()) + if not isinstance(self._data, dict): + self._data = {} + except json.JSONDecodeError: + self._data = {} + else: + self._data = {} key = self._key(compiler) value = self._get_entry(key, allow_empty=False) @@ -410,11 +412,14 @@ def get(self, compiler: spack.spec.Spec) -> CompilerCacheEntry: # Cache miss with self.cache.write_transaction(self.name) as (old, new): - try: - assert old is not None - self._data = json.loads(old.read()) - assert isinstance(self._data, dict) - except (json.JSONDecodeError, AssertionError): + if old is not None: + try: + self._data = json.loads(old.read()) + if not isinstance(self._data, dict): + self._data = {} + except json.JSONDecodeError: + self._data = {} + else: self._data = {} # Use cache entry that may have been created by another process in the meantime. diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index a0aca95bc3ff03..b632f163edf9c4 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -11,6 +11,7 @@ import importlib.machinery import importlib.util import itertools +import math import os import re import shutil @@ -661,40 +662,36 @@ def _update_index(self, name: str, indexer: Indexer, allow_stale: bool = False) cache_filename = f"{name}/{self.namespace}-specfile_v{SPECFILE_FORMAT_VERSION}-index.json" - # Compute which packages needs to be updated in the cache - index_mtime = self.cache.mtime(cache_filename) + with self.cache.read_transaction(cache_filename) as f: + # Get the mtime of the cache if it exists, of -inf. + index_mtime = os.fstat(f.fileno()).st_mtime if f is not None else -math.inf - index_existed = self.cache.init_entry(cache_filename) - if index_existed and allow_stale: - with self.cache.read_transaction(cache_filename) as f: + if f is not None and allow_stale: + # Cache exists and caller accepts stale data: skip the expensive modified_since. indexer.read(f) - self.indexes[name] = indexer.index - return False - - needs_update = self.checker.modified_since(index_mtime) - if index_existed and not needs_update: - # If the index exists and doesn't need an update, read it - with self.cache.read_transaction(cache_filename) as f: - indexer.read(f) - self.indexes[name] = indexer.index - return True + self.indexes[name] = indexer.index + return False - else: - # Otherwise update it and rewrite the cache file - with self.cache.write_transaction(cache_filename) as (old, new): - indexer.read(old) if old else indexer.create() + needs_update = self.checker.modified_since(index_mtime) - # Compute which packages needs to be updated **again** in case someone updated them - # while we waited for the lock - new_index_mtime = self.cache.mtime(cache_filename) - if new_index_mtime != index_mtime: - needs_update = self.checker.modified_since(new_index_mtime) - - indexer.update({f"{self.namespace}.{pkg_name}" for pkg_name in needs_update}) - indexer.write(new) - - self.indexes[name] = indexer.index - return True + if f is not None and not needs_update: + # Cache exists and is up to date. + indexer.read(f) + self.indexes[name] = indexer.index + return True + + # Cache is missing or stale: acquire write lock and rebuild. + with self.cache.write_transaction(cache_filename) as (old, new): + old_mtime = os.fstat(old.fileno()).st_mtime if old is not None else -math.inf + # Re-check in case another writer updated the index while we waited for the lock. + if old_mtime != index_mtime: + needs_update = self.checker.modified_since(old_mtime) + indexer.read(old) if old is not None else indexer.create() + indexer.update({f"{self.namespace}.{pkg_name}" for pkg_name in needs_update}) + indexer.write(new) + + self.indexes[name] = indexer.index + return True class RepoPath: diff --git a/lib/spack/spack/test/util/file_cache.py b/lib/spack/spack/test/util/file_cache.py index 950da277d8f6fc..bb3ea6fca607f8 100644 --- a/lib/spack/spack/test/util/file_cache.py +++ b/lib/spack/spack/test/util/file_cache.py @@ -48,7 +48,7 @@ def test_failed_write_and_read_cache_file(file_cache): assert os.listdir(file_cache.root) == [".lock"] # File does not exist - assert not file_cache.init_entry("test.yaml") + assert not os.path.exists(file_cache.cache_path("test.yaml")) def test_write_and_remove_cache_file(file_cache): @@ -84,39 +84,37 @@ def test_write_and_remove_cache_file(file_cache): @pytest.mark.not_on_windows("Not supported on Windows (yet)") @pytest.mark.skipif(fs.getuid() == 0, reason="user is root") -def test_cache_init_entry_fails(file_cache): - """Test init_entry failures.""" +def test_bad_cache_permissions(file_cache, request): + """Test that transactions raise CacheError on permission problems.""" relpath = fs.join_path("test-dir", "read-only-file.txt") cachefile = file_cache.cache_path(relpath) fs.touchp(cachefile) - # Ensure directory causes exception + # A directory where a file is expected raises CacheError on read + with pytest.raises(CacheError, match="not a file"): + with file_cache.read_transaction(os.path.dirname(relpath)) as _: + pass + + # A directory where a file is expected raises CacheError on write with pytest.raises(CacheError, match="not a file"): - file_cache.init_entry(os.path.dirname(relpath)) + with file_cache.write_transaction(os.path.dirname(relpath)) as _: + pass - # Ensure non-readable file causes exception + # A non-readable file raises CacheError on read os.chmod(cachefile, 0o200) + request.addfinalizer(lambda c=cachefile: os.chmod(c, 0o600)) with pytest.raises(CacheError, match="Cannot access cache file"): - file_cache.init_entry(relpath) - - # Ensure read-only parent causes exception - relpath = fs.join_path("test-dir", "another-file.txxt") - cachefile = file_cache.cache_path(relpath) - os.chmod(os.path.dirname(cachefile), 0o400) - with pytest.raises(CacheError, match="Cannot access cache dir"): - file_cache.init_entry(relpath) - - -@pytest.mark.skipif(fs.getuid() == 0, reason="user is root") -def test_cache_write_readonly_cache_fails(file_cache): - """Test writing a read-only cached file.""" - filename = "read-only-file.txt" - path = file_cache.cache_path(filename) - fs.touch(path) - os.chmod(path, 0o400) - - with pytest.raises(CacheError, match="Insufficient permissions to write"): - file_cache.write_transaction(filename) + with file_cache.read_transaction(relpath) as _: + pass + + # A read-only parent directory raises CacheError on write + relpath2 = fs.join_path("test-dir", "another-file.txxt") + parent = str(file_cache.cache_path(relpath2).parent) + os.chmod(parent, 0o400) + request.addfinalizer(lambda p=parent: os.chmod(p, 0o700)) + with pytest.raises(CacheError): + with file_cache.write_transaction(relpath2) as _: + pass @pytest.mark.regression("31475") diff --git a/lib/spack/spack/util/file_cache.py b/lib/spack/spack/util/file_cache.py index 8b9c9e384ebc96..41d763b2983816 100644 --- a/lib/spack/spack/util/file_cache.py +++ b/lib/spack/spack/util/file_cache.py @@ -2,25 +2,26 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) -import errno import hashlib -import math import os import pathlib import shutil -from typing import IO, Dict, Optional, Tuple, Union +from contextlib import contextmanager +from typing import IO, Dict, Iterator, Optional, Tuple, Union from spack.error import SpackError from spack.llnl.util.filesystem import rename -from spack.util.lock import Lock, ReadTransaction, WriteTransaction +from spack.util.lock import Lock def _maybe_open(path: Union[str, pathlib.Path]) -> Optional[IO[str]]: try: return open(path, "r", encoding="utf-8") - except OSError as e: - if e.errno != errno.ENOENT: - raise + except IsADirectoryError: + raise CacheError("Cache file is not a file: %s" % path) + except PermissionError: + raise CacheError("Cannot access cache file: %s" % path) + except FileNotFoundError: return None @@ -46,7 +47,16 @@ def __init__(self, path: str) -> None: def __enter__(self) -> Tuple[Optional[IO[str]], IO[str]]: """Return (old_file, new_file) file objects, where old_file is optional.""" self.old_file = _maybe_open(self.path) - self.new_file = open(self.tmp_path, "w", encoding="utf-8") + try: + try: + self.new_file = open(self.tmp_path, "w", encoding="utf-8") + except FileNotFoundError: + os.makedirs(os.path.dirname(self.path), exist_ok=True) + self.new_file = open(self.tmp_path, "w", encoding="utf-8") + except PermissionError: + if self.old_file: + self.old_file.close() + raise CacheError(f"Insufficient permissions to write to file cache at {self.path}") return self.old_file, self.new_file def __exit__(self, type, value, traceback): @@ -55,7 +65,10 @@ def __exit__(self, type, value, traceback): self.new_file.close() if value: - os.remove(self.tmp_path) + try: + os.remove(self.tmp_path) + except OSError: + pass else: rename(self.tmp_path, self.path) @@ -127,88 +140,58 @@ def _get_lock(self, key: Union[str, pathlib.Path]): ) return self._locks[key_str] - def init_entry(self, key: Union[str, pathlib.Path]): - """Ensure we can access a cache file. Create a lock for it if needed. - - Return whether the cache file exists yet or not. - """ - cache_path = self.cache_path(key) - # Avoid using pathlib here to allow the logic below to - # function as is - # TODO: Maybe refactor the following logic for pathlib - exists = os.path.exists(cache_path) - if exists: - if not cache_path.is_file(): - raise CacheError("Cache file is not a file: %s" % cache_path) - - if not os.access(cache_path, os.R_OK): - raise CacheError("Cannot access cache file: %s" % cache_path) - else: - # if the file is hierarchical, make parent directories - parent = cache_path.parent - if parent != self.root: - parent.mkdir(parents=True, exist_ok=True) - - if not os.access(parent, os.R_OK | os.W_OK): - raise CacheError("Cannot access cache directory: %s" % parent) - - # ensure lock is created for this key - self._get_lock(key) - return exists - - def read_transaction(self, key: Union[str, pathlib.Path]): + @contextmanager + def read_transaction(self, key: Union[str, pathlib.Path]) -> Iterator[Optional[IO[str]]]: """Get a read transaction on a file cache item. - Returns a ReadTransaction context manager and opens the cache file for - reading. You can use it like this:: + Returns a context manager that yields an open file object for reading, + or None if the cache file does not exist. You can use it like this:: with file_cache_object.read_transaction(key) as cache_file: - cache_file.read() + if cache_file is not None: + cache_file.read() """ - path = self.cache_path(key) - return ReadTransaction( - self._get_lock(key), acquire=lambda: ReadContextManager(path) # type: ignore - ) + lock = self._get_lock(key) + lock.acquire_read() + try: + with ReadContextManager(self.cache_path(key)) as f: + yield f + finally: + lock.release_read() - def write_transaction(self, key: Union[str, pathlib.Path]): + @contextmanager + def write_transaction( + self, key: Union[str, pathlib.Path] + ) -> Iterator[Tuple[Optional[IO[str]], IO[str]]]: """Get a write transaction on a file cache item. - Returns a WriteTransaction context manager that opens a temporary file - for writing. Once the context manager finishes, if nothing went wrong, - moves the file into place on top of the old file atomically. + Returns a context manager that yields (old_file, new_file) where old_file + is the existing cache file (or None), and new_file is a writable temporary + file. Once the context manager exits cleanly, moves the temporary file + into place atomically. """ path = self.cache_path(key) - if os.path.exists(path) and not os.access(path, os.W_OK): + lock = self._get_lock(key) + try: + lock.acquire_write() + except PermissionError: raise CacheError(f"Insufficient permissions to write to file cache at {path}") - - return WriteTransaction( - self._get_lock(key), acquire=lambda: WriteContextManager(path) # type: ignore - ) - - def mtime(self, key: Union[str, pathlib.Path]) -> float: - """Return modification time of cache file, or -inf if it does not exist. - - Time is in units returned by os.stat in the mtime field, which is - platform-dependent. - - """ - if not self.init_entry(key): - return -math.inf - else: - return self.cache_path(key).stat().st_mtime + try: + with WriteContextManager(str(path)) as (old, new): + yield old, new + finally: + lock.release_write() def remove(self, key: Union[str, pathlib.Path]): file = self.cache_path(key) lock = self._get_lock(key) + lock.acquire_write() try: - lock.acquire_write() file.unlink() - except OSError as e: - # File not found is OK, so remove is idempotent. - if e.errno != errno.ENOENT: - raise + except FileNotFoundError: + pass finally: lock.release_write() diff --git a/lib/spack/spack/version/git_ref_lookup.py b/lib/spack/spack/version/git_ref_lookup.py index 607a97e3a16b7c..9a7b7653a80eed 100644 --- a/lib/spack/spack/version/git_ref_lookup.py +++ b/lib/spack/spack/version/git_ref_lookup.py @@ -60,9 +60,6 @@ def cache_key(self): key_base = "git_metadata" self._cache_key = (Path(key_base) / self.repository_uri).as_posix() - # Cache data in MISC_CACHE - # If this is the first lazy access, initialize the cache as well - spack.caches.MISC_CACHE.init_entry(self.cache_key) return self._cache_key @property @@ -103,8 +100,8 @@ def save(self): def load_data(self): """Load data if the path already exists.""" - if os.path.isfile(self.cache_path): - with spack.caches.MISC_CACHE.read_transaction(self.cache_key) as cache_file: + with spack.caches.MISC_CACHE.read_transaction(self.cache_key) as cache_file: + if cache_file is not None: self.data = sjson.load(cache_file) def get(self, ref) -> Tuple[Optional[str], int]: From 3eeebb0fe4669287c087a885f800e0b0a21ea5d1 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Fri, 10 Apr 2026 10:21:53 +0200 Subject: [PATCH 232/337] Add more tests for error messages (#52228) Add three parametrized tests for error messages, partitioned by the "main" cause of the error: 1. User input on the CLI 2. User configuration 3. package.py directives These tests capture the status quo, and check that the error messages contain specific strings. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/test/concretization/errors.py | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/lib/spack/spack/test/concretization/errors.py b/lib/spack/spack/test/concretization/errors.py index 017fd8e6e46954..b4cb3649cfaee7 100644 --- a/lib/spack/spack/test/concretization/errors.py +++ b/lib/spack/spack/test/concretization/errors.py @@ -1,14 +1,23 @@ # Copyright Spack Project Developers. See COPYRIGHT file for details. # # SPDX-License-Identifier: (Apache-2.0 OR MIT) +"""Regression tests for concretizer error messages. +Every test asserts two properties: +1. The correct exception type is raised. +2. The message contains every "actionable part" -- a string from the user's + input (spec token, config key, package name) that helps identify what to + change. +""" import pathlib from io import StringIO +from typing import List import pytest import spack.concretize import spack.config +import spack.error import spack.main import spack.solver.asp import spack.spec @@ -106,3 +115,153 @@ def test_internal_error_handling_formatting(tmp_path: pathlib.Path): assert spack.spec.Spec.from_specfile(files["input-2.json"]) == spack.spec.Spec("bar+y") assert spack.spec.Spec.from_specfile(files["output-1.json"]) == spack.spec.Spec("foo@=1.0~x") assert spack.spec.Spec.from_specfile(files["output-2.json"]) == spack.spec.Spec("x@=1.0~y") + + +def assert_actionable_error(exc_info, *required_part: str) -> None: + """Verify that the error message contains every required part, which is usually a string that + the user can recognize in their own input. + """ + msg = str(exc_info.value) + missing = [h for h in required_part if h not in msg] + assert not missing, f"Error message is missing parts {missing!r}\n" f"Full message:\n{msg}" + + +@pytest.mark.parametrize( + "input_spec,expected_parts", + [ + # fftw is constrained to ~mpi by the explicit request, but quantum-espresso + # requires fftw+mpi when +invino. Both values cannot coexist. + pytest.param( + "quantum-espresso+invino^fftw~mpi", ["fftw", "mpi"], id="variant_value_conflict" + ), + # The user requests a variant that does not exist on the package. + pytest.param( + "quantum-espresso+nonexistent", + ["quantum-espresso", "nonexistent", "No such variant"], + id="variant_undefined", + ), + # quantum-espresso has only version 1.0; @:0.1 cannot be satisfied. + pytest.param( + "quantum-espresso@:0.1", + ["quantum-espresso@:0.1", "No version exists"], + id="version_constraint_unsatisfied", + ), + # hypre propagates ~~shared to its deps, but openblas is explicitly +shared. + pytest.param( + "hypre ~~shared ^openblas +shared", + ["shared", "hypre", "'openblas' requires conflicting variant values"], + id="propagation_excluded", + ), + # dependency-foo-bar (++bar) and direct-dep-foo-bar (~~bar) both propagate + # variant "bar" with different values to their shared transitive dependency. + pytest.param( + "parent-foo-bar ^dependency-foo-bar++bar ^direct-dep-foo-bar~~bar", + ["cannot both propagate variant 'bar'"], + id="propagation_conflict_to_dep", + ), + # gmake is a build dependency of a transitive dep, not directly reachable + # via link/run from multivalue-variant. + pytest.param( + "multivalue-variant ^gmake", + ["gmake is not a direct 'build' or"], + id="literal_not_in_dag", + ), + # mvapich2 file_systems uses auto_or_any_combination_of, but "auto" and "lustre" + # come from disjoint sets and cannot be combined. + pytest.param( + "mvapich2 file_systems=auto,lustre", + ["mvapich2", "file_systems", "the value 'auto' is mutually exclusive"], + id="variant_disjoint_sets", + ), + ], +) +def test_input_spec_driven_errors( + input_spec: str, expected_parts: List[str], mock_packages, mutable_config +) -> None: + """Tests errors caused by a token in the CLI input spec. The message must name both the + affected package and the specific token (variant, version, flag, dep) the user supplied. + """ + with pytest.raises(spack.error.SpackError) as exc_info: + spack.concretize.concretize_one(input_spec) + assert_actionable_error(exc_info, *expected_parts) + + +@pytest.mark.parametrize( + "packages_config,input_spec,expected_parts", + [ + # quantum-espresso is set buildable:false; the available external does not + # satisfy +veritas, so no valid spec can be found. + pytest.param( + { + "packages:quantum-espresso": { + "buildable": False, + "externals": [ + {"spec": "quantum-espresso@1.0~veritas", "prefix": "/path/to/qe"} + ], + } + }, + "quantum-espresso+veritas", + ["quantum-espresso", "it is configured `buildable:false`"], + id="buildable_false", + ), + # The user provided a packages.yaml `require:` with a message field. The error must surface + # the custom message so the user knows the policy and the package name so they can find + # the config section. + pytest.param( + { + "packages:libelf": { + "require": [{"spec": "%clang", "message": "must be compiled with clang"}] + } + }, + "libelf%gcc", + ["libelf", "must be compiled with clang"], + id="requirement_unsatisfied_custom_message", + ), + # Generic message must still name the package so the user knows which entry to look at + pytest.param( + {"packages:libelf": {"require": ["%clang"]}}, + "libelf%gcc", + ["libelf"], + id="requirement_unsatisfied_generic", + ), + ], +) +def test_config_driven_errors( + packages_config, input_spec: str, expected_parts: List[str], mock_packages, mutable_config +) -> None: + """Tests errors caused by user configuration, e,g, a setting in packages.yaml. The message must + identify the package and the config value to fix. + """ + for path, conf in packages_config.items(): + spack.config.set(path, conf) + + with pytest.raises(spack.error.SpackError) as exc_info: + spack.concretize.concretize_one(input_spec) + assert_actionable_error(exc_info, *expected_parts) + + +@pytest.mark.parametrize( + "input_spec,expected_handles", + [ + # conflict-parent@0.9 has conflicts("^conflict~foo", when="@0.9"). When the user requests + # `^conflict~foo` the conflict fires. The auto-generated message includes the package name + # and the when-spec version, giving the user two places to look. + pytest.param( + "conflict-parent@0.9 ^conflict~foo", + ["conflict-parent", "'^conflict~foo' conflicts with '@0.9'"], + id="conflicts_directive", + ), + # requires-clang has `requires("%clang", msg="can only be compiled with Clang")`. When + # compiled with %gcc the requirement is unsatisfied and the custom message is shown + pytest.param("requires-clang %gcc", ["requires-clang", "Clang"], id="requires_directive"), + ], +) +def test_package_py_driven_errors( + input_spec: str, expected_handles: List[str], mock_packages, mutable_config +) -> None: + """Tests errors involving directives in package.py recipes. The error message must name the + package whose directive caused the failure. + """ + with pytest.raises(spack.error.SpackError) as exc_info: + spack.concretize.concretize_one(input_spec) + assert_actionable_error(exc_info, *expected_handles) From ddff4448e64c2b9d3a2f505ada4bed84ecff1a8d Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 10 Apr 2026 11:35:37 +0200 Subject: [PATCH 233/337] LockTransaction: fix typing, context-manager path (#52203) `LockTransaction.__init__` accepted acquire as ``` Union[ReleaseFnType, ContextManager] ``` and `__enter__` detected whether acquire returned a context manager to nest into. No caller uses this: all pass plain callables or `None`. - Remove context-manager detection from __enter__/__exit__ and the self._as instance variable - Type acquire as `Optional[Callable[[], None]]` - Add `ExitFnType` alias and type release as `Optional[ExitFnType]`, fixing the previous incorrect typing. - Fix `ReleaseFnType` return to `Optional[bool]` and coerce with `bool()` in release_read/release_write to satisfy mypy - Raise `NotImplementedError` instead of returning NotImplemented in _enter/_exit - Remove `test_transaction_with_context_manager` which exclusively tested the removed feature; exception suppression via release returning True is still covered by `test_transaction_with_exception` Signed-off-by: Harmen Stoppels --- lib/spack/spack/llnl/util/lock.py | 53 ++++-------- lib/spack/spack/test/llnl/util/lock.py | 115 ------------------------- 2 files changed, 16 insertions(+), 152 deletions(-) diff --git a/lib/spack/spack/llnl/util/lock.py b/lib/spack/spack/llnl/util/lock.py index a45a3ef952bd84..dfecfbbf5f6613 100644 --- a/lib/spack/spack/llnl/util/lock.py +++ b/lib/spack/spack/llnl/util/lock.py @@ -9,7 +9,7 @@ import time from datetime import datetime from types import TracebackType -from typing import IO, Callable, ContextManager, Dict, Generator, Optional, Tuple, Type, Union +from typing import IO, Callable, Dict, Generator, Optional, Tuple, Type from spack.llnl.util import lang, tty @@ -34,7 +34,11 @@ ] -ReleaseFnType = Optional[Callable[[], bool]] +ExitFnType = Callable[ + [Optional[Type[BaseException]], Optional[BaseException], Optional[TracebackType]], + Optional[bool], +] +ReleaseFnType = Optional[Callable[[], Optional[bool]]] DevIno = Tuple[int, int] # (st_dev, st_ino) from os.stat_result @@ -642,7 +646,7 @@ def release_read(self, release_fn: ReleaseFnType = None) -> bool: self._unlock() # can raise LockError. self._reads = 0 self._log_released(locktype) - return result + return bool(result) else: self._reads -= 1 return False @@ -678,7 +682,7 @@ def release_write(self, release_fn: ReleaseFnType = None) -> bool: self._writes = 0 self._log_released(locktype) - return result + return bool(result) else: self._writes -= 1 return False @@ -746,43 +750,27 @@ class LockTransaction: Arguments: lock: underlying lock for this transaction to be acquired on enter and released on exit - acquire: function to be called after lock is acquired, or contextmanager to enter after - acquire and leave before release. - release: function to be called before release. If ``acquire`` is a contextmanager, this - will be called *after* exiting the nested context and before the lock is released. + acquire: function to be called after lock is acquired + release: function to be called before release, with ``(exc_type, exc_value, traceback)`` timeout: number of seconds to set for the timeout when acquiring the lock (default no timeout) - - If the ``acquire_fn`` returns a value, it is used as the return value for ``__enter__``, - allowing it to be passed as the ``as`` argument of a ``with`` statement. - - If ``acquire_fn`` returns a context manager, *its* ``__enter__`` function will be called after - the lock is acquired, and its ``__exit__`` function will be called before ``release_fn`` in - ``__exit__``, allowing you to nest a context manager inside this one. - - Timeout for lock is customizable. """ def __init__( self, lock: Lock, - acquire: Union[ReleaseFnType, ContextManager] = None, - release: Union[ReleaseFnType, ContextManager] = None, + acquire: Optional[Callable[[], None]] = None, + release: Optional[ExitFnType] = None, timeout: Optional[float] = None, ) -> None: self._lock = lock self._timeout = timeout self._acquire_fn = acquire self._release_fn = release - self._as = None def __enter__(self): if self._enter() and self._acquire_fn: - self._as = self._acquire_fn() - if hasattr(self._as, "__enter__"): - return self._as.__enter__() - else: - return self._as + return self._acquire_fn() def __exit__( self, @@ -790,26 +778,17 @@ def __exit__( exc_value: Optional[BaseException], traceback: Optional[TracebackType], ) -> bool: - suppress = False - def release_fn(): if self._release_fn is not None: return self._release_fn(exc_type, exc_value, traceback) - if self._as and hasattr(self._as, "__exit__"): - if self._as.__exit__(exc_type, exc_value, traceback): - suppress = True - - if self._exit(release_fn): - suppress = True - - return suppress + return bool(self._exit(release_fn)) def _enter(self) -> bool: - return NotImplemented + raise NotImplementedError def _exit(self, release_fn: ReleaseFnType) -> bool: - return NotImplemented + raise NotImplementedError class ReadTransaction(LockTransaction): diff --git a/lib/spack/spack/test/llnl/util/lock.py b/lib/spack/spack/test/llnl/util/lock.py index bce9a9101cfb53..589f2b4511154c 100644 --- a/lib/spack/spack/test/llnl/util/lock.py +++ b/lib/spack/spack/test/llnl/util/lock.py @@ -975,121 +975,6 @@ def exit_fn(t, v, tb): assert vals["exception"] -@pytest.mark.parametrize( - "transaction,type", [(lk.ReadTransaction, "read"), (lk.WriteTransaction, "write")] -) -def test_transaction_with_context_manager(lock_path, transaction, type): - class MockLock(AssertLock): - def assert_acquire_read(self): - assert not vals["entered_ctx"] - assert not vals["exited_ctx"] - - def assert_release_read(self): - assert vals["entered_ctx"] - assert vals["exited_ctx"] - - def assert_acquire_write(self): - assert not vals["entered_ctx"] - assert not vals["exited_ctx"] - - def assert_release_write(self): - assert vals["entered_ctx"] - assert vals["exited_ctx"] - - class TestContextManager: - def __enter__(self): - vals["entered_ctx"] = True - - def __exit__(self, t, v, tb): - assert not vals["released_%s" % type] - vals["exited_ctx"] = True - vals["exception_ctx"] = t or v or tb - return exit_ctx_result - - def exit_fn(t, v, tb): - assert not vals["released_%s" % type] - vals["exited_fn"] = True - vals["exception_fn"] = t or v or tb - return exit_fn_result - - exit_fn_result, exit_ctx_result = False, False - vals = collections.defaultdict(lambda: False) - lock = MockLock(lock_path, vals) - - with transaction(lock, acquire=TestContextManager, release=exit_fn): - pass - - assert vals["entered_ctx"] - assert vals["exited_ctx"] - assert vals["exited_fn"] - assert not vals["exception_ctx"] - assert not vals["exception_fn"] - - vals.clear() - with transaction(lock, acquire=TestContextManager): - pass - - assert vals["entered_ctx"] - assert vals["exited_ctx"] - assert not vals["exited_fn"] - assert not vals["exception_ctx"] - assert not vals["exception_fn"] - - # below are tests for exceptions with and without suppression - def assert_ctx_and_fn_exception(raises=True): - vals.clear() - - if raises: - with pytest.raises(Exception): - with transaction(lock, acquire=TestContextManager, release=exit_fn): - raise Exception() - else: - with transaction(lock, acquire=TestContextManager, release=exit_fn): - raise Exception() - - assert vals["entered_ctx"] - assert vals["exited_ctx"] - assert vals["exited_fn"] - assert vals["exception_ctx"] - assert vals["exception_fn"] - - def assert_only_ctx_exception(raises=True): - vals.clear() - - if raises: - with pytest.raises(Exception): - with transaction(lock, acquire=TestContextManager): - raise Exception() - else: - with transaction(lock, acquire=TestContextManager): - raise Exception() - - assert vals["entered_ctx"] - assert vals["exited_ctx"] - assert not vals["exited_fn"] - assert vals["exception_ctx"] - assert not vals["exception_fn"] - - # no suppression - assert_ctx_and_fn_exception(raises=True) - assert_only_ctx_exception(raises=True) - - # suppress exception only in function - exit_fn_result, exit_ctx_result = True, False - assert_ctx_and_fn_exception(raises=False) - assert_only_ctx_exception(raises=True) - - # suppress exception only in context - exit_fn_result, exit_ctx_result = False, True - assert_ctx_and_fn_exception(raises=False) - assert_only_ctx_exception(raises=False) - - # suppress exception in function and context - exit_fn_result, exit_ctx_result = True, True - assert_ctx_and_fn_exception(raises=False) - assert_only_ctx_exception(raises=False) - - def test_nested_write_transaction(lock_path): """Ensure that the outermost write transaction writes.""" From 359b160cd7ca9b7f0eb91d9b16ddf676abef800d Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 10 Apr 2026 16:57:09 +0200 Subject: [PATCH 234/337] reporters: fix hard failure with non-UTF-8 logs (#52235) Use `errors="replace"` to avoid exceptions when creating report entries. Signed-off-by: Harmen Stoppels --- lib/spack/spack/report.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/report.py b/lib/spack/spack/report.py index a36ea58c8ec654..da1f9fba2dcc31 100644 --- a/lib/spack/spack/report.py +++ b/lib/spack/spack/report.py @@ -135,9 +135,11 @@ def fetch_log(self): """Install log comes from install prefix on success, or stage dir on failure.""" try: if os.path.exists(self._package.install_log_path): - stream = gzip.open(self._package.install_log_path, "rt", encoding="utf-8") + stream = gzip.open( + self._package.install_log_path, "rt", encoding="utf-8", errors="replace" + ) else: - stream = open(self._package.log_path, encoding="utf-8") + stream = open(self._package.log_path, encoding="utf-8", errors="replace") with stream as f: return f.read() except OSError: @@ -159,7 +161,7 @@ def fetch_log(self): """Get output from test log""" log_file = os.path.join(self.directory, self._package.test_suite.test_log_name(self._spec)) try: - with open(log_file, "r", encoding="utf-8") as stream: + with open(log_file, "r", encoding="utf-8", errors="replace") as stream: return "".join(stream.readlines()) except Exception: return f"Cannot open log for {self._spec.cshort_spec}" From 6775e4e6d81c4d59fce6baf09640babedcc88bde Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 10 Apr 2026 17:49:44 +0200 Subject: [PATCH 235/337] new_installer.py: use buffered io in log (#52231) The Tee thread used `os.write`, which returns the number of bytes written, possibly less than the input. That's a data loss risk fixed by using buffered io. Presumably we wouldn't hit this, cause the log is on the same file system as the build, but still good to fix. The original consideration was that we should not block a build over logs, but arguably having broken logs is worse. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 51 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index ccd733e5c5c985..f84d95878f92d2 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -205,7 +205,7 @@ def send_installed_from_binary_cache(state_pipe: io.TextIOWrapper) -> None: state_pipe.write("\n") -def tee(control_r: int, log_r: int, file_w: int, parent_w: int) -> None: +def tee(control_r: int, log_r: int, log_path: str, parent_w: int) -> None: """Forward log_r to file_w and parent_w (if echoing is enabled). Echoing is enabled and disabled by reading from control_r.""" echo_on = False @@ -214,22 +214,25 @@ def tee(control_r: int, log_r: int, file_w: int, parent_w: int) -> None: selector.register(control_r, selectors.EVENT_READ) try: - while True: - for key, _ in selector.select(): - if key.fd == log_r: - data = os.read(log_r, OUTPUT_BUFFER_SIZE) - if not data: # EOF: exit the thread - return - os.write(file_w, data) - if echo_on: - os.write(parent_w, data) - - elif key.fd == control_r: - control_data = os.read(control_r, 1) - if not control_data: - return - else: - echo_on = control_data == b"1" + with open(log_path, "wb") as log_file, open(parent_w, "wb", closefd=False) as parent: + while True: + for key, _ in selector.select(): + if key.fd == log_r: + data = os.read(log_r, OUTPUT_BUFFER_SIZE) + if not data: # EOF: exit the thread + return + log_file.write(data) + log_file.flush() + if echo_on: + parent.write(data) + parent.flush() + + elif key.fd == control_r: + control_data = os.read(control_r, 1) + if not control_data: + return + else: + echo_on = control_data == b"1" except OSError: # do not raise pass finally: @@ -240,19 +243,19 @@ class Tee: """Emulates ./build 2>&1 | tee build.log. The output is sent both to a log file and the parent process (if echoing is enabled). The control_fd is used to enable/disable echoing.""" - def __init__(self, control: Connection, parent: Connection, log_fd: int) -> None: + def __init__(self, control: Connection, parent: Connection, log_path: str) -> None: self.control = control self.parent = parent # sys.stdout and sys.stderr may have been replaced with file objects under pytest, so # redirect their file descriptors in addition to the original fds 1 and 2. fds = {sys.stdout.fileno(), sys.stderr.fileno(), 1, 2} self.saved_fds = {fd: os.dup(fd) for fd in fds} - #: The file descriptor of the log file - self.log_fd = log_fd + #: The path of the log file + self.log_path = log_path r, w = os.pipe() self.tee_thread = threading.Thread( target=tee, - args=(self.control.fileno(), r, self.log_fd, self.parent.fileno()), + args=(self.control.fileno(), r, self.log_path, self.parent.fileno()), daemon=True, ) self.tee_thread.start() @@ -274,7 +277,6 @@ def close(self) -> None: # Only then close the other fds. self.control.close() self.parent.close() - os.close(self.log_fd) def install_from_buildcache( @@ -519,9 +521,8 @@ def handle_sigterm(signum, frame): os.close(devnull_fd) sys.stdin = open(os.devnull, "r", encoding=sys.stdin.encoding) - # Open the log file created by the parent process. - log_fd = os.open(log_path, os.O_WRONLY | os.O_TRUNC, 0o644) - tee = Tee(echo_control, parent, log_fd) + # Start the tee thread to forward output to the log file and parent process. + tee = Tee(echo_control, parent, log_path) # Use closedfd=false because of the connection objects. Use line buffering. state_stream = os.fdopen(state.fileno(), "w", buffering=1, closefd=False) From 30f02a2c10a8d15119448ac39a2f07cb770eb0ef Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sat, 11 Apr 2026 00:10:37 +0200 Subject: [PATCH 236/337] new_installer.py: fix stdin double buffering bug (#52234) Fix a bug where pasting a dag hash or package name in `/` mode of the TUI would result in only a single character being added to the filter. The cause of this is double buffering (kernel + TextIOWrapper). The idea was to read one character per event loop iteration. But after `sys.stdin.read(1)` multiple bytes are `os.read` from the stdin fd, draining the pipe and moving the data into the TextIOWrapper. Another key press is needed for that to be read in a later iteration of the event loop. The fix does a bit more: * handle multiple characters per event loop iteration so pasting text isn't laggy * filter out ansi escape characters for cursor movement The only buffering now is for multi-byte UTF-8 chars, which is desirable. --- lib/spack/spack/new_installer.py | 66 ++++++++++++++++++--------- lib/spack/spack/test/installer_tui.py | 46 ++++++++++++++++++- 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index f84d95878f92d2..592fc8206a9555 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -20,6 +20,7 @@ runs an event loop to listen for control messages from the UI process (to enable/disable echoing of logs), and for output from the build process.""" +import codecs import fcntl import glob import io @@ -2144,6 +2145,28 @@ def _signal_children(running_builds: Dict[int, ChildInfo], sig: signal.Signals) pass +class StdinReader: + """Helper class to do non-blocking, incremental decoding of stdin, stripping ANSI escape + sequences. The input is the backing file descriptor for stdin (instead of the TextIOWrapper) to + avoid double buffering issues: the event loop triggers when the fd is ready to read, and if we + do a partial read from the TextIOWrapper, it will likely drain the fd and buffer the remainder + internally, which the event loop is not aware of, and user input doesn't come through.""" + + def __init__(self, fd: int) -> None: + self.fd = fd + #: Handle multi-byte UTF-8 characters + self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + #: For stripping out arrow and navigation keys + self.ansi_escape_re = re.compile(r"\x1b\[[0-9;]*[A-Za-z~]") + + def read(self) -> str: + try: + chars = self.decoder.decode(os.read(self.fd, 1024)) + return self.ansi_escape_re.sub("", chars) + except OSError: + return "" + + class PackageInstaller: explicit: Set[str] @@ -2276,7 +2299,9 @@ def _installer(self) -> None: # Set up terminal handling (cbreak, signals, stdin registration) terminal: Optional[TerminalState] = None + stdin_reader: Optional[StdinReader] = None if sys.stdin.isatty(): + stdin_reader = StdinReader(sys.stdin.fileno()) terminal = TerminalState( selector, self.build_status, @@ -2398,28 +2423,25 @@ def _installer(self) -> None: child.proc.terminate() self.pending_builds.clear() - if stdin_ready: - try: - char = sys.stdin.read(1) - except OSError: - continue - overview = self.build_status.overview_mode - if overview and self.build_status.search_mode: - self.build_status.search_input(char) - elif overview and char == "/": - self.build_status.enter_search() - elif char == "v" or char in ("q", "\x1b") and not overview: - self.build_status.toggle() - elif char == "n": - self.build_status.next(1) - elif char == "p" or char == "N": - self.build_status.next(-1) - elif char == "+": - jobserver.increase_parallelism() - self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) - elif char == "-": - jobserver.decrease_parallelism() - self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) + if stdin_ready and stdin_reader is not None: + for char in stdin_reader.read(): + overview = self.build_status.overview_mode + if overview and self.build_status.search_mode: + self.build_status.search_input(char) + elif overview and char == "/": + self.build_status.enter_search() + elif char == "v" or char in ("q", "\x1b") and not overview: + self.build_status.toggle() + elif char == "n": + self.build_status.next(1) + elif char == "p" or char == "N": + self.build_status.next(-1) + elif char == "+": + jobserver.increase_parallelism() + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) + elif char == "-": + jobserver.decrease_parallelism() + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) # Insert into the database if we have any finished builds, and either the delay # interval has passed, or we're done with all builds. The database save is not diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 2e33d4929ace01..4a1fa8ccdc4f7c 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -17,7 +17,7 @@ from typing import List, Optional, Tuple import spack.new_installer as inst -from spack.new_installer import BuildStatus +from spack.new_installer import BuildStatus, StdinReader class MockConnection: @@ -1459,3 +1459,47 @@ def test_update_works_after_headless_cleared(self): status.dirty = True status.update() assert "[/] pkg0 pkg0@0.0 starting" in stdout.getvalue() + + +class TestStdinReader: + def test_basic_ascii(self): + r, w = os.pipe() + try: + reader = StdinReader(r) + os.write(w, b"abc") + assert reader.read() == "abc" + finally: + os.close(r) + os.close(w) + + def test_ansi_stripping(self): + r, w = os.pipe() + try: + reader = StdinReader(r) + os.write(w, b"hello\x1b[Aworld\x1b[B!") + assert reader.read() == "helloworld!" + finally: + os.close(r) + os.close(w) + + def test_multibyte_utf8(self): + r, w = os.pipe() + try: + reader = StdinReader(r) + encoded = "é".encode("utf-8") # 0xc3 0xa9 + os.write(w, encoded[:1]) + # First read: incomplete char, decoder buffers it + result1 = reader.read() + os.write(w, encoded[1:]) + result2 = reader.read() + assert result1 + result2 == "é" + finally: + os.close(r) + os.close(w) + + def test_oserror_returns_empty(self): + r, w = os.pipe() + os.close(w) + os.close(r) + reader = StdinReader(r) + assert reader.read() == "" From 7c0cc38fac11438c402984215349d4ce6caeaf3c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 13 Apr 2026 09:22:00 +0200 Subject: [PATCH 237/337] core: fix two typos (#52240) Signed-off-by: Harmen Stoppels --- lib/spack/spack/ci/__init__.py | 2 +- lib/spack/spack/cmd/verify.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/ci/__init__.py b/lib/spack/spack/ci/__init__.py index 0e60854be88f5e..2e900bc6ed7969 100644 --- a/lib/spack/spack/ci/__init__.py +++ b/lib/spack/spack/ci/__init__.py @@ -1129,7 +1129,7 @@ def reproduce_ci_job(url, work_dir, autostart, gpg_url, runtime, use_local_head) process_command("reproducer", entrypoint_script, work_dir, run=autostart) inst_list.append("\nOnce on the tagged runner:\n\n") - inst_list.extent( + inst_list.extend( [ " - Run the reproducer script", f" $ {work_dir}/reproducer.{platform_script_ext}", diff --git a/lib/spack/spack/cmd/verify.py b/lib/spack/spack/cmd/verify.py index 81cfec77692d75..cd2ebb1a51c2f4 100644 --- a/lib/spack/spack/cmd/verify.py +++ b/lib/spack/spack/cmd/verify.py @@ -93,10 +93,7 @@ def verify_versions(args): 2. Installed package version not known by the package recipe 3. Installed package version deprecated in the package recipe """ - if args.specs: - specs = args.specs(installed=True) - else: - specs = spack.store.db.query(installed=True) + specs = args.specs(installed=True) msg_lines = _verify_version(specs) if msg_lines: From 9c6e8b2d98cd21058b5a982b0acfdd5d2da4c14c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 13 Apr 2026 11:09:00 +0200 Subject: [PATCH 238/337] new_installer.py: extract two functions (#52241) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 109 +++++++++++++++++-------------- 1 file changed, 60 insertions(+), 49 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 592fc8206a9555..0b6bab0e5020c1 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -126,6 +126,14 @@ class DatabaseAction: def save_to_db(self, db: spack.database.Database) -> None: ... + def release_lock(self) -> None: + if self.prefix_lock is not None: + try: + self.prefix_lock.release_write() + except Exception: + pass + self.prefix_lock = None + class MarkExplicitAction(DatabaseAction): """Action to mark an already installed spec as explicitly installed. Similar to ChildInfo, but @@ -2290,6 +2298,8 @@ def __init__( #: Internal data collected for reports during installation. self.report_data = ReportData(specs) + self.next_database_write = 0.0 + def install(self) -> None: self._installer() @@ -2314,9 +2324,9 @@ def _installer(self) -> None: database_actions: List[DatabaseAction] = [] # Prefix read locks retained after DB flush (downgraded from write locks in _save_to_db). retained_read_locks: List[spack.util.lock.Lock] = [] - next_database_write = 0.0 failures: List[spack.spec.Spec] = [] + finished_pids: List[int] = [] try: # Try to schedule builds immediately. The first job does not require a token. @@ -2351,7 +2361,7 @@ def _installer(self) -> None: timeout = DATABASE_WRITE_INTERVAL events = selector.select(timeout=timeout) - finished_pids = [] + finished_pids.clear() # The transition "suspended to foreground/background" is handled in the signal # handler, but there's no SIGCONT event in the transition of background to @@ -2381,40 +2391,10 @@ def _installer(self) -> None: jobserver.maybe_discard_tokens() self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) - current_time = time.monotonic() - for pid in finished_pids: - build = self.running_builds.pop(pid) - self.capacity += 1 - jobserver.release() - self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) - self._drain_child_output(build, selector) - self._drain_child_state(build, selector) - build.cleanup(selector) - exitcode = build.proc.exitcode - assert exitcode is not None, "Finished build should have exit code set" - self.report_data.finish_record(build.spec, exitcode) - if exitcode == 0: - # Add successful builds for database insertion (after a short delay) - database_actions.append(build) - self.build_graph.enqueue_parents( - build.spec.dag_hash(), self.pending_builds - ) - next_database_write = current_time + DATABASE_WRITE_INTERVAL - self.build_status.update_state(build.spec.dag_hash(), "finished") - elif exitcode == EXIT_STOPPED_AT_PHASE: - # Partial build: neither failure nor success. Should not be persisted in - # the database, but also not treated as a failure in the UI. Just release - # locks and move on. - if build.prefix_lock is not None: - build.prefix_lock.release_write() - build.prefix_lock = None - elif not self.fail_fast or not failures: - # In fail-fast mode, only record the first failure. Subsequent failures may - # be a consequence of us terminating other builds, and should not be - # reported as failures in the UI. - failures.append(build.spec) - self.build_status.update_state(build.spec.dag_hash(), "failed") - self.build_status.parse_log_summary(build.spec.dag_hash()) + if finished_pids: + self._handle_finished_builds( + finished_pids, selector, jobserver, database_actions, failures + ) if failures and self.fail_fast: # Terminate other builds to actually fail fast. We continue in the event loop @@ -2450,7 +2430,7 @@ def _installer(self) -> None: if ( database_actions and ( - current_time >= next_database_write + time.monotonic() >= self.next_database_write or not (self.pending_builds or self.running_builds) ) and self._save_to_db(database_actions, retained_read_locks) @@ -2501,24 +2481,15 @@ def _installer(self) -> None: # Release all held locks best-effort, so that one failure does not prevent the others # from being released. for child in self.running_builds.values(): - try: - if child.prefix_lock is not None: - child.prefix_lock.release_write() - child.prefix_lock = None - except Exception: - pass + child.release_lock() + for lock in retained_read_locks: try: lock.release_read() except Exception: pass for action in database_actions: - try: - if action.prefix_lock is not None: - action.prefix_lock.release_write() - action.prefix_lock = None - except Exception: - pass + action.release_lock() try: self.build_status.overview_mode = True @@ -2547,6 +2518,46 @@ def _installer(self) -> None: "The following packages failed to install:\n" + "\n".join(lines) ) + def _handle_finished_builds( + self, + finished_pids: List[int], + selector: selectors.BaseSelector, + jobserver: JobServer, + database_actions: List[DatabaseAction], + failures: List[spack.spec.Spec], + ) -> None: + """Handle builds that finished since the last event loop iteration.""" + current_time = time.monotonic() + for pid in finished_pids: + build = self.running_builds.pop(pid) + self.capacity += 1 + jobserver.release() + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) + self._drain_child_output(build, selector) + self._drain_child_state(build, selector) + build.cleanup(selector) + exitcode = build.proc.exitcode + assert exitcode is not None, "Finished build should have exit code set" + self.report_data.finish_record(build.spec, exitcode) + if exitcode == 0: + # Add successful builds for database insertion (after a short delay) + database_actions.append(build) + self.build_graph.enqueue_parents(build.spec.dag_hash(), self.pending_builds) + self.next_database_write = current_time + DATABASE_WRITE_INTERVAL + self.build_status.update_state(build.spec.dag_hash(), "finished") + elif exitcode == EXIT_STOPPED_AT_PHASE: + # Partial build: neither failure nor success. Should not be persisted in + # the database, but also not treated as a failure in the UI. Just release + # locks and move on. + build.release_lock() + elif not self.fail_fast or not failures: + # In fail-fast mode, only record the first failure. Subsequent failures may + # be a consequence of us terminating other builds, and should not be + # reported as failures in the UI. + failures.append(build.spec) + self.build_status.update_state(build.spec.dag_hash(), "failed") + self.build_status.parse_log_summary(build.spec.dag_hash()) + def _save_to_db( self, database_actions: List[DatabaseAction], From ac5ae7d806a0c454863f3ac8432bef6942d43529 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Mon, 13 Apr 2026 17:28:39 +0200 Subject: [PATCH 239/337] environment groups: add "explicit" attribute (#52244) With this attribute users can control whether root specs from groups are "explicit" or not (default is True). Root specs from `explicit: False` groups are eligible for garbage collection. Signed-off-by: Massimiliano Culpo --- lib/spack/docs/environments.rst | 32 +++++++++++ lib/spack/spack/cmd/gc.py | 5 +- lib/spack/spack/environment/environment.py | 23 +++++++- lib/spack/spack/schema/env.py | 6 ++ lib/spack/spack/test/cmd/gc.py | 66 ++++++++++++++++++++++ 5 files changed, 128 insertions(+), 4 deletions(-) diff --git a/lib/spack/docs/environments.rst b/lib/spack/docs/environments.rst index 944e778cd3147f..09a69ce07d680c 100644 --- a/lib/spack/docs/environments.rst +++ b/lib/spack/docs/environments.rst @@ -998,6 +998,38 @@ The ``override:`` attribute allows us to override the configuration for a single The overridden part is always added as the *topmost* scope when the current group is concretized. This ensures the override always takes precedence over other sources of configuration. +.. _environment-spec-groups-explicit: + +Controlling garbage collection with ``explicit: false`` +""""""""""""""""""""""""""""""""""""""""""""""""""""""" + +By default every spec group is treated as a set of *explicit* roots. +This means its specs are preserved by ``spack gc`` even when nothing else depends on them. +Setting ``explicit: false`` on a group marks its specs as *implicit*, making them eligible for garbage collection once no other installed spec depends on them: + +.. code-block:: yaml + + spack: + specs: + - group: compiler + explicit: false + specs: + - gcc@15.2 + + - group: apps + needs: [compiler] + specs: + - hdf5 %gcc@15.2 + - libtree %gcc@15.2 + +After the apps are installed, ``spack gc`` will remove the compiler once no installed spec has a link or run dependency on it. + +.. note:: + + Flipping ``explicit: false`` on a group that has already been installed does **not** retroactively update the database record for the already-installed specs. + The flag takes effect only for specs installed, or re-installed, after the change. + To immediately mark an existing spec as implicit, use ``spack mark -i ``. + Modifying Environment Variables ------------------------------- diff --git a/lib/spack/spack/cmd/gc.py b/lib/spack/spack/cmd/gc.py index e0f6f538a804bf..e303df5cf2f094 100644 --- a/lib/spack/spack/cmd/gc.py +++ b/lib/spack/spack/cmd/gc.py @@ -67,7 +67,7 @@ def roots_from_environments(args, active_env): # add root hashes from all considered environments to list of roots root_hashes = set() for env in all_environments: - root_hashes |= {x.hash for x in env.concretized_roots} + root_hashes |= {x.hash for x in env.explicit_roots()} return root_hashes @@ -91,7 +91,8 @@ def gc(parser, args): tty.msg(f"Restricting garbage collection to environment '{active_env.name}'") root_hashes = set(spack.store.STORE.db.all_hashes()) # keep everything root_hashes -= set(active_env.all_hashes()) # except this env - root_hashes |= {x.hash for x in active_env.concretized_roots} # but keep its roots + # but keep its explicit roots + root_hashes |= {x.hash for x in active_env.explicit_roots()} else: # consider all explicit specs roots (the default for db.unused_specs()) root_hashes = None diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index efc59ccf0708e1..d8aae942c5e3d9 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -1288,6 +1288,11 @@ def user_specs_by(self, *, group: Optional[str]) -> SpecList: key = self._user_specs_key(group=group) return self.spec_lists[key] + def explicit_roots(self): + for x in self.concretized_roots: + if self.manifest.is_explicit(group=x.group): + yield x + @property def dev_specs(self): dev_specs = {} @@ -1979,10 +1984,10 @@ def install_specs(self, specs: Optional[List[Spec]] = None, **install_args): *self._dev_specs_that_need_overwrite(), } - # Only environment roots are marked explicit + # Only environment roots in explicit groups are marked explicit install_args["explicit"] = { *install_args.get("explicit", ()), - *(s.dag_hash() for s in roots), + *(x.hash for x in self.explicit_roots()), } builder = spack.installer_dispatch.create_installer( @@ -3158,6 +3163,8 @@ def __init__(self, manifest_dir: Union[pathlib.Path, str], name: Optional[str] = self._user_specs: Dict[str, List] = {DEFAULT_USER_SPEC_GROUP: []} # Configuration overrides for each group self._config_override: Dict[str, Any] = {DEFAULT_USER_SPEC_GROUP: None} + # Whether specs in each group are marked explicit + self._explicit: Dict[str, bool] = {DEFAULT_USER_SPEC_GROUP: True} self._init_user_specs() self.changed = False @@ -3181,6 +3188,7 @@ def _init_user_specs(self): self._user_specs[group] = [] self._groups[group] = tuple(item.get("needs", ())) self._config_override[group] = item.get("override", None) + self._explicit[group] = item.get("explicit", True) if "matrix" in item: # Short form if the group is composed of only one matrix @@ -3192,6 +3200,7 @@ def _clear_user_specs(self) -> None: self._user_specs = {DEFAULT_USER_SPEC_GROUP: []} self._groups = {DEFAULT_USER_SPEC_GROUP: tuple()} self._config_override = {DEFAULT_USER_SPEC_GROUP: None} + self._explicit = {DEFAULT_USER_SPEC_GROUP: True} def _all_matches(self, user_spec: str) -> List[str]: """Maps the input string to the first equivalent user spec in the manifest, @@ -3235,6 +3244,15 @@ def needs(self, *, group: Optional[str] = None) -> Tuple[str, ...]: group = self._ensure_group_exists(group) return self._groups[group] + def is_explicit(self, *, group: Optional[str] = None) -> bool: + """Returns whether specs in a group are marked explicit. + + When False, specs in the group are installed as implicit dependencies + and are eligible for garbage collection once no other spec depends on them. + """ + group = self._ensure_group_exists(group) + return self._explicit[group] + def _ensure_group_exists(self, group: Optional[str]) -> str: group = DEFAULT_USER_SPEC_GROUP if group is None else group if group not in self._groups: @@ -3277,6 +3295,7 @@ def _get_group(self, group: str) -> Dict: self._groups[group] = tuple() self._config_override[group] = None self._user_specs[group] = [] + self._explicit[group] = True return group_entry diff --git a/lib/spack/spack/schema/env.py b/lib/spack/spack/schema/env.py index c20f68947f52b8..267d32399a8e7d 100644 --- a/lib/spack/spack/schema/env.py +++ b/lib/spack/spack/schema/env.py @@ -30,6 +30,12 @@ group_name_and_deps = { "group": {"type": "string", "description": "Name for this group of specs"}, + "explicit": { + "type": "boolean", + "default": True, + "description": "When false, specs in this group are installed as implicit " + "dependencies and are eligible for garbage collection.", + }, "needs": { "type": "array", "description": "Groups of specs that are needed by this group", diff --git a/lib/spack/spack/test/cmd/gc.py b/lib/spack/spack/test/cmd/gc.py index f061010b6dadd9..34b7a4a94174e1 100644 --- a/lib/spack/spack/test/cmd/gc.py +++ b/lib/spack/spack/test/cmd/gc.py @@ -165,3 +165,69 @@ def test_gc_except_specific_dir_env( assert "Restricting garbage collection" not in output assert "Successfully uninstalled zmpi" in output assert not mutable_database.query_local("zmpi") + + +@pytest.fixture +def mock_installed_environment(mutable_database, mutable_mock_env_path): + + def _create_environment(name, spack_yaml): + tmp_env = ev.create(name) + spack_yaml_path = pathlib.Path(tmp_env.path) / "spack.yaml" + spack_yaml_path.write_text(spack_yaml) + e = ev.read(name) + with ev.read(name): + e.concretize() + e.install_all(fake=True) + e.write() + return e + + return _create_environment + + +@pytest.mark.db +@pytest.mark.parametrize( + "explicit,expected_explicit,expected_implicit", + [ + (True, ["gcc@14.0.1", "openblas", "dyninst"], []), + (False, ["dyninst"], ["gcc@14.0.1", "openblas"]), + ], +) +def test_gc_with_explicit_groups( + explicit, expected_explicit, expected_implicit, mutable_database, mock_installed_environment +): + """Tests the semantics of the "explicit" attribute of environment groups""" + e = mock_installed_environment( + "test_gc_explicit", + f""" +spack: + config: + installer: new + specs: + - group: base + explicit: {explicit} + specs: + - gcc@14.0.1 + - openblas + - group: apps + needs: [base] + specs: + - dyninst %c=gcc@14.0.1 +""", + ) + + # Test DB status + for query in expected_explicit: + assert mutable_database.query_local(query, explicit=True) + + for query in expected_implicit: + assert mutable_database.query_local(query, explicit=False) + + with e: + output = gc("-y") + + # Test gc behavior + for query in expected_implicit: + assert f"Successfully uninstalled {query}" in output + + for query in expected_explicit: + assert f"Successfully uninstalled {query}" not in output From 8d5979dd21dd808c987bc8459a94e421e1c6c69d Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 13 Apr 2026 19:59:29 +0200 Subject: [PATCH 240/337] setup-env.sh: speed up when no module command (#52245) The `setup-env.sh` script was slow because it triggers a database search for `environment-modules`, that's bad UX and an unnecessary implicit preference over `lmod`. This commit speeds up `source setup-env.sh` for users who do not have `module` available as a shell function. The user is responsible for making `module` available in their shell, and if so, there's a small startup cost as Spack is queried for MODULEPATH. Signed-off-by: Harmen Stoppels --- lib/spack/spack/main.py | 12 ------ lib/spack/spack/test/cmd/print_shell_vars.py | 20 --------- share/spack/setup-env.fish | 44 +++----------------- share/spack/setup-env.sh | 43 ++----------------- 4 files changed, 8 insertions(+), 111 deletions(-) diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index 130a7a9df6eec7..6d5d0c49db4872 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -42,7 +42,6 @@ import spack.platforms import spack.solver.asp import spack.spec -import spack.store import spack.util.debug import spack.util.environment import spack.util.lock @@ -849,17 +848,6 @@ def shell_set(var, value): roots_val = ":".join(reversed(paths)) shell_set("_sp_%s_roots" % name, roots_val) - # print environment module system if available. This can be expensive - # on clusters, so skip it if not needed. - if "modules" in info: - generic_arch = spack.vendor.archspec.cpu.host().family - module_spec = "environment-modules target={0}".format(generic_arch) - specs = spack.store.STORE.db.query(module_spec) - if specs: - shell_set("_sp_module_prefix", specs[-1].prefix) - else: - shell_set("_sp_module_prefix", "not_installed") - def restore_macos_dyld_vars(): """ diff --git a/lib/spack/spack/test/cmd/print_shell_vars.py b/lib/spack/spack/test/cmd/print_shell_vars.py index 866d4a3da9e46c..d410a4633aa51a 100644 --- a/lib/spack/spack/test/cmd/print_shell_vars.py +++ b/lib/spack/spack/test/cmd/print_shell_vars.py @@ -23,23 +23,3 @@ def test_print_shell_vars_csh(capfd): assert "set _sp_tcl_roots = " in out assert "set _sp_lmod_roots = " in out assert "set _sp_module_prefix = " not in out - - -def test_print_shell_vars_sh_modules(capfd): - print_setup_info("sh", "modules") - out, _ = capfd.readouterr() - - assert "_sp_sys_type=" in out - assert "_sp_tcl_roots=" in out - assert "_sp_lmod_roots=" in out - assert "_sp_module_prefix=" in out - - -def test_print_shell_vars_csh_modules(capfd): - print_setup_info("csh", "modules") - out, _ = capfd.readouterr() - - assert "set _sp_sys_type = " in out - assert "set _sp_tcl_roots = " in out - assert "set _sp_lmod_roots = " in out - assert "set _sp_module_prefix = " in out diff --git a/share/spack/setup-env.fish b/share/spack/setup-env.fish index d2447a26915a49..6319dadda2840a 100644 --- a/share/spack/setup-env.fish +++ b/share/spack/setup-env.fish @@ -716,19 +716,9 @@ set -xg _sp_shell "fish" -if test -z "$SPACK_SKIP_MODULES" +if test -z "$SPACK_SKIP_MODULES"; and begin; type -q module; or type -q use; end # - # Check whether we need environment-variables (module) <= `use` is not available - # - set -l need_module "no" - if not functions -q use; and not functions -q module - set need_module "yes" - end - - - - # - # Make environment-modules available to shell + # Make shell vars available to fish # function sp_apply_shell_vars -d "applies expressions of the type `a='b'` as `set a b`" @@ -740,34 +730,10 @@ if test -z "$SPACK_SKIP_MODULES" set -xg $expr_token[1] (string split ":" $expr_token[2]) end + set -l sp_shell_vars (command spack --print-shell-vars sh) - if test "$need_module" = "yes" - set -l sp_shell_vars (command spack --print-shell-vars sh,modules) - - for sp_var_expr in $sp_shell_vars - sp_apply_shell_vars $sp_var_expr - end - - # _sp_module_prefix is set by spack --print-shell-vars - if test "$_sp_module_prefix" != "not_installed" - set -xg MODULE_PREFIX $_sp_module_prefix - spack_pathadd PATH "$MODULE_PREFIX/bin" - end - - else - - set -l sp_shell_vars (command spack --print-shell-vars sh) - - for sp_var_expr in $sp_shell_vars - sp_apply_shell_vars $sp_var_expr - end - - end - - if test "$need_module" = "yes" - function module -d "wrapper for the `module` command to point at Spack's modules instance" --inherit-variable MODULE_PREFIX - eval $MODULE_PREFIX/bin/modulecmd $SPACK_SHELL $argv - end + for sp_var_expr in $sp_shell_vars + sp_apply_shell_vars $sp_var_expr end diff --git a/share/spack/setup-env.sh b/share/spack/setup-env.sh index 3a4be407ffab48..e7ab2e853459a2 100644 --- a/share/spack/setup-env.sh +++ b/share/spack/setup-env.sh @@ -309,13 +309,6 @@ else fi _spack_pathadd PATH "${_sp_prefix%/}/bin" -# -# Check whether a function of the given name is defined -# -_spack_fn_exists() { - LANG= type $1 2>&1 | grep -q 'function' -} - # Define the spack shell function with some informative no-ops, so when users # run `which spack`, they see the path to spack and where the function is from. eval "spack() { @@ -339,39 +332,9 @@ for cmd in "${SPACK_PYTHON:-}" python3 python python2; do fi done -if [ -z "${SPACK_SKIP_MODULES+x}" ]; then - need_module="no" - if ! _spack_fn_exists use && ! _spack_fn_exists module; then - need_module="yes" - fi; - - # - # make available environment-modules - # - if [ "${need_module}" = "yes" ]; then - eval `spack --print-shell-vars sh,modules` - - # _sp_module_prefix is set by spack --print-sh-vars - if [ "${_sp_module_prefix}" != "not_installed" ]; then - # activate it! - # environment-modules@4: has a bin directory inside its prefix - _sp_module_bin="${_sp_module_prefix}/bin" - if [ ! -d "${_sp_module_bin}" ]; then - # environment-modules@3 has a nested bin directory - _sp_module_bin="${_sp_module_prefix}/Modules/bin" - fi - - # _sp_module_bin and _sp_shell are evaluated here; the quoted - # eval statement and $* are deferred. - _sp_cmd="module() { eval \`${_sp_module_bin}/modulecmd ${_sp_shell} \$*\`; }" - eval "$_sp_cmd" - _spack_pathadd PATH "${_sp_module_bin}" - fi; - else - stdout="$(command spack --print-shell-vars sh)" || return - eval "$stdout" - fi; - +if [ -z "${SPACK_SKIP_MODULES+x}" ] && { type module > /dev/null 2>&1 || type use > /dev/null 2>&1; }; then + stdout="$(command spack --print-shell-vars sh)" || return + eval "$stdout" # # set module system roots From 322df1ab89d9b8b3391c1b8a3f5c7644db49432a Mon Sep 17 00:00:00 2001 From: Ryan Krattiger <80296582+kwryankrattiger@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:10:35 -0500 Subject: [PATCH 241/337] file_cache.py: use mkstemp instead of .tmp suffix (#52238) Signed-off-by: Ryan Krattiger --- lib/spack/spack/util/file_cache.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/spack/spack/util/file_cache.py b/lib/spack/spack/util/file_cache.py index 41d763b2983816..45b46c244a68d3 100644 --- a/lib/spack/spack/util/file_cache.py +++ b/lib/spack/spack/util/file_cache.py @@ -6,6 +6,7 @@ import os import pathlib import shutil +import tempfile from contextlib import contextmanager from typing import IO, Dict, Iterator, Optional, Tuple, Union @@ -25,6 +26,22 @@ def _maybe_open(path: Union[str, pathlib.Path]) -> Optional[IO[str]]: return None +def _open_temp(context_dir: Union[str, pathlib.Path]) -> Tuple[IO[str], str]: + """Open a temporary file in a directory + + This implementation minimizes the number of system calls for the case + the target directory already exists. + """ + try: + fd, path = tempfile.mkstemp(dir=context_dir) + except FileNotFoundError: + os.makedirs(context_dir, exist_ok=True) + fd, path = tempfile.mkstemp(dir=context_dir) + + stream = os.fdopen(fd, "w", encoding="utf-8") + return stream, path + + class ReadContextManager: def __init__(self, path: Union[str, pathlib.Path]) -> None: self.path = path @@ -40,19 +57,14 @@ def __exit__(self, type, value, traceback): class WriteContextManager: - def __init__(self, path: str) -> None: + def __init__(self, path: Union[str, pathlib.Path]) -> None: self.path = path - self.tmp_path = f"{self.path}.tmp" def __enter__(self) -> Tuple[Optional[IO[str]], IO[str]]: """Return (old_file, new_file) file objects, where old_file is optional.""" - self.old_file = _maybe_open(self.path) try: - try: - self.new_file = open(self.tmp_path, "w", encoding="utf-8") - except FileNotFoundError: - os.makedirs(os.path.dirname(self.path), exist_ok=True) - self.new_file = open(self.tmp_path, "w", encoding="utf-8") + self.old_file = _maybe_open(self.path) + self.new_file, self.tmp_path = _open_temp(os.path.dirname(self.path)) except PermissionError: if self.old_file: self.old_file.close() From 42a7a32ce66096903013e8ba247114efb6915c7c Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:13:48 -0400 Subject: [PATCH 242/337] ctest_log_parser.py: respect configured concurrency level (#52215) Respects the general concurrency level of Spack Prevents a bug on large Windows machines where we create a pool of > 63 thread/process handles to wait on and trip an internal win32 api limit Signed-off-by: John Parent --- lib/spack/spack/util/ctest_log_parser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index f8cfe16f2ae14f..77e7cccd10ec25 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -78,6 +78,8 @@ from contextlib import contextmanager from typing import List, Optional, TextIO, Tuple, Union +import spack.config + _error_matches = [ "^FAIL: ", "^FATAL: ", @@ -404,7 +406,7 @@ def parse( lines = [line for line in stream] if jobs is None: - jobs = multiprocessing.cpu_count() + jobs = spack.config.get("config:build_jobs", 16) # single-thread small logs if len(lines) < 10 * jobs: From ac1975157922f3d1804976ad0b6309c06e8f8c0a Mon Sep 17 00:00:00 2001 From: Alec Scott Date: Mon, 13 Apr 2026 16:14:24 -0700 Subject: [PATCH 243/337] Fix Dependabot config and add coverage requirements (#52253) Signed-off-by: Alec Scott --- .github/dependabot.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 136f798711c372..717d11cbff0e5a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,13 @@ version: 2 updates: - # Maintain dependencies for GitHub Actions - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - # Requirements to run style checks and build documentation + - package-ecosystem: "pip" directories: - - "/.github/workflows/requirements/style/*" + - "/.github/workflows/requirements/*" - "/lib/spack/docs" schedule: interval: "daily" From b4ca17b3af605ba9f8e48902af9c09497a75d200 Mon Sep 17 00:00:00 2001 From: Ryan Krattiger <80296582+kwryankrattiger@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:57:14 -0500 Subject: [PATCH 244/337] Update actions/checkout to v6 (#52252) * Update actions/checkout to v6 GHA is deprecating nodejs 20, new minimum require is nodejs 24. checkout action requires at least v5, updating to latest release (6.0.2) Signed-off-by: Ryan Krattiger * Remove labels to avoid confusing dependabot going forward Signed-off-by: Ryan Krattiger --------- Signed-off-by: Ryan Krattiger --- .github/workflows/bootstrap.yml | 10 +++++----- .github/workflows/build-containers.yml | 2 +- .github/workflows/ci.yaml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/import-check.yaml | 8 ++++---- .github/workflows/prechecks.yml | 4 ++-- .github/workflows/unit_tests.yaml | 18 +++++++++--------- 7 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 4b0177031bd1a7..b711fb6b7b4e12 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -38,7 +38,7 @@ jobs: make patch unzip which xz python3 python3-devel tree \ cmake bison - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - name: Bootstrap clingo @@ -61,7 +61,7 @@ jobs: if: ${{ matrix.runner != 'ubuntu-latest' }} run: brew install bison tree - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b @@ -93,7 +93,7 @@ jobs: sudo rm $(command -v gpg gpg2 patchelf) done - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - name: Bootstrap GnuPG @@ -122,7 +122,7 @@ jobs: sudo rm $(command -v gpg gpg2 patchelf) done - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b @@ -165,7 +165,7 @@ jobs: runs-on: "windows-latest" steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index a9728408e414e9..10e6d199a93aae 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -55,7 +55,7 @@ jobs: if: github.repository == 'spack/spack' steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Determine latest release tag id: latest diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5d5303d124b9cd..b5f17f1532f870 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,7 +25,7 @@ jobs: core: ${{ steps.filter.outputs.core }} packages: ${{ steps.filter.outputs.packages }} steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd if: ${{ github.event_name == 'push' || github.event_name == 'merge_group' }} with: fetch-depth: 0 diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a8d0f6dbc5de5e..33c658a12c63b8 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -8,7 +8,7 @@ jobs: upload: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b with: python-version: '3.14' diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index daef15204a211c..3d3866c36a3eb5 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -17,14 +17,14 @@ jobs: # PR: use the base of the PR as the old commit - name: Checkout PR base commit if: github.event_name == 'pull_request' - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: ref: ${{ github.event.pull_request.base.sha }} path: old # not a PR: use the previous commit as the old commit - name: Checkout previous commit if: github.event_name != 'pull_request' - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 2 path: old @@ -33,11 +33,11 @@ jobs: run: git -C old reset --hard HEAD^ - name: Checkout new commit - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: path: new - name: Install circular import checker - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: repository: haampie/circular-import-fighter ref: f1c56367833f3c82f6a85dc58595b2cd7995ad48 diff --git a/.github/workflows/prechecks.yml b/.github/workflows/prechecks.yml index 7c6ef270185483..841fc7b664a7a0 100644 --- a/.github/workflows/prechecks.yml +++ b/.github/workflows/prechecks.yml @@ -19,7 +19,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b with: python-version: '3.13' @@ -46,7 +46,7 @@ jobs: style: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 2 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 4f0be271397054..1f647854746c74 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -37,7 +37,7 @@ jobs: on_develop: false steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b @@ -93,7 +93,7 @@ jobs: shell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b @@ -140,7 +140,7 @@ jobs: dnf install -y \ bzip2 curl gcc-c++ gcc gcc-gfortran git gnupg2 gzip \ make patch tcl unzip which xz - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - name: Setup repo and non-root user run: | git --version @@ -163,7 +163,7 @@ jobs: clingo-cffi: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b @@ -202,7 +202,7 @@ jobs: os: [macos-15-intel, macos-latest] python-version: ["3.14"] steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b @@ -239,7 +239,7 @@ jobs: powershell Invoke-Expression -Command "./share/spack/qa/windows_test_setup.ps1"; {0} runs-on: windows-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: fetch-depth: 0 - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b @@ -271,16 +271,16 @@ jobs: steps: - name: Checkout Spack (current) - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: path: spack-current - name: Checkout Spack (previous) - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: path: spack-previous ref: ${{ github.event.pull_request.base.sha || github.event.before }} - name: Checkout Spack Packages - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd with: repository: spack/spack-packages path: spack-packages From 0ae120fbce6523a820cb0f8e48ced76eceb3c32e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:57:46 -0700 Subject: [PATCH 245/337] build(deps): bump mypy in /.github/workflows/requirements/style (#52254) Bumps [mypy](https://github.com/python/mypy) from 1.20.0 to 1.20.1. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.20.0...v1.20.1) --- updated-dependencies: - dependency-name: mypy dependency-version: 1.20.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/requirements/style/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index 6fe81760bd0819..eec7f8c866e3db 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -2,7 +2,7 @@ black==25.12.0 clingo==5.8.0 flake8==7.3.0 isort==7.0.0 -mypy==1.20.0 +mypy==1.20.1 types-six==1.17.0.20251009 vermin==1.8.0 pylint==4.0.5 From 7077069ba981e4d8c2ff1acc726407ad4e41734d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:59:33 -0700 Subject: [PATCH 246/337] build(deps): bump julia-actions/cache from 2.1.0 to 3.0.2 (#52255) Bumps [julia-actions/cache](https://github.com/julia-actions/cache) from 2.1.0 to 3.0.2. - [Release notes](https://github.com/julia-actions/cache/releases) - [Commits](https://github.com/julia-actions/cache/compare/d10a6fd8f31b12404a54613ebad242900567f2b9...9a93c5fb3e9c1c20b60fc80a478cae53e38618a4) --- updated-dependencies: - dependency-name: julia-actions/cache dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/import-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index 3d3866c36a3eb5..8380369578d417 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -12,7 +12,7 @@ jobs: - uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7 with: version: '1.10' - - uses: julia-actions/cache@d10a6fd8f31b12404a54613ebad242900567f2b9 # v2.1 + - uses: julia-actions/cache@9a93c5fb3e9c1c20b60fc80a478cae53e38618a4 # v3.0.2 # PR: use the base of the PR as the old commit - name: Checkout PR base commit From 83941b1830a562049a254ba82e05112fd5f1a414 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:00:11 -0700 Subject: [PATCH 247/337] build(deps): bump docker/setup-buildx-action from 3.8.0 to 4.0.0 (#52256) Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.8.0 to 4.0.0. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/6524bf65af31da8d45b59e8c27de4bd072b392f5...4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-containers.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 10e6d199a93aae..9a33b3dae1ffb1 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -103,7 +103,7 @@ jobs: uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf - name: Set up Docker Buildx - uses: docker/setup-buildx-action@6524bf65af31da8d45b59e8c27de4bd072b392f5 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Log in to GitHub Container Registry uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 From c306c49647bcea2787ac5c489ef32a5941d32512 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:01:06 -0700 Subject: [PATCH 248/337] build(deps): bump codecov/codecov-action from 5.1.2 to 6.0.0 (#52257) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.2 to 6.0.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/1e68e06f1dbfde0e4cefc87efeba9e4643565303...57e3a136b779b570ffcdbf80b3bdc90e7fab3de2) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 33c658a12c63b8..5a5dc737a519ea 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -28,7 +28,7 @@ jobs: - run: coverage xml - name: "Upload coverage report to CodeCov" - uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 with: verbose: true fail_ci_if_error: false From 4e89fcd7662449391c82cdc519903ee82d465671 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:02:06 -0700 Subject: [PATCH 249/337] build(deps): bump actions/stale from 9.1.0 to 10.2.0 (#52259) Bumps [actions/stale](https://github.com/actions/stale) from 9.1.0 to 10.2.0. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/5bef64f19d7facfb25b37b414482c7164d639639...b5d41d4e1d5dceea10e7104786b73624c18a190f) --- updated-dependencies: - dependency-name: actions/stale dependency-version: 10.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index 8a5c93bcb35344..d90913ac0f7bb2 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -13,7 +13,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f with: # Issues configuration stale-issue-message: > From 8112906f5fe0ed5bf89a471e40818f2b1ec6fddc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:02:48 -0700 Subject: [PATCH 250/337] build(deps): bump coverage in /.github/workflows/requirements (#52260) Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.11.0 to 7.13.5. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.11.0...7.13.5) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.13.5 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/requirements/coverage/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/requirements/coverage/requirements.txt b/.github/workflows/requirements/coverage/requirements.txt index 3ee65e02e420a8..92a66e3f04f19c 100644 --- a/.github/workflows/requirements/coverage/requirements.txt +++ b/.github/workflows/requirements/coverage/requirements.txt @@ -1 +1 @@ -coverage==7.11.0 +coverage==7.13.5 From 39f0ec12e0318f535607476259bdb68d89af6e8e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 05:37:21 +0000 Subject: [PATCH 251/337] build(deps): bump docker/login-action from 3.3.0 to 4.1.0 (#52258) Bumps [docker/login-action](https://github.com/docker/login-action) from 3.3.0 to 4.1.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/9780b0c442fbb1117ed29e0efdff1e18412f7567...4907a6ddec9925e35a0a9e82d7399ccc52663121) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-containers.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 9a33b3dae1ffb1..55c564973374cb 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -106,7 +106,7 @@ jobs: uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd - name: Log in to GitHub Container Registry - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 with: registry: ghcr.io username: ${{ github.actor }} @@ -114,7 +114,7 @@ jobs: - name: Log in to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} From e8e692e52bbc7c2b213d7ed9245427127b30e534 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 14 Apr 2026 09:12:21 +0200 Subject: [PATCH 252/337] externals: fix parsing single-valued variants (#52250) When parsing specs of externals that contain `x=y` variants, take the package.py into account to figure out whether this is a single- or multivalued variant. This fixes an issue where `build_system=foo` has a value `("foo",)` instead of `"foo"`. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/externals.py | 9 +++++++++ lib/spack/spack/test/externals.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/lib/spack/spack/externals.py b/lib/spack/spack/externals.py index aa1c66d9a15050..5c33174f60fc76 100644 --- a/lib/spack/spack/externals.py +++ b/lib/spack/spack/externals.py @@ -115,6 +115,15 @@ def complete_variants_and_architecture(node: spack.spec.Spec) -> None: if name not in node.variants: # Cannot use Spec.constrain, because we lose information on the variant type node.variants[name] = vdef.make_default() + elif ( + node.variants[name].type != vdef.variant_type + and len(node.variants[name].values) == 1 + ): + # Spec parsing defaults to MULTI for non-boolean variants. Correct the type + # using the package definition, preserving the user-specified value. + existing = node.variants[name] + corrected = vdef.make_variant(*existing.values) + node.variants.substitute(corrected) changed = True diff --git a/lib/spack/spack/test/externals.py b/lib/spack/spack/test/externals.py index a57a70297405b4..a9fbe1f10926ba 100644 --- a/lib/spack/spack/test/externals.py +++ b/lib/spack/spack/test/externals.py @@ -340,3 +340,35 @@ def test_external_node_completion( # Assert all nodes have the namespace set for node in spack.traverse.traverse_nodes(parser.all_specs()): assert node.namespace is not None + + +@pytest.mark.regression("52179") +def test_external_spec_single_valued_variant_type_is_corrected(): + """Tests that an external spec string including a single-valued variant is parsed correctly.""" + externals_dict = [ + {"spec": "dual-cmake-autotools@1.0 build_system=autotools", "prefix": "/usr/dual"} + ] + parser = ExternalSpecsParser(externals_dict, complete_node=complete_variants_and_architecture) + specs = parser.all_specs() + assert len(specs) == 1 + spec = specs[0] + + # Single-valued variants return the value, not a tuple of values + build_system_value = spec.variants["build_system"].value + assert build_system_value == "autotools", ( + f"Expected 'autotools' but got {build_system_value!r} " + f"(type: {type(build_system_value).__name__})" + ) + + +@pytest.mark.regression("52179") +def test_external_spec_multi_valued_variant_is_not_changed(): + """Tests that multi-valued variants in external specs are preserved as they are, even if the + definition in package.py says otherwise. + """ + # Package.py prescribes a single-valued variant in this case + externals_dict = [{"spec": "variant-values@1.0 v=foo,bar", "prefix": "/usr/variant-values"}] + parser = ExternalSpecsParser(externals_dict, complete_node=complete_variants_and_architecture) + specs = parser.all_specs() + assert len(specs) == 1 + assert specs[0].variants["v"].value == ("bar", "foo") From 698572eee8aa447a4e05a042aeb61fd11b3416ba Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 14 Apr 2026 09:56:57 +0200 Subject: [PATCH 253/337] solver: fix weights for unshifted priorities (#52267) Without this the weight for "build unification sets" is duplicated and the report from: ``` spack solve ... ``` may print the weights in a wrong way. This commit does not affect the solution, only how it's reported by `spack solve`. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/concretize.lp | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/spack/spack/solver/concretize.lp b/lib/spack/spack/solver/concretize.lp index d31f3269fb90d2..50b41178d110a3 100644 --- a/lib/spack/spack/solver/concretize.lp +++ b/lib/spack/spack/solver/concretize.lp @@ -2103,21 +2103,21 @@ opt_criterion(310, "requirement weight"). }. % Try hard to reuse installed packages (i.e., minimize the number built) -opt_criterion(110, "number of packages to build (vs. reuse)"). -#minimize { 0@110: #true }. -#minimize { 1@110,PackageNode : build(PackageNode), not treat_node_as_concrete(PackageNode) }. +opt_criterion(120, "number of packages to build (vs. reuse)"). +#minimize { 0@120: #true }. +#minimize { 1@120,PackageNode : build(PackageNode), not treat_node_as_concrete(PackageNode) }. -opt_criterion(100, "number of nodes from the same package"). -#minimize { 0@100: #true }. -#minimize { ID@100,Package : attr("node", node(ID, Package)), not self_build_requirement(_, node(ID, Package)) }. -#minimize { ID@100,Package : attr("virtual_node", node(ID, Package)) }. +opt_criterion(110, "number of nodes from the same package"). +#minimize { 0@110: #true }. +#minimize { ID@110,Package : attr("node", node(ID, Package)), not self_build_requirement(_, node(ID, Package)) }. +#minimize { ID@110,Package : attr("virtual_node", node(ID, Package)) }. #defined optimize_for_reuse/0. % Minimize the unification set ID used for build dependencies. This reduces the number of optimal % solutions that differ only by which node belongs to which unification set. -opt_criterion(90, "build unification sets"). -#minimize{ 0@90: #true }. -#minimize{ ID@90,ParentNode : build_set_id(ParentNode, ID) }. +opt_criterion(100, "build unification sets"). +#minimize{ 0@100: #true }. +#minimize{ ID@100,ParentNode : build_set_id(ParentNode, ID) }. % Minimize the number of deprecated versions being used opt_criterion(73, "deprecated versions used"). From c330943f88a562cc0371897a619c339bb64eb427 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 14 Apr 2026 10:07:52 +0200 Subject: [PATCH 254/337] test: fix chmod issue (#52230) pytest fails to delete the files if the dir has 0o600 permissions, which then end up in garbage dirs forever. Signed-off-by: Harmen Stoppels --- lib/spack/spack/test/cmd/external.py | 37 +++++++++++++++------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/lib/spack/spack/test/cmd/external.py b/lib/spack/spack/test/cmd/external.py index 8c0d06d3bc192a..57f6d0b1e2ee79 100644 --- a/lib/spack/spack/test/cmd/external.py +++ b/lib/spack/spack/test/cmd/external.py @@ -321,16 +321,6 @@ def test_failures_in_scanning_do_not_result_in_an_error( mock_executable, monkeypatch, mutable_config ): """Tests that scanning paths with wrong permissions, won't cause `external find` to error.""" - versions = {"first": "3.19.1", "second": "3.23.3"} - - @classmethod - def _determine_version(cls, exe): - bin_parent = os.path.dirname(exe).split(os.sep)[-2] - return versions[bin_parent] - - cmake_cls = spack.repo.PATH.get_pkg_class("cmake") - monkeypatch.setattr(cmake_cls, "determine_version", _determine_version) - cmake_exe1 = mock_executable( "cmake", output="echo cmake version 3.19.1", subdir=("first", "bin") ) @@ -338,18 +328,31 @@ def _determine_version(cls, exe): "cmake", output="echo cmake version 3.23.3", subdir=("second", "bin") ) - # Remove access from the first directory executable - cmake_exe1.parent.chmod(0o600) + @classmethod + def _determine_version(cls, exe): + name = pathlib.Path(exe).parent.parent.name + if name == "first": + return "3.19.1" + elif name == "second": + return "3.23.3" + assert False, f"Unexpected exe path {exe}" - value = os.pathsep.join([str(cmake_exe1.parent), str(cmake_exe2.parent)]) - monkeypatch.setenv("PATH", value) + cmake_cls = spack.repo.PATH.get_pkg_class("cmake") + monkeypatch.setattr(cmake_cls, "determine_version", _determine_version) + monkeypatch.setenv("PATH", f"{cmake_exe1.parent}{os.pathsep}{cmake_exe2.parent}") + + try: + # Remove access from the first directory executable + cmake_exe1.parent.chmod(0o600) + output = external("find", "cmake") + finally: + cmake_exe1.parent.chmod(0o700) - output = external("find", "cmake") assert external.returncode == 0 assert "The following specs have been" in output assert "cmake" in output - for vers in versions.values(): - assert vers in output + assert "3.19.1" in output + assert "3.23.3" in output def test_detect_virtuals(mock_executable, mutable_config, monkeypatch): From 410e216ecec13fd1f5747d9fd9741941be252cdf Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 14 Apr 2026 10:38:46 +0200 Subject: [PATCH 255/337] requirements.py: warn on `prefer:all:[%foo]` (#52142) Since `packages:all:prefer:[%c=gcc]` and similar typically don't mean what the user intended, issue a warning when concretizing. Signed-off-by: Harmen Stoppels --- lib/spack/spack/solver/requirements.py | 65 +++++++++++++++++++++++++- lib/spack/spack/test/error_messages.py | 8 ++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/solver/requirements.py b/lib/spack/spack/solver/requirements.py index 9b2598f5b7c8eb..394b0dccbe5a0c 100644 --- a/lib/spack/spack/solver/requirements.py +++ b/lib/spack/spack/solver/requirements.py @@ -2,7 +2,8 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) import enum -from typing import List, NamedTuple, Optional, Sequence, Tuple +import warnings +from typing import List, NamedTuple, Optional, Sequence, Tuple, Union import spack.config import spack.error @@ -11,6 +12,7 @@ import spack.spec import spack.spec_parser import spack.traverse +import spack.util.spack_yaml from spack.enums import PropagationPolicy from spack.llnl.util import tty from spack.util.spack_yaml import get_mark_from_yaml_data @@ -106,6 +108,7 @@ def __init__(self, configuration: spack.config.Configuration): self.compiler_pkgs = spack.repo.PATH.packages_with_tags("compiler") self.preferences_from_input: List[Tuple[spack.spec.Spec, str]] = [] self.toolchains = configuration.get_config("toolchains") + self._warned_compiler_all: set = set() def _parse_and_expand(self, string: str, *, named: bool = False) -> spack.spec.Spec: result = parse_spec_from_yaml_string(string, named=named) @@ -183,6 +186,9 @@ def _rules_from_preferences( ) -> List[RequirementRule]: result = [] for item in preferences: + if kind == RequirementKind.DEFAULT: + # Warn about %gcc type of preferences under `all`. + self._maybe_warn_compiler_in_all(item, "prefer") spec, condition, msg = self._parse_prefer_conflict_item(item) result.append( preference(pkg_name, constraint=spec, condition=condition, kind=kind, message=msg) @@ -254,6 +260,10 @@ def _rules_from_requirements( constraints = [constraints] policy = "one_of" + if kind == RequirementKind.DEFAULT: + # Warn about %gcc type of requirements under `all`. + self._maybe_warn_compiler_in_all(constraints, "require") + # validate specs from YAML first, and fail with line numbers if parsing fails. constraints = [ self._parse_and_expand(constraint, named=kind == RequirementKind.VIRTUAL) @@ -314,6 +324,59 @@ def reject_requirement_constraint( return True return False + def _maybe_warn_compiler_in_all(self, items: Union[str, list, dict], section: str) -> None: + """Warn once if a packages:all: prefer/require entry has compiler dependencies.""" + # Stick to single items, not complex one_of / any_of groups to keep things simple. + if isinstance(items, str): + spec_str = items + elif isinstance(items, dict) and "spec" in items and isinstance(items["spec"], str): + spec_str = items["spec"] + elif isinstance(items, list) and len(items) == 1 and isinstance(items[0], str): + spec_str = items[0] + else: + return + if spec_str in self._warned_compiler_all: + return + self._warned_compiler_all.add(spec_str) + suggestions = [] + for edge in self._parse_and_expand(spec_str).edges_to_dependencies(): + if edge.when != spack.spec.EMPTY_SPEC: + # Conditional dependencies are fine (includes toolchains after expansion). + continue + elif edge.virtuals: + # The case `%c,cxx=gcc` or similar. + keys = edge.virtuals + comment = "" + elif edge.spec.name in self.compiler_pkgs: + # Just a package `%gcc`. + keys = ("c",) + comment = "# For each language virtual (c, cxx, fortran, ...):\n" + else: + # Maybe %mpich or so? Just give a generic suggestion. + keys = ("",) + comment = "# For each virtual:\n" + data = {"packages": {k: {section: [str(edge.spec)]} for k in keys}} + suggestion = spack.util.spack_yaml.dump(data).rstrip() + suggestions.append(f"{comment}{suggestion}") + if suggestions: + mark = get_mark_from_yaml_data(spec_str) + location = f"{mark.name}:{mark.line + 1}: " if mark else "" + prefix = ( + f"{location}'packages: all: {section}: [\"{spec_str}\"]' applies a dependency " + f"constraint to all packages" + ) + suffix = "Consider instead:\n" + "\n".join(suggestions) + if section == "prefer": + warnings.warn( + f"{prefix}. This can lead to unexpected concretizations. This was likely " + f"intended as a preference for a provider of a (language) virtual. {suffix}" + ) + else: + warnings.warn( + f"{prefix}. This often leads to concretization errors. This was likely " + f"intended as a requirement for a provider of a (language) virtual. {suffix}" + ) + def _split_edge_on_virtuals(edge: spack.spec.DependencySpec) -> List[spack.spec.Spec]: """Split the edge on virtuals and removes the parent.""" diff --git a/lib/spack/spack/test/error_messages.py b/lib/spack/spack/test/error_messages.py index 795145303d6888..b03b5f5deff2f6 100644 --- a/lib/spack/spack/test/error_messages.py +++ b/lib/spack/spack/test/error_messages.py @@ -540,3 +540,11 @@ def test_errmsg_requirements_external_mismatch(concretize_scope, test_repo): with expect_failure_and_print(should_mention=important_points): concretize_one("t1") + + +@pytest.mark.parametrize("section", ["prefer", "require"]) +def test_warns_on_compiler_constraint_in_all(concretize_scope, mock_packages, section): + """Compiler constraints under packages:all: are a footgun and should warn.""" + update_packages_config(f"packages:\n all:\n {section}:\n - '%c=gcc'\n") + with pytest.warns(UserWarning, match="packages: all:"): + concretize_one("gmake") From 804a031ee5399bdeb831c297329603220de6e687 Mon Sep 17 00:00:00 2001 From: Alec Scott Date: Tue, 14 Apr 2026 08:59:02 -0700 Subject: [PATCH 256/337] spack repo: render help docs for sub-commands like spack ci (#52159) Signed-off-by: Alec Scott --- lib/spack/spack/cmd/repo.py | 43 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/lib/spack/spack/cmd/repo.py b/lib/spack/spack/cmd/repo.py index 58b929f61195ff..d085c56444e38e 100644 --- a/lib/spack/spack/cmd/repo.py +++ b/lib/spack/spack/cmd/repo.py @@ -23,6 +23,8 @@ from spack.error import SpackError from spack.llnl.util.tty import color +from . import doc_dedented, doc_first_line + description = "manage package source repositories" section = "config" level = "long" @@ -32,7 +34,9 @@ def setup_parser(subparser: argparse.ArgumentParser): sp = subparser.add_subparsers(metavar="SUBCOMMAND", dest="repo_command") # Create - create_parser = sp.add_parser("create", help=repo_create.__doc__) + create_parser = sp.add_parser( + "create", description=doc_dedented(repo_create), help=doc_first_line(repo_create) + ) create_parser.add_argument("directory", help="directory to create the repo in") create_parser.add_argument( "namespace", help="name or namespace to identify packages in the repository" @@ -48,7 +52,9 @@ def setup_parser(subparser: argparse.ArgumentParser): ) # List - list_parser = sp.add_parser("list", aliases=["ls"], help=repo_list.__doc__) + list_parser = sp.add_parser( + "list", aliases=["ls"], description=doc_dedented(repo_list), help=doc_first_line(repo_list) + ) list_parser.add_argument( "--scope", action=arguments.ConfigScope, @@ -65,7 +71,9 @@ def setup_parser(subparser: argparse.ArgumentParser): ) # Add - add_parser = sp.add_parser("add", help=repo_add.__doc__) + add_parser = sp.add_parser( + "add", description=doc_dedented(repo_add), help=doc_first_line(repo_add) + ) add_parser.add_argument( "path_or_repo", help="path or git repository of a Spack package repository" ) @@ -96,7 +104,9 @@ def setup_parser(subparser: argparse.ArgumentParser): ) # Set (modify existing repository configuration) - set_parser = sp.add_parser("set", help=repo_set.__doc__) + set_parser = sp.add_parser( + "set", description=doc_dedented(repo_set), help=doc_first_line(repo_set) + ) set_parser.add_argument("namespace", help="namespace of a Spack package repository") set_parser.add_argument( "--destination", help="destination to clone git repository into", action="store" @@ -116,7 +126,12 @@ def setup_parser(subparser: argparse.ArgumentParser): ) # Remove - remove_parser = sp.add_parser("remove", help=repo_remove.__doc__, aliases=["rm"]) + remove_parser = sp.add_parser( + "remove", + description=doc_dedented(repo_remove), + help=doc_first_line(repo_remove), + aliases=["rm"], + ) remove_parser.add_argument( "namespace_or_path", help="namespace or path of a Spack package repository" ) @@ -131,7 +146,9 @@ def setup_parser(subparser: argparse.ArgumentParser): ) # Migrate - migrate_parser = sp.add_parser("migrate", help=repo_migrate.__doc__) + migrate_parser = sp.add_parser( + "migrate", description=doc_dedented(repo_migrate), help=doc_first_line(repo_migrate) + ) migrate_parser.add_argument( "namespace_or_path", help="path to a Spack package repository directory" ) @@ -148,7 +165,9 @@ def setup_parser(subparser: argparse.ArgumentParser): ) # Update - update_parser = sp.add_parser("update", help=repo_update.__doc__) + update_parser = sp.add_parser( + "update", description=doc_dedented(repo_update), help=doc_first_line(repo_update) + ) update_parser.add_argument("names", nargs="*", default=[], help="repositories to update") update_parser.add_argument( "--remote", @@ -311,14 +330,6 @@ def repo_list(args): List all package repositories known to Spack. Repositories can be local directories or remote git repositories. - - The output can be filtered by: - --scope= to list repositories from a specific scope - - The output format can be controlled using one of: - --names to show only configuration names - --namespaces to show only repository namespaces - --json to output repositories as machine-readable json records """ descriptors = spack.repo.RepoDescriptors.from_config( lock=spack.repo.package_repository_lock(), config=spack.config.CONFIG, scope=args.scope @@ -399,7 +410,7 @@ def repo_list(args): def _get_repo(name_or_path: str) -> Optional[spack.repo.Repo]: - """Get a repo by path or namespace""" + """get a repo by path or namespace""" try: return spack.repo.from_path(name_or_path) except spack.repo.RepoError: From dce16ac37577f705033f360581a39987e109ced8 Mon Sep 17 00:00:00 2001 From: Ryan Krattiger <80296582+kwryankrattiger@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:36:48 -0500 Subject: [PATCH 257/337] Update checkout action (#52272) * Update actions/checkout to v6 GHA is deprecating nodejs 20, new minimum require is nodejs 24. checkout action requires at least v5, updating to latest release (6.0.2) Signed-off-by: Ryan Krattiger * Remove labels to avoid confusing dependabot going forward Signed-off-by: Ryan Krattiger * Label action versions Signed-off-by: Ryan Krattiger --------- Signed-off-by: Ryan Krattiger --- .github/workflows/bootstrap.yml | 16 +++++------ .github/workflows/build-containers.yml | 18 ++++++------ .github/workflows/ci.yaml | 4 +-- .github/workflows/coverage.yml | 6 ++-- .github/workflows/import-check.yaml | 8 +++--- .github/workflows/prechecks.yml | 8 +++--- .github/workflows/stale.yaml | 2 +- .github/workflows/triage.yml | 2 +- .github/workflows/unit_tests.yaml | 38 +++++++++++++------------- 9 files changed, 51 insertions(+), 51 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index b711fb6b7b4e12..9038c95c75e316 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -38,7 +38,7 @@ jobs: make patch unzip which xz python3 python3-devel tree \ cmake bison - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Bootstrap clingo @@ -61,10 +61,10 @@ jobs: if: ${{ matrix.runner != 'ubuntu-latest' }} run: brew install bison tree - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" - name: Bootstrap clingo @@ -93,7 +93,7 @@ jobs: sudo rm $(command -v gpg gpg2 patchelf) done - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Bootstrap GnuPG @@ -122,10 +122,10 @@ jobs: sudo rm $(command -v gpg gpg2 patchelf) done - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: | 3.8 @@ -165,10 +165,10 @@ jobs: runs-on: "windows-latest" steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: "3.12" - name: Setup Windows diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 55c564973374cb..05be7b4926fe56 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -55,7 +55,7 @@ jobs: if: github.repository == 'spack/spack' steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Determine latest release tag id: latest @@ -63,7 +63,7 @@ jobs: git fetch --quiet --tags echo "tag=$(git tag --list --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)" | tee -a $GITHUB_OUTPUT - - uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 + - uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1 id: docker_meta with: images: | @@ -94,19 +94,19 @@ jobs: fi - name: Upload Dockerfile - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b + uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: dockerfiles_${{ matrix.dockerfile[0] }} path: dockerfiles - name: Set up QEMU - uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf + uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -114,13 +114,13 @@ jobs: - name: Log in to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build & Deploy ${{ matrix.dockerfile[0] }} - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 + uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 with: context: dockerfiles/${{ matrix.dockerfile[0] }} platforms: ${{ matrix.dockerfile[1] }} @@ -133,7 +133,7 @@ jobs: needs: deploy-images steps: - name: Merge Artifacts - uses: actions/upload-artifact/merge@6f51ac03b9356f520e9adb1b1b7802705f340c2b + uses: actions/upload-artifact/merge@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: dockerfiles pattern: dockerfiles_* diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b5f17f1532f870..67c525abdbedda 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,12 +25,12 @@ jobs: core: ${{ steps.filter.outputs.core }} packages: ${{ steps.filter.outputs.packages }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ github.event_name == 'push' || github.event_name == 'merge_group' }} with: fetch-depth: 0 # For pull requests it's not necessary to checkout the code - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 id: filter with: # For merge group events, compare against the target branch (main) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5a5dc737a519ea..a9cbe1baded540 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -8,8 +8,8 @@ jobs: upload: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: '3.14' @@ -17,7 +17,7 @@ jobs: run: pip install -r .github/workflows/requirements/coverage/requirements.txt - name: Download coverage artifact files - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 with: pattern: coverage-* path: coverage diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index 8380369578d417..1ae2a18cd12bfc 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -17,14 +17,14 @@ jobs: # PR: use the base of the PR as the old commit - name: Checkout PR base commit if: github.event_name == 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.event.pull_request.base.sha }} path: old # not a PR: use the previous commit as the old commit - name: Checkout previous commit if: github.event_name != 'pull_request' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 path: old @@ -33,11 +33,11 @@ jobs: run: git -C old reset --hard HEAD^ - name: Checkout new commit - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: new - name: Install circular import checker - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: haampie/circular-import-fighter ref: f1c56367833f3c82f6a85dc58595b2cd7995ad48 diff --git a/.github/workflows/prechecks.yml b/.github/workflows/prechecks.yml index 841fc7b664a7a0..1752abe5a71a75 100644 --- a/.github/workflows/prechecks.yml +++ b/.github/workflows/prechecks.yml @@ -19,8 +19,8 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: '3.13' - name: Install Python Packages @@ -46,10 +46,10 @@ jobs: style: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: '3.13' - name: Install Python packages diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index d90913ac0f7bb2..c37c363829e010 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -13,7 +13,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: # Issues configuration stale-issue-message: > diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 9fbc25c8e62069..66611cb8cdc1b2 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -19,4 +19,4 @@ jobs: pull-requests: write issues: write steps: - - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b + - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 1f647854746c74..7a2724a2f72924 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -37,10 +37,10 @@ jobs: on_develop: false steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: ${{ matrix.python-version }} - name: Install System packages @@ -84,7 +84,7 @@ jobs: UNIT_TEST_COVERAGE: ${{ matrix.python-version == '3.14' }} run: | share/spack/qa/run-unit-tests - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b + - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: coverage-${{ matrix.os }}-python${{ matrix.python-version }} path: coverage @@ -93,10 +93,10 @@ jobs: shell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: '3.11' - name: Install System packages @@ -123,7 +123,7 @@ jobs: COVERAGE: true run: | share/spack/qa/run-shell-tests - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b + - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: coverage-shell path: coverage @@ -140,7 +140,7 @@ jobs: dnf install -y \ bzip2 curl gcc-c++ gcc gcc-gfortran git gnupg2 gzip \ make patch tcl unzip which xz - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup repo and non-root user run: | git --version @@ -163,10 +163,10 @@ jobs: clingo-cffi: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: '3.13' - name: Install System packages @@ -189,7 +189,7 @@ jobs: spack bootstrap status spack solve zlib pytest --verbose --cov --cov-config=pyproject.toml --cov-report=xml:coverage.xml -x -n3 lib/spack/spack/test/concretization/core.py - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b + - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: coverage-clingo-cffi path: coverage @@ -202,10 +202,10 @@ jobs: os: [macos-15-intel, macos-latest] python-version: ["3.14"] steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: ${{ matrix.python-version }} - name: Install Python packages @@ -226,7 +226,7 @@ jobs: spack bootstrap disable spack-install spack solve zlib python3 -m pytest --verbose --cov --cov-config=pyproject.toml --cov-report=xml:coverage.xml --dist loadfile -x -n4 - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b + - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: coverage-${{ matrix.os }}-python${{ matrix.python-version }} path: coverage @@ -239,10 +239,10 @@ jobs: powershell Invoke-Expression -Command "./share/spack/qa/windows_test_setup.ps1"; {0} runs-on: windows-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: python-version: '3.14' - name: Install Python packages @@ -257,7 +257,7 @@ jobs: run: | python -m pytest -x --verbose --cov --cov-config=pyproject.toml ./share/spack/qa/validate_last_exit.ps1 - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b + - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 with: name: coverage-windows path: coverage @@ -271,16 +271,16 @@ jobs: steps: - name: Checkout Spack (current) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: spack-current - name: Checkout Spack (previous) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: path: spack-previous ref: ${{ github.event.pull_request.base.sha || github.event.before }} - name: Checkout Spack Packages - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: spack/spack-packages path: spack-packages From 61cbce392bcd950981bd909af541637e1de477fe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:15:50 -0700 Subject: [PATCH 258/337] build(deps): bump docker/setup-qemu-action from 3.2.0 to 4.0.0 (#52277) Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3.2.0 to 4.0.0. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/49b3bc8e6bdd4a60e6116a5414239cba5943d3cf...ce360397dd3f832beb865e1373c09c0e9f86d70a) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-containers.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 05be7b4926fe56..e1706355c8786d 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -100,7 +100,7 @@ jobs: path: dockerfiles - name: Set up QEMU - uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 From 5f1050c30a0c03061340cb4a8f42246c371747e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:16:23 -0700 Subject: [PATCH 259/337] build(deps): bump docker/metadata-action from 5.6.1 to 6.0.0 (#52276) Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5.6.1 to 6.0.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/369eb591f429131d6889c46b94e711f089e6ca96...030e881283bb7a6894de51c315a6bfe6a94e05cf) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-containers.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index e1706355c8786d..452256b1d0c648 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -63,7 +63,7 @@ jobs: git fetch --quiet --tags echo "tag=$(git tag --list --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1)" | tee -a $GITHUB_OUTPUT - - uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96 # v5.6.1 + - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0 id: docker_meta with: images: | From 94091199e017890c2e89f9c5b21bf682a664c27b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:17:54 -0700 Subject: [PATCH 260/337] build(deps): bump actions/upload-artifact from 4.5.0 to 7.0.1 (#52273) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.5.0 to 7.0.1. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/6f51ac03b9356f520e9adb1b1b7802705f340c2b...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-containers.yml | 4 ++-- .github/workflows/unit_tests.yaml | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 452256b1d0c648..503b5428e1b282 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -94,7 +94,7 @@ jobs: fi - name: Upload Dockerfile - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dockerfiles_${{ matrix.dockerfile[0] }} path: dockerfiles @@ -133,7 +133,7 @@ jobs: needs: deploy-images steps: - name: Merge Artifacts - uses: actions/upload-artifact/merge@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + uses: actions/upload-artifact/merge@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dockerfiles pattern: dockerfiles_* diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 7a2724a2f72924..794d7242411151 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -84,7 +84,7 @@ jobs: UNIT_TEST_COVERAGE: ${{ matrix.python-version == '3.14' }} run: | share/spack/qa/run-unit-tests - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.os }}-python${{ matrix.python-version }} path: coverage @@ -123,7 +123,7 @@ jobs: COVERAGE: true run: | share/spack/qa/run-shell-tests - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-shell path: coverage @@ -189,7 +189,7 @@ jobs: spack bootstrap status spack solve zlib pytest --verbose --cov --cov-config=pyproject.toml --cov-report=xml:coverage.xml -x -n3 lib/spack/spack/test/concretization/core.py - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-clingo-cffi path: coverage @@ -226,7 +226,7 @@ jobs: spack bootstrap disable spack-install spack solve zlib python3 -m pytest --verbose --cov --cov-config=pyproject.toml --cov-report=xml:coverage.xml --dist loadfile -x -n4 - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-${{ matrix.os }}-python${{ matrix.python-version }} path: coverage @@ -257,7 +257,7 @@ jobs: run: | python -m pytest -x --verbose --cov --cov-config=pyproject.toml ./share/spack/qa/validate_last_exit.ps1 - - uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-windows path: coverage From 1cb6b2f97b42d33f8ad8ff9975d54afc57db1317 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:18:21 -0700 Subject: [PATCH 261/337] build(deps): bump dorny/paths-filter from 3.0.2 to 4.0.1 (#52275) Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 3.0.2 to 4.0.1. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/de90cc6fb38fc0963ad72b210f1f284cd68cea36...fbd0ab8f3e69293af611ebaee6363fc25e6d187d) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 67c525abdbedda..2fd6c466ce814d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -30,7 +30,7 @@ jobs: with: fetch-depth: 0 # For pull requests it's not necessary to checkout the code - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: # For merge group events, compare against the target branch (main) From 346bfdc5d02f8cf3f71ebac5d1856bed8347c72c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:18:49 -0700 Subject: [PATCH 262/337] build(deps): bump docker/build-push-action from 6.10.0 to 7.1.0 (#52274) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.10.0 to 7.1.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/48aba3b46d1b1fec4febb7c5d0c644b249a11355...bcafcacb16a39f128d818304e6c9c0c18556b85f) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build-containers.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml index 503b5428e1b282..8ba6287783114c 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/build-containers.yml @@ -120,7 +120,7 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build & Deploy ${{ matrix.dockerfile[0] }} - uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: dockerfiles/${{ matrix.dockerfile[0] }} platforms: ${{ matrix.dockerfile[1] }} From bbc543bd2a391b8bdd8282d4e8ded72562baa0de Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 15 Apr 2026 11:37:07 +0200 Subject: [PATCH 263/337] ctest_log_parser.py: faster and sequential (#52249) * make the log parser sequential and streaming, while running faster than the previous multithreaded version * fix an issue when the log contains invalid UTF-8 * fix a bug where the parser called `line.strip()`, which drops leading whitespace, which is necessary for the ascii underlines or pointers used by compilers: ``` foo bar ^^^ ``` * deprecate the `spack log-parser -j` flag The changes to the regexes make the sequential case run 2.5x faster than the `-j6` case previously. The idea is to give all regexes a positive anchor, instead of starting with something negative like `[^ :]`. The latter results in many attempts to match, that fail on the second byte, which comes with a large performance penalty. The cost is up to 80x. The new installer benefits from a sequential version too, since it does log parsing as part of a concurrent build loop. Signed-off-by: Harmen Stoppels --- lib/spack/spack/cmd/log_parse.py | 27 +++-- lib/spack/spack/test/util/log_parser.py | 41 +++++++ lib/spack/spack/util/ctest_log_parser.py | 144 +++++++++++------------ lib/spack/spack/util/log_parse.py | 9 +- share/spack/spack-completion.fish | 1 - 5 files changed, 124 insertions(+), 98 deletions(-) diff --git a/lib/spack/spack/cmd/log_parse.py b/lib/spack/spack/cmd/log_parse.py index 910908c246329d..b0444ac8c558ec 100644 --- a/lib/spack/spack/cmd/log_parse.py +++ b/lib/spack/spack/cmd/log_parse.py @@ -3,7 +3,9 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import argparse +import io import sys +import warnings import spack.llnl.util.tty as tty from spack.util.log_parse import make_log_context, parse_log_events @@ -45,13 +47,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: help="wrap width: auto-size to terminal by default; 0 for no wrap", ) subparser.add_argument( - "-j", - "--jobs", - action="store", - type=int, - default=None, - help="number of jobs to parse log file (default: 1 for short logs, " - "ncpus for long logs)", + "-j", "--jobs", action="store", type=int, default=None, help=argparse.SUPPRESS ) subparser.add_argument("file", help="a log file containing build output, or - for stdin") @@ -60,9 +56,14 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def log_parse(parser, args): input = args.file if args.file == "-": - input = sys.stdin + input = io.TextIOWrapper( + sys.stdin.buffer, encoding="utf-8", errors="replace", closefd=False + ) + + if args.jobs is not None: + warnings.warn("The --jobs option is deprecated and will be removed in Spack v1.3") - errors, warnings = parse_log_events(input, args.context, args.jobs, args.profile) + log_errors, log_warnings = parse_log_events(input, args.context, args.profile) if args.profile: return @@ -73,10 +74,10 @@ def log_parse(parser, args): events = [] if "errors" in types: - events.extend(errors) - print("%d errors" % len(errors)) + events.extend(log_errors) + print("%d errors" % len(log_errors)) if "warnings" in types: - events.extend(warnings) - print("%d warnings" % len(warnings)) + events.extend(log_warnings) + print("%d warnings" % len(log_warnings)) print(make_log_context(events, args.width)) diff --git a/lib/spack/spack/test/util/log_parser.py b/lib/spack/spack/test/util/log_parser.py index be933b725e0fda..768c7bf52da422 100644 --- a/lib/spack/spack/test/util/log_parser.py +++ b/lib/spack/spack/test/util/log_parser.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) +import io import pathlib from spack.util.ctest_log_parser import CTestLogParser @@ -35,3 +36,43 @@ def test_log_parser(tmp_path: pathlib.Path): assert len(warnings) == 1 assert all(w.text.endswith("W") for w in warnings) + + +def test_log_parser_stream(): + """parse() accepts a file-like object.""" + log = io.StringIO( + "error: weird_error.c:145: something weird happened E\n" + "checking for gcc... irrelevant line\n" + "/var/tmp/build/foo.py:60: warning: some weird warning W\n" + ) + parser = CTestLogParser() + errors, warnings = parser.parse(log) + + assert len(errors) == 1 + assert errors[0].text.endswith("E") + assert len(warnings) == 1 + assert warnings[0].text.endswith("W") + + +def test_log_parser_preserves_leading_whitespace(): + """Leading whitespace (e.g. compiler caret underlines) must not be stripped.""" + log = io.StringIO( + "/path/to/file.c:10: error: use of undeclared identifier 'x'\n" + " int y = x + 1;\n" + " ^\n" + ) + parser = CTestLogParser() + errors, _ = parser.parse(log, context=6) + + assert len(errors) == 1 + assert errors[0].post_context[0] == " int y = x + 1;" + assert errors[0].post_context[1] == " ^" + + +def test_log_parser_non_utf8_bytes(tmp_path: pathlib.Path): + """parse() does not raise UnicodeDecodeError on non-UTF-8 log files.""" + log_file = tmp_path / "log.bin" + log_file.write_bytes(b"checking things...\nerror: \x80\xff something broke\ndone\n") + parser = CTestLogParser() + errors, _ = parser.parse(str(log_file)) + assert len(errors) == 1 diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index 77e7cccd10ec25..1fecd0cf82444f 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -70,15 +70,11 @@ """ import io import math -import multiprocessing import re -import sys -import threading import time +from collections import deque from contextlib import contextmanager -from typing import List, Optional, TextIO, Tuple, Union - -import spack.config +from typing import List, TextIO, Tuple, Union _error_matches = [ "^FAIL: ", @@ -89,21 +85,22 @@ "^[Bb]us [Ee]rror", "^[Ss]egmentation [Vv]iolation", "^[Ss]egmentation [Ff]ault", - ":.*[Pp]ermission [Dd]enied", - "[^ :]:[0-9]+: [^ \\t]", - "[^:]: error[ \\t]*[0-9]+[ \\t]*:", + "Permission [Dd]enied", + "permission [Dd]enied", + ":[0-9]+: [^ \\t]", + ": error[ \\t]*[0-9]+[ \\t]*:", "^Error ([0-9]+):", "^Fatal", "^[Ee]rror: ", "^Error ", - "[0-9] ERROR: ", + " ERROR: ", '^"[^"]+", line [0-9]+: [^Ww]', "^cc[^C]*CC: ERROR File = ([^,]+), Line = ([0-9]+)", "^ld([^:])*:([ \\t])*ERROR([^:])*:", "^ild:([ \\t])*\\(undefined symbol\\)", - "[^ :] : (error|fatal error|catastrophic error)", - "[^:]: (Error:|error|undefined reference|multiply defined)", - "[^:]\\([^\\)]+\\) ?: (error|fatal error|catastrophic error)", + ": (error|fatal error|catastrophic error)", + ": (Error:|error|undefined reference|multiply defined)", + "\\([^\\)]+\\) ?: (error|fatal error|catastrophic error)", "^fatal error C[0-9]+:", ": syntax error ", "^collect2: ld returned 1 exit status", @@ -154,28 +151,27 @@ " ok", "Note:", ":[ \\t]+Where:", - "[^ :]:[0-9]+: Warning", + ":[0-9]+: Warning", "------ Build started: .* ------", ] #: Regexes to match file/line numbers in error/warning messages _warning_matches = [ - "[^ :]:[0-9]+: warning:", - "[^ :]:[0-9]+: note:", + ":[0-9]+: warning:", + ":[0-9]+: note:", "^cc[^C]*CC: WARNING File = ([^,]+), Line = ([0-9]+)", "^ld([^:])*:([ \\t])*WARNING([^:])*:", - "[^:]: warning [0-9]+:", + ": warning [0-9]+:", '^"[^"]+", line [0-9]+: [Ww](arning|arnung)', - "[^:]: warning[ \\t]*[0-9]+[ \\t]*:", + ": warning[ \\t]*[0-9]+[ \\t]*:", "^(Warning|Warnung) ([0-9]+):", "^(Warning|Warnung)[ :]", "WARNING: ", - "[^ :] : warning", - "[^:]: warning", + ": warning", '", line [0-9]+\\.[0-9]+: [0-9]+-[0-9]+ \\([WI]\\)', "^cxx: Warning:", "file: .* has no symbols", - "[^ :]:[0-9]+: (Warning|Warnung)", + ":[0-9]+: (Warning|Warnung)", "\\([0-9]*\\): remark #[0-9]*", '".*", line [0-9]+: remark\\([0-9]*\\):', "cc-[0-9]* CC: REMARK File = .*, Line = [0-9]*", @@ -312,7 +308,7 @@ def _profile_match(matches, exceptions, line, match_times, exc_times): return True -def _parse(lines, offset, profile): +def _parse(stream, profile, context): def compile(regex_array): return [re.compile(regex) for regex in regex_array] @@ -335,28 +331,63 @@ def compile(regex_array): errors = [] warnings = [] - for i, line in enumerate(lines): + # rolling window of recent lines + pre_context = deque(maxlen=context) + # list of (event, remaining_post_context_lines) + pending_events: List[Tuple[LogEvent, int]] = [] + + for i, line in enumerate(stream): + rstripped_line = line.rstrip() + + # feed this line into every event still collecting post_context + if pending_events: + active_events = [] + for event, remaining in pending_events: + event.post_context.append(rstripped_line) + if remaining > 1: + active_events.append((event, remaining - 1)) + elif isinstance(event, BuildError): + errors.append(event) + else: + warnings.append(event) + pending_events = active_events + # use CTest's regular expressions to scrape the log for events if matcher(error_matches, error_exceptions, line, *timings[:2]): - event = BuildError(line.strip(), offset + i + 1) - errors.append(event) + event = BuildError(rstripped_line, i + 1) elif matcher(warning_matches, warning_exceptions, line, *timings[2:]): - event = BuildWarning(line.strip(), offset + i + 1) - warnings.append(event) + event = BuildWarning(rstripped_line, i + 1) else: + pre_context.append(rstripped_line) continue - # get file/line number for each event, if possible + event.pre_context = list(pre_context) + event.post_context = [] + + # get file/line number for the event, if possible for flm in file_line_matches: match = flm.search(line) if match: event.source_file, event.source_line_no = match.groups() + break - return errors, warnings, timings + if context > 0: + pending_events.append((event, context)) + elif isinstance(event, BuildError): + errors.append(event) + else: + warnings.append(event) + pre_context.append(rstripped_line) -def _parse_unpack(args): - return _parse(*args) + # flush events whose post_context window extends past EOF + for event, _ in pending_events: + if isinstance(event, BuildError): + errors.append(event) + else: + warnings.append(event) + + return errors, warnings, timings class CTestLogParser: @@ -388,7 +419,7 @@ def stringify(elt): index += 1 def parse( - self, stream: Union[str, TextIO], context: int = 6, jobs: Optional[int] = None + self, stream: Union[str, TextIO], context: int = 6 ) -> Tuple[List[BuildError], List[BuildWarning]]: """Parse a log file by searching each line for errors and warnings. @@ -400,51 +431,8 @@ def parse( two lists containing :class:`BuildError` and :class:`BuildWarning` objects. """ if isinstance(stream, str): - with open(stream) as f: - return self.parse(f, context, jobs) - - lines = [line for line in stream] - - if jobs is None: - jobs = spack.config.get("config:build_jobs", 16) - - # single-thread small logs - if len(lines) < 10 * jobs: - errors, warnings, self.timings = _parse(lines, 0, self.profile) - - else: - # Build arguments for parallel jobs - args = [] - offset = 0 - for chunk in chunks(lines, jobs): - args.append((chunk, offset, self.profile)) - offset += len(chunk) - - # create a pool and farm out the matching job - pool = multiprocessing.Pool(jobs) - try: - # this is a workaround for a Python bug in Pool with ctrl-C - if sys.version_info >= (3, 2): - max_timeout = threading.TIMEOUT_MAX - else: - max_timeout = 9999999 - results = pool.map_async(_parse_unpack, args, 1).get(max_timeout) - - errors, warnings, timings = zip(*results) - finally: - pool.terminate() - - # merge results - errors = sum(errors, []) - warnings = sum(warnings, []) - - if self.profile: - self.timings = [[sum(i) for i in zip(*t)] for t in zip(*timings)] - - # add log context to all events - for event in errors + warnings: - i = event.line_no - 1 - event.pre_context = [x.rstrip() for x in lines[i - context : i]] - event.post_context = [x.rstrip() for x in lines[i + 1 : i + context + 1]] + with open(stream, encoding="utf-8", errors="replace") as f: + return self.parse(f, context) + errors, warnings, self.timings = _parse(stream, self.profile, context) return errors, warnings diff --git a/lib/spack/spack/util/log_parse.py b/lib/spack/spack/util/log_parse.py index e27740ee741e21..2f37fa90aa47ad 100644 --- a/lib/spack/spack/util/log_parse.py +++ b/lib/spack/spack/util/log_parse.py @@ -5,7 +5,7 @@ import io import shutil import sys -from typing import Optional, TextIO, Union +from typing import TextIO, Union from spack.llnl.util.tty.color import cescape, colorize from spack.util.ctest_log_parser import BuildError, BuildWarning, CTestLogParser @@ -13,15 +13,12 @@ __all__ = ["parse_log_events", "make_log_context"] -def parse_log_events( - stream: Union[str, TextIO], context: int = 6, jobs: Optional[int] = None, profile: bool = False -): +def parse_log_events(stream: Union[str, TextIO], context: int = 6, profile: bool = False): """Extract interesting events from a log file as a list of LogEvent. Args: stream: build log name or file object context: lines of context to extract around each log event - jobs: number of jobs to parse with; default ncpus profile: print out profile information for parsing Returns: @@ -37,7 +34,7 @@ def parse_log_events( parser = CTestLogParser(profile=profile) setattr(parse_log_events, "ctest_parser", parser) - result = parser.parse(stream, context, jobs) + result = parser.parse(stream, context) if profile: parser.print_timings() return result diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 52341d10c81d7e..1b693d214cd5b2 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -2302,7 +2302,6 @@ complete -c spack -n '__fish_spack_using_command log-parse' -s p -l profile -d ' complete -c spack -n '__fish_spack_using_command log-parse' -s w -l width -r -f -a width complete -c spack -n '__fish_spack_using_command log-parse' -s w -l width -r -d 'wrap width: auto-size to terminal by default; 0 for no wrap' complete -c spack -n '__fish_spack_using_command log-parse' -s j -l jobs -r -f -a jobs -complete -c spack -n '__fish_spack_using_command log-parse' -s j -l jobs -r -d 'number of jobs to parse log file (default: 1 for short logs, ncpus for long logs)' # spack logs set -g __fish_spack_optspecs_spack_logs h/help From e362be24f4ed191a02e24b9d1807e23164807da7 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 15 Apr 2026 13:16:11 +0200 Subject: [PATCH 264/337] concretizer: raise a clear error on unknown concrete targets (#52246) Previously, when a user asked for a concrete target that is unknown to `archspec`, the solver would raise a confusing error message about unmatched target constraints. Now user input is checked pre-solve and the error is clearer: ```console $ spack solve zlib-ng target=foo ==> Error: the target 'foo' in 'zlib-ng target=foo' is not a known target. Run 'spack arch --known-targets' to see valid targets. ``` The errors have also been improved for requirements stemming from configuration files. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/asp.py | 9 ++- lib/spack/spack/solver/requirements.py | 49 +++++++++++++++- lib/spack/spack/test/error_messages.py | 80 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 80ef87d3b0f070..60e1264df2172f 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -2604,8 +2604,15 @@ def target_defaults(self, specs): if not spec.architecture or not spec.architecture.target: continue - target = spack.vendor.archspec.cpu.TARGETS.get(spec.target.name) + target_name = spec.target.name + target = spack.vendor.archspec.cpu.TARGETS.get(target_name) if not target: + if spec.architecture.target_concrete: + raise spack.error.SpecError( + f"the target '{target_name}' in '{spec} is not a known target. " + f"Run 'spack arch --known-targets' to see valid targets." + ) + # range/list constraint (contains ':' or ','): keep existing path self.target_ranges(spec, None) continue diff --git a/lib/spack/spack/solver/requirements.py b/lib/spack/spack/solver/requirements.py index 394b0dccbe5a0c..8e0c4ab6d9539e 100644 --- a/lib/spack/spack/solver/requirements.py +++ b/lib/spack/spack/solver/requirements.py @@ -5,6 +5,8 @@ import warnings from typing import List, NamedTuple, Optional, Sequence, Tuple, Union +import spack.vendor.archspec.cpu + import spack.config import spack.error import spack.package_base @@ -18,6 +20,47 @@ from spack.util.spack_yaml import get_mark_from_yaml_data +def _mark_str(raw) -> str: + """Return a 'file:line: ' prefix from the YAML mark on *raw*, or empty string.""" + mark = get_mark_from_yaml_data(raw) + return f"{mark.name}:{mark.line + 1}: " if mark else "" + + +def _check_unknown_targets( + raw_strs: List[str], specs: List["spack.spec.Spec"], *, always_warn: bool = False +) -> None: + """Either warns or raises for unknown concrete target names in a set of specs. + + UserWarnings are emitted if *always_warn* is True or if there is at least one spec without + unknown targets. If all the specs have unknown targets raises an error. + """ + specs_with_unknown_targets = [ + (raw, spec) + for raw, spec in zip(raw_strs, specs) + if spec.architecture + and spec.architecture.target_concrete + and spec.target.name not in spack.vendor.archspec.cpu.TARGETS + ] + if not specs_with_unknown_targets: + return + + errors = [ + f"{_mark_str(raw)}'{spec}' contains unknown targets" + for raw, spec in specs_with_unknown_targets + ] + if len(errors) == 1: + msg = f"{errors[0]}. Run 'spack arch --known-targets' to see valid targets." + else: + details = "\n".join([f"{idx}. {part}" for idx, part in enumerate(errors, 1)]) + msg = ( + f"unknown targets have been detected in requirements\n{details}\n" + f"Run 'spack arch --known-targets' to see valid targets." + ) + if not always_warn and len(specs_with_unknown_targets) == len(specs): + raise spack.error.SpecError(msg) + warnings.warn(msg) + + class RequirementKind(enum.Enum): """Purpose / provenance of a requirement""" @@ -220,6 +263,8 @@ def _parse_prefer_conflict_item(self, item): spec = self._parse_and_expand(item["spec"]) condition = spack.spec.Spec(item.get("when")) message = item.get("message") + raw_key = item if isinstance(item, str) else item.get("spec", item) + _check_unknown_targets([raw_key], [spec], always_warn=True) return spec, condition, message def _raw_yaml_data(self, pkg_name: str, *, section: str, virtual: bool = False): @@ -265,10 +310,12 @@ def _rules_from_requirements( self._maybe_warn_compiler_in_all(constraints, "require") # validate specs from YAML first, and fail with line numbers if parsing fails. + raw_strs = list(constraints) constraints = [ self._parse_and_expand(constraint, named=kind == RequirementKind.VIRTUAL) - for constraint in constraints + for constraint in raw_strs ] + _check_unknown_targets(raw_strs, constraints) when_str = requirement.get("when") when = self._parse_and_expand(when_str) if when_str else spack.spec.Spec() diff --git a/lib/spack/spack/test/error_messages.py b/lib/spack/spack/test/error_messages.py index b03b5f5deff2f6..cde15404989028 100644 --- a/lib/spack/spack/test/error_messages.py +++ b/lib/spack/spack/test/error_messages.py @@ -10,6 +10,8 @@ import pytest +import spack.vendor.archspec.cpu + import spack.config import spack.error import spack.repo @@ -548,3 +550,81 @@ def test_warns_on_compiler_constraint_in_all(concretize_scope, mock_packages, se update_packages_config(f"packages:\n all:\n {section}:\n - '%c=gcc'\n") with pytest.warns(UserWarning, match="packages: all:"): concretize_one("gmake") + + +@pytest.mark.regression("52209") +def test_unknown_concrete_target_in_input_spec(concretize_scope, test_repo): + """Tests that an input spec with an unknown concrete target raises a clear error naming + the bad target, rather than a confusing 'cannot satisfy constraint' solver error. + """ + spec_str = "x4 target=not-a-real-uarch" + with pytest.raises(spack.error.SpackError) as exc_info: + concretize_one(spec_str) + check_error(str(exc_info.value), should_mention=[spec_str, "not a known target"]) + + +@pytest.mark.regression("52209") +def test_require_single_unknown_target_errors(concretize_scope, test_repo): + """Tests that a single-option require with an unknown target raises a clear error.""" + target_str = "target=not-a-real-uarch" + update_packages_config( + f"""\ +packages: + x4: + require: {target_str} +""" + ) + with pytest.raises(spack.error.SpackError) as exc_info: + concretize_one("x4") + check_error(str(exc_info.value), should_mention=[target_str, "unknown target"]) + + +@pytest.mark.regression("52209") +def test_require_all_unknown_targets_errors(concretize_scope, test_repo): + """Tests that a group where every option has an unknown target also raises a clear error.""" + update_packages_config( + """\ +packages: + x4: + require: + - any_of: ["target=not-a-real-uarch", "target=also-fake"] +""" + ) + with pytest.raises(spack.error.SpackError) as exc_info: + concretize_one("x4") + check_error( + str(exc_info.value), + should_mention=["target=not-a-real-uarch", "target=also-fake", "unknown target"], + ) + + +@pytest.mark.regression("52209") +@pytest.mark.skipif( + str(spack.vendor.archspec.cpu.host().family) != "x86_64", reason="test assumes x86_64 uarchs" +) +def test_require_mixed_unknown_and_valid_target_warns(concretize_scope, test_repo): + """Tests that a "require" group with at least one valid option just warns.""" + update_packages_config( + """\ +packages: + x4: + require: + - one_of: ["target=not-a-real-uarch", "target=x86_64"] +""" + ) + with pytest.warns(UserWarning, match="not-a-real-uarch"): + concretize_one("x4") + + +@pytest.mark.regression("52209") +def test_prefer_unknown_target_warns(concretize_scope, test_repo): + """A preference with an unknown target has the @: fallback, so it only warns.""" + update_packages_config( + """\ +packages: + x4: + prefer: ["target=not-a-real-uarch"] +""" + ) + with pytest.warns(UserWarning, match="not-a-real-uarch"): + concretize_one("x4") From 852dee2fe3624a257fc63e34b30332ce8a6ae0c2 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 15 Apr 2026 15:39:08 +0200 Subject: [PATCH 265/337] log parser: change formatting (#52269) * drop line wrapping in the log parser * use a `grep` / `diff` style format * fix bug where warning in error context is rendered red instead of yellow * drop extra trailing newline in `spack log-parse` Fixed line wrapping is problematic: * cannot select and copy the full line by triple clicking * error messages with "underline" on the next line are confusing with line wrapping: ``` error: this is a long line ^^^^ error: this is a lo ng line ^^^^ ``` With this commit, the style is like this: ``` -- lines x to y -- pre context a pre context b > error: the error line post context a post context b ``` --- lib/spack/spack/cmd/log_parse.py | 11 ++-- lib/spack/spack/test/util/log_parser.py | 52 +++++++++++++++ lib/spack/spack/util/ctest_log_parser.py | 7 ++ lib/spack/spack/util/log_parse.py | 82 +++++++++--------------- share/spack/spack-completion.fish | 1 - 5 files changed, 92 insertions(+), 61 deletions(-) diff --git a/lib/spack/spack/cmd/log_parse.py b/lib/spack/spack/cmd/log_parse.py index b0444ac8c558ec..11668c704ae7ed 100644 --- a/lib/spack/spack/cmd/log_parse.py +++ b/lib/spack/spack/cmd/log_parse.py @@ -39,12 +39,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: help="print out a profile of time spent in regexes during parse", ) subparser.add_argument( - "-w", - "--width", - action="store", - type=int, - default=None, - help="wrap width: auto-size to terminal by default; 0 for no wrap", + "-w", "--width", action="store", type=int, default=None, help=argparse.SUPPRESS ) subparser.add_argument( "-j", "--jobs", action="store", type=int, default=None, help=argparse.SUPPRESS @@ -60,6 +55,8 @@ def log_parse(parser, args): sys.stdin.buffer, encoding="utf-8", errors="replace", closefd=False ) + if args.width is not None: + warnings.warn("The --width option is deprecated and will be removed in Spack v1.3") if args.jobs is not None: warnings.warn("The --jobs option is deprecated and will be removed in Spack v1.3") @@ -80,4 +77,4 @@ def log_parse(parser, args): events.extend(log_warnings) print("%d warnings" % len(log_warnings)) - print(make_log_context(events, args.width)) + print(make_log_context(events), end="") diff --git a/lib/spack/spack/test/util/log_parser.py b/lib/spack/spack/test/util/log_parser.py index 768c7bf52da422..73df2cf898d3c5 100644 --- a/lib/spack/spack/test/util/log_parser.py +++ b/lib/spack/spack/test/util/log_parser.py @@ -5,7 +5,9 @@ import io import pathlib +from spack.llnl.util.tty.color import color_when from spack.util.ctest_log_parser import CTestLogParser +from spack.util.log_parse import make_log_context def test_log_parser(tmp_path: pathlib.Path): @@ -69,6 +71,56 @@ def test_log_parser_preserves_leading_whitespace(): assert errors[0].post_context[1] == " ^" +def test_make_log_context_merges_overlapping_events(tmp_path: pathlib.Path): + """Overlapping or adjacent context windows should produce a single merged block.""" + + # Two errors close together: lines 5 and 10 with context=3 means windows overlap. + lines = [f"line {i}\n" for i in range(1, 21)] + lines[4] = "error: first problem\n" # line 5 + lines[9] = "error: second problem\n" # line 10 + + log_file = tmp_path / "log.txt" + log_file.write_text("".join(lines)) + + parser = CTestLogParser() + errors, warnings = parser.parse(str(log_file), context=3) + + log_events = sorted([*errors, *warnings], key=lambda e: e.line_no) + output = make_log_context(log_events) + + # Should be exactly one header for the merged block, not two. + assert output.count("-- lines") == 1 + + # The header should cover the full merged range. + assert "-- lines 2 to 13 --" in output + + +def test_make_log_context_warning_in_error_context_keeps_yellow(tmp_path: pathlib.Path): + """A warning line inside an error's context window must be highlighted yellow, not red.""" + # Line 5 = error, line 8 = warning, context=3 so error window covers lines 2-11 + # meaning the warning at line 8 falls inside the error's context. + lines = [f"line {i}\n" for i in range(1, 16)] + lines[4] = "error: something broke\n" # line 5 + lines[7] = "/tmp/foo.c:1: warning: something fishy\n" # line 8 + + log_file = tmp_path / "log.txt" + log_file.write_text("".join(lines)) + + parser = CTestLogParser() + errors, warnings = parser.parse(str(log_file), context=3) + + assert len(errors) == len(warnings) == 1 + + log_events = sorted([*errors, *warnings], key=lambda e: e.line_no) + + with color_when("always"): + output = make_log_context(log_events) + + # The error line should be red (ANSI 91), the warning yellow (ANSI 93). + assert "\x1b[0;91m> " in output and "something broke" in output + assert "\x1b[0;93m> " in output and "something fishy" in output + + def test_log_parser_non_utf8_bytes(tmp_path: pathlib.Path): """parse() does not raise UnicodeDecodeError on non-UTF-8 log files.""" log_file = tmp_path / "log.bin" diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index 1fecd0cf82444f..a90baa42301a68 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -212,6 +212,9 @@ class LogEvent: """Class representing interesting events (e.g., errors) in a build log.""" + #: color name when rendering in the terminal + color = "" + def __init__( self, text, @@ -262,10 +265,14 @@ def __str__(self): class BuildError(LogEvent): """LogEvent subclass for build errors.""" + color = "R" + class BuildWarning(LogEvent): """LogEvent subclass for build warnings.""" + color = "Y" + def chunks(xs, n): """Divide xs into n approximately-even chunks.""" diff --git a/lib/spack/spack/util/log_parse.py b/lib/spack/spack/util/log_parse.py index 2f37fa90aa47ad..8331d81f9d4e05 100644 --- a/lib/spack/spack/util/log_parse.py +++ b/lib/spack/spack/util/log_parse.py @@ -3,12 +3,10 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import io -import shutil -import sys -from typing import TextIO, Union +from typing import List, TextIO, Union from spack.llnl.util.tty.color import cescape, colorize -from spack.util.ctest_log_parser import BuildError, BuildWarning, CTestLogParser +from spack.util.ctest_log_parser import CTestLogParser, LogEvent __all__ = ["parse_log_events", "make_log_context"] @@ -44,75 +42,53 @@ def parse_log_events(stream: Union[str, TextIO], context: int = 6, profile: bool parse_log_events.ctest_parser = None # type: ignore[attr-defined] -def _wrap(text, width): - """Break text into lines of specific width.""" - lines = [] - pos = 0 - while pos < len(text): - lines.append(text[pos : pos + width]) - pos += width - return lines - - -def make_log_context(log_events, width=None): +def make_log_context(log_events: List[LogEvent]) -> str: """Get error context from a log file. Args: - log_events (list): list of events created by - ``ctest_log_parser.parse()`` - width (int or None): wrap width; ``0`` for no limit; ``None`` to - auto-size for terminal + log_events: list of events created by ``ctest_log_parser.parse()`` + Returns: str: context from the build log with errors highlighted - Parses the log file for lines containing errors, and prints them out - with line numbers and context. Errors are highlighted with ``>>`` and - with red highlighting (if color is enabled). - - Events are sorted by line number before they are displayed. + Parses the log file for lines containing errors, and prints them out with context. + Errors are highlighted in red and warnings in yellow. Events are sorted by line number. """ - error_lines = set(e.line_no for e in log_events) + event_colors = {e.line_no: e.color for e in log_events} log_events = sorted(log_events, key=lambda e: e.line_no) - num_width = len(str(max(error_lines or [0]))) + 4 - line_fmt = "%%-%dd%%s" % num_width - indent = " " * (5 + num_width) - - if width is None: - width = shutil.get_terminal_size().columns - if width <= 0: - width = sys.maxsize - wrap_width = width - num_width - 6 - out = io.StringIO() next_line = 1 - for event in log_events: - start = event.start + block_start = -1 + block_lines: List[str] = [] - if isinstance(event, BuildError): - color = "R" - elif isinstance(event, BuildWarning): - color = "Y" - else: - color = "W" + def flush_block(): + block_end = block_start + len(block_lines) - 1 + out.write(colorize("@c{-- lines %d to %d --}\n" % (block_start, block_end))) + out.writelines(block_lines) + block_lines.clear() - if next_line != 1 and start > next_line: - out.write("\n ...\n\n") + for event in log_events: + start = event.start if start < next_line: start = next_line + elif block_lines: + flush_block() - for i in range(start, event.end): - # wrap to width - lines = _wrap(event[i], wrap_width) - lines[1:] = [indent + ln for ln in lines[1:]] - wrapped_line = line_fmt % (i, "\n".join(lines)) + if not block_lines: + block_start = start - if i in error_lines: - out.write(colorize(" @%s{>> %s}\n" % (color, cescape(wrapped_line)))) + for i in range(start, event.end): + if i in event_colors: + color = event_colors[i] + block_lines.append(colorize("@%s{> %s}\n" % (color, cescape(event[i])))) else: - out.write(" %s\n" % wrapped_line) + block_lines.append(" %s\n" % event[i]) next_line = event.end + if block_lines: + flush_block() + return out.getvalue() diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 1b693d214cd5b2..1894673a18a7f3 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -2300,7 +2300,6 @@ complete -c spack -n '__fish_spack_using_command log-parse' -s c -l context -r - complete -c spack -n '__fish_spack_using_command log-parse' -s p -l profile -f -a profile complete -c spack -n '__fish_spack_using_command log-parse' -s p -l profile -d 'print out a profile of time spent in regexes during parse' complete -c spack -n '__fish_spack_using_command log-parse' -s w -l width -r -f -a width -complete -c spack -n '__fish_spack_using_command log-parse' -s w -l width -r -d 'wrap width: auto-size to terminal by default; 0 for no wrap' complete -c spack -n '__fish_spack_using_command log-parse' -s j -l jobs -r -f -a jobs # spack logs From b4dff70768d6a93e7eadd945af4b622f40c1d360 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 15 Apr 2026 21:56:38 +0200 Subject: [PATCH 266/337] main: error on unrecognized top-level flags (#52283) After 7db386a changed `finish_parse_and_run` to parse `main_args.command` instead of re-parsing `sys.argv`, unrecognized flags between `spack` and the subcommand (e.g. `spack -o mirror list`) were silently ignored. Error on them instead. Signed-off-by: Harmen Stoppels --- lib/spack/spack/main.py | 2 +- lib/spack/spack/test/main.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index 6d5d0c49db4872..ae4635e8c5c883 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -969,7 +969,7 @@ def _main(argv=None): # them, which reduces startup latency. parser = make_argument_parser() parser.add_argument("command", nargs=argparse.REMAINDER) - args, unknown = parser.parse_known_args(argv) + args = parser.parse_args(argv) # Just print help and exit if run with no arguments at all no_args = (len(sys.argv) == 1) if argv is None else (len(argv) == 0) diff --git a/lib/spack/spack/test/main.py b/lib/spack/spack/test/main.py index 0ee7c3c05da636..032d0c2ff1ef7c 100644 --- a/lib/spack/spack/test/main.py +++ b/lib/spack/spack/test/main.py @@ -98,6 +98,10 @@ def test_main_calls_get_version(capfd, working_env, monkeypatch): assert spack.spack_version == out.strip() +def test_unrecognized_top_level_flag(): + assert spack.main.main(["-o", "mirror", "list"]) != 0 + + def test_get_version_bad_git(tmp_path: pathlib.Path, working_env, monkeypatch): bad_git = str(tmp_path / "git") with open(bad_git, "w", encoding="utf-8") as f: From 9ae226e9777b6f601dfadaaeeb8e6b73c4ef316e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:48:45 +0200 Subject: [PATCH 267/337] build(deps): bump actions/download-artifact from 4.1.8 to 8.0.1 (#52287) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.8 to 8.0.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/fa0a91b85d4f404e444e00e005971372dc801d16...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a9cbe1baded540..5508f8c14b889e 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -17,7 +17,7 @@ jobs: run: pip install -r .github/workflows/requirements/coverage/requirements.txt - name: Download coverage artifact files - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: coverage-* path: coverage From 54365b03c3d1da508cca3baf02dcf389df4a5c86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:50:28 +0200 Subject: [PATCH 268/337] build(deps): bump actions/setup-python from 5.3.0 to 6.2.0 (#52286) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5.3.0 to 6.2.0. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/0b93645e9fea7318ecaed2b359559ac225c90a2b...a309ff8b426b58ec0e2a45f0f869d46889d02405) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/bootstrap.yml | 6 +++--- .github/workflows/coverage.yml | 2 +- .github/workflows/prechecks.yml | 4 ++-- .github/workflows/unit_tests.yaml | 10 +++++----- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 9038c95c75e316..88b2087f5f88a8 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -64,7 +64,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" - name: Bootstrap clingo @@ -125,7 +125,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: | 3.8 @@ -168,7 +168,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" - name: Setup Windows diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5508f8c14b889e..2d99a07ead3601 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.14' diff --git a/.github/workflows/prechecks.yml b/.github/workflows/prechecks.yml index 1752abe5a71a75..1d2095be4df15b 100644 --- a/.github/workflows/prechecks.yml +++ b/.github/workflows/prechecks.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Install Python Packages @@ -49,7 +49,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 2 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Install Python packages diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 794d7242411151..a0c02640c573f7 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install System packages @@ -96,7 +96,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - name: Install System packages @@ -166,7 +166,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Install System packages @@ -205,7 +205,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Install Python packages @@ -242,7 +242,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.14' - name: Install Python packages From 18f78d0b22ef343b2e81979d57c565f3b468aadb Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 16 Apr 2026 12:22:56 +0200 Subject: [PATCH 269/337] new_installer.py: take db write lock if needed (#52268) When the store is not writable, the prefix lock in the installer loop will fail, reaching the finally block, which unconditionally takes another database write lock, also failing, and masking the original problem. Fix by taking a write lock in the finally bit iff something needs to be persisted to the database. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 0b6bab0e5020c1..633a9d7a97ab60 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2453,12 +2453,13 @@ def _installer(self) -> None: # Flush any not-yet-written successful builds to the DB; save the exception on error # to be re-raised after best-effort cleanup. db_exc = None - try: - with self.db.write_transaction(): - for action in database_actions: - action.save_to_db(self.db) - except Exception as e: - db_exc = e + if database_actions: + try: + with self.db.write_transaction(): + for action in database_actions: + action.save_to_db(self.db) + except Exception as e: + db_exc = e # Send SIGTERM to running builds; this is a no-op in the successful case. for child in self.running_builds.values(): From db1102bb24ad8168310805fc6b0d8f0192386b7a Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 16 Apr 2026 13:10:15 +0200 Subject: [PATCH 270/337] main.py: deprecate --pdb, drop SIGINT handler (#52281) * Remove `spack --debug` SIGINT handler * Deprecate `spack --pdb` over `python3 -m pdb spack` The automatic SIGINT handler for ^C that drops the user in a debugger if run under `spack --debug` is unexpected for users who use `--debug` merely for verbose error messages; but we ask users for that regularly. Reported issues: * the terminal settings are not restored on Ctrl+C of `spack -d install`, which makes it additionally confusing. * the `finally:` bits of the installer are delayed, meaning pending database writes only happen after the user exits the debugger, which is brittle. Instead, users can simply run ``` python3 -m pdb -c continue path/to/spack ... ``` which makes pdb install the SIGINT handler and also restores the terminal settings. Similarly, instead of `spack --pdb`, users can run ``` python3 -m pdb path/to/spack ... ``` as shown in the deprecation warning. Signed-off-by: Harmen Stoppels --- lib/spack/spack/main.py | 11 ++++++++--- lib/spack/spack/util/debug.py | 26 -------------------------- share/spack/spack-completion.fish | 1 - 3 files changed, 8 insertions(+), 30 deletions(-) delete mode 100644 lib/spack/spack/util/debug.py diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index ae4635e8c5c883..63e8cb71e0794d 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -42,7 +42,6 @@ import spack.platforms import spack.solver.asp import spack.spec -import spack.util.debug import spack.util.environment import spack.util.lock @@ -505,7 +504,7 @@ def make_argument_parser(**kwargs): default="SPACK_BACKTRACE" in os.environ, help="always show backtraces for exceptions", ) - debug.add_argument("--pdb", action="store_true", help="run spack under the pdb debugger") + debug.add_argument("--pdb", action="store_true", help=argparse.SUPPRESS) debug.add_argument("--timestamp", action="store_true", help="add a timestamp to tty output") debug.add_argument( "-m", "--mock", action="store_true", help="use mock packages instead of real ones" @@ -587,7 +586,6 @@ def setup_main_options(args): spack.error.SHOW_BACKTRACE = True if args.debug: - spack.util.debug.register_interrupt_handler() spack.config.set("config:debug", True, scope="command_line") spack.util.environment.TRACING_ENABLED = True @@ -1088,6 +1086,13 @@ def finish_parse_and_run(parser, cmd_name, main_args, env_format_error): if main_args.spack_profile or main_args.sorted_profile or main_args.profile_file: _profile_wrapper(command, main_args, parser, args, unknown) elif main_args.pdb: + new_args = [sys.executable, "-m", "pdb", spack.paths.spack_script] + new_args.extend(arg for arg in sys.argv[1:] if arg != "--pdb") + formatted_args = " ".join(shlex.quote(arg) for arg in new_args) + tty.warn( + "The --pdb flag is deprecated and will be removed in Spack v1.3. " + f"Use `{formatted_args}` instead." + ) import pdb pdb.runctx("_invoke_command(command, parser, args, unknown)", globals(), locals()) diff --git a/lib/spack/spack/util/debug.py b/lib/spack/spack/util/debug.py deleted file mode 100644 index aa277c880a8517..00000000000000 --- a/lib/spack/spack/util/debug.py +++ /dev/null @@ -1,26 +0,0 @@ -# Copyright Spack Project Developers. See COPYRIGHT file for details. -# -# SPDX-License-Identifier: (Apache-2.0 OR MIT) - -"""Debug signal handler: enters pdb on ctrl-C.""" -import os -import signal - -_DEBUG_PID = None - - -def debug_handler(sig, frame): - """Signal handler for SIGINT. Enters pdb if the signal is sent to this process.""" - if os.getpid() != _DEBUG_PID: - raise KeyboardInterrupt - - import pdb - - pdb.Pdb().set_trace(frame) - - -def register_interrupt_handler(): - """Register the debug handler for SIGINT.""" - global _DEBUG_PID - _DEBUG_PID = os.getpid() - signal.signal(signal.SIGINT, debug_handler) diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 1894673a18a7f3..4093550c237705 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -458,7 +458,6 @@ complete -c spack -n '__fish_spack_using_command ' -s d -l debug -d 'write out d complete -c spack -n '__fish_spack_using_command ' -s t -l backtrace -f -a backtrace complete -c spack -n '__fish_spack_using_command ' -s t -l backtrace -d 'always show backtraces for exceptions' complete -c spack -n '__fish_spack_using_command ' -l pdb -f -a pdb -complete -c spack -n '__fish_spack_using_command ' -l pdb -d 'run spack under the pdb debugger' complete -c spack -n '__fish_spack_using_command ' -l timestamp -f -a timestamp complete -c spack -n '__fish_spack_using_command ' -l timestamp -d 'add a timestamp to tty output' complete -c spack -n '__fish_spack_using_command ' -s m -l mock -f -a mock From 80377d09205875d484f693e85b839e94d0908bb7 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 16 Apr 2026 13:18:33 +0200 Subject: [PATCH 271/337] globals: use cast(..., singleton) (#52242) Signed-off-by: Harmen Stoppels --- lib/spack/spack/binary_distribution.py | 4 ++-- lib/spack/spack/caches.py | 8 +++----- lib/spack/spack/compilers/libraries.py | 6 ++---- lib/spack/spack/config.py | 4 ++-- lib/spack/spack/repo.py | 13 ++++++------- lib/spack/spack/store.py | 6 +++--- 6 files changed, 18 insertions(+), 23 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index 7fb1d29296f93d..34cb2041016ebe 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -26,7 +26,7 @@ import warnings from collections import defaultdict from contextlib import closing -from typing import IO, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Union +from typing import IO, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Union, cast import spack.caches import spack.config @@ -506,7 +506,7 @@ def binary_index_location(): #: Default binary cache index instance -BINARY_INDEX: BinaryCacheIndex = spack.llnl.util.lang.Singleton(BinaryCacheIndex) # type: ignore +BINARY_INDEX = cast(BinaryCacheIndex, spack.llnl.util.lang.Singleton(BinaryCacheIndex)) def compute_hash(data): diff --git a/lib/spack/spack/caches.py b/lib/spack/spack/caches.py index a01189cf2f0248..ffaaf00d655d2b 100644 --- a/lib/spack/spack/caches.py +++ b/lib/spack/spack/caches.py @@ -4,6 +4,7 @@ """Caches used by Spack to store data""" import os +from typing import cast import spack.config import spack.fetch_strategy @@ -30,9 +31,7 @@ def _misc_cache(): #: Spack's cache for small data -MISC_CACHE: spack.util.file_cache.FileCache = spack.llnl.util.lang.Singleton( # type: ignore - _misc_cache -) +MISC_CACHE = cast(spack.util.file_cache.FileCache, spack.llnl.util.lang.Singleton(_misc_cache)) def fetch_cache_location(): @@ -69,5 +68,4 @@ def store(self, fetcher, relative_dest): #: Spack's local cache for downloaded source archives -FETCH_CACHE: "spack.fetch_strategy.FsCache" -FETCH_CACHE = spack.llnl.util.lang.Singleton(_fetch_cache) # type: ignore +FETCH_CACHE = cast(spack.fetch_strategy.FsCache, spack.llnl.util.lang.Singleton(_fetch_cache)) diff --git a/lib/spack/spack/compilers/libraries.py b/lib/spack/spack/compilers/libraries.py index 4be8c06c0b11df..bac07a5b7e47f9 100644 --- a/lib/spack/spack/compilers/libraries.py +++ b/lib/spack/spack/compilers/libraries.py @@ -10,7 +10,7 @@ import stat import sys import tempfile -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple, cast import spack.caches import spack.llnl.path @@ -443,6 +443,4 @@ def _make_compiler_cache(): return FileCompilerCache(spack.caches.MISC_CACHE) -COMPILER_CACHE: CompilerCache = spack.llnl.util.lang.Singleton( # type: ignore - _make_compiler_cache -) +COMPILER_CACHE = cast(CompilerCache, spack.llnl.util.lang.Singleton(_make_compiler_cache)) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index bb812e749b8f87..f25d65984ae97a 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -37,7 +37,7 @@ import tempfile from collections import defaultdict from itertools import chain -from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union +from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union, cast from spack.vendor import jsonschema @@ -1559,7 +1559,7 @@ def create() -> Configuration: #: This is the singleton configuration instance for Spack. -CONFIG: Configuration = lang.Singleton(create_incremental) # type: ignore +CONFIG = cast(Configuration, lang.Singleton(create_incremental)) def add_from_file(filename: str, scope: Optional[str] = None) -> None: diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index b632f163edf9c4..54a52c4a7dd81f 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -35,6 +35,7 @@ Tuple, Type, Union, + cast, ) import spack @@ -43,7 +44,6 @@ import spack.error import spack.llnl.path import spack.llnl.util.filesystem as fs -import spack.llnl.util.lang import spack.llnl.util.tty as tty import spack.patch import spack.paths @@ -58,6 +58,7 @@ import spack.util.path import spack.util.spack_yaml as syaml from spack.llnl.util.filesystem import working_dir +from spack.llnl.util.lang import Singleton, memoized if TYPE_CHECKING: import spack.package_base @@ -800,11 +801,11 @@ def first_repo(self) -> Optional["Repo"]: """Get the first repo in precedence order.""" return self.repos[0] if self.repos else None - @spack.llnl.util.lang.memoized + @memoized def _all_package_names_set(self, include_virtuals) -> Set[str]: return {name for repo in self.repos for name in repo.all_package_names(include_virtuals)} - @spack.llnl.util.lang.memoized + @memoized def _all_package_names(self, include_virtuals: bool) -> List[str]: """Return all unique package names in all repositories.""" return sorted(self._all_package_names_set(include_virtuals), key=lambda n: n.lower()) @@ -1571,7 +1572,7 @@ def unmarshal(root, cache, overrides): def marshal(self): cache = self._cache - if isinstance(cache, spack.llnl.util.lang.Singleton): + if isinstance(cache, Singleton): cache = cache.instance return self.root, cache, self.overrides @@ -2101,9 +2102,7 @@ def create_and_enable(config: spack.config.Configuration) -> RepoPath: #: Global package repository instance. -PATH: RepoPath = spack.llnl.util.lang.Singleton( - lambda: create_and_enable(spack.config.CONFIG) -) # type: ignore[assignment] +PATH = cast(RepoPath, Singleton(lambda: create_and_enable(spack.config.CONFIG))) # Add the finder to sys.meta_path diff --git a/lib/spack/spack/store.py b/lib/spack/spack/store.py index bfacc45c99d7d2..02601e144440b4 100644 --- a/lib/spack/spack/store.py +++ b/lib/spack/spack/store.py @@ -20,7 +20,7 @@ import pathlib import re import uuid -from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union, cast import spack.config import spack.database @@ -237,7 +237,7 @@ def _create_global() -> Store: #: Singleton store instance -STORE: Store = spack.llnl.util.lang.Singleton(_create_global) # type: ignore +STORE = cast(Store, spack.llnl.util.lang.Singleton(_create_global)) def reinitialize(): @@ -247,7 +247,7 @@ def reinitialize(): global STORE token = STORE - STORE = spack.llnl.util.lang.Singleton(_create_global) + STORE = cast(Store, spack.llnl.util.lang.Singleton(_create_global)) return token From 3dc8127c28e2f1cd14b48ea2e322c5c8105baefa Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 16 Apr 2026 13:38:05 +0200 Subject: [PATCH 272/337] ci: remove unused types-six (#52290) Signed-off-by: Harmen Stoppels --- .github/workflows/requirements/style/requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index eec7f8c866e3db..443e179f62c87a 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -3,7 +3,6 @@ clingo==5.8.0 flake8==7.3.0 isort==7.0.0 mypy==1.20.1 -types-six==1.17.0.20251009 vermin==1.8.0 pylint==4.0.5 docutils==0.22.4 From 82d287347279c8d6368144b84388da775a9299af Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 16 Apr 2026 13:44:05 +0200 Subject: [PATCH 273/337] new_installer.py: bump log buffer (#52232) Bump the number of bytes to read for the log to at most 32KB per iteration instead of 4KB to reduce the number of event loop iterations when the build produces lots of logging output. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 633a9d7a97ab60..fcf9528be8f2f5 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -104,7 +104,7 @@ DATABASE_WRITE_INTERVAL = 5.0 #: Size of the output buffer for child processes -OUTPUT_BUFFER_SIZE = 4096 +OUTPUT_BUFFER_SIZE = 32768 #: Suffix for temporary backup during overwrite install OVERWRITE_BACKUP_SUFFIX = ".old" From b0f0aa7aff89026719fd02c1ef00a08e32e8ad75 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 16 Apr 2026 13:47:46 +0200 Subject: [PATCH 274/337] reporters: disable when not needed (#52229) Signed-off-by: Harmen Stoppels --- lib/spack/spack/cmd/install.py | 4 ++- lib/spack/spack/environment/environment.py | 2 +- lib/spack/spack/installer.py | 27 +++++++++++----- lib/spack/spack/installer_dispatch.py | 2 ++ lib/spack/spack/new_installer.py | 32 ++++++++++++++++--- lib/spack/spack/report.py | 36 ++++++++++++++++++++++ 6 files changed, 90 insertions(+), 13 deletions(-) diff --git a/lib/spack/spack/cmd/install.py b/lib/spack/spack/cmd/install.py index 24619f81a926c2..cbdef814b14aed 100644 --- a/lib/spack/spack/cmd/install.py +++ b/lib/spack/spack/cmd/install.py @@ -441,7 +441,9 @@ def install_without_active_env(args, install_kwargs, reporter): install_kwargs["explicit"] = [s.dag_hash() for s in concrete_specs] try: - builder = spack.installer_dispatch.create_installer(installs, **install_kwargs) + builder = spack.installer_dispatch.create_installer( + installs, create_reports=reporter is not None, **install_kwargs + ) builder.install() finally: if reporter: diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index d8aae942c5e3d9..2c97663aed0dc5 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -1991,7 +1991,7 @@ def install_specs(self, specs: Optional[List[Spec]] = None, **install_args): } builder = spack.installer_dispatch.create_installer( - [spec.package for spec in specs], **install_args + [spec.package for spec in specs], create_reports=reporter is not None, **install_args ) try: diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index c11837d85b88cb..1de0692e12aaa3 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -925,7 +925,8 @@ def __init__( self.request = request # Report for tracking install success/failure - self.record = spack.report.InstallRecord(self.pkg.spec) + record_cls = self.request.install_args.get("record_cls", spack.report.InstallRecord) + self.record = record_cls(self.pkg.spec) # Initialize the status to an active state. The status is used to # ensure priority queue invariants when tasks are "removed" from the @@ -1485,6 +1486,7 @@ def __init__( concurrent_packages: Optional[int] = None, root_policy: InstallPolicy = "auto", dependencies_policy: InstallPolicy = "auto", + create_reports: bool = False, ) -> None: """ Arguments: @@ -1510,6 +1512,7 @@ def __init__( concurrent_packages: Max packages to be built concurrently root_policy: ``"auto"``, ``"cache_only"``, ``"source_only"``. dependencies_policy: ``"auto"``, ``"cache_only"``, ``"source_only"``. + create_reports: whether to generate reports for each install """ if sys.platform == "win32": # No locks on Windows, we should always use 1 process @@ -1554,6 +1557,11 @@ def __init__( # List of build requests self.build_requests = [BuildRequest(pkg, install_args) for pkg in packages] + # When no reporter is configured, use NullInstallRecord to skip log file reads. + if not create_reports: + for br in self.build_requests: + br.install_args["record_cls"] = spack.report.NullInstallRecord + # Priority queue of tasks self.build_pq: List[Tuple[Tuple[int, int], Task]] = [] @@ -1586,12 +1594,17 @@ def __init__( self.max_active_tasks = self.concurrent_packages # Reports on install success/failure - self.reports: Dict[str, spack.report.RequestRecord] = {} - for build_request in self.build_requests: - # Skip reporting for already installed specs - request_record = spack.report.RequestRecord(build_request.pkg.spec) - request_record.skip_installed() - self.reports[build_request.pkg_id] = request_record + if create_reports: + self.reports: Dict[str, spack.report.RequestRecord] = {} + for build_request in self.build_requests: + # Skip reporting for already installed specs + request_record = spack.report.RequestRecord(build_request.pkg.spec) + request_record.skip_installed() + self.reports[build_request.pkg_id] = request_record + else: + self.reports = { + br.pkg_id: spack.report.NullRequestRecord() for br in self.build_requests + } def __repr__(self) -> str: """Returns a formal representation of the package installer.""" diff --git a/lib/spack/spack/installer_dispatch.py b/lib/spack/spack/installer_dispatch.py index dee36ec513702d..dc29f1ec3d2752 100644 --- a/lib/spack/spack/installer_dispatch.py +++ b/lib/spack/spack/installer_dispatch.py @@ -40,6 +40,7 @@ def create_installer( concurrent_packages: Optional[int] = None, root_policy: Literal["auto", "cache_only", "source_only"] = "auto", dependencies_policy: Literal["auto", "cache_only", "source_only"] = "auto", + create_reports: bool = False, ) -> Union["spack.installer.PackageInstaller", "spack.new_installer.PackageInstaller"]: """Create an installer based on the current configuration and feature support.""" use_old_installer = ( @@ -81,4 +82,5 @@ def create_installer( concurrent_packages=concurrent_packages, root_policy=root_policy, dependencies_policy=dependencies_policy, + create_reports=create_reports, ) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index fcf9528be8f2f5..393df9d72f4167 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2005,6 +2005,26 @@ def finalize( reports[root_hash].append_record(record) +class NullReportData(ReportData): + """No-op drop-in for ReportData when no reporter is configured. + + Avoids creating InstallRecords and reading log files on every completed build.""" + + def __init__(self) -> None: + pass + + def start_record(self, spec: spack.spec.Spec) -> None: + pass + + def finish_record(self, spec: spack.spec.Spec, exitcode: int) -> None: + pass + + def finalize( + self, reports: Dict[str, spack.report.RequestRecord], build_graph: "BuildGraph" + ) -> None: + pass + + class TerminalState: """Manages terminal settings, stdin selector registration, and suspend/resume signals. @@ -2204,6 +2224,7 @@ def __init__( concurrent_packages: Optional[int] = None, root_policy: InstallPolicy = "auto", dependencies_policy: InstallPolicy = "auto", + create_reports: bool = False, ) -> None: assert install_package or install_deps, "Must install package, dependencies or both" @@ -2293,10 +2314,13 @@ def __init__( else: self.capacity = concurrent_packages - #: The reports property is what the old installer has and used as public interface. - self.reports = {spec.dag_hash(): spack.report.RequestRecord(spec) for spec in specs} - #: Internal data collected for reports during installation. - self.report_data = ReportData(specs) + # The reports property is what the old installer has and used as public interface. + if create_reports: + self.reports = {spec.dag_hash(): spack.report.RequestRecord(spec) for spec in specs} + self.report_data = ReportData(specs) + else: + self.reports = {} + self.report_data = NullReportData() self.next_database_write = 0.0 diff --git a/lib/spack/spack/report.py b/lib/spack/spack/report.py index da1f9fba2dcc31..ae21f3f75bf2f3 100644 --- a/lib/spack/spack/report.py +++ b/lib/spack/spack/report.py @@ -150,6 +150,42 @@ def succeed(self): self.installed_from_binary_cache = self._package.installed_from_binary_cache +class NullInstallRecord(InstallRecord): + """No-op drop-in for InstallRecord when no reporter is configured. + + Avoids reading log files from disk on every completed build.""" + + def start(self) -> None: + pass + + def succeed(self) -> None: + pass + + def fail(self, exc) -> None: + pass + + def skip(self, msg: str = "") -> None: + pass + + +class NullRequestRecord(RequestRecord): + """No-op drop-in for RequestRecord when no reporter is configured. + + Avoids traversing the DAG and accumulating data that will not be reported.""" + + def __init__(self) -> None: + dict.__init__(self) + + def skip_installed(self) -> None: + pass + + def append_record(self, record) -> None: + pass + + def summarize(self) -> None: + pass + + class TestRecord(SpecRecord): """Record class with specialization for test logs.""" From 42611f079cbd9320e19b34b6177f9b17bbd580aa Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 16 Apr 2026 15:33:54 +0200 Subject: [PATCH 275/337] spack env view: fix bash tab completion (#52288) Currently `spack env view ` autocompletes with fish, but not with bash. This fixes it. Signed-off-by: Harmen Stoppels --- lib/spack/spack/cmd/commands.py | 28 ++++++++++++++++++---------- lib/spack/spack/test/cmd/commands.py | 8 ++++++++ share/spack/spack-completion.bash | 2 +- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lib/spack/spack/cmd/commands.py b/lib/spack/spack/cmd/commands.py index d6a5f577a8aae2..726ae9c3ecf7af 100644 --- a/lib/spack/spack/cmd/commands.py +++ b/lib/spack/spack/cmd/commands.py @@ -196,9 +196,7 @@ def format(self, cmd: Command) -> str: assert not (cmd.positionals and cmd.subcommands) # one or the other # We only care about the arguments/flags, not the help messages - positionals: Tuple[str, ...] = () - if cmd.positionals: - positionals, _, _, _ = zip(*cmd.positionals) + positionals = cmd.positionals or () optionals, _, _, _, _ = zip(*cmd.optionals) subcommands: Tuple[str, ...] = () if cmd.subcommands: @@ -237,12 +235,12 @@ def end_function(self, prog: str) -> str: return "}\n" def body( - self, positionals: Sequence[str], optionals: Sequence[str], subcommands: Sequence[str] + self, positionals: Sequence, optionals: Sequence[str], subcommands: Sequence[str] ) -> str: """Return the body of the function. Args: - positionals: List of positional arguments. + positionals: List of positional argument tuples (name, choices, nargs, help). optionals: List of optional arguments. subcommands: List of subcommand parsers. @@ -272,21 +270,31 @@ def body( {self.optionals(optionals)} """ - def positionals(self, positionals: Sequence[str]) -> str: + def positionals(self, positionals: Sequence) -> str: """Return the syntax for reporting positional arguments. Args: - positionals: List of positional arguments. + positionals: List of positional argument tuples (name, choices, nargs, help). Returns: Syntax for positional arguments. """ - # If match found, return function name - for positional in positionals: + for name, choices, nargs, help in positionals: + # Check for a predefined subroutine mapping for key, value in _positional_to_subroutine.items(): - if positional.startswith(key): + if name.startswith(key): return value + # Use choices if available + if choices is not None: + if isinstance(choices, dict): + choices = sorted(choices.keys()) + elif isinstance(choices, (set, frozenset)): + choices = sorted(choices) + else: + choices = sorted(choices) + return 'SPACK_COMPREPLY="{}"'.format(" ".join(str(c) for c in choices)) + # If no matches found, return empty list return 'SPACK_COMPREPLY=""' diff --git a/lib/spack/spack/test/cmd/commands.py b/lib/spack/spack/test/cmd/commands.py index 29f4e3857cfe26..c8362b539bc2ea 100644 --- a/lib/spack/spack/test/cmd/commands.py +++ b/lib/spack/spack/test/cmd/commands.py @@ -199,6 +199,14 @@ def test_bash_completion(): assert "_spack_compiler_add() {" in out2 +def test_bash_completion_choices(): + """Test that bash completion includes choices for positional arguments.""" + out = commands("--format=bash") + + # `spack env view` has a positional `action` with choices + assert 'SPACK_COMPREPLY="disable enable regenerate"' in out + + def test_fish_completion(): """Test the fish completion writer.""" out1 = commands("--format=fish") diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 463639513e064d..5f7157f7c502bd 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -1137,7 +1137,7 @@ _spack_env_view() { then SPACK_COMPREPLY="-h --help" else - SPACK_COMPREPLY="" + SPACK_COMPREPLY="disable enable regenerate" fi } From 58885eed00abe88fdd6811fab895ded3f5159f02 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Thu, 16 Apr 2026 16:52:58 +0200 Subject: [PATCH 276/337] environment: minor code cleanup (#52293) * environment: optimize concreteness check for included envs Replace two `Environment(env_path)` constructions, which come at the cost of additional YAML parsing and lock creation / destruction, with direct path and name computations. * environment: shorten creation of a list for include_concrete Signed-off-by: Massimiliano Culpo --- lib/spack/spack/environment/environment.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 2c97663aed0dc5..71147d8c800a0b 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -584,8 +584,8 @@ def validate_included_envs_concrete(include_concrete: List[str]) -> None: non_concrete_envs = set() for env_path in include_concrete: - if not os.path.exists(Environment(env_path).lock_path): - non_concrete_envs.add(Environment(env_path).name) + if not os.path.exists(os.path.join(env_path, lockfile_name)): + non_concrete_envs.add(environment_name(env_path)) if non_concrete_envs: msg = "The following environment(s) are not concrete: {0}\nPlease run:".format( @@ -3348,11 +3348,7 @@ def set_include_concrete(self, include_concrete: List[str]) -> None: Args: include_concrete: list of already existing concrete environments to include """ - self.configuration[lockfile_include_key] = [] - - for env_path in include_concrete: - self.configuration[lockfile_include_key].append(env_path) - + self.configuration[lockfile_include_key] = list(include_concrete) self.changed = True def add_definition(self, user_spec: str, list_name: str) -> None: From ef80d66fd0113e3b89fb1e45c9f3cdb64ee875b2 Mon Sep 17 00:00:00 2001 From: Caetano Melone Date: Thu, 16 Apr 2026 10:18:41 -0500 Subject: [PATCH 277/337] docs: explain stage_name config option (#52220) Signed-off-by: Caetano Melone --- etc/spack/defaults/base/config.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/etc/spack/defaults/base/config.yaml b/etc/spack/defaults/base/config.yaml index 96050aed47721c..ee4401b50fc3db 100644 --- a/etc/spack/defaults/base/config.yaml +++ b/etc/spack/defaults/base/config.yaml @@ -71,6 +71,9 @@ config: - $user_cache_path/stage # - $spack/var/spack/stage + # Naming format for individual stage directories + stage_name: "spack-stage-{name}-{version}-{hash}" + # Directory in which to run tests and store test results. # Tests will be stored in directories named by date/time and package # name/hash. @@ -89,7 +92,6 @@ config: # This can be purged with `spack clean --misc-cache` misc_cache: $user_cache_path/cache - # Abort downloads after this many seconds if not data is received. # Setting this to 0 will disable the timeout. connect_timeout: 30 From 2bad55629cc0270a25205707960f71ad05653dbd Mon Sep 17 00:00:00 2001 From: Greg Becker Date: Thu, 16 Apr 2026 19:45:57 -0700 Subject: [PATCH 278/337] Add a check that load_module succeeds (#51957) * Add a check that load_module succeeds It's non-trivial to check that the subprocess succeeded at loading the module. We check by comparing the LOADEDMODULES env var before/after loading. Based on work from #21253 and rebased/refactored with claude code. --------- Signed-off-by: Gregory Becker --- lib/spack/spack/build_environment.py | 4 +- lib/spack/spack/test/build_environment.py | 38 +++++++++++++- lib/spack/spack/test/compilers/libraries.py | 18 +++++++ lib/spack/spack/test/util/module_cmd.py | 57 +++++++++++++++++++++ lib/spack/spack/util/module_cmd.py | 21 ++++++++ 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 lib/spack/spack/test/util/module_cmd.py diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py index 0d1ad28139d0e4..abf2d61b6f041b 100644 --- a/lib/spack/spack/build_environment.py +++ b/lib/spack/spack/build_environment.py @@ -79,6 +79,7 @@ import spack.store import spack.subprocess_context import spack.util.executable +import spack.util.module_cmd from spack import traverse from spack.context import Context from spack.error import InstallError, NoHeadersError, NoLibrariesError @@ -100,7 +101,6 @@ ) from spack.util.executable import Executable from spack.util.log_parse import make_log_context, parse_log_events -from spack.util.module_cmd import load_module # # This can be set by the user to globally disable parallel builds. @@ -1117,7 +1117,7 @@ def load_external_modules(context: SetupContext) -> None: for spec, _ in context.external: external_modules = spec.external_modules or [] for external_module in external_modules: - load_module(external_module) + spack.util.module_cmd.load_module(external_module) def _setup_pkg_and_run( diff --git a/lib/spack/spack/test/build_environment.py b/lib/spack/spack/test/build_environment.py index 3c6c68f6a30971..4d6b6231686d41 100644 --- a/lib/spack/spack/test/build_environment.py +++ b/lib/spack/spack/test/build_environment.py @@ -20,6 +20,7 @@ import spack.package_base import spack.spec import spack.util.environment +import spack.util.module_cmd import spack.util.spack_yaml as syaml from spack.build_environment import UseMode, _static_to_shared_library, dso_suffix from spack.context import Context @@ -159,7 +160,7 @@ def _set_wrong_cc(x): os.environ["CC"] = "NOT_THIS_PLEASE" os.environ["ANOTHER_VAR"] = "THIS_IS_SET" - monkeypatch.setattr(spack.build_environment, "load_module", _set_wrong_cc) + monkeypatch.setattr(spack.util.module_cmd, "load_module", _set_wrong_cc) s = spack.concretize.concretize_one("cmake %gcc@14") spack.build_environment.setup_package(s.package, dirty=False) @@ -287,6 +288,39 @@ def platform_pathsep(pathlist): assert name not in os.environ +@pytest.mark.not_on_windows("Module files are not supported on Windows") +def test_load_external_modules_error(working_env, monkeypatch): + """Test that load_external_modules raises an exception when a module cannot be loaded""" + + # Create a mock spec object with the minimum attributes needed for the test + class MockSpec: + def __init__(self): + self.external_modules = ["non_existent_module"] + + def __str__(self): + return "mock-external-spec" + + mock_spec = MockSpec() + + # Create a simplified SetupContext-like class that only contains what we need + class MockSetupContext: + def __init__(self, spec): + self.external = [(spec, None)] + + context = MockSetupContext(mock_spec) + + # Mock the load_module function to raise an exception + def mock_load_module(module_name): + # Simulate module load failure + raise spack.util.module_cmd.ModuleLoadError(module_name) + + monkeypatch.setattr(spack.util.module_cmd, "load_module", mock_load_module) + + # Test that load_external_modules raises ModuleLoadError + with pytest.raises(spack.util.module_cmd.ModuleLoadError): + spack.build_environment.load_external_modules(context) + + def test_external_config_env(mock_packages, mutable_config, working_env): cmake_config = { "externals": [ @@ -320,7 +354,7 @@ def test_spack_paths_before_module_paths( def _set_wrong_cc(x): os.environ["PATH"] = module_path + os.pathsep + os.environ["PATH"] - monkeypatch.setattr(spack.build_environment, "load_module", _set_wrong_cc) + monkeypatch.setattr(spack.util.module_cmd, "load_module", _set_wrong_cc) s = spack.concretize.concretize_one("cmake") diff --git a/lib/spack/spack/test/compilers/libraries.py b/lib/spack/spack/test/compilers/libraries.py index 74591af4230c1d..6b042de4c9cc7e 100644 --- a/lib/spack/spack/test/compilers/libraries.py +++ b/lib/spack/spack/test/compilers/libraries.py @@ -94,6 +94,7 @@ def module(*args): return "" elif args[0] == "load": monkeypatch.setenv("MODULE_LOADED", "1") + monkeypatch.setenv("LOADEDMODULES", "turn_on") monkeypatch.setattr(spack.util.module_cmd, "module", module) @@ -125,3 +126,20 @@ def test_compiler_environment(self, working_env, mock_gcc, monkeypatch): detector = spack.compilers.libraries.CompilerPropertyDetector(mock_gcc) with detector.compiler_environment(): assert os.environ["TEST"] == "yes" + + @pytest.mark.not_on_windows("Module files are not supported on Windows") + def test_compiler_invalid_module_raises(self, working_env, mock_gcc, monkeypatch): + """Test if an exception is raised when a module cannot be loaded""" + + def mock_load_module(module_name): + # Simulate module load failure + raise spack.util.module_cmd.ModuleLoadError(module_name) + + monkeypatch.setattr(spack.util.module_cmd, "load_module", mock_load_module) + + mock_gcc.external_modules = ["non_existent"] + detector = spack.compilers.libraries.CompilerPropertyDetector(mock_gcc) + + with pytest.raises(spack.util.module_cmd.ModuleLoadError): + with detector.compiler_environment(): + pass diff --git a/lib/spack/spack/test/util/module_cmd.py b/lib/spack/spack/test/util/module_cmd.py new file mode 100644 index 00000000000000..d348a1bf4503d9 --- /dev/null +++ b/lib/spack/spack/test/util/module_cmd.py @@ -0,0 +1,57 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +import os + +import pytest + +import spack.util.module_cmd + + +@pytest.mark.not_on_windows("Module files are not supported on Windows") +def test_load_module_success(monkeypatch, working_env): + """Test that load_module properly handles successful module loads. + + This is a very lightweight test that only confirms that successful + loads are not flagged as failed.""" + + # Mock the module function to simulate a successful module load + def mock_module(*args, **kwargs): + if args[0] == "show": + return "" + elif args[0] == "load": + # Simulate successful module load by adding to LOADEDMODULES + current_modules = os.environ.get("LOADEDMODULES", "") + if current_modules: + os.environ["LOADEDMODULES"] = f"{current_modules}:{args[1]}" + else: + os.environ["LOADEDMODULES"] = args[1] + + monkeypatch.setattr(spack.util.module_cmd, "module", mock_module) + + # This should succeed + spack.util.module_cmd.load_module("test_module") + spack.util.module_cmd.load_module("test_module_2") + + # Confirm LOADEDMODULES was modified + assert "test_module:test_module_2" in os.environ["LOADEDMODULES"] + + +@pytest.mark.not_on_windows("Module files are not supported on Windows") +def test_load_module_failure(monkeypatch, working_env): + """Test that load_module raises an exception when a module load fails.""" + + # Mock the module function to simulate a failed module load + def mock_module(*args, **kwargs): + if args[0] == "show": + return "" + elif args[0] == "load": + # Simulate module load failure by not changing LOADEDMODULES + pass + + monkeypatch.setattr(spack.util.module_cmd, "module", mock_module) + + # This should fail with ModuleLoadError + with pytest.raises(spack.util.module_cmd.ModuleLoadError): + spack.util.module_cmd.load_module("non_existent_module") diff --git a/lib/spack/spack/util/module_cmd.py b/lib/spack/spack/util/module_cmd.py index f6c63467e27925..6247d20a7d0107 100644 --- a/lib/spack/spack/util/module_cmd.py +++ b/lib/spack/spack/util/module_cmd.py @@ -12,6 +12,7 @@ from typing import MutableMapping, Optional import spack.llnl.util.tty as tty +from spack.error import SpackError # This list is not exhaustive. Currently we only use load and unload # If we need another option that changes the environment, add it here. @@ -87,6 +88,9 @@ def load_module(mod): """Takes a module name and removes modules until it is possible to load that module. It then loads the provided module. Depends on the modulecmd implementation of modules used in cray and lmod. + + Raises: + ModuleLoadError: if the module could not be loaded """ tty.debug("module_cmd.load_module: {0}".format(mod)) # Read the module and remove any conflicting modules @@ -98,10 +102,20 @@ def load_module(mod): if word == "conflict": module("unload", text[i + 1]) + # Store the LOADEDMODULES before trying to load the new module + loaded_modules_before = os.environ.get("LOADEDMODULES", "") + # Load the module now that there are no conflicts # Some module systems use stdout and some use stderr module("load", mod) + # Check if the module was actually loaded by comparing LOADEDMODULES + loaded_modules_after = os.environ.get("LOADEDMODULES", "") + + # If LOADEDMODULES didn't change, the module wasn't loaded + if loaded_modules_before == loaded_modules_after: + raise ModuleLoadError(mod) + def get_path_args_from_module_line(line): if "(" in line and ")" in line: @@ -237,3 +251,10 @@ def match_flag_and_strip(line, flag, strip=[]): # Unable to find path in module return None + + +class ModuleLoadError(SpackError): + """Raised when a module cannot be loaded.""" + + def __init__(self, module): + super().__init__(f"Module '{module}' could not be loaded.") From c21db15fff3fcee11f3c044e6e1f1d862b8166f0 Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:57:49 -0400 Subject: [PATCH 279/337] Spack Style: Ruff (#52156) * Adds ruff as a command in Spack style * ruff format and ruff check are each their own tool * Adds setup methods for each format and check with a core method to invoke ruff * Refactors the usage of the tool registry to avoid passing superfluous information around * Converts the style module to use pathlib when appropriate * Updates style tests to be appropriate for ruff (basically just a lot of flake8|black|isort -> ruff) * Large refactor of style module to remove unnecessary code now that ruff can run on the whole codebase in less than a second --------- Signed-off-by: John Parent --- .github/workflows/bootstrap.yml | 28 + .../requirements/style/requirements.txt | 1 + .../requirements/unit_tests/requirements.txt | 6 + .github/workflows/unit_tests.yaml | 17 +- lib/spack/docs/requirements.txt | 1 + lib/spack/spack/audit.py | 1 + lib/spack/spack/binary_distribution.py | 31 +- lib/spack/spack/bootstrap/_common.py | 1 + lib/spack/spack/bootstrap/clingo.py | 1 + lib/spack/spack/bootstrap/config.py | 4 +- lib/spack/spack/bootstrap/environment.py | 63 ++- lib/spack/spack/bootstrap/status.py | 26 +- lib/spack/spack/build_environment.py | 6 +- lib/spack/spack/caches.py | 1 + lib/spack/spack/ci/common.py | 1 - lib/spack/spack/ci/generator_registry.py | 1 + lib/spack/spack/cmd/bootstrap.py | 8 +- lib/spack/spack/cmd/buildcache.py | 1 - lib/spack/spack/cmd/ci.py | 6 +- lib/spack/spack/cmd/common/arguments.py | 8 +- lib/spack/spack/cmd/common/spec_strings.py | 232 ++++++++ lib/spack/spack/cmd/config.py | 3 +- lib/spack/spack/cmd/create.py | 9 +- lib/spack/spack/cmd/deprecate.py | 1 + lib/spack/spack/cmd/list.py | 4 +- lib/spack/spack/cmd/mirror.py | 5 +- lib/spack/spack/cmd/stage.py | 1 - lib/spack/spack/cmd/style.py | 507 +++++------------- lib/spack/spack/cmd/view.py | 1 + lib/spack/spack/compilers/config.py | 1 + lib/spack/spack/concretize.py | 7 +- lib/spack/spack/config.py | 15 +- lib/spack/spack/container/__init__.py | 1 + lib/spack/spack/container/images.py | 1 + lib/spack/spack/container/writers.py | 3 +- lib/spack/spack/dependency.py | 1 + lib/spack/spack/detection/common.py | 1 + lib/spack/spack/detection/path.py | 1 + lib/spack/spack/detection/test.py | 1 + lib/spack/spack/directives.py | 6 +- lib/spack/spack/directory_layout.py | 6 +- lib/spack/spack/enums.py | 1 + lib/spack/spack/environment/__init__.py | 9 +- lib/spack/spack/environment/environment.py | 10 +- lib/spack/spack/environment/shell.py | 2 +- lib/spack/spack/extensions.py | 1 + lib/spack/spack/externals.py | 1 + lib/spack/spack/fetch_strategy.py | 1 + lib/spack/spack/graph.py | 5 +- lib/spack/spack/hooks/__init__.py | 1 + lib/spack/spack/hooks/licensing.py | 16 +- lib/spack/spack/install_test.py | 10 +- lib/spack/spack/installer.py | 27 +- lib/spack/spack/llnl/path.py | 1 + lib/spack/spack/llnl/string.py | 1 + lib/spack/spack/llnl/url.py | 11 +- lib/spack/spack/llnl/util/argparsewriter.py | 16 +- lib/spack/spack/llnl/util/filesystem.py | 1 - lib/spack/spack/llnl/util/lang.py | 2 +- lib/spack/spack/llnl/util/tty/colify.py | 1 + lib/spack/spack/llnl/util/tty/color.py | 1 + lib/spack/spack/llnl/util/tty/log.py | 1 + lib/spack/spack/main.py | 5 +- lib/spack/spack/mixins.py | 1 + lib/spack/spack/modules/common.py | 1 + lib/spack/spack/modules/tcl.py | 1 + lib/spack/spack/multimethod.py | 7 +- lib/spack/spack/new_installer.py | 1 - lib/spack/spack/package.py | 3 +- lib/spack/spack/package_base.py | 11 +- lib/spack/spack/paths.py | 1 + lib/spack/spack/provider_index.py | 1 + lib/spack/spack/relocate.py | 5 +- lib/spack/spack/repo.py | 5 +- lib/spack/spack/repo_migrate.py | 1 - lib/spack/spack/report.py | 3 +- lib/spack/spack/rewiring.py | 4 +- lib/spack/spack/schema/__init__.py | 1 + lib/spack/spack/schema/bootstrap.py | 14 + lib/spack/spack/schema/buildcache_spec.py | 1 + lib/spack/spack/schema/cdash.py | 1 + lib/spack/spack/schema/ci.py | 1 + lib/spack/spack/schema/compilers.py | 1 + lib/spack/spack/schema/concretizer.py | 1 + lib/spack/spack/schema/config.py | 1 + lib/spack/spack/schema/container.py | 1 + lib/spack/spack/schema/cray_manifest.py | 1 + lib/spack/spack/schema/database_index.py | 1 + lib/spack/spack/schema/definitions.py | 1 + lib/spack/spack/schema/develop.py | 3 +- lib/spack/spack/schema/env_vars.py | 1 + lib/spack/spack/schema/environment.py | 1 + lib/spack/spack/schema/include.py | 1 + lib/spack/spack/schema/merged.py | 1 + lib/spack/spack/schema/mirrors.py | 1 + lib/spack/spack/schema/modules.py | 1 + lib/spack/spack/schema/packages.py | 1 + lib/spack/spack/schema/projections.py | 1 + lib/spack/spack/schema/spec.py | 1 + lib/spack/spack/schema/spec_list.py | 2 +- lib/spack/spack/schema/toolchains.py | 1 + .../spack/schema/url_buildcache_manifest.py | 1 + lib/spack/spack/schema/view.py | 1 + lib/spack/spack/solver/asp.py | 12 +- lib/spack/spack/solver/core.py | 1 + lib/spack/spack/solver/input_analysis.py | 1 + lib/spack/spack/solver/runtimes.py | 2 +- lib/spack/spack/spec.py | 10 +- lib/spack/spack/spec_parser.py | 3 +- lib/spack/spack/store.py | 1 + lib/spack/spack/subprocess_context.py | 1 + lib/spack/spack/tag.py | 1 + lib/spack/spack/test/binary_distribution.py | 12 +- lib/spack/spack/test/bootstrap.py | 4 +- lib/spack/spack/test/cc.py | 1 + lib/spack/spack/test/cmd/checksum.py | 4 + .../spack/test/cmd/common/spec_strings.py | 112 ++++ lib/spack/spack/test/cmd/env.py | 17 +- lib/spack/spack/test/cmd/find.py | 18 +- lib/spack/spack/test/cmd/gpg.py | 2 +- lib/spack/spack/test/cmd/info.py | 3 +- lib/spack/spack/test/cmd/install.py | 2 +- lib/spack/spack/test/cmd/list.py | 18 +- lib/spack/spack/test/cmd/stage.py | 6 +- lib/spack/spack/test/cmd/style.py | 252 +++------ lib/spack/spack/test/cmd/verify.py | 1 + lib/spack/spack/test/cmd_extensions.py | 4 +- lib/spack/spack/test/compilers/conversion.py | 1 + lib/spack/spack/test/concretization/core.py | 26 +- lib/spack/spack/test/concretization/errors.py | 3 +- .../spack/test/concretization/requirements.py | 8 +- lib/spack/spack/test/config.py | 42 +- lib/spack/spack/test/conftest.py | 4 +- lib/spack/spack/test/cray_manifest.py | 1 + lib/spack/spack/test/database.py | 11 +- lib/spack/spack/test/directory_layout.py | 1 + lib/spack/spack/test/installer.py | 6 +- lib/spack/spack/test/installer_build_graph.py | 1 + lib/spack/spack/test/llnl/url.py | 1 + lib/spack/spack/test/llnl/util/filesystem.py | 1 + lib/spack/spack/test/llnl/util/lock.py | 1 + lib/spack/spack/test/llnl/util/symlink.py | 1 + lib/spack/spack/test/main.py | 4 +- lib/spack/spack/test/make_executable.py | 1 + lib/spack/spack/test/modules/common.py | 8 +- lib/spack/spack/test/oci/image.py | 10 +- lib/spack/spack/test/oci/integration_test.py | 2 +- lib/spack/spack/test/oci/mock_registry.py | 7 +- lib/spack/spack/test/packaging.py | 1 + lib/spack/spack/test/patch.py | 6 +- lib/spack/spack/test/provider_index.py | 1 + lib/spack/spack/test/repo.py | 6 +- lib/spack/spack/test/reporters.py | 12 +- lib/spack/spack/test/sbang.py | 5 +- lib/spack/spack/test/spack_yaml.py | 1 + lib/spack/spack/test/spec_dag.py | 1 + lib/spack/spack/test/spec_semantics.py | 6 +- lib/spack/spack/test/spec_syntax.py | 8 +- lib/spack/spack/test/spec_yaml.py | 1 + lib/spack/spack/test/stage.py | 1 + lib/spack/spack/test/tag.py | 1 + lib/spack/spack/test/util/environment.py | 1 + lib/spack/spack/test/util/executable.py | 4 +- lib/spack/spack/test/util/file_cache.py | 1 + .../spack/test/util/spack_lock_wrapper.py | 1 + lib/spack/spack/test/util/util_url.py | 1 + lib/spack/spack/test/utilities.py | 1 + lib/spack/spack/test/verification.py | 1 + lib/spack/spack/test/versions.py | 1 + lib/spack/spack/tokenize.py | 1 + lib/spack/spack/url.py | 3 +- lib/spack/spack/util/ctest_log_parser.py | 2 +- lib/spack/spack/util/editor.py | 1 + lib/spack/spack/util/environment.py | 1 + lib/spack/spack/util/lock.py | 1 + lib/spack/spack/util/module_cmd.py | 3 +- lib/spack/spack/util/path.py | 1 + lib/spack/spack/util/prefix.py | 1 + lib/spack/spack/util/remote_file_cache.py | 6 +- lib/spack/spack/util/spack_json.py | 1 + lib/spack/spack/util/spack_yaml.py | 1 + lib/spack/spack/util/timer.py | 1 + lib/spack/spack/util/typing.py | 1 - lib/spack/spack/util/unparse/unparser.py | 1 + lib/spack/spack/util/web.py | 2 +- lib/spack/spack/variant.py | 3 +- lib/spack/spack/version/version_types.py | 2 +- pyproject.toml | 43 +- share/spack/qa/config_state.py | 1 + share/spack/spack-completion.fish | 6 +- share/spack/templates/bootstrap/spack.yaml | 12 +- .../packages/mixing_parent/package.py | 1 - .../packages/{flake8 => ruff}/package.py | 40 +- .../tutorial/packages/hdf5/package.py | 13 +- 194 files changed, 1069 insertions(+), 998 deletions(-) create mode 100644 .github/workflows/requirements/unit_tests/requirements.txt create mode 100644 lib/spack/spack/cmd/common/spec_strings.py create mode 100644 lib/spack/spack/test/cmd/common/spec_strings.py rename var/spack/test_repos/spack_repo/builtin_mock/packages/{flake8 => ruff}/package.py (59%) diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 88b2087f5f88a8..66205e3ed1b72f 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -189,3 +189,31 @@ jobs: spack -d gpg list ./share/spack/qa/validate_last_exit.ps1 tree $env:userprofile/.spack/bootstrap/store/ + + dev-bootstrap: + runs-on: ubuntu-latest + container: registry.access.redhat.com/ubi8/ubi + steps: + - name: Install dependencies + run: | + dnf install -y \ + bzip2 curl gcc-c++ gcc gcc-gfortran git gnupg2 gzip \ + make patch python3.11 tcl unzip which xz + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Setup repo and non-root user + run: | + git --version + git config --global --add safe.directory '*' + git fetch --unshallow + . .github/workflows/bin/setup_git.sh + - name: Setup a virtual environment with platform-python + run: | + python3.11 -m venv ~/platform-spack-311 + source ~/platform-spack-311/bin/activate + pip install --upgrade pip clingo + - name: Bootstrap Spack development environment + run: | + source ~/platform-spack-311/bin/activate + source share/spack/setup-env.sh + spack debug report + spack -d bootstrap now --dev diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index 443e179f62c87a..f98de1c9ab517f 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -8,3 +8,4 @@ pylint==4.0.5 docutils==0.22.4 ruamel.yaml==0.19.1 slotscheck==0.19.1 +ruff==0.15.7 diff --git a/.github/workflows/requirements/unit_tests/requirements.txt b/.github/workflows/requirements/unit_tests/requirements.txt new file mode 100644 index 00000000000000..369ee2aced9661 --- /dev/null +++ b/.github/workflows/requirements/unit_tests/requirements.txt @@ -0,0 +1,6 @@ +pytest==9.0.2 +pytest-cov==7.1.0 +pytest-xdist==3.3.1 +coverage[toml]<=7.11.0 +clingo==5.8.0 +click==8.1.7 diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index a0c02640c573f7..159e517de861d6 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -60,7 +60,7 @@ jobs: run: | # See https://github.com/coveragepy/coveragepy/issues/2082 pip install --upgrade pip pytest pytest-xdist pytest-cov "coverage<=7.11.0" - pip install --upgrade flake8 "isort>=4.3.5" "mypy>=0.900" "click" "black" + pip install --upgrade "mypy>=0.900" "click" "ruff" - name: Setup git configuration run: | # Need this for the git tests to succeed. @@ -112,7 +112,7 @@ jobs: run: "brew install kcov" - name: Install Python packages run: | - pip install --upgrade pip pytest coverage[toml] pytest-xdist + pip install --upgrade pip -r .github/workflows/requirements/unit_tests/requirements.txt - name: Setup git configuration run: | # Need this for the git tests to succeed. @@ -149,7 +149,7 @@ jobs: . .github/workflows/bin/setup_git.sh - name: Setup a virtual environment with platform-python run: | - /usr/libexec/platform-python -m venv ~/platform-spack + /usr/libexec/platform-python -m venv ~/platform-spack source ~/platform-spack/bin/activate pip install --upgrade pip pytest coverage[toml] pytest-xdist - name: Bootstrap Spack development environment and run unit tests @@ -157,7 +157,7 @@ jobs: source ~/platform-spack/bin/activate source share/spack/setup-env.sh spack debug report - spack -d bootstrap now --dev + spack -d bootstrap now pytest --verbose -x -n3 --dist loadfile -k 'not cvs and not svn and not hg' # Test for the clingo based solver (using clingo-cffi) clingo-cffi: @@ -175,8 +175,8 @@ jobs: sudo apt-get -y install coreutils gfortran graphviz gnupg2 - name: Install Python packages run: | - pip install --upgrade pip pytest coverage[toml] pytest-cov clingo pytest-xdist - pip install --upgrade flake8 "isort>=4.3.5" "mypy>=0.900" "click" "black" + pip install --upgrade pip -r .github/workflows/requirements/unit_tests/requirements.txt + pip install --upgrade -r .github/workflows/requirements/style/requirements.txt - name: Run unit tests (full suite with coverage) env: COVERAGE: true @@ -212,7 +212,7 @@ jobs: run: | pip install --upgrade pip # See https://github.com/coveragepy/coveragepy/issues/2082 - pip install --upgrade pytest coverage[toml] pytest-xdist pytest-cov "coverage<=7.11.0" + pip install --upgrade -r .github/workflows/requirements/unit_tests/requirements.txt - name: Setup Homebrew packages run: | brew install dash fish gcc gnupg kcov @@ -247,7 +247,8 @@ jobs: python-version: '3.14' - name: Install Python packages run: | - python -m pip install --upgrade pip pywin32 pytest-cov clingo "coverage<=7.11.0" + python -m pip install --upgrade pip pywin32 -r .github/workflows/requirements/unit_tests/requirements.txt + python -m pip install --upgrade pip -r .github/workflows/requirements/style/requirements.txt - name: Create local develop run: | ./.github/workflows/bin/setup_git.ps1 diff --git a/lib/spack/docs/requirements.txt b/lib/spack/docs/requirements.txt index 3286b63ae285e2..8355adc6b6066c 100644 --- a/lib/spack/docs/requirements.txt +++ b/lib/spack/docs/requirements.txt @@ -7,3 +7,4 @@ sphinx-sitemap==2.9.0 furo==2025.12.19 docutils==0.22.4 pygments==2.20.0 +pytest==9.0.2 diff --git a/lib/spack/spack/audit.py b/lib/spack/spack/audit.py index 2393b4571ed69b..9065510fc92f6d 100644 --- a/lib/spack/spack/audit.py +++ b/lib/spack/spack/audit.py @@ -34,6 +34,7 @@ def _search_duplicate_compilers(error_cls): the decorator object, that will forward the keyword arguments passed as input. """ + import ast import collections import collections.abc diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index 34cb2041016ebe..96b08cd649cd42 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -2251,17 +2251,22 @@ def get_keys( if not mirror_collection: tty.die("Please add a spack mirror to allow " + "download of build caches.") + fingerprints = [] for mirror in mirror_collection.values(): if not mirror.signed: # Don't bother fetching keys for unsigned mirrors continue - for layout_version in mirror.supported_layout_versions: fetch_url = mirror.fetch_url if layout_version == 2: - _get_keys_v2(fetch_url, install, trust, force) + mirror_layout_fingerprints = _get_keys_v2(fetch_url, install, trust, force) else: - _get_keys(fetch_url, layout_version, install, trust, force) + mirror_layout_fingerprints = _get_keys( + fetch_url, layout_version, install, trust, force + ) + if mirror_layout_fingerprints: + fingerprints.extend(mirror_layout_fingerprints) + return fingerprints def _get_keys( @@ -2270,7 +2275,7 @@ def _get_keys( install: bool = False, trust: bool = False, force: bool = False, -) -> None: +) -> Optional[List[str]]: cache_class = get_url_buildcache_class(layout_version=layout_version) tty.debug("Finding public keys in {0}".format(url_util.format(mirror_url))) @@ -2287,12 +2292,13 @@ def _get_keys( except BuildcacheEntryError as e: tty.debug(f"Failed to fetch key index due to: {e}") index_entry.destroy() - return + return None with open(index_blob_path, encoding="utf-8") as fd: json_index = json.load(fd) index_entry.destroy() + saved_fingerprints = [] for fingerprint, _ in json_index["keys"].items(): key_manifest_url = url_util.join(keys_prefix, f"{fingerprint}.key.manifest.json") key_entry = cache_class(mirror_url, allow_unsigned=True) @@ -2309,16 +2315,17 @@ def _get_keys( if trust: spack.util.gpg.trust(key_blob_path) tty.debug(f"Added {fingerprint} to trusted keys.") + saved_fingerprints.append(fingerprint) else: tty.debug( - "Will not add this key to trusted keys." - "Use -t to install all downloaded keys" + "Will not add this key to trusted keys.Use -t to install all downloaded keys" ) key_entry.destroy() + return saved_fingerprints -def _get_keys_v2(mirror_url, install=False, trust=False, force=False): +def _get_keys_v2(mirror_url, install=False, trust=False, force=False) -> Optional[List[str]]: cache_class = get_url_buildcache_class(layout_version=2) keys_url = url_util.join( @@ -2338,8 +2345,9 @@ def _get_keys_v2(mirror_url, install=False, trust=False, force=False): f" caught exception attempting to read from {url_util.format(keys_index)}." ) tty.error(url_err) - return + return None + saved_fingerprints = [] for fingerprint, key_attributes in json_index["keys"].items(): link = os.path.join(keys_url, fingerprint + ".pub") @@ -2357,11 +2365,12 @@ def _get_keys_v2(mirror_url, install=False, trust=False, force=False): if trust: spack.util.gpg.trust(stage.save_filename) tty.debug("Added this key to trusted keys.") + saved_fingerprints.append(fingerprint) else: tty.debug( - "Will not add this key to trusted keys." - "Use -t to install all downloaded keys" + "Will not add this key to trusted keys.Use -t to install all downloaded keys" ) + return saved_fingerprints def _url_push_keys( diff --git a/lib/spack/spack/bootstrap/_common.py b/lib/spack/spack/bootstrap/_common.py index 8531f92df058db..f1b45364e9d5fa 100644 --- a/lib/spack/spack/bootstrap/_common.py +++ b/lib/spack/spack/bootstrap/_common.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Common basic functions used through the spack.bootstrap package""" + import fnmatch import glob import importlib diff --git a/lib/spack/spack/bootstrap/clingo.py b/lib/spack/spack/bootstrap/clingo.py index eac32e18656e82..130dcd5b7f7424 100644 --- a/lib/spack/spack/bootstrap/clingo.py +++ b/lib/spack/spack/bootstrap/clingo.py @@ -9,6 +9,7 @@ This module contains the logic to get a concrete spec for clingo, starting from a prototype JSON file for a similar platform. """ + import pathlib import sys from typing import Dict, Optional, Tuple, Type diff --git a/lib/spack/spack/bootstrap/config.py b/lib/spack/spack/bootstrap/config.py index ef048e24d23022..09fec22acea7d5 100644 --- a/lib/spack/spack/bootstrap/config.py +++ b/lib/spack/spack/bootstrap/config.py @@ -147,9 +147,7 @@ def _ensure_bootstrap_configuration() -> Generator: ), spack.config.use_configuration( # Default configuration scopes excluding command line and builtin *_bootstrap_config_scopes() - ), spack.store.use_store( - bootstrap_store_path, extra_data={"padded_length": 0} - ): + ), spack.store.use_store(bootstrap_store_path, extra_data={"padded_length": 0}): spack.config.set("bootstrap", user_configuration["bootstrap"]) spack.config.set("config", user_configuration["config"]) spack.config.set("repos", user_configuration["repos"]) diff --git a/lib/spack/spack/bootstrap/environment.py b/lib/spack/spack/bootstrap/environment.py index 999db5d1f8cab3..a5d311dbe5d1e0 100644 --- a/lib/spack/spack/bootstrap/environment.py +++ b/lib/spack/spack/bootstrap/environment.py @@ -2,6 +2,8 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Bootstrap non-core Spack dependencies from an environment.""" + +import contextlib import hashlib import os import pathlib @@ -10,13 +12,15 @@ import spack.vendor.archspec.cpu +import spack.binary_distribution +import spack.config import spack.environment import spack.spec import spack.tengine +import spack.util.gpg import spack.util.path from spack.llnl.util import tty -from ._common import _root_spec from .config import root_path, spec_for_current_python, store_path from .core import _add_externals_if_missing @@ -37,13 +41,7 @@ def __init__(self) -> None: @classmethod def spack_dev_requirements(cls) -> List[str]: """Spack development requirements""" - return [ - isort_root_spec(), - mypy_root_spec(), - black_root_spec(), - flake8_root_spec(), - pytest_root_spec(), - ] + return [pytest_root_spec(), ruff_root_spec(), mypy_root_spec()] @classmethod def environment_root(cls) -> pathlib.Path: @@ -78,6 +76,12 @@ def spack_yaml(cls) -> pathlib.Path: """Environment spack.yaml file""" return cls.environment_root().joinpath("spack.yaml") + @contextlib.contextmanager + def trust_bootstrap_mirror_keys(self): + with spack.util.gpg.gnupghome_override(os.path.join(root_path(), ".bootstrap-gpg")): + spack.binary_distribution.get_keys(install=True, trust=True) + yield + def update_installations(self) -> None: """Update the installations of this environment.""" log_enabled = tty.is_debug() or tty.is_verbose() @@ -91,8 +95,16 @@ def update_installations(self) -> None: tty.msg(f"[BOOTSTRAPPING] Installing dependencies ({', '.join(colorized_specs)})") self.write(regenerate=False) with tty.SuppressOutput(msg_enabled=log_enabled, warn_enabled=log_enabled): - self.install_all(fail_fast=True) - self.write(regenerate=True) + with self.trust_bootstrap_mirror_keys(): + fetch_policy = ( + "cache_only" + if not spack.config.get("bootstrap:dev:enable_source", False) + else "auto" + ) + self.install_all( + fail_fast=True, root_policy=fetch_policy, dependencies_policy=fetch_policy + ) + self.write(regenerate=True) def load(self) -> None: """Update PATH and sys.path.""" @@ -115,34 +127,35 @@ def _write_spack_yaml_file(self) -> None: "environment_path": self.environment_root(), "environment_specs": self.spack_dev_requirements(), "store_path": store_path(), + "bootstrap_mirrors": dev_bootstrap_mirror_names(), } self.environment_root().mkdir(parents=True, exist_ok=True) self.spack_yaml().write_text(template.render(context), encoding="utf-8") -def isort_root_spec() -> str: - """Return the root spec used to bootstrap isort""" - return _root_spec("py-isort@5") - - def mypy_root_spec() -> str: """Return the root spec used to bootstrap mypy""" - return _root_spec("py-mypy@0.900: ^py-mypy-extensions@:1.0") + return "py-mypy@0.900: ^py-mypy-extensions@:1.0" -def black_root_spec() -> str: - """Return the root spec used to bootstrap black""" - return _root_spec("py-black@:25.1.0") +def pytest_root_spec() -> str: + """Return the root spec used to bootstrap pytest""" + return "py-pytest@6.2.4:" -def flake8_root_spec() -> str: - """Return the root spec used to bootstrap flake8""" - return _root_spec("py-flake8@3.8.2:") +def ruff_root_spec() -> str: + """Return the root spec used to bootstrap ruff""" + return "py-ruff@0.15.0" -def pytest_root_spec() -> str: - """Return the root spec used to bootstrap flake8""" - return _root_spec("py-pytest@6.2.4:") +def dev_bootstrap_mirror_names() -> List[str]: + """Return the mirror names used for bootstrapping dev + requirements""" + return [ + "developer-tools-darwin", + "developer-tools-x86_64_v3-linux-gnu", + "developer-tools-aarch64-linux-gnu", + ] def ensure_environment_dependencies() -> None: diff --git a/lib/spack/spack/bootstrap/status.py b/lib/spack/spack/bootstrap/status.py index 8a0210ead1d223..4e1fbda20b7661 100644 --- a/lib/spack/spack/bootstrap/status.py +++ b/lib/spack/spack/bootstrap/status.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Query the status of bootstrapping on this machine""" + import sys from typing import List, Optional, Sequence, Tuple, Union @@ -10,14 +11,7 @@ from ._common import _executables_in_store, _python_import, _try_import_from_store from .config import ensure_bootstrap_configuration from .core import clingo_root_spec, gnupg_root_spec, patchelf_root_spec -from .environment import ( - BootstrapEnvironment, - black_root_spec, - flake8_root_spec, - isort_root_spec, - mypy_root_spec, - pytest_root_spec, -) +from .environment import BootstrapEnvironment, mypy_root_spec, pytest_root_spec, ruff_root_spec ExecutablesType = Union[str, Sequence[str]] RequiredResponseType = Tuple[bool, Optional[str]] @@ -126,20 +120,16 @@ def _development_requirements() -> List[RequiredResponseType]: env.load() return [ - _required_executable( - "isort", isort_root_spec(), _missing("isort", "required for style checks", False) - ), - _required_executable( - "mypy", mypy_root_spec(), _missing("mypy", "required for style checks", False) + _required_python_module( + "pytest", pytest_root_spec(), _missing("pytest", "required to run unit-test", False) ), _required_executable( - "flake8", flake8_root_spec(), _missing("flake8", "required for style checks", False) + "ruff", + ruff_root_spec(), + _missing("ruff", "required for code checking/formatting", False), ), _required_executable( - "black", black_root_spec(), _missing("black", "required for code formatting", False) - ), - _required_python_module( - "pytest", pytest_root_spec(), _missing("pytest", "required to run unit-test", False) + "mypy", mypy_root_spec(), _missing("mypy", "required for type checks", False) ), ] diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py index abf2d61b6f041b..07c74bfc3df5e9 100644 --- a/lib/spack/spack/build_environment.py +++ b/lib/spack/spack/build_environment.py @@ -787,9 +787,9 @@ def setup_package(pkg, dirty, context: Context = Context.BUILD): tty.debug("setup_package: adding compiler wrappers paths") env_by_name = env_mods.group_by_name() for x in env_by_name["SPACK_COMPILER_WRAPPER_PATH"]: - assert isinstance( - x, PrependPath - ), "unexpected setting used for SPACK_COMPILER_WRAPPER_PATH" + assert isinstance(x, PrependPath), ( + "unexpected setting used for SPACK_COMPILER_WRAPPER_PATH" + ) env_mods.prepend_path("PATH", x.value) # Check whether we want to force RPATH or RUNPATH diff --git a/lib/spack/spack/caches.py b/lib/spack/spack/caches.py index ffaaf00d655d2b..66f44952726943 100644 --- a/lib/spack/spack/caches.py +++ b/lib/spack/spack/caches.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Caches used by Spack to store data""" + import os from typing import cast diff --git a/lib/spack/spack/ci/common.py b/lib/spack/spack/ci/common.py index ee3eadacbb3b7a..581ce99b5704a0 100644 --- a/lib/spack/spack/ci/common.py +++ b/lib/spack/spack/ci/common.py @@ -92,7 +92,6 @@ def copy_files_to_artifacts( compress_artifacts (bool): option to compress copied artifacts using Gzip """ try: - if compress_artifacts: copy_gzipped(src, artifacts_dir) else: diff --git a/lib/spack/spack/ci/generator_registry.py b/lib/spack/spack/ci/generator_registry.py index f77b257e162e9d..70572f4c83a2ec 100644 --- a/lib/spack/spack/ci/generator_registry.py +++ b/lib/spack/spack/ci/generator_registry.py @@ -5,6 +5,7 @@ """Generators that support writing out pipelines for various CI platforms, using a common pipeline graph definition. """ + import spack.error _generators = {} diff --git a/lib/spack/spack/cmd/bootstrap.py b/lib/spack/spack/cmd/bootstrap.py index 0de13d0d2fa2c1..a5b9dda0f99a6e 100644 --- a/lib/spack/spack/cmd/bootstrap.py +++ b/lib/spack/spack/cmd/bootstrap.py @@ -270,9 +270,8 @@ def _write_bootstrapping_source_status(name, enabled, scope=None): matches = [s for s in sources if s["name"] == name] if not matches: names = [s["name"] for s in sources] - msg = ( - 'there is no bootstrapping method named "{0}". Valid ' - "method names are: {1}".format(name, ", ".join(names)) + msg = 'there is no bootstrapping method named "{0}". Valid method names are: {1}'.format( + name, ", ".join(names) ) raise RuntimeError(msg) @@ -381,8 +380,7 @@ def _remove(args): sources = [s for s in sources if s["name"] != args.name] spack.config.set("bootstrap:sources", sources, scope=current_scope) msg = ( - 'Removed the bootstrapping source named "{0}" from the ' - '"{1}" configuration scope.' + 'Removed the bootstrapping source named "{0}" from the "{1}" configuration scope.' ) spack.llnl.util.tty.msg(msg.format(args.name, current_scope)) trusted = spack.config.get("bootstrap:trusted", scope=current_scope) or [] diff --git a/lib/spack/spack/cmd/buildcache.py b/lib/spack/spack/cmd/buildcache.py index 48b95987f49410..312b9de8262543 100644 --- a/lib/spack/spack/cmd/buildcache.py +++ b/lib/spack/spack/cmd/buildcache.py @@ -1043,7 +1043,6 @@ def check_index_fn(args): ) for spec_manifest in manifest_files: - # Spec manifests have a naming format # --.spec.manifest.json spec_hash = spec_manifest.rsplit("-", 1)[1].split(".", 1)[0] diff --git a/lib/spack/spack/cmd/ci.py b/lib/spack/spack/cmd/ci.py index ce41ba078c42a8..99b27235566870 100644 --- a/lib/spack/spack/cmd/ci.py +++ b/lib/spack/spack/cmd/ci.py @@ -740,9 +740,9 @@ def validate_standard_versions( for version in versions: url = pkg.find_valid_url_for_version(version) - assert ( - url is not None - ), f"Package {pkg.name} does not have a valid URL for version {version}" + assert url is not None, ( + f"Package {pkg.name} does not have a valid URL for version {version}" + ) url_dict[version] = url version_hashes = spack.stage.get_checksums_for_versions( diff --git a/lib/spack/spack/cmd/common/arguments.py b/lib/spack/spack/cmd/common/arguments.py index 300d979c34b972..e31de62a73bbbc 100644 --- a/lib/spack/spack/cmd/common/arguments.py +++ b/lib/spack/spack/cmd/common/arguments.py @@ -105,7 +105,7 @@ def __call__(self, parser, namespace, jobs, option_string): # Jobs is a single integer, type conversion is already applied # see https://docs.python.org/3/library/argparse.html#action-classes if jobs < 1: - msg = 'invalid value for argument "{0}" ' '[expected a positive integer, got "{1}"]' + msg = 'invalid value for argument "{0}" [expected a positive integer, got "{1}"]' raise ValueError(msg.format(option_string, jobs)) spack.config.set("config:build_jobs", jobs, scope="command_line") @@ -122,7 +122,7 @@ class SetConcurrentPackages(argparse.Action): def __call__(self, parser, namespace, concurrent_packages, option_string): if concurrent_packages < 1: - msg = 'invalid value for argument "{0}" ' '[expected a positive integer, got "{1}"]' + msg = 'invalid value for argument "{0}" [expected a positive integer, got "{1}"]' raise ValueError(msg.format(option_string, concurrent_packages)) spack.config.set("config:concurrent_packages", concurrent_packages, scope="command_line") @@ -513,10 +513,10 @@ def add_cdash_args(subparser, add_help): "defaults to spec of the package to operate on" ) cdash_help["site"] = ( - "site name that will be reported to CDash\n\n" "defaults to current system hostname" + "site name that will be reported to CDash\n\ndefaults to current system hostname" ) cdash_help["track"] = ( - "results will be reported to this group on CDash\n\n" "defaults to Experimental" + "results will be reported to this group on CDash\n\ndefaults to Experimental" ) cdash_help["buildstamp"] = ( "use custom buildstamp\n\n" diff --git a/lib/spack/spack/cmd/common/spec_strings.py b/lib/spack/spack/cmd/common/spec_strings.py new file mode 100644 index 00000000000000..ec9ef96acba6a7 --- /dev/null +++ b/lib/spack/spack/cmd/common/spec_strings.py @@ -0,0 +1,232 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +import ast +import os +import re +import sys +import warnings +from typing import Callable, List, Optional + +import spack.llnl.util.tty as tty +import spack.util.spack_yaml +from spack.spec_parser import NAME, VERSION_LIST, SpecTokens +from spack.tokenize import Token, TokenBase, Tokenizer + +IS_PROBABLY_COMPILER = re.compile(r"%[a-zA-Z_][a-zA-Z0-9\-]") + + +class _LegacySpecTokens(TokenBase): + """Reconstructs the tokens for previous specs, so we can reuse code to rotate them""" + + # Dependency + START_EDGE_PROPERTIES = r"(?:\^\[)" + END_EDGE_PROPERTIES = r"(?:\])" + DEPENDENCY = r"(?:\^)" + # Version + VERSION_HASH_PAIR = SpecTokens.VERSION_HASH_PAIR.regex + GIT_VERSION = SpecTokens.GIT_VERSION.regex + VERSION = SpecTokens.VERSION.regex + # Variants + PROPAGATED_BOOL_VARIANT = SpecTokens.PROPAGATED_BOOL_VARIANT.regex + BOOL_VARIANT = SpecTokens.BOOL_VARIANT.regex + PROPAGATED_KEY_VALUE_PAIR = SpecTokens.PROPAGATED_KEY_VALUE_PAIR.regex + KEY_VALUE_PAIR = SpecTokens.KEY_VALUE_PAIR.regex + # Compilers + COMPILER_AND_VERSION = rf"(?:%\s*(?:{NAME})(?:[\s]*)@\s*(?:{VERSION_LIST}))" + COMPILER = rf"(?:%\s*(?:{NAME}))" + # FILENAME + FILENAME = SpecTokens.FILENAME.regex + # Package name + FULLY_QUALIFIED_PACKAGE_NAME = SpecTokens.FULLY_QUALIFIED_PACKAGE_NAME.regex + UNQUALIFIED_PACKAGE_NAME = SpecTokens.UNQUALIFIED_PACKAGE_NAME.regex + # DAG hash + DAG_HASH = SpecTokens.DAG_HASH.regex + # White spaces + WS = SpecTokens.WS.regex + # Unexpected character(s) + UNEXPECTED = SpecTokens.UNEXPECTED.regex + + +def _spec_str_reorder_compiler(idx: int, blocks: List[List[Token]]) -> None: + # only move the compiler to the back if it exists and is not already at the end + if not 0 <= idx < len(blocks) - 1: + return + # if there's only whitespace after the compiler, don't move it + if all(token.kind == _LegacySpecTokens.WS for block in blocks[idx + 1 :] for token in block): + return + # rotate left and always add at least one WS token between compiler and previous token + compiler_block = blocks.pop(idx) + if compiler_block[0].kind != _LegacySpecTokens.WS: + compiler_block.insert(0, Token(_LegacySpecTokens.WS, " ")) + # delete the WS tokens from the new first block if it was at the very start, to prevent leading + # WS tokens. + while idx == 0 and blocks[0][0].kind == _LegacySpecTokens.WS: + blocks[0].pop(0) + blocks.append(compiler_block) + + +def _spec_str_format(spec_str: str) -> Optional[str]: + """Given any string, try to parse as spec string, and rotate the compiler token to the end + of each spec instance. Returns the formatted string if it was changed, otherwise None.""" + # We parse blocks of tokens that include leading whitespace, and move the compiler block to + # the end when we hit a dependency ^... or the end of a string. + # [@3.1][ +foo][ +bar][ %gcc@3.1][ +baz] + # [@3.1][ +foo][ +bar][ +baz][ %gcc@3.1] + + current_block: List[Token] = [] + blocks: List[List[Token]] = [] + compiler_block_idx = -1 + in_edge_attr = False + + legacy_tokenizer = Tokenizer(_LegacySpecTokens) + + for token in legacy_tokenizer.tokenize(spec_str): + if token.kind == _LegacySpecTokens.UNEXPECTED: + # parsing error, we cannot fix this string. + return None + elif token.kind in (_LegacySpecTokens.COMPILER, _LegacySpecTokens.COMPILER_AND_VERSION): + # multiple compilers are not supported in Spack v0.x, so early return + if compiler_block_idx != -1: + return None + current_block.append(token) + blocks.append(current_block) + current_block = [] + compiler_block_idx = len(blocks) - 1 + elif token.kind in ( + _LegacySpecTokens.START_EDGE_PROPERTIES, + _LegacySpecTokens.DEPENDENCY, + _LegacySpecTokens.UNQUALIFIED_PACKAGE_NAME, + _LegacySpecTokens.FULLY_QUALIFIED_PACKAGE_NAME, + ): + _spec_str_reorder_compiler(compiler_block_idx, blocks) + compiler_block_idx = -1 + if token.kind == _LegacySpecTokens.START_EDGE_PROPERTIES: + in_edge_attr = True + current_block.append(token) + blocks.append(current_block) + current_block = [] + elif token.kind == _LegacySpecTokens.END_EDGE_PROPERTIES: + in_edge_attr = False + current_block.append(token) + blocks.append(current_block) + current_block = [] + elif in_edge_attr: + current_block.append(token) + elif token.kind in ( + _LegacySpecTokens.VERSION_HASH_PAIR, + _LegacySpecTokens.GIT_VERSION, + _LegacySpecTokens.VERSION, + _LegacySpecTokens.PROPAGATED_BOOL_VARIANT, + _LegacySpecTokens.BOOL_VARIANT, + _LegacySpecTokens.PROPAGATED_KEY_VALUE_PAIR, + _LegacySpecTokens.KEY_VALUE_PAIR, + _LegacySpecTokens.DAG_HASH, + ): + current_block.append(token) + blocks.append(current_block) + current_block = [] + elif token.kind == _LegacySpecTokens.WS: + current_block.append(token) + else: + raise ValueError(f"unexpected token {token}") + + if current_block: + blocks.append(current_block) + _spec_str_reorder_compiler(compiler_block_idx, blocks) + + new_spec_str = "".join(token.value for block in blocks for token in block) + return new_spec_str if spec_str != new_spec_str else None + + +SpecStrHandler = Callable[[str, int, int, str, str], None] + + +def _spec_str_default_handler(path: str, line: int, col: int, old: str, new: str): + """A SpecStrHandler that prints formatted spec strings and their locations.""" + print(f"{path}:{line}:{col}: `{old}` -> `{new}`") + + +def _spec_str_fix_handler(path: str, line: int, col: int, old: str, new: str): + """A SpecStrHandler that updates formatted spec strings in files.""" + with open(path, "r", encoding="utf-8") as f: + lines = f.readlines() + new_line = lines[line - 1].replace(old, new) + if new_line == lines[line - 1]: + tty.warn(f"{path}:{line}:{col}: could not apply fix: `{old}` -> `{new}`") + return + lines[line - 1] = new_line + print(f"{path}:{line}:{col}: fixed `{old}` -> `{new}`") + with open(path, "w", encoding="utf-8") as f: + f.writelines(lines) + + +def _spec_str_ast(path: str, tree: ast.AST, handler: SpecStrHandler) -> None: + """Walk the AST of a Python file and apply handler to formatted spec strings.""" + for node in ast.walk(tree): + if sys.version_info >= (3, 8): + if isinstance(node, ast.Constant) and isinstance(node.value, str): + current_str = node.value + else: + continue + elif isinstance(node, ast.Str): + current_str = node.s + else: + continue + if not IS_PROBABLY_COMPILER.search(current_str): + continue + new = _spec_str_format(current_str) + if new is not None: + handler(path, node.lineno, node.col_offset, current_str, new) + + +def _spec_str_json_and_yaml(path: str, data: dict, handler: SpecStrHandler) -> None: + """Walk a YAML or JSON data structure and apply handler to formatted spec strings.""" + queue = [data] + seen = set() + + while queue: + current = queue.pop(0) + if id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, dict): + queue.extend(current.values()) + queue.extend(current.keys()) + elif isinstance(current, list): + queue.extend(current) + elif isinstance(current, str) and IS_PROBABLY_COMPILER.search(current): + new = _spec_str_format(current) + if new is not None: + mark = getattr(current, "_start_mark", None) + if mark: + line, col = mark.line + 1, mark.column + 1 + else: + line, col = 0, 0 + handler(path, line, col, current, new) + + +def _check_spec_strings( + paths: List[str], handler: SpecStrHandler = _spec_str_default_handler +) -> None: + """Open Python, JSON and YAML files, and format their string literals that look like spec + strings. A handler is called for each formatting, which can be used to print or apply fixes.""" + for path in paths: + is_json_or_yaml = path.endswith(".json") or path.endswith(".yaml") or path.endswith(".yml") + is_python = path.endswith(".py") + if not is_json_or_yaml and not is_python: + continue + + try: + with open(path, "r", encoding="utf-8") as f: + # skip files that are likely too large to be user code or config + if os.fstat(f.fileno()).st_size > 1024 * 1024: + warnings.warn(f"skipping {path}: too large.") + continue + if is_json_or_yaml: + _spec_str_json_and_yaml(path, spack.util.spack_yaml.load_config(f), handler) + elif is_python: + _spec_str_ast(path, ast.parse(f.read()), handler) + except (OSError, spack.util.spack_yaml.SpackYAMLError, SyntaxError, ValueError): + warnings.warn(f"skipping {path}") + continue diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index ec7b1c9dd07102..2d86c60ff56dbc 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -146,8 +146,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: remove_parser = sp.add_parser("remove", aliases=["rm"], help="remove configuration parameters") remove_parser.add_argument( "path", - help="colon-separated path to config that should be removed," - " e.g. 'config:default:true'", + help="colon-separated path to config that should be removed, e.g. 'config:default:true'", ) # Make the add parser available later diff --git a/lib/spack/spack/cmd/create.py b/lib/spack/spack/cmd/create.py index 74db857d36b343..3e17b6af2783aa 100644 --- a/lib/spack/spack/cmd/create.py +++ b/lib/spack/spack/cmd/create.py @@ -414,7 +414,7 @@ def __init__(self, name, url, versions, languages: List[str]): # e.g. https://files.pythonhosted.org/packages/source/n/numpy/numpy-1.19.4.zip # PyPI URLs containing hash: - # https:///packages//// + # https:///packages//// # noqa: E501 # e.g. https://pypi.io/packages/c5/63/a48648ebc57711348420670bb074998f79828291f68aebfff1642be212ec/numpy-1.19.4.zip # e.g. https://files.pythonhosted.org/packages/c5/63/a48648ebc57711348420670bb074998f79828291f68aebfff1642be212ec/numpy-1.19.4.zip # e.g. https://files.pythonhosted.org/packages/c5/63/a48648ebc57711348420670bb074998f79828291f68aebfff1642be212ec/numpy-1.19.4.zip#sha256=141ec3a3300ab89c7f2b0775289954d193cc8edb621ea05f99db9cb181530512 @@ -491,7 +491,7 @@ def __init__(self, name, url, versions, languages: List[str]): bioc = re.search(r"(?:bioconductor)[^/]+/packages" + "/([^/]+)" * 5, url) if bioc: - self.url_line = ' url = "{0}"\n' ' bioc = "{1}"'.format(url, r_name) + self.url_line = ' url = "{0}"\n bioc = "{1}"'.format(url, r_name) super().__init__(name, url, versions, languages) @@ -1061,8 +1061,9 @@ def get_repository(args: argparse.Namespace, name: str) -> spack.repo.Repo: repo = spack.repo.from_path(repo_path) if spec.namespace and spec.namespace != repo.namespace: tty.die( - "Can't create package with namespace {0} in repo with " - "namespace {1}".format(spec.namespace, repo.namespace) + "Can't create package with namespace {0} in repo with namespace {1}".format( + spec.namespace, repo.namespace + ) ) else: if spec.namespace: diff --git a/lib/spack/spack/cmd/deprecate.py b/lib/spack/spack/cmd/deprecate.py index b92b813fc90dc4..2fc95d26ebe537 100644 --- a/lib/spack/spack/cmd/deprecate.py +++ b/lib/spack/spack/cmd/deprecate.py @@ -12,6 +12,7 @@ It is up to the user to ensure binary compatibility between the deprecated installation and its deprecator. """ + import argparse import spack.cmd diff --git a/lib/spack/spack/cmd/list.py b/lib/spack/spack/cmd/list.py index fcc57d1580c10f..1a341ef7b0060c 100644 --- a/lib/spack/spack/cmd/list.py +++ b/lib/spack/spack/cmd/list.py @@ -316,7 +316,7 @@ def head(n, span_id, title, anchor=None): if pkg_cls.homepage: out.write( - ("
  • " '%s' "
  • \n") + ('
  • %s
  • \n') % (pkg_cls.homepage, escape(pkg_cls.homepage, True)) ) else: @@ -326,7 +326,7 @@ def head(n, span_id, title, anchor=None): out.write("
    Spack package:
    \n") out.write('
    \n") diff --git a/lib/spack/spack/cmd/mirror.py b/lib/spack/spack/cmd/mirror.py index 0bdd2c9addc977..32c1f46d80128c 100644 --- a/lib/spack/spack/cmd/mirror.py +++ b/lib/spack/spack/cmd/mirror.py @@ -588,8 +588,9 @@ def versions_per_spec(args): num_versions = int(args.versions_per_spec) except ValueError: raise SpackError( - "'--versions-per-spec' must be a number or 'all'," - " got '{0}'".format(args.versions_per_spec) + "'--versions-per-spec' must be a number or 'all', got '{0}'".format( + args.versions_per_spec + ) ) return num_versions diff --git a/lib/spack/spack/cmd/stage.py b/lib/spack/spack/cmd/stage.py index a12d6ca11700d6..2dbdee6b79a314 100644 --- a/lib/spack/spack/cmd/stage.py +++ b/lib/spack/spack/cmd/stage.py @@ -104,7 +104,6 @@ def stage(parser, args): def _stage_env(env: ev.Environment, filter): tty.msg(f"Staging specs from environment {env.name}") for spec in spack.traverse.traverse_nodes(env.concrete_roots()): - if filter(spec): continue diff --git a/lib/spack/spack/cmd/style.py b/lib/spack/spack/cmd/style.py index d7b8fe09380343..a9e3e20bf14904 100644 --- a/lib/spack/spack/cmd/style.py +++ b/lib/spack/spack/cmd/style.py @@ -6,42 +6,33 @@ import os import re import sys -import warnings -from itertools import zip_longest -from typing import Callable, Dict, List, Optional, Set +from pathlib import Path +from typing import Dict, List, Optional, Set, Union import spack.llnl.util.tty as tty import spack.llnl.util.tty.color as color import spack.paths import spack.repo import spack.util.git -import spack.util.spack_yaml +from spack.cmd.common.spec_strings import ( + _check_spec_strings, + _spec_str_default_handler, + _spec_str_fix_handler, +) from spack.llnl.util.filesystem import working_dir -from spack.spec_parser import NAME, VERSION_LIST, SpecTokens -from spack.tokenize import Token, TokenBase, Tokenizer from spack.util.executable import Executable, which description = "runs source code style checks on spack" section = "developer" level = "long" - -def grouper(iterable, n, fillvalue=None): - """Collect data into fixed-length chunks or blocks""" - # grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx" - args = [iter(iterable)] * n - for group in zip_longest(*args, fillvalue=fillvalue): - yield filter(None, group) - - #: List of paths to exclude from checks -- relative to spack root exclude_paths = [os.path.relpath(spack.paths.vendor_path, spack.paths.prefix)] -#: Order in which tools should be run. flake8 is last so that it can -#: double-check the results of other tools (if, e.g., ``--fix`` was provided) +#: Order in which tools should be run. #: The list maps an executable name to a method to ensure the tool is #: bootstrapped or present in the environment. -tool_names = ["import", "isort", "black", "flake8", "mypy"] +tool_names = ["import", "ruff-format", "ruff-check", "mypy"] #: warnings to ignore in mypy mypy_ignores = [ @@ -51,22 +42,15 @@ def grouper(iterable, n, fillvalue=None): ] -def is_package(f): - """Whether flake8 should consider a file as a core file or a package. - - We run flake8 with different exceptions for the core and for - packages, since we allow ``from spack.package import *`` and poking globals - into packages. - """ - return "spack_repo" in f and f.endswith("package.py") - - #: decorator for adding tools to the list class tool: - def __init__(self, name: str, required: bool = False, external: bool = True) -> None: + def __init__( + self, name: str, cmd: Optional[str] = None, required: bool = False, external: bool = True + ) -> None: self.name = name self.external = external self.required = required + self.cmd = cmd if cmd else name def __call__(self, fun): self.fun = fun @@ -75,18 +59,18 @@ def __call__(self, fun): @property def installed(self) -> bool: - return bool(which(self.name)) if self.external else True + return bool(which(self.cmd)) if self.external else True @property def executable(self) -> Optional[Executable]: - return which(self.name) if self.external else None + return which(self.cmd) if self.external else None #: tools we run in spack style tools: Dict[str, tool] = {} -def changed_files(base="develop", untracked=True, all_files=False, root=None): +def changed_files(base="develop", untracked=True, all_files=False, root=None) -> List[Path]: """Get list of changed files in the Spack repository. Arguments: @@ -145,7 +129,7 @@ def changed_files(base="develop", untracked=True, all_files=False, root=None): if any(os.path.realpath(f).startswith(e) for e in excludes): continue - changed.add(f) + changed.add(Path(f)) return sorted(changed) @@ -159,7 +143,10 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: help="branch to compare against to determine changed files (default: develop)", ) subparser.add_argument( - "-a", "--all", action="store_true", help="check all files, not just changed files" + "-a", + "--all", + action="store_true", + help="check all files, not just changed files (applies only to Import Check)", ) subparser.add_argument( "-r", @@ -212,21 +199,27 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: subparser.add_argument("files", nargs=argparse.REMAINDER, help="specific files to check") -def cwd_relative(path, root, initial_working_dir): +def cwd_relative(path: Path, root: Union[Path, str], initial_working_dir: Path) -> Path: """Translate prefix-relative path to current working directory-relative.""" - return os.path.relpath(os.path.join(root, path), initial_working_dir) + if path.is_absolute(): + return path + return Path(os.path.relpath((root / path), initial_working_dir)) def rewrite_and_print_output( - output, args, re_obj=re.compile(r"^(.+):([0-9]+):"), replacement=r"{0}:{1}:" + output, + root, + working_dir, + root_relative, + re_obj=re.compile(r"^(.+):([0-9]+):"), + replacement=r"{0}:{1}:", ): """rewrite output with :: format to respect path args""" # print results relative to current working directory def translate(match): return replacement.format( - cwd_relative(match.group(1), args.root, args.initial_working_dir), - *list(match.groups()[1:]), + cwd_relative(Path(match.group(1)), root, working_dir), *list(match.groups()[1:]) ) for line in output.split("\n"): @@ -236,28 +229,11 @@ def translate(match): # some mypy annotations can't be disabled in older mypys (e.g. .971, which # is the only mypy that supports python 3.6), so we filter them here. continue - if not args.root_relative and re_obj: + if not root_relative and re_obj: line = re_obj.sub(translate, line) print(line) -def print_style_header(file_list, args, tools_to_run): - tty.msg("Running style checks on spack", "selected: " + ", ".join(tools_to_run)) - # translate modified paths to cwd_relative if needed - paths = [filename.strip() for filename in file_list] - if not args.root_relative: - paths = [cwd_relative(filename, args.root, args.initial_working_dir) for filename in paths] - - tty.msg("Modified files", *paths) - sys.stdout.flush() - - -def print_tool_header(tool): - sys.stdout.flush() - tty.msg("Running %s checks" % tool) - sys.stdout.flush() - - def print_tool_result(tool, returncode): if returncode == 0: color.cprint(" @g{%s checks were clean}" % tool) @@ -265,30 +241,65 @@ def print_tool_result(tool, returncode): color.cprint(" @r{%s found errors}" % tool) -@tool("flake8", required=True) -def run_flake8(flake8_cmd, file_list, args): - returncode = 0 - output = "" - # run in chunks of 100 at a time to avoid line length limit - # filename parameter in config *does not work* for this reliably - for chunk in grouper(file_list, 100): - output = flake8_cmd( - # always run with config from running spack prefix - "--config=%s" % os.path.join(spack.paths.prefix, ".flake8"), - *chunk, - fail_on_error=False, - output=str, - ) - returncode |= flake8_cmd.returncode +@tool("ruff-check", cmd="ruff") +def ruff_check(file_list, args): + """Run the ruff-check command. Handles config and non generic ruff argument logic""" + cmd_args = ["--config", os.path.join(spack.paths.prefix, "pyproject.toml"), "--quiet"] + if args.fix: + cmd_args += ["--fix", "--no-unsafe-fixes"] + else: + cmd_args += ["--no-fix"] + return run_ruff( + file_list, "check", cmd_args, args.root, args.initial_working_dir, args.root_relative + ) - rewrite_and_print_output(output, args) - print_tool_result("flake8", returncode) +@tool("ruff-format", cmd="ruff") +def ruff_format(file_list, args): + """Run the ruff format command""" + cmd_args = ["--config", os.path.join(spack.paths.prefix, "pyproject.toml"), "--quiet"] + if not args.fix: + cmd_args += ["--check", "--diff"] + return run_ruff( + file_list, "format", cmd_args, args.root, args.initial_working_dir, args.root_relative + ) + + +def run_ruff( + file_list: List[Path], + cmd: str, + args: List[str], + root: Path, + working_dir: Path, + root_relative: bool, +): + """Run the ruff tool""" + ruff_cmd = tools[f"ruff-{cmd}"].executable + if not ruff_cmd: + tty.warn("Cannot execute requested tool: ruff\nCannot find tool") + return -1 + + files = (str(x) for x in file_list) + if color.get_color_when(): + args += ("--color", "auto") + pat = re.compile("would reformat +(.*)") + replacement = "would reformat {0}" + + packed_args = (cmd,) + (*args,) + tuple(files) + output = ruff_cmd(*packed_args, fail_on_error=False, output=str, error=str) + returncode = ruff_cmd.returncode + rewrite_and_print_output(output, root, working_dir, root_relative, pat, replacement) + + print_tool_result(f"ruff-{cmd}", returncode) return returncode @tool("mypy") -def run_mypy(mypy_cmd, file_list, args): +def run_mypy(file_list, args): + mypy_cmd = tools["mypy"].executable + if not mypy_cmd: + tty.warn("Cannot execute requested tool: mypy\nCannot find tool") + return -1 # always run with config from running spack prefix common_mypy_args = [ "--config-file", @@ -306,80 +317,26 @@ def run_mypy(mypy_cmd, file_list, args): output = mypy_cmd(*mypy_args, fail_on_error=False, output=str) returncode |= mypy_cmd.returncode - rewrite_and_print_output(output, args) + rewrite_and_print_output(output, args.root, args.initial_working_dir, args.root_relative) print_tool_result("mypy", returncode) return returncode -@tool("isort") -def run_isort(isort_cmd, file_list, args): - # always run with config from running spack prefix - isort_args = ("--settings-path", os.path.join(spack.paths.prefix, "pyproject.toml")) - if not args.fix: - isort_args += ("--check", "--diff") - - pat = re.compile("ERROR: (.*) Imports are incorrectly sorted") - replacement = "ERROR: {0} Imports are incorrectly sorted" - returncode = [0] - - def process_files(file_list, is_args): - for chunk in grouper(file_list, 100): - packed_args = is_args + tuple(chunk) - output = isort_cmd(*packed_args, fail_on_error=False, output=str, error=str) - returncode[0] |= isort_cmd.returncode - - rewrite_and_print_output(output, args, pat, replacement) - - # packages - process_files(filter(is_package, file_list), isort_args) - # non-packages - process_files(filter(lambda f: not is_package(f), file_list), isort_args) - - print_tool_result("isort", returncode[0]) - return returncode[0] - - -@tool("black") -def run_black(black_cmd, file_list, args): - # always run with config from running spack prefix - black_args = ("--config", os.path.join(spack.paths.prefix, "pyproject.toml")) - if not args.fix: - black_args += ("--check", "--diff") - if color.get_color_when(): # only show color when spack would - black_args += ("--color",) - - pat = re.compile("would reformat +(.*)") - replacement = "would reformat {0}" - returncode = 0 - output = "" - # run in chunks of 100 at a time to avoid line length limit - # filename parameter in config *does not work* for this reliably - for chunk in grouper(file_list, 100): - packed_args = black_args + tuple(chunk) - output = black_cmd(*packed_args, fail_on_error=False, output=str, error=str) - returncode |= black_cmd.returncode - rewrite_and_print_output(output, args, pat, replacement) - - print_tool_result("black", returncode) - - return returncode - - -def _module_part(root: str, expr: str): +def _module_part(root: Path, expr: str): parts = expr.split(".") # spack.pkg is for repositories, don't try to resolve it here. if expr.startswith(spack.repo.PKG_MODULE_PREFIX_V1) or expr == "spack.pkg": return None while parts: - f1 = os.path.join(root, "lib", "spack", *parts) + ".py" - f2 = os.path.join(root, "lib", "spack", *parts, "__init__.py") + f1 = (root / "lib" / "spack").joinpath(*parts).with_suffix(".py") + f2 = (root / "lib" / "spack").joinpath(*parts, "__init__.py") if ( - os.path.exists(f1) + f1.exists() # ensure case sensitive match - and f"{parts[-1]}.py" in os.listdir(os.path.dirname(f1)) - or os.path.exists(f2) + and any(p.name == f"{parts[-1]}.py" for p in f1.parent.iterdir()) + or f2.exists() ): return ".".join(parts) parts.pop() @@ -387,13 +344,15 @@ def _module_part(root: str, expr: str): def _run_import_check( - file_list: List[str], + file_list: List[Path], *, fix: bool, root_relative: bool, - root=spack.paths.prefix, - working_dir=spack.paths.prefix, + root: Path, + working_dir: Path, out=sys.stdout, + base="develop", + all=False, ): if sys.version_info < (3, 9): print("import check requires Python 3.9 or later") @@ -402,8 +361,8 @@ def _run_import_check( is_use = re.compile(r"(? None: - # only move the compiler to the back if it exists and is not already at the end - if not 0 <= idx < len(blocks) - 1: - return - # if there's only whitespace after the compiler, don't move it - if all(token.kind == _LegacySpecTokens.WS for block in blocks[idx + 1 :] for token in block): - return - # rotate left and always add at least one WS token between compiler and previous token - compiler_block = blocks.pop(idx) - if compiler_block[0].kind != _LegacySpecTokens.WS: - compiler_block.insert(0, Token(_LegacySpecTokens.WS, " ")) - # delete the WS tokens from the new first block if it was at the very start, to prevent leading - # WS tokens. - while idx == 0 and blocks[0][0].kind == _LegacySpecTokens.WS: - blocks[0].pop(0) - blocks.append(compiler_block) - - -def _spec_str_format(spec_str: str) -> Optional[str]: - """Given any string, try to parse as spec string, and rotate the compiler token to the end - of each spec instance. Returns the formatted string if it was changed, otherwise None.""" - # We parse blocks of tokens that include leading whitespace, and move the compiler block to - # the end when we hit a dependency ^... or the end of a string. - # [@3.1][ +foo][ +bar][ %gcc@3.1][ +baz] - # [@3.1][ +foo][ +bar][ +baz][ %gcc@3.1] - - current_block: List[Token] = [] - blocks: List[List[Token]] = [] - compiler_block_idx = -1 - in_edge_attr = False - - legacy_tokenizer = Tokenizer(_LegacySpecTokens) - - for token in legacy_tokenizer.tokenize(spec_str): - if token.kind == _LegacySpecTokens.UNEXPECTED: - # parsing error, we cannot fix this string. - return None - elif token.kind in (_LegacySpecTokens.COMPILER, _LegacySpecTokens.COMPILER_AND_VERSION): - # multiple compilers are not supported in Spack v0.x, so early return - if compiler_block_idx != -1: - return None - current_block.append(token) - blocks.append(current_block) - current_block = [] - compiler_block_idx = len(blocks) - 1 - elif token.kind in ( - _LegacySpecTokens.START_EDGE_PROPERTIES, - _LegacySpecTokens.DEPENDENCY, - _LegacySpecTokens.UNQUALIFIED_PACKAGE_NAME, - _LegacySpecTokens.FULLY_QUALIFIED_PACKAGE_NAME, - ): - _spec_str_reorder_compiler(compiler_block_idx, blocks) - compiler_block_idx = -1 - if token.kind == _LegacySpecTokens.START_EDGE_PROPERTIES: - in_edge_attr = True - current_block.append(token) - blocks.append(current_block) - current_block = [] - elif token.kind == _LegacySpecTokens.END_EDGE_PROPERTIES: - in_edge_attr = False - current_block.append(token) - blocks.append(current_block) - current_block = [] - elif in_edge_attr: - current_block.append(token) - elif token.kind in ( - _LegacySpecTokens.VERSION_HASH_PAIR, - _LegacySpecTokens.GIT_VERSION, - _LegacySpecTokens.VERSION, - _LegacySpecTokens.PROPAGATED_BOOL_VARIANT, - _LegacySpecTokens.BOOL_VARIANT, - _LegacySpecTokens.PROPAGATED_KEY_VALUE_PAIR, - _LegacySpecTokens.KEY_VALUE_PAIR, - _LegacySpecTokens.DAG_HASH, - ): - current_block.append(token) - blocks.append(current_block) - current_block = [] - elif token.kind == _LegacySpecTokens.WS: - current_block.append(token) - else: - raise ValueError(f"unexpected token {token}") - - if current_block: - blocks.append(current_block) - _spec_str_reorder_compiler(compiler_block_idx, blocks) - - new_spec_str = "".join(token.value for block in blocks for token in block) - return new_spec_str if spec_str != new_spec_str else None - - -SpecStrHandler = Callable[[str, int, int, str, str], None] - - -def _spec_str_default_handler(path: str, line: int, col: int, old: str, new: str): - """A SpecStrHandler that prints formatted spec strings and their locations.""" - print(f"{path}:{line}:{col}: `{old}` -> `{new}`") - - -def _spec_str_fix_handler(path: str, line: int, col: int, old: str, new: str): - """A SpecStrHandler that updates formatted spec strings in files.""" - with open(path, "r", encoding="utf-8") as f: - lines = f.readlines() - new_line = lines[line - 1].replace(old, new) - if new_line == lines[line - 1]: - tty.warn(f"{path}:{line}:{col}: could not apply fix: `{old}` -> `{new}`") - return - lines[line - 1] = new_line - print(f"{path}:{line}:{col}: fixed `{old}` -> `{new}`") - with open(path, "w", encoding="utf-8") as f: - f.writelines(lines) - - -def _spec_str_ast(path: str, tree: ast.AST, handler: SpecStrHandler) -> None: - """Walk the AST of a Python file and apply handler to formatted spec strings.""" - for node in ast.walk(tree): - if sys.version_info >= (3, 8): - if isinstance(node, ast.Constant) and isinstance(node.value, str): - current_str = node.value - else: - continue - elif isinstance(node, ast.Str): - current_str = node.s - else: - continue - if not IS_PROBABLY_COMPILER.search(current_str): - continue - new = _spec_str_format(current_str) - if new is not None: - handler(path, node.lineno, node.col_offset, current_str, new) - - -def _spec_str_json_and_yaml(path: str, data: dict, handler: SpecStrHandler) -> None: - """Walk a YAML or JSON data structure and apply handler to formatted spec strings.""" - queue = [data] - seen = set() - - while queue: - current = queue.pop(0) - if id(current) in seen: - continue - seen.add(id(current)) - if isinstance(current, dict): - queue.extend(current.values()) - queue.extend(current.keys()) - elif isinstance(current, list): - queue.extend(current) - elif isinstance(current, str) and IS_PROBABLY_COMPILER.search(current): - new = _spec_str_format(current) - if new is not None: - mark = getattr(current, "_start_mark", None) - if mark: - line, col = mark.line + 1, mark.column + 1 - else: - line, col = 0, 0 - handler(path, line, col, current, new) - - -def _check_spec_strings( - paths: List[str], handler: SpecStrHandler = _spec_str_default_handler -) -> None: - """Open Python, JSON and YAML files, and format their string literals that look like spec - strings. A handler is called for each formatting, which can be used to print or apply fixes.""" - for path in paths: - is_json_or_yaml = path.endswith(".json") or path.endswith(".yaml") or path.endswith(".yml") - is_python = path.endswith(".py") - if not is_json_or_yaml and not is_python: - continue - - try: - with open(path, "r", encoding="utf-8") as f: - # skip files that are likely too large to be user code or config - if os.fstat(f.fileno()).st_size > 1024 * 1024: - warnings.warn(f"skipping {path}: too large.") - continue - if is_json_or_yaml: - _spec_str_json_and_yaml(path, spack.util.spack_yaml.load_config(f), handler) - elif is_python: - _spec_str_ast(path, ast.parse(f.read()), handler) - except (OSError, spack.util.spack_yaml.SpackYAMLError, SyntaxError, ValueError): - warnings.warn(f"skipping {path}") - continue - - def style(parser, args): if args.spec_strings: if not args.files: @@ -757,26 +512,22 @@ def style(parser, args): return _check_spec_strings(args.files, handler) # save initial working directory for relativizing paths later - args.initial_working_dir = os.getcwd() + args.initial_working_dir = Path.cwd() # ensure that the config files we need actually exist in the spack prefix. # assertions b/c users should not ever see these errors -- they're checked in CI. - assert os.path.isfile(os.path.join(spack.paths.prefix, "pyproject.toml")) - assert os.path.isfile(os.path.join(spack.paths.prefix, ".flake8")) + assert (Path(spack.paths.prefix) / "pyproject.toml").is_file() # validate spack root if the user provided one - args.root = os.path.realpath(args.root) if args.root else spack.paths.prefix - spack_script = os.path.join(args.root, "bin", "spack") - if not os.path.exists(spack_script): + args.root = Path(args.root).resolve() if args.root else Path(spack.paths.prefix) + spack_script = args.root / "bin" / "spack" + if not spack_script.exists(): tty.die("This does not look like a valid spack root.", "No such file: '%s'" % spack_script) - file_list = args.files - if file_list: - - def prefix_relative(path): - return os.path.relpath(os.path.abspath(os.path.realpath(path)), args.root) + def prefix_relative(path: Union[Path, str]) -> Path: + return Path(os.path.relpath(os.path.abspath(os.path.realpath(path)), args.root)) - file_list = [prefix_relative(p) for p in file_list] + file_list = [prefix_relative(file) for file in args.files] # process --tool and --skip arguments selected = set(tool_names) @@ -794,16 +545,12 @@ def prefix_relative(path): _bootstrap_dev_dependencies() return_code = 0 - with working_dir(args.root): - if not file_list: - file_list = changed_files(args.base, args.untracked, args.all) - + with working_dir(str(args.root)): print_style_header(file_list, args, tools_to_run) for tool_name in tools_to_run: tool = tools[tool_name] - print_tool_header(tool_name) - return_code |= tool.fun(tool.executable, file_list, args) - + tty.msg(f"Running {tool.name} checks") + return_code |= tool.fun(file_list, args) if return_code == 0: tty.msg(color.colorize("@*{spack style checks were clean}")) else: diff --git a/lib/spack/spack/cmd/view.py b/lib/spack/spack/cmd/view.py index 406fa279fd9f82..e4c09e8f1f3ccc 100644 --- a/lib/spack/spack/cmd/view.py +++ b/lib/spack/spack/cmd/view.py @@ -32,6 +32,7 @@ YamlFilesystemView. """ + import argparse import sys diff --git a/lib/spack/spack/compilers/config.py b/lib/spack/spack/compilers/config.py index a193784f6efd98..244a7400404164 100644 --- a/lib/spack/spack/compilers/config.py +++ b/lib/spack/spack/compilers/config.py @@ -4,6 +4,7 @@ """This module contains functions related to finding compilers on the system, and configuring Spack to use multiple compilers. """ + import os import re import sys diff --git a/lib/spack/spack/concretize.py b/lib/spack/spack/concretize.py index afceeca1d8d68f..b884b82a266e81 100644 --- a/lib/spack/spack/concretize.py +++ b/lib/spack/spack/concretize.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """High-level functions to concretize list of specs""" + import importlib import sys import time @@ -260,9 +261,9 @@ def concretize_one( name = providers[0] node = SpecBuilder.make_node(pkg=name) - assert ( - node in answer - ), f"cannot find {name} in the list of specs {','.join([n.pkg for n in answer.keys()])}" + assert node in answer, ( + f"cannot find {name} in the list of specs {','.join([n.pkg for n in answer.keys()])}" + ) concretized = answer[node] return concretized diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index f25d65984ae97a..3498a0eb79670a 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -26,6 +26,7 @@ schemas are in submodules of :py:mod:`spack.schema`. """ + import contextlib import copy import functools @@ -601,9 +602,9 @@ def remove_scope(self, scope_name: str) -> Optional[ConfigScope]: # transitively remove included scopes for included_scope in scope.included_scopes: - assert ( - included_scope.name in self.scopes - ), f"Included scope '{included_scope.name}' was never added to configuration!" + assert included_scope.name in self.scopes, ( + f"Included scope '{included_scope.name}' was never added to configuration!" + ) self.remove_scope(included_scope.name) return scope @@ -1190,9 +1191,9 @@ def _scope( def _validate_parent_scope(self, parent_scope: ConfigScope): """Validates that a parent scope is a valid configuration object""" # enforced by type checking but those can always be # type: ignore'd - assert isinstance( - parent_scope, ConfigScope - ), f"Includes must be within a configuration scope (ConfigScope), not {type(parent_scope)}" + assert isinstance(parent_scope, ConfigScope), ( + f"Includes must be within a configuration scope (ConfigScope), not {type(parent_scope)}" # noqa: E501 + ) assert parent_scope.name.strip(), "Parent scope of an include must have a name" @@ -2121,7 +2122,7 @@ def ensure_latest_format_fn(section: str) -> Callable[[YamlConfigDict], bool]: @contextlib.contextmanager def use_configuration( - *scopes_or_paths: Union[ScopeWithOptionalPriority, str] + *scopes_or_paths: Union[ScopeWithOptionalPriority, str], ) -> Generator[Configuration, None, None]: """Use the configuration scopes passed as arguments within the context manager. diff --git a/lib/spack/spack/container/__init__.py b/lib/spack/spack/container/__init__.py index a8710f24ed4f45..16e414e624e2e1 100644 --- a/lib/spack/spack/container/__init__.py +++ b/lib/spack/spack/container/__init__.py @@ -4,6 +4,7 @@ """Package that provides functions and classes to generate container recipes from a Spack environment """ + import warnings import spack.vendor.jsonschema diff --git a/lib/spack/spack/container/images.py b/lib/spack/spack/container/images.py index 35d15efc28bf4b..222f689d461f55 100644 --- a/lib/spack/spack/container/images.py +++ b/lib/spack/spack/container/images.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Manages the details on the images used in the various stages.""" + import json import os import shlex diff --git a/lib/spack/spack/container/writers.py b/lib/spack/spack/container/writers.py index c6c80d0ea0415e..f3f2b860188698 100644 --- a/lib/spack/spack/container/writers.py +++ b/lib/spack/spack/container/writers.py @@ -4,6 +4,7 @@ """Writers for different kind of recipes and related convenience functions. """ + import copy import shlex from collections import namedtuple @@ -85,7 +86,7 @@ def _stage_base_images(images_config): # Check the OS is mentioned in the internal data stored in a JSON file images_json = data()["images"] if not any(os_name == operating_system for os_name in images_json): - msg = 'invalid operating system name "{0}". ' "[Allowed values are {1}]" + msg = 'invalid operating system name "{0}". [Allowed values are {1}]' msg = msg.format(operating_system, ", ".join(data()["images"])) raise ValueError(msg) diff --git a/lib/spack/spack/dependency.py b/lib/spack/spack/dependency.py index 5415ddf4ae7424..66642c5dc8a30c 100644 --- a/lib/spack/spack/dependency.py +++ b/lib/spack/spack/dependency.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Data structures that represent Spack's dependency relationships.""" + from typing import TYPE_CHECKING, Dict, List, Type import spack.deptypes as dt diff --git a/lib/spack/spack/detection/common.py b/lib/spack/spack/detection/common.py index 6a4e6ac68eecc3..ecf1db9d9a62bb 100644 --- a/lib/spack/spack/detection/common.py +++ b/lib/spack/spack/detection/common.py @@ -12,6 +12,7 @@ The module also contains other functions that might be useful across different detection mechanisms. """ + import glob import itertools import os diff --git a/lib/spack/spack/detection/path.py b/lib/spack/spack/detection/path.py index 6e088061f3c920..b058d3fde5cd39 100644 --- a/lib/spack/spack/detection/path.py +++ b/lib/spack/spack/detection/path.py @@ -4,6 +4,7 @@ """Detection of software installed in the system, based on paths inspections and running executables. """ + import collections import concurrent.futures import os diff --git a/lib/spack/spack/detection/test.py b/lib/spack/spack/detection/test.py index 3741c1a18c680a..2c66cc8884d347 100644 --- a/lib/spack/spack/detection/test.py +++ b/lib/spack/spack/detection/test.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Create and run mock e2e tests for package detection.""" + import collections import contextlib import pathlib diff --git a/lib/spack/spack/directives.py b/lib/spack/spack/directives.py index d35752b14970e6..f9cdb42e2810d3 100644 --- a/lib/spack/spack/directives.py +++ b/lib/spack/spack/directives.py @@ -39,6 +39,7 @@ def example_directive(arg1, arg2): def _execute_example_directive(pkg, arg1, arg2): # modify pkg.example based on arg1 and arg2 """ + import collections import collections.abc import os @@ -247,7 +248,7 @@ def _execute_version(pkg: PackageType, ver: Union[str, int], kwargs: dict): and not pkg.has_code ): raise VersionChecksumError( - f"{pkg.name}: Checksums not allowed in no-code packages " f"(see '{ver}' version)." + f"{pkg.name}: Checksums not allowed in no-code packages (see '{ver}' version)." ) if not isinstance(ver, (int, str)): @@ -411,8 +412,7 @@ def _execute_redistribute( return elif (source is True) or (binary is True): raise DirectiveError( - "Source/binary distribution are true by default, they can only " - "be explicitly disabled." + "Source/binary distribution are true by default, they can only be explicitly disabled." ) if source is None: diff --git a/lib/spack/spack/directory_layout.py b/lib/spack/spack/directory_layout.py index 77b9ac96db1120..d57674716a3f4f 100644 --- a/lib/spack/spack/directory_layout.py +++ b/lib/spack/spack/directory_layout.py @@ -283,9 +283,9 @@ def remove_install_directory(self, spec: "spack.spec.Spec", deprecated: bool = F Raised RemoveFailedError if something goes wrong. """ path = self.path_for_spec(spec) - assert path.startswith( - self.root - ), f"Attempted to remove dir outside Spack's install tree. PATH: {path}, ROOT: {self.root}" + assert path.startswith(self.root), ( + "Attempted to remove dir outside Spack's install tree. PATH: {path}, ROOT: {self.root}" + ) if deprecated: if os.path.exists(path): diff --git a/lib/spack/spack/enums.py b/lib/spack/spack/enums.py index 2e06d012f324a7..2bede7f043635b 100644 --- a/lib/spack/spack/enums.py +++ b/lib/spack/spack/enums.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Enumerations used throughout Spack""" + import enum diff --git a/lib/spack/spack/environment/__init__.py b/lib/spack/spack/environment/__init__.py index 5bd16aec084906..fe3c425065ff2d 100644 --- a/lib/spack/spack/environment/__init__.py +++ b/lib/spack/spack/environment/__init__.py @@ -495,10 +495,11 @@ Version 6 uses specs where compilers are modeled as real dependencies, and not as a node attribute. It doesn't change the top-level lockfile format. -As part of Spack v1.0, compilers stopped being a node attribute, and became a build-only dependency. Packages may -declare a dependency on the c, cxx, or fortran languages, which are now treated as virtuals, and compilers would -be providers for one or more of those languages. Compilers can also inject runtime dependency, on the node being -compiled. The compiler-wrapper is explicitly represented as a node in the DAG, and enters the hash. +As part of Spack v1.0, compilers stopped being a node attribute, and became a build-only +dependency. Packages may declare a dependency on the c, cxx, or fortran languages, which are now +treated as virtuals, and compilers would be providers for one or more of those languages. Compilers +can also inject runtime dependency, on the node being compiled. The compiler-wrapper is explicitly +represented as a node in the DAG, and enters the hash. .. code-block:: json diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 71147d8c800a0b..d3e29a384b1f3a 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -150,9 +150,7 @@ def default_manifest_yaml(): view: true concretizer: unify: {} -""".format( - "true" if spack.config.get("concretizer:unify") else "false" - ) +""".format("true" if spack.config.get("concretizer:unify") else "false") sep_re = re.escape(os.sep) @@ -1720,9 +1718,9 @@ def deconcretize_by_user_spec( discarded, self.concretized_roots = stable_partition( self.concretized_roots, lambda x: x.group == group and x.root == spec ) - assert ( - len({x.hash for x in discarded}) == 1 - ), "More than one hash associated with a single user spec" + assert len({x.hash for x in discarded}) == 1, ( + "More than one hash associated with a single user spec" + ) dag_hash = discarded[0].hash self._maybe_remove_dag_hash(dag_hash) diff --git a/lib/spack/spack/environment/shell.py b/lib/spack/spack/environment/shell.py index 957d52d829ee12..f639c25cc90e81 100644 --- a/lib/spack/spack/environment/shell.py +++ b/lib/spack/spack/environment/shell.py @@ -194,7 +194,7 @@ def activate( "Environment view is broken due to a missing package or repo.\n", " To activate without views enabled, activate with:\n", " spack env activate -V {0}\n".format(env.name), - " To remove it and resolve the issue, " "force concretize with the command:\n", + " To remove it and resolve the issue, force concretize with the command:\n", " spack -e {0} concretize --force".format(env.name), ) diff --git a/lib/spack/spack/extensions.py b/lib/spack/spack/extensions.py index 19183ab17ca8d7..895b136b29eba6 100644 --- a/lib/spack/spack/extensions.py +++ b/lib/spack/spack/extensions.py @@ -4,6 +4,7 @@ """Service functions and classes to implement the hooks for Spack's command extensions. """ + import glob import importlib import os diff --git a/lib/spack/spack/externals.py b/lib/spack/spack/externals.py index 5c33174f60fc76..a86d6e737071e5 100644 --- a/lib/spack/spack/externals.py +++ b/lib/spack/spack/externals.py @@ -14,6 +14,7 @@ The helper function ``extract_dicts_from_configuration`` is used to transform the configuration into the intermediate representation. """ + import re import uuid import warnings diff --git a/lib/spack/spack/fetch_strategy.py b/lib/spack/spack/fetch_strategy.py index ca98fb9b5e2264..39dff1e338c410 100644 --- a/lib/spack/spack/fetch_strategy.py +++ b/lib/spack/spack/fetch_strategy.py @@ -25,6 +25,7 @@ ``archive()`` Archive a source directory, e.g. for creating a mirror. """ + import copy import functools import hashlib diff --git a/lib/spack/spack/graph.py b/lib/spack/spack/graph.py index 37f3962a0fbdf8..3835e2c9ad7b47 100644 --- a/lib/spack/spack/graph.py +++ b/lib/spack/spack/graph.py @@ -37,6 +37,7 @@ :func:`graph_dot` will output a graph of a spec (or multiple specs) in dot format. """ + import enum import sys from typing import List, Optional, Set, TextIO, Tuple @@ -522,11 +523,11 @@ def edge_entry(self, edge): colormap = {"build": "dodgerblue", "link": "crimson", "run": "goldenrod"} label = "" if edge.virtuals: - label = f" xlabel=\"virtuals={','.join(edge.virtuals)}\"" + label = f' xlabel="virtuals={",".join(edge.virtuals)}"' return ( edge.parent.dag_hash(), edge.spec.dag_hash(), - f"[color=\"{':'.join(colormap[x] for x in dt.flag_to_tuple(edge.depflag))}\"" + f'[color="{":".join(colormap[x] for x in dt.flag_to_tuple(edge.depflag))}"' + label + "]", ) diff --git a/lib/spack/spack/hooks/__init__.py b/lib/spack/spack/hooks/__init__.py index 02f597c4878fb2..436d04dd3bbfef 100644 --- a/lib/spack/spack/hooks/__init__.py +++ b/lib/spack/spack/hooks/__init__.py @@ -19,6 +19,7 @@ systems (e.g. modules, lmod, etc.) or to add other custom features. """ + import importlib import types from typing import List, Optional diff --git a/lib/spack/spack/hooks/licensing.py b/lib/spack/spack/hooks/licensing.py index 4fb35560afbe51..4411ce28990fdb 100644 --- a/lib/spack/spack/hooks/licensing.py +++ b/lib/spack/spack/hooks/licensing.py @@ -91,9 +91,7 @@ def write_license_file(pkg, license_path): file UNCHANGED. The system may be configured if: - A license file is installed in a default location. -""".format( - pkg.name - ) +""".format(pkg.name) if envvars: txt += """\ @@ -101,9 +99,7 @@ def write_license_file(pkg, license_path): a module file: {0} -""".format( - envvars - ) +""".format(envvars) txt += """\ * Otherwise, depending on the license you have, enter AT THE BEGINNING of @@ -116,18 +112,14 @@ def write_license_file(pkg, license_path): this Spack-global file (relative to the installation prefix). {0} -""".format( - linktargets - ) +""".format(linktargets) if url: txt += """\ * For further information on licensing, see: {0} -""".format( - url - ) +""".format(url) txt += """\ Recap: diff --git a/lib/spack/spack/install_test.py b/lib/spack/spack/install_test.py index 7f92c05110cf80..f240fd243ea12c 100644 --- a/lib/spack/spack/install_test.py +++ b/lib/spack/spack/install_test.py @@ -476,9 +476,9 @@ def test_part( wdir = "." if work_dir is None else work_dir tester = pkg.tester - assert test_name and test_name.startswith( - "test_" - ), f"Test name must start with 'test_' but {test_name} was provided" + assert test_name and test_name.startswith("test_"), ( + f"Test name must start with 'test_' but {test_name} was provided" + ) title = "test: {}: {}".format(test_name, purpose or "unspecified purpose") with fs.working_dir(wdir, create=True): @@ -511,9 +511,7 @@ def test_part( if exc_type is spack.util.executable.ProcessError or exc_type is TypeError: iostr = io.StringIO() - write_log_summary( - iostr, "test", tester.test_log_file, last=1 - ) # type: ignore[assignment] + write_log_summary(iostr, "test", tester.test_log_file, last=1) # type: ignore[assignment] m = iostr.getvalue() else: # We're below the package context, so get context from diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index 1de0692e12aaa3..fad9c3a16c3477 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -1289,9 +1289,9 @@ def _start_build_process(self): def poll(self): """Check if task has successfully executed, caused an InstallError, or the child process has information ready to receive.""" - assert ( - self.started or self.no_op - ), "Can't call `poll()` before `start()` or identified no-operation task" + assert self.started or self.no_op, ( + "Can't call `poll()` before `start()` or identified no-operation task" + ) return self.no_op or self.success_result or self.error_result or self.process_handle.poll() def succeed(self): @@ -1324,9 +1324,9 @@ def complete(self): Complete the installation of the requested spec and/or dependency represented by the build task. """ - assert ( - self.started or self.no_op - ), "Can't call `complete()` before `start()` or identified no-operation task" + assert self.started or self.no_op, ( + "Can't call `complete()` before `start()` or identified no-operation task" + ) pkg = self.pkg self.status = BuildStatus.INSTALLING @@ -1841,10 +1841,9 @@ def _ensure_locked( Return: (lock_type, lock) tuple where lock will be None if it could not be obtained """ - assert lock_type in [ - "read", - "write", - ], f'"{lock_type}" is not a supported package management lock type' + assert lock_type in ["read", "write"], ( + f'"{lock_type}" is not a supported package management lock type' + ) pkg_id = package_id(pkg.spec) ltype, lock = self.locks.get(pkg_id, (lock_type, None)) @@ -2380,9 +2379,7 @@ def complete_task(self, task: Task, install_status: InstallStatus) -> Optional[T except KeyboardInterrupt as exc: # The build has been terminated with a Ctrl-C so terminate # regardless of the number of remaining specs. - tty.error( - f"Failed to install {pkg.name} due to " f"{exc.__class__.__name__}: {str(exc)}" - ) + tty.error(f"Failed to install {pkg.name} due to {exc.__class__.__name__}: {str(exc)}") raise except BuildcacheEntryError as exc: @@ -2423,7 +2420,7 @@ def complete_task(self, task: Task, install_status: InstallStatus) -> Optional[T # lower levels -- skip printing if already printed. # TODO: sort out this and SpackError.print_context() tty.error( - f"Failed to install {pkg.name} due to " f"{exc.__class__.__name__}: {str(exc)}" + f"Failed to install {pkg.name} due to {exc.__class__.__name__}: {str(exc)}" ) # Terminate if requested to do so on the first failure. if self.fail_fast: @@ -2524,7 +2521,7 @@ def _install(self) -> None: # be dependencies of this task. term_status.clear() tty.error( - f"Detected uninstalled dependencies for {task.pkg_id}: " f"{task.uninstalled_deps}" + f"Detected uninstalled dependencies for {task.pkg_id}: {task.uninstalled_deps}" ) left = [dep_id for dep_id in task.uninstalled_deps if dep_id not in self.installed] if not left: diff --git a/lib/spack/spack/llnl/path.py b/lib/spack/spack/llnl/path.py index 30b9f2f24e2533..11dff3c92ba0fe 100644 --- a/lib/spack/spack/llnl/path.py +++ b/lib/spack/spack/llnl/path.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Path primitives that just require Python standard library.""" + import functools import sys from typing import List, Optional diff --git a/lib/spack/spack/llnl/string.py b/lib/spack/spack/llnl/string.py index 17bf5d72741b4d..472b7ff1ac0150 100644 --- a/lib/spack/spack/llnl/string.py +++ b/lib/spack/spack/llnl/string.py @@ -4,6 +4,7 @@ """String manipulation functions that do not have other dependencies than Python standard library """ + from typing import List, Optional, Sequence diff --git a/lib/spack/spack/llnl/url.py b/lib/spack/spack/llnl/url.py index 6abc0552df35df..b5d2c3c10e5d02 100644 --- a/lib/spack/spack/llnl/url.py +++ b/lib/spack/spack/llnl/url.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """URL primitives that just require Python standard library.""" + import itertools import os import re @@ -90,11 +91,9 @@ def find_list_urls(url: str) -> Set[str]: ( r"luarocks[^/]+/(?:modules|manifests)/(?P[^/]+)/" + r"(?P.+?)-[0-9.-]*\.src\.rock", - lambda m: "https://luarocks.org/modules/" - + m.group("org") - + "/" - + m.group("name") - + "/", + lambda m: ( + "https://luarocks.org/modules/" + m.group("org") + "/" + m.group("name") + "/" + ), ), ] @@ -223,7 +222,7 @@ def split_url_extension(url: str) -> Tuple[str, ...]: 1. ``('https://github.com/losalamos/CLAMR/blob/packages/PowerParser_v2.0.7', '.tgz', '?raw=true')`` 2. ``('http://www.apache.org/dyn/closer.cgi?path=/cassandra/1.2.0/apache-cassandra-1.2.0-rc2-bin', '.tar.gz', None)`` 3. ``('https://gitlab.kitware.com/vtk/vtk/repository/archive', '.tar.bz2', '?ref=v7.0.0')`` - """ + """ # noqa: E501 # Strip off sourceforge download suffix. # e.g. https://sourceforge.net/projects/glew/files/glew/2.0.0/glew-2.0.0.tgz/download prefix, suffix = split_url_on_sourceforge_suffix(url) diff --git a/lib/spack/spack/llnl/util/argparsewriter.py b/lib/spack/spack/llnl/util/argparsewriter.py index 8771b32e40f490..ef1a27c24e48ed 100644 --- a/lib/spack/spack/llnl/util/argparsewriter.py +++ b/lib/spack/spack/llnl/util/argparsewriter.py @@ -263,9 +263,7 @@ def begin_command(self, prog: str) -> str: {1} {2} -""".format( - prog.replace(" ", "-"), prog, self.rst_levels[self.level] * len(prog) - ) +""".format(prog.replace(" ", "-"), prog, self.rst_levels[self.level] * len(prog)) def description(self, description: str) -> str: """Description of a command. @@ -292,9 +290,7 @@ def usage(self, usage: str) -> str: {0} -""".format( - usage - ) +""".format(usage) def begin_positionals(self) -> str: """Text to print before positional arguments. @@ -318,9 +314,7 @@ def positional(self, name: str, help: str) -> str: ``{0}`` {1} -""".format( - name, help - ) +""".format(name, help) def end_positionals(self) -> str: """Text to print after positional arguments. @@ -352,9 +346,7 @@ def optional(self, opts: str, help: str) -> str: ``{0}`` {1} -""".format( - opts, help - ) +""".format(opts, help) def end_optionals(self) -> str: """Text to print after optional arguments. diff --git a/lib/spack/spack/llnl/util/filesystem.py b/lib/spack/spack/llnl/util/filesystem.py index 8c0d3472655fdf..f9cab433d800ad 100644 --- a/lib/spack/spack/llnl/util/filesystem.py +++ b/lib/spack/spack/llnl/util/filesystem.py @@ -1863,7 +1863,6 @@ def _find_max_depth( with dir_iter: for dir_entry in dir_iter: - # Match filename only patterns if filename_only_patterns: m = regex.match(os.path.normcase(dir_entry.name)) diff --git a/lib/spack/spack/llnl/util/lang.py b/lib/spack/spack/llnl/util/lang.py index 555a7ae6d1754a..b0fada151ae35f 100644 --- a/lib/spack/spack/llnl/util/lang.py +++ b/lib/spack/spack/llnl/util/lang.py @@ -487,7 +487,7 @@ def match(string): return True else: raise ValueError( - "args to match_predicate must be regex, " "list of regexes, or callable." + "args to match_predicate must be regex, list of regexes, or callable." ) return False diff --git a/lib/spack/spack/llnl/util/tty/colify.py b/lib/spack/spack/llnl/util/tty/colify.py index beeb535887b0f0..d6b7a8cfdd5712 100644 --- a/lib/spack/spack/llnl/util/tty/colify.py +++ b/lib/spack/spack/llnl/util/tty/colify.py @@ -5,6 +5,7 @@ """ Routines for printing columnar output. See ``colify()`` for more information. """ + import io import os import shutil diff --git a/lib/spack/spack/llnl/util/tty/color.py b/lib/spack/spack/llnl/util/tty/color.py index 57d8fdfc9eb8ed..4f9f6a574d627b 100644 --- a/lib/spack/spack/llnl/util/tty/color.py +++ b/lib/spack/spack/llnl/util/tty/color.py @@ -58,6 +58,7 @@ To output an ``@``, use ``@@``. To output a ``}`` inside braces, use ``}}``. """ + import io import os import re diff --git a/lib/spack/spack/llnl/util/tty/log.py b/lib/spack/spack/llnl/util/tty/log.py index c0489ddedfeb6b..a82272094119be 100644 --- a/lib/spack/spack/llnl/util/tty/log.py +++ b/lib/spack/spack/llnl/util/tty/log.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Utility classes for logging the output of blocks of code.""" + import atexit import ctypes import errno diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index 63e8cb71e0794d..e31b0a8ced77aa 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -7,6 +7,7 @@ In a normal Spack installation, this is invoked from the bin/spack script after the system path is set up. """ + import argparse import gc import inspect @@ -155,7 +156,6 @@ def _format_usage(self, usage, actions, groups, prefix=None): def add_argument(self, action): if action.help is not argparse.SUPPRESS: - # find all invocations get_invocation = self._format_action_invocation invocation_lengths = [color.clen(get_invocation(action)) + self._current_indent] @@ -886,8 +886,7 @@ def resolve_alias(cmd_name: str, cmd: List[str]) -> Tuple[str, List[str]]: ) if key in all_commands: tty.warn( - f"Alias '{key}' (mapping to '{value}') attempts to override" - " built-in command." + f"Alias '{key}' (mapping to '{value}') attempts to override built-in command." ) if cmd_name not in all_commands: diff --git a/lib/spack/spack/mixins.py b/lib/spack/spack/mixins.py index 257be86a4e1781..db5dc0fd0831d6 100644 --- a/lib/spack/spack/mixins.py +++ b/lib/spack/spack/mixins.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """This module contains additional behavior that can be attached to any given package.""" + import os from typing import Optional diff --git a/lib/spack/spack/modules/common.py b/lib/spack/spack/modules/common.py index 833b3f22049778..472e40973b9749 100644 --- a/lib/spack/spack/modules/common.py +++ b/lib/spack/spack/modules/common.py @@ -23,6 +23,7 @@ Each of the four classes needs to be sub-classed when implementing a new module type. """ + import collections import contextlib import copy diff --git a/lib/spack/spack/modules/tcl.py b/lib/spack/spack/modules/tcl.py index 78542b2568ccbe..a1468331578a87 100644 --- a/lib/spack/spack/modules/tcl.py +++ b/lib/spack/spack/modules/tcl.py @@ -5,6 +5,7 @@ """This module implements the classes necessary to generate Tcl non-hierarchical modules. """ + import os from typing import Dict, Optional, Tuple diff --git a/lib/spack/spack/multimethod.py b/lib/spack/spack/multimethod.py index e8fe74a15a6604..3fda8115283138 100644 --- a/lib/spack/spack/multimethod.py +++ b/lib/spack/spack/multimethod.py @@ -23,6 +23,7 @@ depending on the scenario, regular old conditionals might be clearer, so package authors should use their judgement. """ + import functools from contextlib import contextmanager from typing import Optional, Union @@ -250,9 +251,9 @@ def __init__(self, condition: Union[str, bool]): self.when = condition def __call__(self, method): - assert ( - MultiMethodMeta._locals is not None - ), "cannot use multimethod, missing MultiMethodMeta metaclass?" + assert MultiMethodMeta._locals is not None, ( + "cannot use multimethod, missing MultiMethodMeta metaclass?" + ) # Create a multimethod with this name if there is not one already original_method = MultiMethodMeta._locals.get(method.__name__) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 393df9d72f4167..77eccf9aa63a1d 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2196,7 +2196,6 @@ def read(self) -> str: class PackageInstaller: - explicit: Set[str] def __init__( diff --git a/lib/spack/spack/package.py b/lib/spack/spack/package.py index 80a0c240e0d327..cd361702c83d3b 100644 --- a/lib/spack/spack/package.py +++ b/lib/spack/spack/package.py @@ -132,10 +132,9 @@ from spack.url import substitute_version as substitute_version_in_url from spack.user_environment import environment_modifications_for_specs from spack.util.elf import delete_needed_from_elf, delete_rpath, get_elf_compat, parse_elf -from spack.util.environment import EnvironmentModifications +from spack.util.environment import EnvironmentModifications, set_env from spack.util.environment import filter_system_paths as _filter_system_paths from spack.util.environment import is_system_path as _is_system_path -from spack.util.environment import set_env from spack.util.executable import Executable, ProcessError, which, which_string from spack.util.filesystem import fix_darwin_install_name from spack.util.libc import libc_from_dynamic_linker, parse_dynamic_linker diff --git a/lib/spack/spack/package_base.py b/lib/spack/spack/package_base.py index 353db92f7540a6..86ff1cd3dcd4db 100644 --- a/lib/spack/spack/package_base.py +++ b/lib/spack/spack/package_base.py @@ -320,9 +320,7 @@ def _wrapper(instance, *args, **kwargs): has_all_attributes = all([hasattr(instance, key) for key in attr_dict]) if has_all_attributes: has_the_right_values = all( - [ - getattr(instance, key) == value for key, value in attr_dict.items() - ] # NOQA: ignore=E501 + [getattr(instance, key) == value for key, value in attr_dict.items()] # NOQA: ignore=E501 ) if has_the_right_values: func(instance, *args, **kwargs) @@ -924,7 +922,7 @@ def keep_werror(self) -> Optional[Literal["all", "specific", "none"]]: def version(self): if not self.spec.versions.concrete: raise ValueError( - "Version requested for a package that" " does not have a concrete version." + "Version requested for a package that does not have a concrete version." ) return self.spec.versions[0] @@ -1628,8 +1626,9 @@ def do_fetch(self, mirror_only=False): deprecated = spack.config.get("config:deprecated") if not deprecated and self.versions.get(self.version, {}).get("deprecated", False): tty.warn( - "{0} is deprecated and may be removed in a future Spack " - "release.".format(self.spec.format("{name}{@version}")) + "{0} is deprecated and may be removed in a future Spack release.".format( + self.spec.format("{name}{@version}") + ) ) # Ask the user whether to install deprecated version if we're diff --git a/lib/spack/spack/paths.py b/lib/spack/spack/paths.py index bfede02ea60ce4..707dc60184ac91 100644 --- a/lib/spack/spack/paths.py +++ b/lib/spack/spack/paths.py @@ -8,6 +8,7 @@ throughout Spack and should bring in a minimal number of external dependencies. """ + import os from pathlib import PurePath diff --git a/lib/spack/spack/provider_index.py b/lib/spack/spack/provider_index.py index 7fe5ac9be49c8d..674014543099ce 100644 --- a/lib/spack/spack/provider_index.py +++ b/lib/spack/spack/provider_index.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Classes and functions to manage providers of virtual dependencies""" + from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Union import spack.error diff --git a/lib/spack/spack/relocate.py b/lib/spack/spack/relocate.py index 6cfefecf608f9d..bc61e3a20521f8 100644 --- a/lib/spack/spack/relocate.py +++ b/lib/spack/spack/relocate.py @@ -419,8 +419,9 @@ def fixup_macos_rpaths(spec): if not os.path.exists(prefix): raise RuntimeError( - "Could not fix up install prefix spec {0} because it does " - "not exist: {1!s}".format(prefix, spec.name) + "Could not fix up install prefix spec {0} because it does not exist: {1!s}".format( + prefix, spec.name + ) ) # Explore the installation prefix of the spec diff --git a/lib/spack/spack/repo.py b/lib/spack/spack/repo.py index 54a52c4a7dd81f..c62be55213511a 100644 --- a/lib/spack/spack/repo.py +++ b/lib/spack/spack/repo.py @@ -540,9 +540,8 @@ def read(self, stream): self.index = spack.provider_index.ProviderIndex.from_json(stream, self.repository) def update(self, pkgs_fullname: Set[str]): - is_virtual = ( - lambda name: not self.repository.exists(name) - or self.repository.get_pkg_class(name).virtual + is_virtual = lambda name: ( + not self.repository.exists(name) or self.repository.get_pkg_class(name).virtual ) non_virtual_pkgs_fullname = {p for p in pkgs_fullname if not is_virtual(p.split(".")[-1])} non_virtual_pkgs_names = {p.split(".")[-1] for p in non_virtual_pkgs_fullname} diff --git a/lib/spack/spack/repo_migrate.py b/lib/spack/spack/repo_migrate.py index aac1c3212e4312..e777e6910684a6 100644 --- a/lib/spack/spack/repo_migrate.py +++ b/lib/spack/spack/repo_migrate.py @@ -447,7 +447,6 @@ def migrate_v2_imports( elif isinstance(node, ast.ImportFrom): # Keep track of old style spack.pkg imports, to be replaced. if node.module and node.module.startswith("spack.pkg.") and node.level == 0: - depth = node.module.count(".") # not all python versions have end_lineno for ImportFrom diff --git a/lib/spack/spack/report.py b/lib/spack/spack/report.py index ae21f3f75bf2f3..bb10a98fbb4707 100644 --- a/lib/spack/spack/report.py +++ b/lib/spack/spack/report.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tools to produce reports of spec installations or tests""" + import collections import gzip import os @@ -55,7 +56,7 @@ def __init__(self, spec): self.time = None self.timestamp = time.strftime("%a, %d %b %Y %H:%M:%S", time.gmtime()) self.properties = [ - Property("architecture", spec.architecture), + Property("architecture", spec.architecture) # Property("compiler", spec.compiler), ] self.packages = [] diff --git a/lib/spack/spack/rewiring.py b/lib/spack/spack/rewiring.py index de7231254ad339..e31e4c04c8b534 100644 --- a/lib/spack/spack/rewiring.py +++ b/lib/spack/spack/rewiring.py @@ -59,7 +59,5 @@ def __init__(self, spliced_spec, build_spec, dep): super().__init__( """Rewire of {0} failed due to missing install of build spec {1} - for spec {2}""".format( - spliced_spec, build_spec, dep - ) + for spec {2}""".format(spliced_spec, build_spec, dep) ) diff --git a/lib/spack/spack/schema/__init__.py b/lib/spack/spack/schema/__init__.py index 206fcf6aa8b6dd..94d4e6e171f78b 100644 --- a/lib/spack/spack/schema/__init__.py +++ b/lib/spack/spack/schema/__init__.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """This module contains jsonschema files for all of Spack's YAML formats.""" + import copy import typing import warnings diff --git a/lib/spack/spack/schema/bootstrap.py b/lib/spack/spack/schema/bootstrap.py index 33d384ed5b2657..11334956d965ec 100644 --- a/lib/spack/spack/schema/bootstrap.py +++ b/lib/spack/spack/schema/bootstrap.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Schema for bootstrap.yaml configuration file.""" + from typing import Any, Dict #: Schema of a single source @@ -23,6 +24,18 @@ "required": ["name", "metadata"], } +#: schema for dev bootstrap configuration +_dev_schema: Dict[str, Any] = { + "type": "object", + "description": "Dev Bootstrap configuration", + "properties": { + "enable_source": { + "type": "boolean", + "description": "Enable bootstrapping dev dependencies from source", + } + }, +} + properties: Dict[str, Any] = { "bootstrap": { "type": "object", @@ -48,6 +61,7 @@ "additionalProperties": {"type": "boolean"}, "description": "Controls which sources are enabled for automatic bootstrapping", }, + "dev": _dev_schema, }, } } diff --git a/lib/spack/spack/schema/buildcache_spec.py b/lib/spack/spack/schema/buildcache_spec.py index c52af939e6cff8..f5af6030bf092a 100644 --- a/lib/spack/spack/schema/buildcache_spec.py +++ b/lib/spack/spack/schema/buildcache_spec.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/buildcache_spec.py :lines: 15- """ + from typing import Any, Dict import spack.schema.spec diff --git a/lib/spack/spack/schema/cdash.py b/lib/spack/spack/schema/cdash.py index 49334c6c4ba94e..f6f5523bc33bde 100644 --- a/lib/spack/spack/schema/cdash.py +++ b/lib/spack/spack/schema/cdash.py @@ -6,6 +6,7 @@ .. literalinclude:: ../spack/schema/cdash.py :lines: 13- """ + from typing import Any, Dict #: Properties for inclusion in other schemas diff --git a/lib/spack/spack/schema/ci.py b/lib/spack/spack/schema/ci.py index 97c88700a6c767..f8034338be3c6a 100644 --- a/lib/spack/spack/schema/ci.py +++ b/lib/spack/spack/schema/ci.py @@ -6,6 +6,7 @@ .. literalinclude:: ../spack/schema/ci.py :lines: 16- """ + from typing import Any, Dict # Schema for script fields diff --git a/lib/spack/spack/schema/compilers.py b/lib/spack/spack/schema/compilers.py index df62d27040e187..2bf16ccbb2e5f5 100644 --- a/lib/spack/spack/schema/compilers.py +++ b/lib/spack/spack/schema/compilers.py @@ -6,6 +6,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/compilers.py :lines: 15- """ + from typing import Any, Dict import spack.schema.environment diff --git a/lib/spack/spack/schema/concretizer.py b/lib/spack/spack/schema/concretizer.py index 52da5788dacb0d..19bab4fc03b4d0 100644 --- a/lib/spack/spack/schema/concretizer.py +++ b/lib/spack/spack/schema/concretizer.py @@ -6,6 +6,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/concretizer.py :lines: 12- """ + from typing import Any, Dict LIST_OF_SPECS = {"type": "array", "items": {"type": "string"}} diff --git a/lib/spack/spack/schema/config.py b/lib/spack/spack/schema/config.py index b9391481c0668c..d9ab4b1060b79b 100644 --- a/lib/spack/spack/schema/config.py +++ b/lib/spack/spack/schema/config.py @@ -6,6 +6,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/config.py :lines: 17- """ + from typing import Any, Dict import spack.schema diff --git a/lib/spack/spack/schema/container.py b/lib/spack/spack/schema/container.py index ff92fe2afa4347..46a712c83d54d2 100644 --- a/lib/spack/spack/schema/container.py +++ b/lib/spack/spack/schema/container.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Schema for the ``container`` subsection of Spack environments.""" + from typing import Any, Dict _stages_from_dockerhub = { diff --git a/lib/spack/spack/schema/cray_manifest.py b/lib/spack/spack/schema/cray_manifest.py index 987576df2fe40a..922221c8f36db7 100644 --- a/lib/spack/spack/schema/cray_manifest.py +++ b/lib/spack/spack/schema/cray_manifest.py @@ -10,6 +10,7 @@ This does not specify a configuration - it is an input format that is consumed and transformed into Spack DB records. """ + from typing import Any, Dict properties: Dict[str, Any] = { diff --git a/lib/spack/spack/schema/database_index.py b/lib/spack/spack/schema/database_index.py index b72cc17862df6f..5a2d886616f18b 100644 --- a/lib/spack/spack/schema/database_index.py +++ b/lib/spack/spack/schema/database_index.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/database_index.py :lines: 17- """ + from typing import Any, Dict import spack.schema.spec diff --git a/lib/spack/spack/schema/definitions.py b/lib/spack/spack/schema/definitions.py index 5944a9640f79c6..fddbe0cd71edaf 100644 --- a/lib/spack/spack/schema/definitions.py +++ b/lib/spack/spack/schema/definitions.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/definitions.py :lines: 16- """ + from typing import Any, Dict from .spec_list import spec_list_schema diff --git a/lib/spack/spack/schema/develop.py b/lib/spack/spack/schema/develop.py index e02d83a12ac5b7..c6a8ce184d82e5 100644 --- a/lib/spack/spack/schema/develop.py +++ b/lib/spack/spack/schema/develop.py @@ -11,8 +11,7 @@ "additionalProperties": { "type": "object", "additionalProperties": False, - "description": "Name of a package to develop, with its spec and optional " - "source path", + "description": "Name of a package to develop, with its spec and optional source path", "required": ["spec"], "properties": { "spec": { diff --git a/lib/spack/spack/schema/env_vars.py b/lib/spack/spack/schema/env_vars.py index 4f7a4337c08b14..5c319428ebe8ef 100644 --- a/lib/spack/spack/schema/env_vars.py +++ b/lib/spack/spack/schema/env_vars.py @@ -6,6 +6,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/env_vars.py :lines: 15- """ + from typing import Any, Dict import spack.schema.environment diff --git a/lib/spack/spack/schema/environment.py b/lib/spack/spack/schema/environment.py index 815fced752a5b7..0882e0fe00970a 100644 --- a/lib/spack/spack/schema/environment.py +++ b/lib/spack/spack/schema/environment.py @@ -4,6 +4,7 @@ """Schema for environment modifications. Meant for inclusion in other schemas. """ + import collections.abc from typing import Any, Dict diff --git a/lib/spack/spack/schema/include.py b/lib/spack/spack/schema/include.py index 1cc1fcb4c13e4a..6938dee47df929 100644 --- a/lib/spack/spack/schema/include.py +++ b/lib/spack/spack/schema/include.py @@ -6,6 +6,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/include.py :lines: 12- """ + from typing import Any, Dict #: Properties for inclusion in other schemas diff --git a/lib/spack/spack/schema/merged.py b/lib/spack/spack/schema/merged.py index f414e8b65f15ab..16c6144a554aed 100644 --- a/lib/spack/spack/schema/merged.py +++ b/lib/spack/spack/schema/merged.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/merged.py :lines: 32- """ + from typing import Any, Dict import spack.schema.bootstrap diff --git a/lib/spack/spack/schema/mirrors.py b/lib/spack/spack/schema/mirrors.py index 839679bc608bdc..50b23a1dba94fa 100644 --- a/lib/spack/spack/schema/mirrors.py +++ b/lib/spack/spack/schema/mirrors.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/mirrors.py :lines: 13- """ + from typing import Any, Dict #: Common properties for connection specification diff --git a/lib/spack/spack/schema/modules.py b/lib/spack/spack/schema/modules.py index 9cceba5f792576..0d7b3cda9be5dd 100644 --- a/lib/spack/spack/schema/modules.py +++ b/lib/spack/spack/schema/modules.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/modules.py :lines: 16- """ + from typing import Any, Dict import spack.schema.environment diff --git a/lib/spack/spack/schema/packages.py b/lib/spack/spack/schema/packages.py index e8c429cc9c7200..4f4e28bd715084 100644 --- a/lib/spack/spack/schema/packages.py +++ b/lib/spack/spack/schema/packages.py @@ -6,6 +6,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/packages.py :lines: 14- """ + from typing import Any, Dict import spack.schema.environment diff --git a/lib/spack/spack/schema/projections.py b/lib/spack/spack/schema/projections.py index aebb9404052e16..d8952d24959fd4 100644 --- a/lib/spack/spack/schema/projections.py +++ b/lib/spack/spack/schema/projections.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/projections.py :lines: 14- """ + from typing import Any, Dict #: Properties for inclusion in other schemas diff --git a/lib/spack/spack/schema/spec.py b/lib/spack/spack/schema/spec.py index 7ae2a14c31173d..82cfdc48bfdc0e 100644 --- a/lib/spack/spack/schema/spec.py +++ b/lib/spack/spack/schema/spec.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/spec.py :lines: 15- """ + from typing import Any, Dict target = { diff --git a/lib/spack/spack/schema/spec_list.py b/lib/spack/spack/schema/spec_list.py index 2af176c6ebf1db..179b5597010fd0 100644 --- a/lib/spack/spack/schema/spec_list.py +++ b/lib/spack/spack/schema/spec_list.py @@ -15,7 +15,7 @@ "matrix": matrix_schema, "exclude": { "type": "array", - "description": "List of specific spec combinations to exclude from the " "matrix", + "description": "List of specific spec combinations to exclude from the matrix", "items": {"type": "string"}, }, } diff --git a/lib/spack/spack/schema/toolchains.py b/lib/spack/spack/schema/toolchains.py index 743abf3df021c2..65701bfdf08c38 100644 --- a/lib/spack/spack/schema/toolchains.py +++ b/lib/spack/spack/schema/toolchains.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/toolchains.py :lines: 13- """ + from typing import Any, Dict #: Properties for inclusion in other schemas diff --git a/lib/spack/spack/schema/url_buildcache_manifest.py b/lib/spack/spack/schema/url_buildcache_manifest.py index e3dc4340fcbc00..15c12a9d59c1ea 100644 --- a/lib/spack/spack/schema/url_buildcache_manifest.py +++ b/lib/spack/spack/schema/url_buildcache_manifest.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/url_buildcache_manifest.py :lines: 11- """ + from typing import Any, Dict properties: Dict[str, Any] = { diff --git a/lib/spack/spack/schema/view.py b/lib/spack/spack/schema/view.py index 9ee55937d95133..08b5e6489ded31 100644 --- a/lib/spack/spack/schema/view.py +++ b/lib/spack/spack/schema/view.py @@ -7,6 +7,7 @@ .. literalinclude:: _spack_root/lib/spack/spack/schema/view.py :lines: 15- """ + from typing import Any, Dict import spack.schema.projections diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 60e1264df2172f..3d17bf2d61e167 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -438,8 +438,8 @@ def to_dict(self) -> dict: Does not include anything related to unsatisfiability as we are only interested in storing satisfiable results """ - serial_node_arg = ( - lambda node_dict: f"""{{"id": "{node_dict.id}", "pkg": "{node_dict.pkg}"}}""" + serial_node_arg = lambda node_dict: ( + f"""{{"id": "{node_dict.id}", "pkg": "{node_dict.pkg}"}}""" ) ret = dict() ret["criteria"] = self.criteria @@ -744,10 +744,7 @@ def fetch(self, problem: str) -> Union[Tuple[Result, Dict], Tuple[None, None]]: # update mod/access time for use w/ LRU cleanup os.utime(cache_path) - return ( - self._results_from_cache(cache_content), - self._stats_from_cache(cache_content), - ) # type: ignore + return (self._results_from_cache(cache_content), self._stats_from_cache(cache_content)) # type: ignore def _is_checksummed_git_version(v): @@ -865,7 +862,7 @@ def message(self, errors) -> str: input_specs = ", ".join(elide_list([f"`{s}`" for s in self.input_specs], 5)) header = f"failed to concretize {input_specs} for the following reasons:" messages = ( - f" {idx+1:2}. {self.handle_error(msg, *args)}" + f" {idx + 1:2}. {self.handle_error(msg, *args)}" for idx, (_, msg, args) in enumerate(errors) ) return "\n".join((header, *messages)) @@ -4089,7 +4086,6 @@ def __init__(self, msg): class OutputDoesNotSatisfyInputError(InternalConcretizerError): - def __init__( self, input_to_output: List[Tuple[spack.spec.Spec, Optional[spack.spec.Spec]]] ) -> None: diff --git a/lib/spack/spack/solver/core.py b/lib/spack/spack/solver/core.py index 6d1a9787bab3fe..5c98702725e8d4 100644 --- a/lib/spack/spack/solver/core.py +++ b/lib/spack/spack/solver/core.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Low-level wrappers around clingo API and other basic functionality related to ASP""" + import importlib import pathlib from types import ModuleType diff --git a/lib/spack/spack/solver/input_analysis.py b/lib/spack/spack/solver/input_analysis.py index d8a0e5d148d614..821d2d48e6cacc 100644 --- a/lib/spack/spack/solver/input_analysis.py +++ b/lib/spack/spack/solver/input_analysis.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Classes to analyze the input of a solve, and provide information to set up the ASP problem""" + import collections from typing import Dict, List, NamedTuple, Set, Tuple, Union diff --git a/lib/spack/spack/solver/runtimes.py b/lib/spack/spack/solver/runtimes.py index a7523b6a2f87b8..2b9c52619c9e1a 100644 --- a/lib/spack/spack/solver/runtimes.py +++ b/lib/spack/spack/solver/runtimes.py @@ -115,7 +115,7 @@ def depends_on(self, dependency_str: str, *, when: str, type: str, description: f" provider(ProviderNode, {runtime_node}),\n" ) - rule = f"{head_str} :-\n" f"{depends_on_constraint}" f"{body_str}." + rule = f"{head_str} :-\n{depends_on_constraint}{body_str}." self.rules.append(rule) self.reset() diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 20134dcc810d8a..6299dddafc1b20 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -46,6 +46,7 @@ 6. The architecture to build with. """ + import collections import collections.abc import enum @@ -928,7 +929,6 @@ def _shared_subset_pair_iterate(container1, container2): class FlagMap(lang.HashableMap[str, List[CompilerFlag]]): - def satisfies(self, other): return all(f in self and set(self[f]) >= set(other[f]) for f in other) @@ -2909,9 +2909,9 @@ def _patches_assigned(self): # ensure that patch state is consistent patch_variant = self.variants["patches"] - assert hasattr( - patch_variant, "_patches_in_order_of_appearance" - ), "patches should always be assigned with a patch variant." + assert hasattr(patch_variant, "_patches_in_order_of_appearance"), ( + "patches should always be assigned with a patch variant." + ) return True @@ -5173,7 +5173,7 @@ def __setitem__(self, name, vspec): # Raise an error if name and vspec.name don't match if name != vspec.name: raise KeyError( - f'Inconsistent key "{name}", must be "{vspec.name}" to ' "match VariantSpec" + f'Inconsistent key "{name}", must be "{vspec.name}" to match VariantSpec' ) # Set the item diff --git a/lib/spack/spack/spec_parser.py b/lib/spack/spack/spec_parser.py index 25b39ffb190fab..67f5e0e2516108 100644 --- a/lib/spack/spack/spec_parser.py +++ b/lib/spack/spack/spec_parser.py @@ -56,6 +56,7 @@ specs to avoid ambiguity. Both are provided because ``~`` can cause shell expansion when it is the first character in an id typed on the command line. """ + import json import pathlib import re @@ -707,7 +708,7 @@ class SpecParsingError(spack.error.SpecSyntaxError): def __init__(self, message, token, text): message += f"\n{text}" if token: - underline = f"\n{' '*token.start}{'^'*(token.end - token.start)}" + underline = f"\n{' ' * token.start}{'^' * (token.end - token.start)}" message += color.colorize(f"@*r{{{underline}}}") super().__init__(message) diff --git a/lib/spack/spack/store.py b/lib/spack/spack/store.py index 02601e144440b4..f4e13fef0e5907 100644 --- a/lib/spack/spack/store.py +++ b/lib/spack/spack/store.py @@ -15,6 +15,7 @@ debugging easier. """ + import contextlib import os import pathlib diff --git a/lib/spack/spack/subprocess_context.py b/lib/spack/spack/subprocess_context.py index c48b642d7763e9..0fbd05114a9d6d 100644 --- a/lib/spack/spack/subprocess_context.py +++ b/lib/spack/spack/subprocess_context.py @@ -11,6 +11,7 @@ modifications to global state in memory that must be replicated in the child process. """ + import importlib import io import multiprocessing diff --git a/lib/spack/spack/tag.py b/lib/spack/spack/tag.py index ecb1d15db898b9..855c24bf938e1f 100644 --- a/lib/spack/spack/tag.py +++ b/lib/spack/spack/tag.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Classes and functions to manage package tags""" + from typing import TYPE_CHECKING, Dict, List, Set import spack.error diff --git a/lib/spack/spack/test/binary_distribution.py b/lib/spack/spack/test/binary_distribution.py index 46812ec6821540..9d0cb22e0ae59d 100644 --- a/lib/spack/spack/test/binary_distribution.py +++ b/lib/spack/spack/test/binary_distribution.py @@ -572,7 +572,11 @@ def response_304(request: urllib.request.Request): if url == f"https://www.example.com/build_cache/{INDEX_JSON_FILE}": assert request.get_header("If-none-match") == '"112a8bbc1b3f7f185621c1ee335f0502"' raise urllib.error.HTTPError( - url, 304, "Not Modified", hdrs={}, fp=None # type: ignore[arg-type] + url, + 304, + "Not Modified", + hdrs={}, # type: ignore[arg-type] + fp=None, # type: ignore[arg-type] ) assert False, "Should not fetch {}".format(url) @@ -1255,7 +1259,11 @@ def response_304(request: urllib.request.Request): if url.endswith(INDEX_MANIFEST_FILE): assert request.get_header("If-none-match") == '"112a8bbc1b3f7f185621c1ee335f0502"' raise urllib.error.HTTPError( - url, 304, "Not Modified", hdrs={}, fp=None # type: ignore[arg-type] + url, + 304, + "Not Modified", + hdrs={}, # type: ignore[arg-type] + fp=None, # type: ignore[arg-type] ) assert False, "Unexpected request {}".format(url) diff --git a/lib/spack/spack/test/bootstrap.py b/lib/spack/spack/test/bootstrap.py index d36ca7472a3444..9cdcf5999f365a 100644 --- a/lib/spack/spack/test/bootstrap.py +++ b/lib/spack/spack/test/bootstrap.py @@ -181,9 +181,7 @@ def test_bootstrap_custom_store_in_environment(mutable_config, tmp_path: pathlib config: install_tree: root: {0} -""".format( - install_root - ) +""".format(install_root) ) with spack.environment.Environment(str(tmp_path)): assert spack.environment.active_environment() diff --git a/lib/spack/spack/test/cc.py b/lib/spack/spack/test/cc.py index aa69658bb242ba..03d17e8e088682 100644 --- a/lib/spack/spack/test/cc.py +++ b/lib/spack/spack/test/cc.py @@ -6,6 +6,7 @@ This test checks that the Spack cc compiler wrapper is parsing arguments correctly. """ + import os import pytest diff --git a/lib/spack/spack/test/cmd/checksum.py b/lib/spack/spack/test/cmd/checksum.py index fa676aab11023b..de9e4e87aa7d25 100644 --- a/lib/spack/spack/test/cmd/checksum.py +++ b/lib/spack/spack/test/cmd/checksum.py @@ -366,6 +366,7 @@ def install(self, spec, prefix): version("1.2.5", sha256="abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") version("1.2.3", sha256="1795c7d067a43174113fdf03447532f373e1c6c57c08d61d9e4e9be5e244b05e") """ + # ruff: disable[E501] # two new versions are added assert spack.cmd.checksum.add_versions_to_pkg(str(pkg_path), version_lines) == 2 assert ( @@ -388,3 +389,6 @@ def install(self, spec, prefix): make("install") """ ) + + +# ruff: enable[E501] diff --git a/lib/spack/spack/test/cmd/common/spec_strings.py b/lib/spack/spack/test/cmd/common/spec_strings.py new file mode 100644 index 00000000000000..f1ea2ea89d6a17 --- /dev/null +++ b/lib/spack/spack/test/cmd/common/spec_strings.py @@ -0,0 +1,112 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +import pathlib + +import spack.cmd.common.spec_strings + + +def test_spec_strings(tmp_path: pathlib.Path): + (tmp_path / "example.py").write_text( + """\ +def func(x): + print("dont fix %s me" % x, 3) + return x.satisfies("+foo %gcc +bar") and x.satisfies("%gcc +baz") +""" + ) + (tmp_path / "example.json").write_text( + """\ +{ + "spec": [ + "+foo %gcc +bar~nope ^dep %clang +yup @3.2 target=x86_64 /abcdef ^another %gcc ", + "%gcc +baz" + ], + "%gcc x=y": 2 +} +""" + ) + (tmp_path / "example.yaml").write_text( + """\ +spec: + - "+foo %gcc +bar" + - "%gcc +baz" + - "this is fine %clang" +"%gcc x=y": 2 +""" + ) + + issues = set() + + def collect_issues(path: str, line: int, col: int, old: str, new: str): + issues.add((path, line, col, old, new)) + + # check for issues with custom handler + spack.cmd.common.spec_strings._check_spec_strings( + [ + str(tmp_path / "nonexistent.py"), + str(tmp_path / "example.py"), + str(tmp_path / "example.json"), + str(tmp_path / "example.yaml"), + ], + handler=collect_issues, + ) + + assert issues == { + ( + str(tmp_path / "example.json"), + 3, + 9, + "+foo %gcc +bar~nope ^dep %clang +yup @3.2 target=x86_64 /abcdef ^another %gcc ", + "+foo +bar~nope %gcc ^dep +yup @3.2 target=x86_64 /abcdef %clang ^another %gcc ", + ), + (str(tmp_path / "example.json"), 4, 9, "%gcc +baz", "+baz %gcc"), + (str(tmp_path / "example.json"), 6, 5, "%gcc x=y", "x=y %gcc"), + (str(tmp_path / "example.py"), 3, 23, "+foo %gcc +bar", "+foo +bar %gcc"), + (str(tmp_path / "example.py"), 3, 57, "%gcc +baz", "+baz %gcc"), + (str(tmp_path / "example.yaml"), 2, 5, "+foo %gcc +bar", "+foo +bar %gcc"), + (str(tmp_path / "example.yaml"), 3, 5, "%gcc +baz", "+baz %gcc"), + (str(tmp_path / "example.yaml"), 5, 1, "%gcc x=y", "x=y %gcc"), + } + + # fix the issues in the files + spack.cmd.common.spec_strings._check_spec_strings( + [ + str(tmp_path / "nonexistent.py"), + str(tmp_path / "example.py"), + str(tmp_path / "example.json"), + str(tmp_path / "example.yaml"), + ], + handler=spack.cmd.common.spec_strings._spec_str_fix_handler, + ) + + assert ( + (tmp_path / "example.json").read_text() + == """\ +{ + "spec": [ + "+foo +bar~nope %gcc ^dep +yup @3.2 target=x86_64 /abcdef %clang ^another %gcc ", + "+baz %gcc" + ], + "x=y %gcc": 2 +} +""" + ) + assert ( + (tmp_path / "example.py").read_text() + == """\ +def func(x): + print("dont fix %s me" % x, 3) + return x.satisfies("+foo +bar %gcc") and x.satisfies("+baz %gcc") +""" + ) + assert ( + (tmp_path / "example.yaml").read_text() + == """\ +spec: + - "+foo +bar %gcc" + - "+baz %gcc" + - "this is fine %clang" +"x=y %gcc": 2 +""" + ) diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index a9fda3ea88e4c9..d4b6f6fb905819 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -265,7 +265,6 @@ def test_change_match_spec(): e = ev.read("test") with e: - add("mpileaks@2.1") add("mpileaks@2.2") @@ -1161,9 +1160,7 @@ def test_env_view_external_prefix(tmp_path: pathlib.Path, mutable_database, mock - spec: pkg-a@2.0 prefix: {a_prefix} buildable: false -""".format( - a_prefix=str(fake_prefix) - ) +""".format(a_prefix=str(fake_prefix)) ) external_config_dict = spack.util.spack_yaml.load_config(external_config) @@ -1347,9 +1344,7 @@ def mpileaks_env_config(include_path): - {0} specs: - mpileaks -""".format( - include_path - ) +""".format(include_path) def test_env_with_included_config_file(mutable_mock_env_path, packages_file): @@ -3105,7 +3100,7 @@ def test_view_link_run( "dtlink2", "dtlink3", "dtlink4", - "dtlink5" "dtbuild1", + "dtlink5dtbuild1", "dtbuild2", "dtbuild3", ): @@ -3781,9 +3776,7 @@ def test_custom_store_in_environment(mutable_config, tmp_path: pathlib.Path): config: install_tree: root: {0} -""".format( - install_root - ) +""".format(install_root) ) current_store_root = str(spack.store.STORE.root) assert str(current_store_root) != str(install_root) @@ -4508,7 +4501,7 @@ def test_env_include_mixed_views( f"""\ spack: include: -{''.join(includes)} +{"".join(includes)} specs: - mpileaks """ diff --git a/lib/spack/spack/test/cmd/find.py b/lib/spack/spack/test/cmd/find.py index 4eefaaae254769..aaa8adba0c1ff4 100644 --- a/lib/spack/spack/test/cmd/find.py +++ b/lib/spack/spack/test/cmd/find.py @@ -286,15 +286,15 @@ def test_find_format_deps_paths(database, config): output == f"""\ mpileaks-2.3 {mpileaks.prefix} - callpath-1.0 {mpileaks['callpath'].prefix} - dyninst-8.2 {mpileaks['dyninst'].prefix} - libdwarf-20130729 {mpileaks['libdwarf'].prefix} - libelf-0.8.13 {mpileaks['libelf'].prefix} - compiler-wrapper-1.0 {mpileaks['compiler-wrapper'].prefix} - gcc-10.2.1 {mpileaks['gcc'].prefix} - gcc-runtime-10.2.1 {mpileaks['gcc-runtime'].prefix} - zmpi-1.0 {mpileaks['zmpi'].prefix} - fake-1.0 {mpileaks['fake'].prefix} + callpath-1.0 {mpileaks["callpath"].prefix} + dyninst-8.2 {mpileaks["dyninst"].prefix} + libdwarf-20130729 {mpileaks["libdwarf"].prefix} + libelf-0.8.13 {mpileaks["libelf"].prefix} + compiler-wrapper-1.0 {mpileaks["compiler-wrapper"].prefix} + gcc-10.2.1 {mpileaks["gcc"].prefix} + gcc-runtime-10.2.1 {mpileaks["gcc-runtime"].prefix} + zmpi-1.0 {mpileaks["zmpi"].prefix} + fake-1.0 {mpileaks["fake"].prefix} """ ) diff --git a/lib/spack/spack/test/cmd/gpg.py b/lib/spack/spack/test/cmd/gpg.py index db95110699fd41..5a745e10f1b945 100644 --- a/lib/spack/spack/test/cmd/gpg.py +++ b/lib/spack/spack/test/cmd/gpg.py @@ -33,7 +33,7 @@ ], ) def test_find_gpg(cmd_name, version, tmp_path: pathlib.Path, mock_gnupghome, monkeypatch): - TEMPLATE = "#!/bin/sh\n" 'echo "{version}"\n' + TEMPLATE = '#!/bin/sh\necho "{version}"\n' with fs.working_dir(str(tmp_path)): for fname in (cmd_name, "gpgconf"): diff --git a/lib/spack/spack/test/cmd/info.py b/lib/spack/spack/test/cmd/info.py index 92007712794a86..099692460b953e 100644 --- a/lib/spack/spack/test/cmd/info.py +++ b/lib/spack/spack/test/cmd/info.py @@ -50,7 +50,8 @@ def test_is_externally_detectable(pkg_query, expected): @pytest.mark.parametrize( - "pkg_query", ["vtk-m", "gcc"] # This should ensure --test's c_names processing loop covered + "pkg_query", + ["vtk-m", "gcc"], # This should ensure --test's c_names processing loop covered ) @pytest.mark.parametrize("extra_args", [[], ["--by-name"]]) def test_info_fields(pkg_query, extra_args): diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index 8252859fde5940..9dbaae65e59481 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -443,7 +443,7 @@ def test_junit_output_with_errors( tmp_path: pathlib.Path, monkeypatch, ): - throw = _keyboard_error if expected_exc == KeyboardInterrupt else _runtime_error + throw = _keyboard_error if expected_exc is KeyboardInterrupt else _runtime_error monkeypatch.setattr(spack.installer.BuildTask, "complete", throw) with fs.working_dir(str(tmp_path)): diff --git a/lib/spack/spack/test/cmd/list.py b/lib/spack/spack/test/cmd/list.py index d13ea5b3207491..8f1d8de5ac5eb3 100644 --- a/lib/spack/spack/test/cmd/list.py +++ b/lib/spack/spack/test/cmd/list.py @@ -235,21 +235,21 @@ def test_list_github_url_fails(repo_builder: RepoBuilder, monkeypatch): try: # Check that a repository with no python path has no URL monkeypatch.setattr(repo, "python_path", None) - assert ( - spack.cmd.list.github_url(pkg) is None - ), "Expected no python path means unable to determine the repo URL" + assert spack.cmd.list.github_url(pkg) is None, ( + "Expected no python path means unable to determine the repo URL" + ) # Check that a repository path that doesn't exist has no URL monkeypatch.setattr(repo, "python_path", "/repo/root/does/not/exists") - assert ( - spack.cmd.list.github_url(pkg) is None - ), "Expected bad repo path means unable to determine the repo URL" + assert spack.cmd.list.github_url(pkg) is None, ( + "Expected bad repo path means unable to determine the repo URL" + ) finally: monkeypatch.setattr(repo, "python_path", old_path) # Check that missing git results in the file path monkeypatch.setattr(spack.util.git, "git", lambda: None) filepath = spack.cmd.list.github_url(pkg) - assert filepath and filepath.startswith( - "file://" - ), "Expected missing 'git' results in a file URI" + assert filepath and filepath.startswith("file://"), ( + "Expected missing 'git' results in a file URI" + ) diff --git a/lib/spack/spack/test/cmd/stage.py b/lib/spack/spack/test/cmd/stage.py index cfaf2fa1edc287..25858d2b04166f 100644 --- a/lib/spack/spack/test/cmd/stage.py +++ b/lib/spack/spack/test/cmd/stage.py @@ -179,6 +179,6 @@ def is_installed(self): specs_to_stage = [s for s in all_specs if not filter(s)] specs_were_filtered = [skip not in specs_to_stage for skip in should_be_filtered] - assert all( - specs_were_filtered - ), f"Packages associated with bools: {[s.name for s in should_be_filtered]}" + assert all(specs_were_filtered), ( + f"Packages associated with bools: {[s.name for s in should_be_filtered]}" + ) diff --git a/lib/spack/spack/test/cmd/style.py b/lib/spack/spack/test/cmd/style.py index fb983bf3fc657e..f47ad503a5a16d 100644 --- a/lib/spack/spack/test/cmd/style.py +++ b/lib/spack/spack/test/cmd/style.py @@ -25,10 +25,11 @@ style = spack.main.SpackCommand("style") +pytestmark = pytest.mark.skipif( + sys.platform == "win32", reason="CI uses cross drive paths that raise errors with relpath" +) -ISORT = which("isort") -BLACK = which("black") -FLAKE8 = which("flake8") +RUFF = which("ruff") MYPY = which("mypy") @@ -41,15 +42,15 @@ def has_develop_branch(git): @pytest.fixture(scope="function") -def flake8_package(tmp_path: pathlib.Path): +def ruff_package(tmp_path: pathlib.Path): """Style only checks files that have been modified. This fixture makes a small - change to the ``flake8`` mock package, yields the filename, then undoes the + change to the ``ruff`` mock package, yields the filename, then undoes the change on cleanup. """ repo = spack.repo.from_path(spack.paths.mock_packages_path) - filename = repo.filename_for_package_name("flake8") + filename = repo.filename_for_package_name("ruff") rel_path = os.path.dirname(os.path.relpath(filename, spack.paths.prefix)) - tmp = tmp_path / rel_path / "flake8-ci-package.py" + tmp = tmp_path / rel_path / "ruff-ci-package.py" tmp.parent.mkdir(parents=True, exist_ok=True) tmp.touch() tmp = str(tmp) @@ -61,19 +62,19 @@ def flake8_package(tmp_path: pathlib.Path): @pytest.fixture -def flake8_package_with_errors(scope="function"): - """A flake8 package with errors.""" +def ruff_package_with_errors(scope="function"): + """A ruff package with errors.""" repo = spack.repo.from_path(spack.paths.mock_packages_path) - filename = repo.filename_for_package_name("flake8") + filename = repo.filename_for_package_name("ruff") tmp = filename + ".tmp" shutil.copy(filename, tmp) package = FileFilter(tmp) - # this is a black error (quote style and spacing before/after operator) + # this is a ruff error (quote style and spacing before/after operator) package.filter('state = "unmodified"', "state = 'modified'", string=True) - # this is an isort error (orderign) and a flake8 error (unused import) + # this is two ruff errors (unused import) (orderign) package.filter( "from spack.package import *", "from spack.package import *\nimport os", string=True ) @@ -91,13 +92,13 @@ def test_changed_files_from_git_rev_base(git, tmp_path: pathlib.Path): (tmp_path / "bin").mkdir(parents=True, exist_ok=True) (tmp_path / "bin" / "spack").touch() - assert changed_files(base="HEAD") == ["bin/spack"] - assert changed_files(base="main") == ["bin/spack"] + assert changed_files(base="HEAD") == [pathlib.Path("bin/spack")] + assert changed_files(base="main") == [pathlib.Path("bin/spack")] git("add", "bin/spack") git("commit", "--no-gpg-sign", "-m", "v1") assert changed_files(base="HEAD") == [] - assert changed_files(base="HEAD~") == ["bin/spack"] + assert changed_files(base="HEAD~") == [pathlib.Path("bin/spack")] def test_changed_no_base(git, tmp_path: pathlib.Path, capfd): @@ -140,7 +141,7 @@ def test_changed_files_all_files(mock_packages): # a mock package repo = spack.repo.from_path(spack.paths.mock_packages_path) - filename = repo.filename_for_package_name("flake8") + filename = repo.filename_for_package_name("ruff") assert filename in files # this test @@ -157,21 +158,9 @@ def test_bad_root(tmp_path: pathlib.Path): assert style.returncode != 0 -def test_style_is_package(): - """Ensure the is_package() function works.""" - assert spack.cmd.style.is_package( - "var/spack/repos/spack_repo/builtin/packages/hdf5/package.py" - ) - assert spack.cmd.style.is_package( - "var/spack/repos/spack_repo/builtin/packages/zlib/package.py" - ) - assert not spack.cmd.style.is_package("lib/spack/spack/spec.py") - assert not spack.cmd.style.is_package("lib/spack/external/pytest.py") - - @pytest.fixture -def external_style_root(git, flake8_package_with_errors, tmp_path: pathlib.Path): - """Create a mock git repository for running spack style.""" +def external_style_root(git, ruff_package_with_errors, tmp_path: pathlib.Path): + """Create a mock repository for running spack style.""" # create a sort-of spack-looking directory script = tmp_path / "bin" / "spack" script.parent.mkdir(parents=True, exist_ok=True) @@ -196,18 +185,12 @@ def external_style_root(git, flake8_package_with_errors, tmp_path: pathlib.Path) # copy the buggy package in py_file = spack_dir / "dummy.py" py_file.touch() - shutil.copy(flake8_package_with_errors, str(py_file)) - - # add the buggy file on the feature branch - with working_dir(str(tmp_path)): - git("add", str(py_file)) - git("commit", "--no-gpg-sign", "-m", "add new file") + shutil.copy(ruff_package_with_errors, str(py_file)) yield tmp_path, py_file -@pytest.mark.skipif(not ISORT, reason="isort is not installed.") -@pytest.mark.skipif(not BLACK, reason="black is not installed.") +@pytest.mark.skipif(not RUFF, reason="ruff is not installed.") def test_fix_style(external_style_root): """Make sure spack style --fix works.""" tmp_path, py_file = external_style_root @@ -219,16 +202,18 @@ def test_fix_style(external_style_root): shutil.copy(broken_dummy, broken_py) assert not filecmp.cmp(broken_py, fixed_py) - # black and isort are the tools that actually fix things - style("--root", str(tmp_path), "--tool", "isort,black", "--fix") - + # dummy.py is in the same directory and will raise errors unrelated to this + # check, don't fail on those errors, just check to make sure + # we fixed the intended file correctly + # Note: can't just specify the correct file due to cross drive issues on Windows + style( + "--root", str(tmp_path), "--tool", "ruff-check,ruff-format", "--fix", fail_on_error=False + ) assert filecmp.cmp(broken_py, fixed_py) -@pytest.mark.skipif(not FLAKE8, reason="flake8 is not installed.") -@pytest.mark.skipif(not ISORT, reason="isort is not installed.") +@pytest.mark.skipif(not RUFF, reason="ruff is not installed.") @pytest.mark.skipif(not MYPY, reason="mypy is not installed.") -@pytest.mark.skipif(not BLACK, reason="black is not installed.") def test_external_root(external_style_root): """Ensure we can run in a separate root directory w/o configuration files.""" tmp_path, py_file = external_style_root @@ -240,68 +225,67 @@ def test_external_root(external_style_root): # make sure it failed assert style.returncode != 0 - # isort error - assert "%s Imports are incorrectly sorted" % str(py_file) in output + # ruff-check error + assert "Import block is un-sorted or un-formatted\n --> lib/spack/spack/dummy.py" in output # mypy error - assert 'lib/spack/spack/dummy.py:47: error: Name "version" is not defined' in output + assert 'lib/spack/spack/dummy.py:45: error: Name "version" is not defined' in output - # black error + # ruff-format error assert "--- lib/spack/spack/dummy.py" in output assert "+++ lib/spack/spack/dummy.py" in output - # flake8 error - assert "lib/spack/spack/dummy.py:8: [F401] 'os' imported but unused" in output + # ruff-check error + assert "`os` imported but unused\n --> lib/spack/spack/dummy.py" in output -@pytest.mark.skipif(not FLAKE8, reason="flake8 is not installed.") -def test_style(flake8_package, tmp_path: pathlib.Path): - root_relative = os.path.relpath(flake8_package, spack.paths.prefix) +@pytest.mark.skipif(not RUFF, reason="ruff is not installed.") +def test_style(ruff_package, tmp_path: pathlib.Path): + root_relative = os.path.relpath(ruff_package, spack.paths.prefix) # use a working directory to test cwd-relative paths, as tests run in # the spack prefix by default with working_dir(str(tmp_path)): - relative = os.path.relpath(flake8_package) + relative = os.path.relpath(ruff_package) # one specific arg - output = style("--tool", "flake8", flake8_package, fail_on_error=False) + output = style("--tool", "ruff-check", ruff_package, fail_on_error=False) assert relative in output assert "spack style checks were clean" in output # specific file that isn't changed - output = style("--tool", "flake8", __file__, fail_on_error=False) + output = style("--tool", "ruff-check", __file__, fail_on_error=False) assert relative not in output assert __file__ in output assert "spack style checks were clean" in output # root-relative paths - output = style("--tool", "flake8", "--root-relative", flake8_package) + output = style("--tool", "ruff-check", "--root-relative", ruff_package) assert root_relative in output assert "spack style checks were clean" in output -@pytest.mark.skipif(not FLAKE8, reason="flake8 is not installed.") -def test_style_with_errors(flake8_package_with_errors): - root_relative = os.path.relpath(flake8_package_with_errors, spack.paths.prefix) +@pytest.mark.skipif(not RUFF, reason="ruff is not installed.") +def test_style_with_errors(ruff_package_with_errors): + root_relative = os.path.relpath(ruff_package_with_errors, spack.paths.prefix) output = style( - "--tool", "flake8", "--root-relative", flake8_package_with_errors, fail_on_error=False + "--tool", "ruff-check", "--root-relative", ruff_package_with_errors, fail_on_error=False ) assert root_relative in output assert style.returncode != 0 assert "spack style found errors" in output -@pytest.mark.skipif(not BLACK, reason="black is not installed.") -@pytest.mark.skipif(not FLAKE8, reason="flake8 is not installed.") -def test_style_with_black(flake8_package_with_errors): - output = style("--tool", "black,flake8", flake8_package_with_errors, fail_on_error=False) - assert "black found errors" in output +@pytest.mark.skipif(not RUFF, reason="ruff is not installed.") +def test_style_with_ruff_format(ruff_package_with_errors): + output = style("--tool", "ruff-format", ruff_package_with_errors, fail_on_error=False) + assert "ruff-format found errors" in output assert style.returncode != 0 assert "spack style found errors" in output def test_skip_tools(): - output = style("--skip", "import,isort,mypy,black,flake8") + output = style("--skip", "import,ruff-check,ruff-format,mypy") assert "Nothing to run" in output @@ -337,12 +321,12 @@ def something(y: spack.util.url.Url): ... root = str(tmp_path) output_buf = io.StringIO() exit_code = _run_import_check( - [str(file)], + [file], fix=False, out=output_buf, root_relative=False, - root=spack.paths.prefix, - working_dir=root, + root=pathlib.Path(spack.paths.prefix), + working_dir=pathlib.Path(root), ) output = output_buf.getvalue() @@ -360,12 +344,12 @@ def something(y: spack.util.url.Url): ... # run it with --fix, should have the same output. output_buf = io.StringIO() exit_code = _run_import_check( - [str(file)], + [file], fix=True, out=output_buf, root_relative=False, - root=spack.paths.prefix, - working_dir=root, + root=pathlib.Path(spack.paths.prefix), + working_dir=pathlib.Path(root), ) output = output_buf.getvalue() assert exit_code == 1 @@ -378,12 +362,12 @@ def something(y: spack.util.url.Url): ... # after fix a second fix is idempotent output_buf = io.StringIO() exit_code = _run_import_check( - [str(file)], + [file], fix=True, out=output_buf, root_relative=False, - root=spack.paths.prefix, - working_dir=root, + root=pathlib.Path(spack.paths.prefix), + working_dir=pathlib.Path(root), ) output = output_buf.getvalue() assert exit_code == 0 @@ -403,12 +387,12 @@ def test_run_import_check_syntax_error_and_missing(tmp_path: pathlib.Path): (tmp_path / "syntax-error.py").write_text("""this 'is n(ot python code""") output_buf = io.StringIO() exit_code = _run_import_check( - [str(tmp_path / "syntax-error.py"), str(tmp_path / "missing.py")], + [tmp_path / "syntax-error.py", tmp_path / "missing.py"], fix=False, out=output_buf, root_relative=True, - root=str(tmp_path), - working_dir=str(tmp_path / "does-not-matter"), + root=tmp_path, + working_dir=tmp_path / "does-not-matter", ) output = output_buf.getvalue() assert "syntax-error.py: could not parse" in output @@ -421,114 +405,12 @@ def test_case_sensitive_imports(tmp_path: pathlib.Path): (tmp_path / "lib" / "spack" / "example").mkdir(parents=True) (tmp_path / "lib" / "spack" / "example" / "__init__.py").write_text("class Example:\n pass") (tmp_path / "lib" / "spack" / "example" / "example.py").write_text("foo = 1") - assert spack.cmd.style._module_part(str(tmp_path), "example.Example") == "example" + assert spack.cmd.style._module_part(tmp_path, "example.Example") == "example" def test_pkg_imports(): - assert spack.cmd.style._module_part(spack.paths.prefix, "spack.pkg.builtin.boost") is None - assert spack.cmd.style._module_part(spack.paths.prefix, "spack.pkg") is None - - -def test_spec_strings(tmp_path: pathlib.Path): - (tmp_path / "example.py").write_text( - """\ -def func(x): - print("dont fix %s me" % x, 3) - return x.satisfies("+foo %gcc +bar") and x.satisfies("%gcc +baz") -""" - ) - (tmp_path / "example.json").write_text( - """\ -{ - "spec": [ - "+foo %gcc +bar~nope ^dep %clang +yup @3.2 target=x86_64 /abcdef ^another %gcc ", - "%gcc +baz" - ], - "%gcc x=y": 2 -} -""" - ) - (tmp_path / "example.yaml").write_text( - """\ -spec: - - "+foo %gcc +bar" - - "%gcc +baz" - - "this is fine %clang" -"%gcc x=y": 2 -""" - ) - - issues = set() - - def collect_issues(path: str, line: int, col: int, old: str, new: str): - issues.add((path, line, col, old, new)) - - # check for issues with custom handler - spack.cmd.style._check_spec_strings( - [ - str(tmp_path / "nonexistent.py"), - str(tmp_path / "example.py"), - str(tmp_path / "example.json"), - str(tmp_path / "example.yaml"), - ], - handler=collect_issues, - ) - - assert issues == { - ( - str(tmp_path / "example.json"), - 3, - 9, - "+foo %gcc +bar~nope ^dep %clang +yup @3.2 target=x86_64 /abcdef ^another %gcc ", - "+foo +bar~nope %gcc ^dep +yup @3.2 target=x86_64 /abcdef %clang ^another %gcc ", - ), - (str(tmp_path / "example.json"), 4, 9, "%gcc +baz", "+baz %gcc"), - (str(tmp_path / "example.json"), 6, 5, "%gcc x=y", "x=y %gcc"), - (str(tmp_path / "example.py"), 3, 23, "+foo %gcc +bar", "+foo +bar %gcc"), - (str(tmp_path / "example.py"), 3, 57, "%gcc +baz", "+baz %gcc"), - (str(tmp_path / "example.yaml"), 2, 5, "+foo %gcc +bar", "+foo +bar %gcc"), - (str(tmp_path / "example.yaml"), 3, 5, "%gcc +baz", "+baz %gcc"), - (str(tmp_path / "example.yaml"), 5, 1, "%gcc x=y", "x=y %gcc"), - } - - # fix the issues in the files - spack.cmd.style._check_spec_strings( - [ - str(tmp_path / "nonexistent.py"), - str(tmp_path / "example.py"), - str(tmp_path / "example.json"), - str(tmp_path / "example.yaml"), - ], - handler=spack.cmd.style._spec_str_fix_handler, - ) - - assert ( - (tmp_path / "example.json").read_text() - == """\ -{ - "spec": [ - "+foo +bar~nope %gcc ^dep +yup @3.2 target=x86_64 /abcdef %clang ^another %gcc ", - "+baz %gcc" - ], - "x=y %gcc": 2 -} -""" - ) - assert ( - (tmp_path / "example.py").read_text() - == """\ -def func(x): - print("dont fix %s me" % x, 3) - return x.satisfies("+foo +bar %gcc") and x.satisfies("+baz %gcc") -""" - ) assert ( - (tmp_path / "example.yaml").read_text() - == """\ -spec: - - "+foo +bar %gcc" - - "+baz %gcc" - - "this is fine %clang" -"x=y %gcc": 2 -""" + spack.cmd.style._module_part(pathlib.Path(spack.paths.prefix), "spack.pkg.builtin.boost") + is None ) + assert spack.cmd.style._module_part(pathlib.Path(spack.paths.prefix), "spack.pkg") is None diff --git a/lib/spack/spack/test/cmd/verify.py b/lib/spack/spack/test/cmd/verify.py index 9ba39e1ff48593..f7e5b7cf6a15fc 100644 --- a/lib/spack/spack/test/cmd/verify.py +++ b/lib/spack/spack/test/cmd/verify.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for the `spack verify` command""" + import os import pathlib import platform diff --git a/lib/spack/spack/test/cmd_extensions.py b/lib/spack/spack/test/cmd_extensions.py index ad63d8e559d4b4..63c2e82ccbefd3 100644 --- a/lib/spack/spack/test/cmd_extensions.py +++ b/lib/spack/spack/test/cmd_extensions.py @@ -143,9 +143,7 @@ def hello(parser, args): hello_folks() elif args.subcommand == 'global': print(global_message) -""".format( - ext_pname=extension.pname - ), +""".format(ext_pname=extension.pname), ) init_file = extension.main / "__init__.py" diff --git a/lib/spack/spack/test/compilers/conversion.py b/lib/spack/spack/test/compilers/conversion.py index 2d77319773badf..deb3b5fd1a3fe4 100644 --- a/lib/spack/spack/test/compilers/conversion.py +++ b/lib/spack/spack/test/compilers/conversion.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests conversions from compilers.yaml""" + import pathlib import pytest diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index 69780bd02a6bc8..de3b73e2b211df 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -3655,9 +3655,9 @@ def test_compiler_match_for_externals_is_taken_into_account( libelf: externals: - spec: "libelf@0.8.12 %gcc@10" - prefix: {tmp_path / 'gcc'} + prefix: {tmp_path / "gcc"} - spec: "libelf@0.8.13 %clang" - prefix: {tmp_path / 'clang'} + prefix: {tmp_path / "clang"} """ ) mutable_config.set("packages", packages_yaml["packages"]) @@ -3689,9 +3689,9 @@ def test_compiler_match_for_externals_with_versions( buildable: false externals: - spec: "libelf@0.8.12 %gcc@10" - prefix: {tmp_path / 'libelf-gcc10'} + prefix: {tmp_path / "libelf-gcc10"} - spec: "libelf@0.8.13 %gcc@9.4.0" - prefix: {tmp_path / 'libelf-gcc9'} + prefix: {tmp_path / "libelf-gcc9"} """ ) mutable_config.set("packages", packages_yaml["packages"]) @@ -3795,7 +3795,7 @@ def test_installing_external_with_compilers_directly( buildable: false externals: - spec: {spec_str} - prefix: {tmp_path / 'libelf'} + prefix: {tmp_path / "libelf"} """ ) mutable_config.set("packages", packages_yaml["packages"]) @@ -3818,7 +3818,7 @@ def test_using_externals_with_compilers(mutable_config, mock_packages, tmp_path: buildable: false externals: - spec: libelf@0.8.12 %gcc@10 - prefix: {tmp_path / 'libelf'} + prefix: {tmp_path / "libelf"} """ ) mutable_config.set("packages", packages_yaml["packages"]) @@ -3865,17 +3865,17 @@ def test_concrete_multi_valued_variants_in_externals( buildable: false externals: - spec: gcc@12.1.0 languages:='c,c++' - prefix: {tmp_path / 'gcc-12'} + prefix: {tmp_path / "gcc-12"} extra_attributes: compilers: - c: {tmp_path / 'gcc-12'}/bin/gcc - cxx: {tmp_path / 'gcc-12'}/bin/g++ + c: {tmp_path / "gcc-12"}/bin/gcc + cxx: {tmp_path / "gcc-12"}/bin/g++ - spec: gcc@14.1.0 languages:=fortran - prefix: {tmp_path / 'gcc-14'} + prefix: {tmp_path / "gcc-14"} extra_attributes: compilers: - fortran: {tmp_path / 'gcc-14'}/bin/gfortran + fortran: {tmp_path / "gcc-14"}/bin/gfortran """ ) mutable_config.set("packages", packages_yaml["packages"]) @@ -3991,7 +3991,7 @@ def test_spec_parts_on_fresh_compilers( buildable: false externals: - spec: "llvm@20 +clang {constraint_in_yaml}" - prefix: {tmp_path / 'llvm-20'} + prefix: {tmp_path / "llvm-20"} """ ) mutable_config.set("packages", packages_yaml["packages"]) @@ -4054,7 +4054,7 @@ def test_spec_parts_on_reused_compilers( buildable: false externals: - spec: "llvm+clang@20 {constraint_in_yaml}" - prefix: {tmp_path / 'llvm-20'} + prefix: {tmp_path / "llvm-20"} mpileaks: buildable: true """ diff --git a/lib/spack/spack/test/concretization/errors.py b/lib/spack/spack/test/concretization/errors.py index b4cb3649cfaee7..66c2c9c6ec4d91 100644 --- a/lib/spack/spack/test/concretization/errors.py +++ b/lib/spack/spack/test/concretization/errors.py @@ -9,6 +9,7 @@ input (spec token, config key, package name) that helps identify what to change. """ + import pathlib from io import StringIO from typing import List @@ -123,7 +124,7 @@ def assert_actionable_error(exc_info, *required_part: str) -> None: """ msg = str(exc_info.value) missing = [h for h in required_part if h not in msg] - assert not missing, f"Error message is missing parts {missing!r}\n" f"Full message:\n{msg}" + assert not missing, f"Error message is missing parts {missing!r}\nFull message:\n{msg}" @pytest.mark.parametrize( diff --git a/lib/spack/spack/test/concretization/requirements.py b/lib/spack/spack/test/concretization/requirements.py index 1b3f4894186447..e29b716d9816c8 100644 --- a/lib/spack/spack/test/concretization/requirements.py +++ b/lib/spack/spack/test/concretization/requirements.py @@ -159,9 +159,7 @@ def test_requirement_adds_new_version( packages: v: require: "@{0}=2.2" -""".format( - a_commit_hash - ) +""".format(a_commit_hash) update_packages_config(conf_str) s1 = spack.concretize.concretize_one("v") @@ -192,9 +190,7 @@ def test_requirement_adds_version_satisfies( packages: t: require: "@{0}=2.2" -""".format( - commits[0] - ) +""".format(commits[0]) update_packages_config(conf_str) s1 = spack.concretize.concretize_one("t") diff --git a/lib/spack/spack/test/config.py b/lib/spack/spack/test/config.py index b5cb98ef1b0617..9a160439b0f763 100644 --- a/lib/spack/spack/test/config.py +++ b/lib/spack/spack/test/config.py @@ -2019,9 +2019,9 @@ def __call__(self, *args, **kwargs) -> str: # type: ignore def test_missing_include_scope_list(mock_missing_dir_include_scopes): """Tests that an included scope with a non existent file/directory is still listed as a scope under spack.config.CONFIG.scopes""" - assert "sub_base" in list( - spack.config.CONFIG.scopes - ), "Missing Optional Scope Missing from Config Scopes" + assert "sub_base" in list(spack.config.CONFIG.scopes), ( + "Missing Optional Scope Missing from Config Scopes" + ) def test_missing_include_scope_writable_list(mock_missing_dir_include_scopes): @@ -2053,34 +2053,34 @@ def test_missing_include_scope_yaml_ext_is_file_scope(mock_missing_file_include_ def test_missing_include_scope_writeable_not_readable(mock_missing_dir_include_scopes): """Tests that an included scope with a non existent file/directory can be written to (and created)""" - assert spack.config.CONFIG.scopes[ - "sub_base" - ].writable, "Missing Optional Scope should be writable" - assert not spack.config.CONFIG.scopes[ - "sub_base" - ].exists, "Missing Optional Scope should not exist" + assert spack.config.CONFIG.scopes["sub_base"].writable, ( + "Missing Optional Scope should be writable" + ) + assert not spack.config.CONFIG.scopes["sub_base"].exists, ( + "Missing Optional Scope should not exist" + ) def test_missing_include_scope_empty_read(mock_missing_dir_include_scopes): """Tests that an included scope with a non existent file/directory returns an empty dict on read and has "exists" set to false""" - assert ( - spack.config.CONFIG.get("config", scope="sub_base") == {} - ), "Missing optional include scope does not return an empty value." - assert not spack.config.CONFIG.scopes[ - "sub_base" - ].exists, "Missing optional include should not be created on read" + assert spack.config.CONFIG.get("config", scope="sub_base") == {}, ( + "Missing optional include scope does not return an empty value." + ) + assert not spack.config.CONFIG.scopes["sub_base"].exists, ( + "Missing optional include should not be created on read" + ) def test_missing_include_scope_file_empty_read(mock_missing_file_include_scopes): """Tests that an include scope with a non existent file returns an empty dict and has exists set to false""" - assert ( - spack.config.CONFIG.get("config", scope="sub_base") == {} - ), "Missing optional include scope does not return an empty value." - assert not spack.config.CONFIG.scopes[ - "sub_base" - ].exists, "Missing optional include should not be created on read" + assert spack.config.CONFIG.get("config", scope="sub_base") == {}, ( + "Missing optional include scope does not return an empty value." + ) + assert not spack.config.CONFIG.scopes["sub_base"].exists, ( + "Missing optional include should not be created on read" + ) def test_missing_include_scope_write_directory(mock_missing_dir_include_scopes): diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index 8afedd2a833e97..3db5345150794e 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -2256,9 +2256,7 @@ def _factory(rpaths, message="Hello world!", dynamic_linker="/lib64/ld-linux.so. int main(){{ printf("{0}"); }} - """.format( - message - ) + """.format(message) ) gcc = spack.util.executable.which("gcc", required=True) executable = source.parent / "main.x" diff --git a/lib/spack/spack/test/cray_manifest.py b/lib/spack/spack/test/cray_manifest.py index 61ec79fbcd6bbc..865537cb2a11bb 100644 --- a/lib/spack/spack/test/cray_manifest.py +++ b/lib/spack/spack/test/cray_manifest.py @@ -8,6 +8,7 @@ establish dependency relationships (and in general the manifest-parsing logic needs to consume all related specs in a single pass). """ + import json import pathlib diff --git a/lib/spack/spack/test/database.py b/lib/spack/spack/test/database.py index 7f6eaa7d682037..480b538412bed2 100644 --- a/lib/spack/spack/test/database.py +++ b/lib/spack/spack/test/database.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Check the database is functioning properly, both in memory and in its file.""" + import contextlib import datetime import functools @@ -352,11 +353,13 @@ def test_recursive_upstream_dbs( ) assert db_a_from_scratch.db_for_spec_hash(spec.dag_hash()) == (db_a_from_scratch) - assert db_a_from_scratch.db_for_spec_hash(spec["y"].dag_hash()) == ( - upstream_dbs_from_scratch[0] + assert ( + db_a_from_scratch.db_for_spec_hash(spec["y"].dag_hash()) + == (upstream_dbs_from_scratch[0]) ) - assert db_a_from_scratch.db_for_spec_hash(spec["z"].dag_hash()) == ( - upstream_dbs_from_scratch[1] + assert ( + db_a_from_scratch.db_for_spec_hash(spec["z"].dag_hash()) + == (upstream_dbs_from_scratch[1]) ) db_a_from_scratch._check_ref_counts() diff --git a/lib/spack/spack/test/directory_layout.py b/lib/spack/spack/test/directory_layout.py index c74827f081dff1..d5f9b9a3bf207b 100644 --- a/lib/spack/spack/test/directory_layout.py +++ b/lib/spack/spack/test/directory_layout.py @@ -5,6 +5,7 @@ """ This test verifies that the Spack directory layout works properly. """ + import os import pathlib from pathlib import Path diff --git a/lib/spack/spack/test/installer.py b/lib/spack/spack/test/installer.py index 40027c44d35e75..495c51bff2bcb7 100644 --- a/lib/spack/spack/test/installer.py +++ b/lib/spack/spack/test/installer.py @@ -1084,9 +1084,9 @@ def test_install_fail_fast_on_detect(install_mockery, monkeypatch, capfd): assert b_id in installer.failed, "Expected b to be marked as failed" assert c_id in installer.failed, "Expected c to be marked as failed" - assert ( - a_id not in installer.installed - ), "Package a cannot install due to its dependencies failing" + assert a_id not in installer.installed, ( + "Package a cannot install due to its dependencies failing" + ) # check that b's active process got killed when c failed assert f"{b_id} failed to install" in capfd.readouterr().err diff --git a/lib/spack/spack/test/installer_build_graph.py b/lib/spack/spack/test/installer_build_graph.py index d3cfb33cf9c1d1..28719a10552943 100644 --- a/lib/spack/spack/test/installer_build_graph.py +++ b/lib/spack/spack/test/installer_build_graph.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for BuildGraph class in new_installer""" + import sys from typing import Dict, List, Tuple, Union diff --git a/lib/spack/spack/test/llnl/url.py b/lib/spack/spack/test/llnl/url.py index cafbde9eae94f5..0c49d51b1d24b3 100644 --- a/lib/spack/spack/test/llnl/url.py +++ b/lib/spack/spack/test/llnl/url.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for spack.llnl.url functions""" + import itertools import pytest diff --git a/lib/spack/spack/test/llnl/util/filesystem.py b/lib/spack/spack/test/llnl/util/filesystem.py index 408303cda1b8af..fc3a485c4799ec 100644 --- a/lib/spack/spack/test/llnl/util/filesystem.py +++ b/lib/spack/spack/test/llnl/util/filesystem.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for ``llnl/util/filesystem.py``""" + import filecmp import os import pathlib diff --git a/lib/spack/spack/test/llnl/util/lock.py b/lib/spack/spack/test/llnl/util/lock.py index 589f2b4511154c..a7b3f9aa6da651 100644 --- a/lib/spack/spack/test/llnl/util/lock.py +++ b/lib/spack/spack/test/llnl/util/lock.py @@ -25,6 +25,7 @@ dict. """ + import collections import errno import getpass diff --git a/lib/spack/spack/test/llnl/util/symlink.py b/lib/spack/spack/test/llnl/util/symlink.py index f9196c105e3c15..69b75e3f71868f 100644 --- a/lib/spack/spack/test/llnl/util/symlink.py +++ b/lib/spack/spack/test/llnl/util/symlink.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for Windows symlink functionality.""" + import os import pathlib import tempfile diff --git a/lib/spack/spack/test/main.py b/lib/spack/spack/test/main.py index 032d0c2ff1ef7c..ba18643bca7f8b 100644 --- a/lib/spack/spack/test/main.py +++ b/lib/spack/spack/test/main.py @@ -67,9 +67,7 @@ def test_git_sha_output(tmp_path: pathlib.Path, working_env, monkeypatch): f.write( """#!/bin/sh echo {0} -""".format( - sha - ) +""".format(sha) ) fs.set_executable(str(git)) diff --git a/lib/spack/spack/test/make_executable.py b/lib/spack/spack/test/make_executable.py index 33bbc8cac36341..1cb0e73505b551 100644 --- a/lib/spack/spack/test/make_executable.py +++ b/lib/spack/spack/test/make_executable.py @@ -7,6 +7,7 @@ This just tests whether the right args are getting passed to make. """ + import os import pathlib diff --git a/lib/spack/spack/test/modules/common.py b/lib/spack/spack/test/modules/common.py index 87a9083f7f2785..092345abaeecdb 100644 --- a/lib/spack/spack/test/modules/common.py +++ b/lib/spack/spack/test/modules/common.py @@ -116,9 +116,7 @@ def test_upstream_module_index(): {0}: path: /path/to/a use_name: a -""".format( - s1.dag_hash() - ) +""".format(s1.dag_hash()) module_indices = [{"tcl": spack.modules.common._read_module_index(tcl_module_index)}, {}] @@ -154,9 +152,7 @@ def test_get_module_upstream(): {0}: path: /path/to/a use_name: a -""".format( - s1.dag_hash() - ) +""".format(s1.dag_hash()) module_indices = [{}, {"tcl": spack.modules.common._read_module_index(tcl_module_index)}] diff --git a/lib/spack/spack/test/oci/image.py b/lib/spack/spack/test/oci/image.py index 9334d4235bba15..ba13c75dab5dac 100644 --- a/lib/spack/spack/test/oci/image.py +++ b/lib/spack/spack/test/oci/image.py @@ -11,13 +11,13 @@ "image_ref, expected", [ ( - f"example.com:1234/a/b/c:tag@sha256:{'a'*64}", + f"example.com:1234/a/b/c:tag@sha256:{'a' * 64}", ("example.com:1234", "a/b/c", "tag", Digest.from_sha256("a" * 64)), ), ("example.com:1234/a/b/c:tag", ("example.com:1234", "a/b/c", "tag", None)), ("example.com:1234/a/b/c", ("example.com:1234", "a/b/c", "latest", None)), ( - f"example.com:1234/a/b/c@sha256:{'a'*64}", + f"example.com:1234/a/b/c@sha256:{'a' * 64}", ("example.com:1234", "a/b/c", "latest", Digest.from_sha256("a" * 64)), ), # ipv4 @@ -45,7 +45,7 @@ def test_name_parsing(image_ref, expected): "image_ref", [ # wrong order of tag and sha - f"example.com:1234/a/b/c@sha256:{'a'*64}:tag", + f"example.com:1234/a/b/c@sha256:{'a' * 64}:tag", # double tag "example.com:1234/a/b/c:tag:tag", # empty tag @@ -53,9 +53,9 @@ def test_name_parsing(image_ref, expected): # empty digest "example.com:1234/a/b/c@sha256:", # unsupported digest algorithm - f"example.com:1234/a/b/c@sha512:{'a'*128}", + f"example.com:1234/a/b/c@sha512:{'a' * 128}", # invalid digest length - f"example.com:1234/a/b/c@sha256:{'a'*63}", + f"example.com:1234/a/b/c@sha256:{'a' * 63}", # whitespace "example.com:1234/a/b/c :tag", "example.com:1234/a/b/c: tag", diff --git a/lib/spack/spack/test/oci/integration_test.py b/lib/spack/spack/test/oci/integration_test.py index a6e74ee4d87078..770314f46f608a 100644 --- a/lib/spack/spack/test/oci/integration_test.py +++ b/lib/spack/spack/test/oci/integration_test.py @@ -256,7 +256,7 @@ def test_uploading_with_base_image_in_docker_image_manifest_v2_format( "history": [ { "created": "2015-10-31T22:22:54.690851953Z", - "created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /", + "created_by": "/bin/sh -c #(nop) ADD file:a3bc1e842b69636f9df5256c49c5374fb4eef1e281fe3f282c65fb853ee171c5 in /", # noqa: E501 } ], } diff --git a/lib/spack/spack/test/oci/mock_registry.py b/lib/spack/spack/test/oci/mock_registry.py index aa98ab07771e9b..807c6a7713b6b1 100644 --- a/lib/spack/spack/test/oci/mock_registry.py +++ b/lib/spack/spack/test/oci/mock_registry.py @@ -198,10 +198,9 @@ def put_manifest(self, req: Request, name: str, ref: str): else: for manifest in index_or_manifest["manifests"]: - assert ( - name, - manifest["digest"], - ) in self.manifests, "Missing manifest while uploading index" + assert (name, manifest["digest"]) in self.manifests, ( + "Missing manifest while uploading index" + ) self.manifests[(name, ref)] = index_or_manifest diff --git a/lib/spack/spack/test/packaging.py b/lib/spack/spack/test/packaging.py index d38dda9ea80106..3cfae44d84ed33 100644 --- a/lib/spack/spack/test/packaging.py +++ b/lib/spack/spack/test/packaging.py @@ -5,6 +5,7 @@ """ This test checks the binary packaging infrastructure """ + import argparse import os import pathlib diff --git a/lib/spack/spack/test/patch.py b/lib/spack/spack/test/patch.py index 6fa20952af0898..28318b0b742fec 100644 --- a/lib/spack/spack/test/patch.py +++ b/lib/spack/spack/test/patch.py @@ -392,7 +392,11 @@ def test_conditional_patched_dependencies(mock_packages, config): def check_multi_dependency_patch_specs( - libelf, libdwarf, fake, owner, package_dir # specs + libelf, + libdwarf, + fake, + owner, + package_dir, # specs ): # parent spec properties """Validate patches on dependencies of patch-several-dependencies.""" # basic patch on libelf diff --git a/lib/spack/spack/test/provider_index.py b/lib/spack/spack/test/provider_index.py index c13c1e77a9b39e..a402f2c90746ac 100644 --- a/lib/spack/spack/test/provider_index.py +++ b/lib/spack/spack/test/provider_index.py @@ -17,6 +17,7 @@ mpi@:10.0: set([zmpi])}, 'stuff': {stuff: set([externalvirtual])}} """ + import io import spack.repo diff --git a/lib/spack/spack/test/repo.py b/lib/spack/spack/test/repo.py index 06a90559af9a3f..0cba89b5167fb1 100644 --- a/lib/spack/spack/test/repo.py +++ b/lib/spack/spack/test/repo.py @@ -83,9 +83,9 @@ def test_repo_last_mtime(mock_packages): modified_after = "\n ".join( f"{path} ({mtime})" for mtime, path in mtime_with_package_py if mtime > repo_mtime ) - assert ( - max_mtime <= repo_mtime - ), f"the following files were modified while running tests:\n {modified_after}" + assert max_mtime <= repo_mtime, ( + f"the following files were modified while running tests:\n {modified_after}" + ) assert max_mtime == repo_mtime, f"last_mtime incorrect for {max_file}" diff --git a/lib/spack/spack/test/reporters.py b/lib/spack/spack/test/reporters.py index ae8e9e713d9008..a829f273baf369 100644 --- a/lib/spack/spack/test/reporters.py +++ b/lib/spack/spack/test/reporters.py @@ -41,9 +41,7 @@ def test_reporters_extract_basics(): ==> [2022-02-15-18:44:21.250165] test: {0}: {1} ==> [2022-02-15-18:44:21.250200] '{2}' {3}: {0} -""".format( - name, desc, fake_bin, status - ).splitlines() +""".format(name, desc, fake_bin, status).splitlines() parts = spack.reporters.extract.extract_test_parts("fake", outputs) assert len(parts) == 1 @@ -63,9 +61,7 @@ def test_reporters_extract_no_parts(capfd): ==> Testing package fake-1.0-abcdefg ==> [2022-02-11-17:14:38.875259] Installing {0} to {1} {2} -""".format( - fake_install_test_root, fake_test_cache, status - ).splitlines() +""".format(fake_install_test_root, fake_test_cache, status).splitlines() parts = spack.reporters.extract.extract_test_parts("fake", outputs) err = capfd.readouterr()[1] @@ -125,9 +121,7 @@ def test_reporters_extract_skipped(state): outputs = """ ==> Testing package fake-1.0-abcdefg {0} -""".format( - expected - ).splitlines() +""".format(expected).splitlines() parts = spack.reporters.extract.extract_test_parts("fake", outputs) diff --git a/lib/spack/spack/test/sbang.py b/lib/spack/spack/test/sbang.py index 0ae04c3a0ec3ac..165bb20bb1a978 100644 --- a/lib/spack/spack/test/sbang.py +++ b/lib/spack/spack/test/sbang.py @@ -5,6 +5,7 @@ """\ Test that Spack's shebang filtering works correctly. """ + import filecmp import os import pathlib @@ -280,9 +281,7 @@ def configure_group_perms(): read: world write: group group: {0} -""".format( - group_name - ) +""".format(group_name) ) spack.config.set("packages", conf, scope="user") diff --git a/lib/spack/spack/test/spack_yaml.py b/lib/spack/spack/test/spack_yaml.py index ae04e0396127a3..20389626351cf6 100644 --- a/lib/spack/spack/test/spack_yaml.py +++ b/lib/spack/spack/test/spack_yaml.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Test Spack's custom YAML format.""" + import io import pathlib diff --git a/lib/spack/spack/test/spec_dag.py b/lib/spack/spack/test/spec_dag.py index 798603c08d9442..9d7b3250ad2761 100644 --- a/lib/spack/spack/test/spec_dag.py +++ b/lib/spack/spack/test/spec_dag.py @@ -4,6 +4,7 @@ """ These tests check Spec DAG operations using dummy packages. """ + import pytest import spack.concretize diff --git a/lib/spack/spack/test/spec_semantics.py b/lib/spack/spack/test/spec_semantics.py index ae782ff6e0a710..813c1ef1365146 100644 --- a/lib/spack/spack/test/spec_semantics.py +++ b/lib/spack/spack/test/spec_semantics.py @@ -1126,7 +1126,7 @@ class Pkg: fn = variant("foo", values=spack.variant.any_combination_of("fee", "foom"), default="bar") with pytest.raises(spack.directives.DirectiveError) as exc_info: fn(Pkg()) - assert " it is handled by an attribute of the 'values' " "argument" in str(exc_info.value) + assert " it is handled by an attribute of the 'values' argument" in str(exc_info.value) # We can't leave None as a default value fn = variant("foo", default=None) @@ -2135,7 +2135,9 @@ def test_virtual_queries_work_for_strings_and_lists(): """Ensure that ``dependencies()`` works with both virtuals=str and virtuals=[str, ...].""" parent, child = Spec("parent"), Spec("child") parent._add_dependency( - child, depflag=dt.BUILD, virtuals=("cxx", "fortran") # multi-char dep names + child, + depflag=dt.BUILD, + virtuals=("cxx", "fortran"), # multi-char dep names ) assert not parent.dependencies(virtuals="c") # not in virtuals but shares a char with cxx diff --git a/lib/spack/spack/test/spec_syntax.py b/lib/spack/spack/test/spec_syntax.py index 37592611224fe7..a9cebf91df8f78 100644 --- a/lib/spack/spack/test/spec_syntax.py +++ b/lib/spack/spack/test/spec_syntax.py @@ -417,12 +417,12 @@ def _specfile_for(spec_str, filename): ), # Version hash pair ( - rf"develop-branch-version@{'abc12'*8}=develop", + rf"develop-branch-version@{'abc12' * 8}=develop", [ Token(SpecTokens.UNQUALIFIED_PACKAGE_NAME, value="develop-branch-version"), - Token(SpecTokens.VERSION_HASH_PAIR, value=f"@{'abc12'*8}=develop"), + Token(SpecTokens.VERSION_HASH_PAIR, value=f"@{'abc12' * 8}=develop"), ], - rf"develop-branch-version@{'abc12'*8}=develop", + rf"develop-branch-version@{'abc12' * 8}=develop", ), # Redundant specs ( @@ -1655,7 +1655,7 @@ def test_parse_specfile_dependency(default_mock_concretization, tmp_path: pathli # Should also be accepted: "spack spec ..//libelf.yaml" spec = SpecParser( - f"libdwarf^..{os.path.sep}{specfile.parent.name}" f"{os.path.sep}{specfile.name}" + f"libdwarf^..{os.path.sep}{specfile.parent.name}{os.path.sep}{specfile.name}" ).next_spec() assert spec and spec["libelf"] == s["libelf"] diff --git a/lib/spack/spack/test/spec_yaml.py b/lib/spack/spack/test/spec_yaml.py index ac2015876b33ec..b0561a4f4f2c36 100644 --- a/lib/spack/spack/test/spec_yaml.py +++ b/lib/spack/spack/test/spec_yaml.py @@ -7,6 +7,7 @@ The YAML and JSON formats preserve DAG information in the spec. """ + import collections import collections.abc import gzip diff --git a/lib/spack/spack/test/stage.py b/lib/spack/spack/test/stage.py index aa817b4b53a097..5e1f89b49e4ef4 100644 --- a/lib/spack/spack/test/stage.py +++ b/lib/spack/spack/test/stage.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Test that the Stage class works correctly.""" + import collections import errno import getpass diff --git a/lib/spack/spack/test/tag.py b/lib/spack/spack/test/tag.py index a3092e285e63d5..80903ab4e49149 100644 --- a/lib/spack/spack/test/tag.py +++ b/lib/spack/spack/test/tag.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for tag index cache files.""" + import io import pytest diff --git a/lib/spack/spack/test/util/environment.py b/lib/spack/spack/test/util/environment.py index 40f9f92bc643b1..9bba875d9d05e6 100644 --- a/lib/spack/spack/test/util/environment.py +++ b/lib/spack/spack/test/util/environment.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Test Spack's environment utility functions.""" + import os import pathlib import sys diff --git a/lib/spack/spack/test/util/executable.py b/lib/spack/spack/test/util/executable.py index a3aba7bac39d7a..b44e449c9ff312 100644 --- a/lib/spack/spack/test/util/executable.py +++ b/lib/spack/spack/test/util/executable.py @@ -32,9 +32,7 @@ def test_read_unicode(tmp_path: pathlib.Path, working_env): f.write( """#!{0} print(u'\\xc3') -""".format( - sys.executable - ) +""".format(sys.executable) ) # make it executable diff --git a/lib/spack/spack/test/util/file_cache.py b/lib/spack/spack/test/util/file_cache.py index bb3ea6fca607f8..25181f31dacc0c 100644 --- a/lib/spack/spack/test/util/file_cache.py +++ b/lib/spack/spack/test/util/file_cache.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Test Spack's FileCache.""" + import os import pathlib diff --git a/lib/spack/spack/test/util/spack_lock_wrapper.py b/lib/spack/spack/test/util/spack_lock_wrapper.py index dc252103355738..793b61849c3db6 100644 --- a/lib/spack/spack/test/util/spack_lock_wrapper.py +++ b/lib/spack/spack/test/util/spack_lock_wrapper.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for Spack's wrapper module around spack.llnl.util.lock.""" + import os import pathlib diff --git a/lib/spack/spack/test/util/util_url.py b/lib/spack/spack/test/util/util_url.py index af44ec8b28dbe9..dd1975677c9d8e 100644 --- a/lib/spack/spack/test/util/util_url.py +++ b/lib/spack/spack/test/util/util_url.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Test Spack's URL handling utility functions.""" + import os import pathlib import urllib.parse diff --git a/lib/spack/spack/test/utilities.py b/lib/spack/spack/test/utilities.py index c5ddae6f4a827b..3df7bf2f9efb86 100644 --- a/lib/spack/spack/test/utilities.py +++ b/lib/spack/spack/test/utilities.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Non-fixture utilities for test code. Must be imported.""" + from spack.main import make_argument_parser diff --git a/lib/spack/spack/test/verification.py b/lib/spack/spack/test/verification.py index bdbcedff1b982a..c5a60a7e2f0b3c 100644 --- a/lib/spack/spack/test/verification.py +++ b/lib/spack/spack/test/verification.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Tests for the `spack.verify` module""" + import os import pathlib import shutil diff --git a/lib/spack/spack/test/versions.py b/lib/spack/spack/test/versions.py index 89c7f713cbb64e..dce64f0b9a9c9a 100644 --- a/lib/spack/spack/test/versions.py +++ b/lib/spack/spack/test/versions.py @@ -6,6 +6,7 @@ We try to maintain compatibility with RPM's version semantics where it makes sense. """ + import os import pathlib diff --git a/lib/spack/spack/tokenize.py b/lib/spack/spack/tokenize.py index 4a0a617a92d732..741b1700c0b155 100644 --- a/lib/spack/spack/tokenize.py +++ b/lib/spack/spack/tokenize.py @@ -4,6 +4,7 @@ """This module provides building blocks for tokenizing strings. Users can define tokens by inheriting from TokenBase and defining tokens as ordered enum members. The Tokenizer class can then be used to iterate over tokens in a string.""" + import enum import re from typing import Generator, Match, Optional, Type diff --git a/lib/spack/spack/url.py b/lib/spack/spack/url.py index 682db8cad91937..711d49d05417c9 100644 --- a/lib/spack/spack/url.py +++ b/lib/spack/spack/url.py @@ -28,6 +28,7 @@ spack doesn't need anyone to tell it where to get the tarball even though it's never been told about that version before. """ + import io import os import pathlib @@ -531,7 +532,7 @@ def substitute_version(path: str, new_version) -> str: >>> substitute_version("https://www.hdfgroup.org/ftp/HDF/releases/HDF4.2.12/src/hdf-4.2.12.tar.gz", "2.3") "https://www.hdfgroup.org/ftp/HDF/releases/HDF2.3/src/hdf-2.3.tar.gz" - """ + """ # noqa: E501 (name, ns, nl, noffs, ver, vs, vl, voffs) = substitution_offsets(path) new_path = "" diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index a90baa42301a68..24e81c01ef24f3 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -68,6 +68,7 @@ up to date with CTest, just make sure the ``*_matches`` and ``*_exceptions`` lists are kept up to date with CTest's build handler. """ + import io import math import re @@ -418,7 +419,6 @@ def stringify(elt): ("warning_matches", _warning_matches), ("warning_exceptions", _warning_exceptions), ]: - print() print(name) for i, elt in enumerate(arr): diff --git a/lib/spack/spack/util/editor.py b/lib/spack/spack/util/editor.py index de65088b74bd7f..9c6ce82a594885 100644 --- a/lib/spack/spack/util/editor.py +++ b/lib/spack/spack/util/editor.py @@ -11,6 +11,7 @@ neither variable is set, we fall back to one of several common editors, raising an OSError if we are unable to find one. """ + import os import shlex from typing import Callable, List diff --git a/lib/spack/spack/util/environment.py b/lib/spack/spack/util/environment.py index 41cccf754ae8cf..598e2986341513 100644 --- a/lib/spack/spack/util/environment.py +++ b/lib/spack/spack/util/environment.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Set, unset or modify environment variables.""" + import collections import contextlib import inspect diff --git a/lib/spack/spack/util/lock.py b/lib/spack/spack/util/lock.py index 8de73be068f9be..39a6bdaeef7351 100644 --- a/lib/spack/spack/util/lock.py +++ b/lib/spack/spack/util/lock.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Wrapper for ``spack.llnl.util.lock`` allows locking to be enabled/disabled.""" + import os import stat import sys diff --git a/lib/spack/spack/util/module_cmd.py b/lib/spack/spack/util/module_cmd.py index 6247d20a7d0107..3294b1bb053916 100644 --- a/lib/spack/spack/util/module_cmd.py +++ b/lib/spack/spack/util/module_cmd.py @@ -6,6 +6,7 @@ This module contains routines related to the module command for accessing and parsing environment modules. """ + import os import re import subprocess @@ -162,7 +163,7 @@ def path_from_modules(modules): candidate_path = get_path_from_module_contents(text, module_name) if candidate_path and not os.path.exists(candidate_path): - msg = "Extracted path from module does not exist " "[module={0}, path={1}]" + msg = "Extracted path from module does not exist [module={0}, path={1}]" tty.warn(msg.format(module_name, candidate_path)) # If anything is found, then it's the best choice. This means diff --git a/lib/spack/spack/util/path.py b/lib/spack/spack/util/path.py index d3d897541f9a6f..bbd6a27f506dac 100644 --- a/lib/spack/spack/util/path.py +++ b/lib/spack/spack/util/path.py @@ -6,6 +6,7 @@ TODO: this is really part of spack.config. Consolidate it. """ + import contextlib import getpass import os diff --git a/lib/spack/spack/util/prefix.py b/lib/spack/spack/util/prefix.py index 09184080720791..6d6543eaad1d3c 100644 --- a/lib/spack/spack/util/prefix.py +++ b/lib/spack/spack/util/prefix.py @@ -5,6 +5,7 @@ """ This file contains utilities for managing the installation prefix of a package. """ + import os from typing import Dict diff --git a/lib/spack/spack/util/remote_file_cache.py b/lib/spack/spack/util/remote_file_cache.py index a2330808d3c439..413a693cab0ac9 100644 --- a/lib/spack/spack/util/remote_file_cache.py +++ b/lib/spack/spack/util/remote_file_cache.py @@ -100,9 +100,9 @@ def local_path(raw_path: str, sha256: str, dest: Optional[str] = None) -> str: if url.scheme in ("http", "https", "ftp"): if not dest: raise ValueError("Requires the destination argument to cache remote files") - assert os.path.isabs( - dest - ), f"Remote file destination '{dest}' must be an absolute path" + assert os.path.isabs(dest), ( + f"Remote file destination '{dest}' must be an absolute path" + ) # Stage the remote configuration file tmpdir = tempfile.mkdtemp() diff --git a/lib/spack/spack/util/spack_json.py b/lib/spack/spack/util/spack_json.py index 8656bc256fb08d..6b49e14379f155 100644 --- a/lib/spack/spack/util/spack_json.py +++ b/lib/spack/spack/util/spack_json.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) """Simple wrapper around JSON to guarantee consistent use of load/dump.""" + import json from typing import Any, Dict, Optional diff --git a/lib/spack/spack/util/spack_yaml.py b/lib/spack/spack/util/spack_yaml.py index f771368025d62f..88ff506d869fdd 100644 --- a/lib/spack/spack/util/spack_yaml.py +++ b/lib/spack/spack/util/spack_yaml.py @@ -11,6 +11,7 @@ default unordered dict. """ + import ctypes import enum import functools diff --git a/lib/spack/spack/util/timer.py b/lib/spack/spack/util/timer.py index b275c71b91b431..158e6886dae642 100644 --- a/lib/spack/spack/util/timer.py +++ b/lib/spack/spack/util/timer.py @@ -8,6 +8,7 @@ a stack trace and drops the user into an interpreter. """ + import collections import sys import time diff --git a/lib/spack/spack/util/typing.py b/lib/spack/spack/util/typing.py index 9c499e3577edce..53b0fbc33809bd 100644 --- a/lib/spack/spack/util/typing.py +++ b/lib/spack/spack/util/typing.py @@ -11,7 +11,6 @@ """ - from typing import TYPE_CHECKING, Any from spack.vendor.typing_extensions import Protocol diff --git a/lib/spack/spack/util/unparse/unparser.py b/lib/spack/spack/util/unparse/unparser.py index 7b5a2ab615ea0e..ba9fe8fd976e26 100644 --- a/lib/spack/spack/util/unparse/unparser.py +++ b/lib/spack/spack/util/unparse/unparser.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: Python-2.0 "Usage: unparse.py " + import ast import sys from ast import AST, FormattedValue, If, JoinedStr, Name, Tuple diff --git a/lib/spack/spack/util/web.py b/lib/spack/spack/util/web.py index 14d5b8384a2e6d..e133a404f0b423 100644 --- a/lib/spack/spack/util/web.py +++ b/lib/spack/spack/util/web.py @@ -240,7 +240,7 @@ def handle_starttag(self, tag, attrs): # GitLab uses a javascript function to place dropdown links: #
    + # data-download-links="[{"path":"/graphviz/graphviz/-/archive/12.0.0/graphviz-12.0.0.zip",...},...]"/> # noqa: E501 if tag == "div" and ("class", "js-source-code-dropdown") in attrs: try: links_str = next(val for key, val in attrs if key == "data-download-links") diff --git a/lib/spack/spack/variant.py b/lib/spack/spack/variant.py index 30a0581e3a5d15..35fe12a0085af9 100644 --- a/lib/spack/spack/variant.py +++ b/lib/spack/spack/variant.py @@ -5,6 +5,7 @@ """The variant module contains data structures that are needed to manage variants both in packages and in specs. """ + import collections.abc import enum import functools @@ -827,7 +828,7 @@ class InconsistentValidationError(spack.error.SpecError): """Raised if the wrong validator is used to validate a variant.""" def __init__(self, vspec, variant): - msg = 'trying to validate variant "{0.name}" ' 'with the validator of "{1.name}"' + msg = 'trying to validate variant "{0.name}" with the validator of "{1.name}"' super().__init__(msg.format(vspec, variant)) diff --git a/lib/spack/spack/version/version_types.py b/lib/spack/spack/version/version_types.py index 3d59c7a36b8d6e..b41d5707f199aa 100644 --- a/lib/spack/spack/version/version_types.py +++ b/lib/spack/spack/version/version_types.py @@ -595,7 +595,7 @@ def ref_version(self) -> StandardVersion: if self.ref_lookup is None: raise VersionLookupError( - f"git ref '{self.ref}' cannot be looked up: " "call attach_lookup first" + f"git ref '{self.ref}' cannot be looked up: call attach_lookup first" ) version_string, distance = self.ref_lookup.get(self.ref) diff --git a/pyproject.toml b/pyproject.toml index 8e24a4d3de7c6c..bf937a121a0a9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,10 +17,8 @@ dev = [ "pytest", "pytest-xdist", "click", - "black", + "ruff", "mypy", - "isort", - "flake8", "vermin", ] ci = ["pytest-cov", "codecov[toml]"] @@ -57,19 +55,26 @@ test = "./bin/spack unit-test" features = ["dev", "ci"] [tool.ruff] +target-version = "py37" line-length = 99 extend-include = ["bin/spack"] -extend-exclude = [ +exclude = [ + "var/spack/environments", "lib/spack/spack/vendor", - "*.pyi", + "opt/spack", ] +src = ["lib"] [tool.ruff.format] skip-magic-trailing-comma = true +quote-style = "double" +indent-style = "space" +line-ending = "lf" [tool.ruff.lint] -extend-select = ["I"] -ignore = ["E731", "E203"] +select = ["E", "F", "W", "I"] +ignore = ["E203", "E731", "F811"] +typing-modules = ["spack.vendor.typing_extensions"] [tool.ruff.lint.isort] split-on-trailing-comma = false @@ -77,40 +82,20 @@ section-order = [ "future", "standard-library", "third-party", + "vendor", "spack", "first-party", "local-folder", ] [tool.ruff.lint.isort.sections] +vendor = ["spack.vendor"] spack = ["spack"] [tool.ruff.lint.per-file-ignores] "var/spack/*/package.py" = ["F403", "F405", "F811", "F821"] "*-ci-package.py" = ["F403", "F405", "F821"] -[tool.black] -line-length = 99 -include = "(lib/spack|var/spack/test_repos)/.*\\.pyi?$|bin/spack$" -extend-exclude = "lib/spack/spack/vendor" -skip-magic-trailing-comma = true - -[tool.isort] -line_length = 99 -profile = "black" -sections = [ - "FUTURE", - "STDLIB", - "THIRDPARTY", - "VENDOR", - "FIRSTPARTY", - "LOCALFOLDER", -] -known_first_party = "spack" -known_vendor = ["spack.vendor"] -skip = ["lib/spack/spack/vendor"] -src_paths = "lib" -honor_noqa = true [tool.mypy] files = ["lib/spack/spack/**/*.py"] diff --git a/share/spack/qa/config_state.py b/share/spack/qa/config_state.py index 027c5672002655..c2572138c62762 100644 --- a/share/spack/qa/config_state.py +++ b/share/spack/qa/config_state.py @@ -6,6 +6,7 @@ The option `config:cache` is supposed to be False, and overridden to True from the command line. """ + import multiprocessing as mp import spack.config diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 4093550c237705..df24ca2f766623 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -3090,7 +3090,7 @@ complete -c spack -n '__fish_spack_using_command style' -s h -l help -d 'show th complete -c spack -n '__fish_spack_using_command style' -s b -l base -r -f -a base complete -c spack -n '__fish_spack_using_command style' -s b -l base -r -d 'branch to compare against to determine changed files (default: develop)' complete -c spack -n '__fish_spack_using_command style' -s a -l all -f -a all -complete -c spack -n '__fish_spack_using_command style' -s a -l all -d 'check all files, not just changed files' +complete -c spack -n '__fish_spack_using_command style' -s a -l all -d 'check all files, not just changed files (applies only to Import Check)' complete -c spack -n '__fish_spack_using_command style' -s r -l root-relative -f -a root_relative complete -c spack -n '__fish_spack_using_command style' -s r -l root-relative -d 'print root-relative paths (default: cwd-relative)' complete -c spack -n '__fish_spack_using_command style' -s U -l no-untracked -f -a untracked @@ -3100,9 +3100,9 @@ complete -c spack -n '__fish_spack_using_command style' -s f -l fix -d 'format a complete -c spack -n '__fish_spack_using_command style' -l root -r -f -a root complete -c spack -n '__fish_spack_using_command style' -l root -r -d 'style check a different spack instance' complete -c spack -n '__fish_spack_using_command style' -s t -l tool -r -f -a tool -complete -c spack -n '__fish_spack_using_command style' -s t -l tool -r -d 'specify which tools to run (default: import, isort, black, flake8, mypy)' +complete -c spack -n '__fish_spack_using_command style' -s t -l tool -r -d 'specify which tools to run (default: import, ruff-format, ruff-check, mypy)' complete -c spack -n '__fish_spack_using_command style' -s s -l skip -r -f -a skip -complete -c spack -n '__fish_spack_using_command style' -s s -l skip -r -d 'specify tools to skip (choose from import, isort, black, flake8, mypy)' +complete -c spack -n '__fish_spack_using_command style' -s s -l skip -r -d 'specify tools to skip (choose from import, ruff-format, ruff-check, mypy)' complete -c spack -n '__fish_spack_using_command style' -l spec-strings -f -a spec_strings complete -c spack -n '__fish_spack_using_command style' -l spec-strings -d 'upgrade spec strings in Python, JSON and YAML files for compatibility with Spack v1.0 and v0.x. Example: spack style ``--spec-strings $(git ls-files)``. Note: must be used only on specs from spack v0.X.' diff --git a/share/spack/templates/bootstrap/spack.yaml b/share/spack/templates/bootstrap/spack.yaml index 8a178d03620459..6b8f0ca1a5e093 100644 --- a/share/spack/templates/bootstrap/spack.yaml +++ b/share/spack/templates/bootstrap/spack.yaml @@ -33,5 +33,15 @@ spack: require: "+wheel" concretizer: - reuse: false + reuse: true unify: true + targets: + granularity: generic + host_compatible: false + + mirrors:: +{% for mirror in bootstrap_mirrors %} + {{mirror}}: + url: https://binaries.spack.io/releases/v2026.03/{{mirror}} + signed: true +{% endfor %} diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/mixing_parent/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/mixing_parent/package.py index cce56c8ad5932b..7b2ccb35d6b05d 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/mixing_parent/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/mixing_parent/package.py @@ -6,7 +6,6 @@ class MixingParent(Package): - homepage = "http://www.example.com" url = "http://www.example.com/a-1.0.tar.gz" diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/flake8/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/ruff/package.py similarity index 59% rename from var/spack/test_repos/spack_repo/builtin_mock/packages/flake8/package.py rename to var/spack/test_repos/spack_repo/builtin_mock/packages/ruff/package.py index 15dc5c69d19bfc..d86eea9400d527 100644 --- a/var/spack/test_repos/spack_repo/builtin_mock/packages/flake8/package.py +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/ruff/package.py @@ -7,14 +7,14 @@ from spack.package import * -class Flake8(Package): - """Package containing as many acceptable ``PEP8`` violations as possible. +class Ruff(Package): + """Package containing ``PEP8`` violations. - All of these violations are exceptions that we allow in ``package.py`` files, and - Spack is more lenient than ``flake8`` is for things like URLs and long SHA sums. + Ruff check + format handle most errors robustly and those that + cannot be handled directly are infrequent enough we can noqa them - See ``share/spack/qa/flake8_formatter.py`` for specifics of how we handle ``flake8`` - exemptions. + This file contains a number of errors ruff should be able to reformat + and pass style over """ @@ -35,31 +35,25 @@ class Flake8(Package): # All URL strings are exempt from line-length checks. # - # flake8 normally would complain about these, but the fix it wants (a multi-line - # string) is ugbly, and we're more lenient since there are many places where Spack - # wants URLs in strings. - hg = "https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-not-ignore-by-default" - list_url = "https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-not-ignore-by-default" - git = "ssh://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-not-ignore-by-default" + # ruff will not complain about these + hg = "https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-not-ignore-by-default" + list_url = "https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-not-ignore-by-default" + git = "ssh://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-not-ignore-by-default" # directives with URLs are exempt as well version( "1.0", - url="https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-not-ignore-by-default", + url="https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-not-ignore-by-default", ) # - # Also test URL comments (though flake8 will ignore these by default anyway) + # Also test URL comments (though ruff will ignore these by default anyway) # - # http://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-ignore-by-default - # https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-ignore-by-default - # ftp://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-ignore-by-default - # ssh://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-ignore-by-default - # file://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-flake8-will-ignore-by-default - - # Strings and comments with really long checksums require no noqa annotation. - sha512sum = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" - # the sha512sum is "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + # http://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-ignore-by-default + # https://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-ignore-by-default + # ftp://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-ignore-by-default + # ssh://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-ignore-by-default + # file://example.com/this-is-a-really-long-url/that-goes-over-99-characters/that-ruff-will-ignore-by-default def install(self, spec, prefix): # Make sure lines with '# noqa' work as expected. Don't just diff --git a/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py b/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py index 2d754eefcf4615..e00ac180aedcef 100644 --- a/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py +++ b/var/spack/test_repos/spack_repo/tutorial/packages/hdf5/package.py @@ -331,7 +331,8 @@ def cmake_args(self): self.define("HDF5_BUILD_EXAMPLES", False), self.define( "BUILD_TESTING", - self.run_tests or + self.run_tests + or # Version 1.8.22 fails to build the tools when shared libraries # are enabled but the tests are disabled. spec.satisfies("@1.8.22+shared+tools"), @@ -386,9 +387,7 @@ def ensure_parallel_compiler_wrappers(self): # 1.10.6 and 1.12.0. The current develop versions do not produce 'h5pfc' # at all. Here, we make sure that 'h5pfc' is available when Fortran and # MPI support are enabled (only for versions that generate 'h5fc'). - if self.spec.satisfies( - "@1.8.22:1.8," "1.10.6:1.10," "1.12.0:1.12," "develop:" "+fortran+mpi" - ): + if self.spec.satisfies("@1.8.22:1.8,1.10.6:1.10,1.12.0:1.12,develop:+fortran+mpi"): with working_dir(self.prefix.bin): # No try/except here, fix the condition above instead: symlink("h5fc", "h5pfc") @@ -458,9 +457,7 @@ def _check_install(self): """ expected = """\ HDF5 version {version} {version} -""".format( - version=str(spec.version.up_to(3)) - ) +""".format(version=str(spec.version.up_to(3))) with open("check.c", "w", encoding="utf-8") as f: f.write(source) if "+mpi" in spec: @@ -536,7 +533,7 @@ def _test_example(self): "h5copy", options, [], installed=True, purpose=reason, skip_missing=True, work_dir="." ) - reason = "test: ensuring h5diff shows no differences between orig and" " copy" + reason = "test: ensuring h5diff shows no differences between orig and copy" self.run_test( "h5diff", [h5_file, "test.h5"], From ec2a0f49f2f89e51db9613e7ac4c830e4aaaba84 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 15:12:42 -0400 Subject: [PATCH 280/337] build(deps): bump pytest from 9.0.2 to 9.0.3 in /lib/spack/docs (#52303) Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.2 to 9.0.3. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.2...9.0.3) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.0.3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lib/spack/docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/docs/requirements.txt b/lib/spack/docs/requirements.txt index 8355adc6b6066c..a5b63b2de19629 100644 --- a/lib/spack/docs/requirements.txt +++ b/lib/spack/docs/requirements.txt @@ -7,4 +7,4 @@ sphinx-sitemap==2.9.0 furo==2025.12.19 docutils==0.22.4 pygments==2.20.0 -pytest==9.0.2 +pytest==9.0.3 From 14304d7e47d961fe25b3d0a7e34f1c5e416f6b93 Mon Sep 17 00:00:00 2001 From: Alec Scott Date: Fri, 17 Apr 2026 12:49:54 -0700 Subject: [PATCH 281/337] spack repo: add show-version-updates command (#52170) Signed-off-by: Alec Scott --- lib/spack/spack/cmd/repo.py | 105 ++++++++++++++++++++- lib/spack/spack/llnl/util/tty/__init__.py | 32 ++----- lib/spack/spack/llnl/util/tty/color.py | 6 +- lib/spack/spack/test/cmd/repo.py | 109 ++++++++++++++++++++++ lib/spack/spack/util/git.py | 21 ++++- share/spack/spack-completion.bash | 11 ++- share/spack/spack-completion.fish | 13 +++ 7 files changed, 266 insertions(+), 31 deletions(-) diff --git a/lib/spack/spack/cmd/repo.py b/lib/spack/spack/cmd/repo.py index d085c56444e38e..d38228f840762c 100644 --- a/lib/spack/spack/cmd/repo.py +++ b/lib/spack/spack/cmd/repo.py @@ -11,9 +11,12 @@ import spack import spack.caches +import spack.ci import spack.config +import spack.llnl.util.filesystem as fs import spack.llnl.util.tty as tty import spack.repo +import spack.spec import spack.util.executable import spack.util.git import spack.util.path @@ -22,6 +25,7 @@ from spack.cmd.common import arguments from spack.error import SpackError from spack.llnl.util.tty import color +from spack.version import StandardVersion from . import doc_dedented, doc_first_line @@ -191,6 +195,27 @@ def setup_parser(subparser: argparse.ArgumentParser): "--commit", "-c", nargs="?", default=None, help="name of a commit to change to" ) + # Show updates + show_version_updates_parser = sp.add_parser( + "show-version-updates", help=repo_show_version_updates.__doc__ + ) + show_version_updates_parser.add_argument( + "--no-manual-packages", action="store_true", help="exclude manual packages" + ) + show_version_updates_parser.add_argument( + "--no-git-versions", action="store_true", help="exclude versions from git" + ) + show_version_updates_parser.add_argument( + "--only-redistributable", action="store_true", help="exclude non-redistributable packages" + ) + show_version_updates_parser.add_argument( + "repository", help="name or path of the repository to analyze" + ) + show_version_updates_parser.add_argument( + "from_ref", help="git ref from which to start looking at changes" + ) + show_version_updates_parser.add_argument("to_ref", help="git ref to end looking at changes") + def repo_create(args): """create a new package repository""" @@ -554,7 +579,7 @@ def _iter_repos_from_descriptors( yield name, descriptor.repository, None # None indicates remote descriptor -def repo_update(args: Any) -> int: +def repo_update(args): """update one or more package repositories""" descriptors = spack.repo.RepoDescriptors.from_config( spack.repo.package_repository_lock(), spack.config.CONFIG @@ -629,7 +654,82 @@ def repo_update(args: Any) -> int: if active_flag: spack.config.set("repos", scope_repos, args.scope) - return 0 + +def repo_show_version_updates(args): + """show version specs that were added between two commits""" + # Get the repository by name or path + repo = _get_repo(args.repository) + + if repo is None: + tty.die(f"No such repository: {args.repository}") + + # Get packages that were changed or added between the refs + pkgs = spack.repo.get_all_package_diffs("AC", repo, args.from_ref, args.to_ref) + + # Filter out manual packages if requested + if args.no_manual_packages: + pkgs = { + pkg_name + for pkg_name in pkgs + if not spack.repo.PATH.get_pkg_class(pkg_name).manual_download + } + + if not pkgs: + tty.info("No packages were added or changed between the specified refs", stream=sys.stderr) + return 0 + + # Collect version specs that were added + specs_to_output = [] + + for pkg_name in pkgs: + pkg_cls = spack.repo.PATH.get_pkg_class(pkg_name) + path = spack.repo.PATH.package_path(pkg_name) + + # Get all versions with checksums or commits + version_to_checksum: Dict[StandardVersion, str] = {} + for version in pkg_cls.versions: + version_dict = pkg_cls.versions[version] + if "sha256" in version_dict: + version_to_checksum[version] = version_dict["sha256"] + elif "commit" in version_dict: + version_to_checksum[version] = version_dict["commit"] + + # Find versions added between the refs + with fs.working_dir(os.path.dirname(path)): + added_checksums = spack.ci.filter_added_checksums( + version_to_checksum.values(), path, from_ref=args.from_ref, to_ref=args.to_ref + ) + new_versions = [v for v, c in version_to_checksum.items() if c in added_checksums] + + # Create specs for new versions + for version in new_versions: + version_spec = spack.spec.Spec(pkg_name) + version_spec.constrain(f"@={version}") + specs_to_output.append(version_spec) + + # Filter out git versions if requested + if args.no_git_versions: + specs_to_output = [ + spec + for spec in specs_to_output + if "commit" not in spack.repo.PATH.get_pkg_class(spec.name).versions[spec.version] + ] + + # Filter out non-redistributable packages if requested + if args.only_redistributable: + specs_to_output = [ + spec + for spec in specs_to_output + if spack.repo.PATH.get_pkg_class(spec.name).redistribute_source(spec) + ] + + if not specs_to_output: + tty.info("No new package versions found between the specified refs", stream=sys.stderr) + return 0 + + # Output specs one per line + for spec in specs_to_output: + print(spec) def repo(parser, args): @@ -643,4 +743,5 @@ def repo(parser, args): "rm": repo_remove, "migrate": repo_migrate, "update": repo_update, + "show-version-updates": repo_show_version_updates, }[args.repo_command](args) diff --git a/lib/spack/spack/llnl/util/tty/__init__.py b/lib/spack/spack/llnl/util/tty/__init__.py index 775a040c56b0fe..80a9ea0ab48f90 100644 --- a/lib/spack/spack/llnl/util/tty/__init__.py +++ b/lib/spack/spack/llnl/util/tty/__init__.py @@ -11,7 +11,7 @@ import traceback from datetime import datetime from types import TracebackType -from typing import Callable, Iterator, NoReturn, Optional, Type, Union +from typing import IO, Callable, Iterator, NoReturn, Optional, Type, Union from .color import cescape, clen, cprint, cwrite @@ -185,7 +185,7 @@ def info( message: Union[Exception, str], *args, format: str = "*b", - stream: Optional[io.IOBase] = None, + stream: Optional[IO[str]] = None, wrap: bool = False, break_long_words: bool = False, countback: int = 3, @@ -201,7 +201,7 @@ def info( cprint( "@%s{%s==>} %s%s" % (format, st_text, get_timestamp(), cescape(_output_filter(str(message)))), - stream=stream, # type: ignore[arg-type] + stream=stream, ) for arg in args: if wrap: @@ -225,44 +225,30 @@ def verbose(message, *args, format: str = "c", **kwargs) -> None: def debug( - message, *args, level: int = 1, format: str = "g", stream: Optional[io.IOBase] = None, **kwargs + message, *args, level: int = 1, format: str = "g", stream: Optional[IO[str]] = None, **kwargs ) -> None: """Print a debug message if the debug level is set.""" if is_debug(level): stream_arg = stream or sys.stderr - info(message, *args, format=format, stream=stream_arg, **kwargs) # type: ignore[arg-type] + info(message, *args, format=format, stream=stream_arg, **kwargs) -def error( - message, *args, format: str = "*r", stream: Optional[io.IOBase] = None, **kwargs -) -> None: +def error(message, *args, format: str = "*r", stream: Optional[IO[str]] = None, **kwargs) -> None: """Print an error message.""" if not error_enabled(): return stream = stream or sys.stderr - info( - f"Error: {message}", - *args, - format=format, - stream=stream, # type: ignore[arg-type] - **kwargs, - ) + info(f"Error: {message}", *args, format=format, stream=stream, **kwargs) -def warn(message, *args, format: str = "*Y", stream: Optional[io.IOBase] = None, **kwargs) -> None: +def warn(message, *args, format: str = "*Y", stream: Optional[IO[str]] = None, **kwargs) -> None: """Print a warning message.""" if not warn_enabled(): return stream = stream or sys.stderr - info( - f"Warning: {message}", - *args, - format=format, - stream=stream, # type: ignore[arg-type] - **kwargs, - ) + info(f"Warning: {message}", *args, format=format, stream=stream, **kwargs) def die(message, *args, countback: int = 4, **kwargs) -> NoReturn: diff --git a/lib/spack/spack/llnl/util/tty/color.py b/lib/spack/spack/llnl/util/tty/color.py index 4f9f6a574d627b..caa327bbc28fa1 100644 --- a/lib/spack/spack/llnl/util/tty/color.py +++ b/lib/spack/spack/llnl/util/tty/color.py @@ -65,7 +65,7 @@ import sys import textwrap from contextlib import contextmanager -from typing import Iterator, List, NamedTuple, Optional, Tuple, Union +from typing import IO, Iterator, List, NamedTuple, Optional, Tuple, Union class ColorParseError(Exception): @@ -375,7 +375,7 @@ def cextra(string: str) -> int: return len("".join(re.findall(r"\033[^m]*m", string))) -def cwrite(string: str, stream: Optional[io.IOBase] = None, color: Optional[bool] = None) -> None: +def cwrite(string: str, stream: Optional[IO[str]] = None, color: Optional[bool] = None) -> None: """Replace all color expressions in string with ANSI control codes and write the result to the stream. If color is False, this will write plain text with no color. If True, @@ -388,7 +388,7 @@ def cwrite(string: str, stream: Optional[io.IOBase] = None, color: Optional[bool stream.write(colorize(string, color=color)) -def cprint(string: str, stream: Optional[io.IOBase] = None, color: Optional[bool] = None) -> None: +def cprint(string: str, stream: Optional[IO[str]] = None, color: Optional[bool] = None) -> None: """Same as cwrite, but writes a trailing newline to the stream.""" cwrite(string + "\n", stream, color) diff --git a/lib/spack/spack/test/cmd/repo.py b/lib/spack/spack/test/cmd/repo.py index 7fb720b634f6e0..a91ac6f115d7a9 100644 --- a/lib/spack/spack/test/cmd/repo.py +++ b/lib/spack/spack/test/cmd/repo.py @@ -940,3 +940,112 @@ def test_repo_update_invalid_flags(monkeypatch, mutable_config, flags): with pytest.raises(SpackError): repo("update", *flags) + + +def test_repo_show_version_updates_no_changes(mock_git_package_changes): + """Test that show-version-updates handles empty results gracefully""" + test_repo, _, commits = mock_git_package_changes + + with spack.repo.use_repositories(test_repo): + # Use the same commit for both refs - no changes + output = repo("show-version-updates", test_repo.root, commits[-1], commits[-1]) + + # Should have warning message + assert "No packages were added or changed" in output + + # Should not have any specs + assert "diff-test@" not in output + + +def test_repo_show_version_updates_success(mock_git_package_changes): + """Test that show-version-updates successfully outputs the correct specs""" + test_repo, _, commits = mock_git_package_changes + + with spack.repo.use_repositories(test_repo): + # commits are ordered from newest to oldest after reversal + # commits[-2] = add v2.1.5, commits[-4] = add v2.1.7 and v2.1.8 + # Find versions added between these commits + # Includes v2.1.6 (git version), v2.1.7, and v2.1.8 (sha256 versions) + output = repo("show-version-updates", test_repo.root, commits[-2], commits[-4]) + + # Verify all three versions are included + assert "diff-test@" in output + assert "2.1.6" in output + assert "2.1.7" in output + assert "2.1.8" in output + + # Should have three specs + lines = [ + line.strip() + for line in output.strip().split("\n") + if line.strip() and "Warning" not in line + ] + assert len(lines) == 3 + + +def test_repo_show_version_updates_excludes_manual_packages(monkeypatch, mock_git_package_changes): + """Test --no-manual-packages flag excludes packages with manual_download=True""" + test_repo, _, commits = mock_git_package_changes + + with spack.repo.use_repositories(test_repo): + # Set manual_download=True on the package + pkg_class = spack.repo.PATH.get_pkg_class("diff-test") + monkeypatch.setattr(pkg_class, "manual_download", True) + + # Run show-version-updates with --no-manual-packages flag + output = repo( + "show-version-updates", + "--no-manual-packages", + test_repo.root, + commits[-2], + commits[-4], + ) + + # Package should be excluded + assert "diff-test@" not in output + assert "No packages were added or changed" in output + + +def test_repo_show_version_updates_excludes_non_redistributable( + monkeypatch, mock_git_package_changes +): + """Test --only-redistributable flag excludes packages if redistribute_source returns False""" + test_repo, _, commits = mock_git_package_changes + + with spack.repo.use_repositories(test_repo): + # Set redistribute_source to return False + pkg_class = spack.repo.PATH.get_pkg_class("diff-test") + monkeypatch.setattr(pkg_class, "redistribute_source", classmethod(lambda cls, spec: False)) + + # Run show-version-updates with --only-redistributable flag + output = repo( + "show-version-updates", + "--only-redistributable", + test_repo.root, + commits[-2], + commits[-4], + ) + + # Package should be excluded + assert "diff-test@" not in output + assert "No new package versions found" in output + + +def test_repo_show_version_updates_excludes_git_versions(mock_git_package_changes): + """Test --no-git-versions flag excludes versions from git (tag/commit)""" + test_repo, _, commits = mock_git_package_changes + + with spack.repo.use_repositories(test_repo): + # commits[-3] = add v2.1.6 (git version), commits[-4] = add v2.1.7 and v2.1.8 (sha256) + # Without --no-git-versions, v2.1.6 would be included + output = repo( + "show-version-updates", "--no-git-versions", test_repo.root, commits[-3], commits[-4] + ) + + # v2.1.6 (git version) should be excluded + assert "2.1.6" not in output + + # v2.1.7 and v2.1.8 (sha256 versions) should be included + assert "diff-test@" in output + assert "2.1.7" in output + assert "2.1.8" in output diff --git a/lib/spack/spack/util/git.py b/lib/spack/spack/util/git.py index ef80d8a0857cd6..75308b5076bcce 100644 --- a/lib/spack/spack/util/git.py +++ b/lib/spack/spack/util/git.py @@ -14,6 +14,7 @@ import spack.llnl.util.filesystem as fs import spack.llnl.util.lang import spack.util.executable as exe +from spack.util.environment import EnvironmentModifications # regex for a commit version COMMIT_VERSION = re.compile(r"^[a-f0-9]{40}$") @@ -103,7 +104,16 @@ def git(required: bool = ...) -> Optional[GitExecutable]: ... def git(required: bool = False) -> Optional[GitExecutable]: - """Get a git executable. Raises CommandNotFoundError if ``required`` and git is not found.""" + """Get a git executable. + + The returned executable automatically unsets ``GIT_EXTERNAL_DIFF`` and ``GIT_DIFF_OPTS`` + environment variables that can interfere with spack git diff operations. + + Args: + required (bool): if True, raises CommandNotFoundError when git is not found + + Returns: GitExecutable, or None if git is not found and required is False + """ git_path = _find_git() if not git_path: @@ -115,9 +125,16 @@ def git(required: bool = False) -> Optional[GitExecutable]: # If we're running under pytest, add this to ignore the fix for CVE-2022-39253 in # git 2.38.1+. Do this in one place; we need git to do this in all parts of Spack. - if git and "pytest" in sys.modules: + if "pytest" in sys.modules: git.add_default_arg("-c", "protocol.file.allow=always") + # Block environment variables that can interfere with git diff operations + # this can cause problems for spack ci verify-versions and spack repo show-version-updates + env_blocklist = EnvironmentModifications() + env_blocklist.unset("GIT_EXTERNAL_DIFF") + env_blocklist.unset("GIT_DIFF_OPTS") + git.add_default_envmod(env_blocklist) + return git diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 5f7157f7c502bd..bb52561a9d5c7f 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -1802,7 +1802,7 @@ _spack_repo() { then SPACK_COMPREPLY="-h --help" else - SPACK_COMPREPLY="create list ls add set remove rm migrate update" + SPACK_COMPREPLY="create list ls add set remove rm migrate update show-version-updates" fi } @@ -1877,6 +1877,15 @@ _spack_repo_update() { fi } +_spack_repo_show_version_updates() { + if $list_options + then + SPACK_COMPREPLY="-h --help --no-manual-packages --no-git-versions --only-redistributable" + else + SPACK_COMPREPLY="" + fi +} + _spack_resource() { if $list_options then diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index df24ca2f766623..2d4be897b95a08 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -2843,6 +2843,7 @@ complete -c spack -n '__fish_spack_using_command_pos 0 repo' -f -a remove -d 're complete -c spack -n '__fish_spack_using_command_pos 0 repo' -f -a rm -d 'remove a repository from Spack'"'"'s configuration' complete -c spack -n '__fish_spack_using_command_pos 0 repo' -f -a migrate -d 'migrate a package repository to the latest Package API' complete -c spack -n '__fish_spack_using_command_pos 0 repo' -f -a update -d 'update one or more package repositories' +complete -c spack -n '__fish_spack_using_command_pos 0 repo' -f -a show-version-updates -d 'show version specs that were added between two commits' complete -c spack -n '__fish_spack_using_command repo' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command repo' -s h -l help -d 'show this help message and exit' @@ -2950,6 +2951,18 @@ complete -c spack -n '__fish_spack_using_command repo update' -l tag -s t -r -d complete -c spack -n '__fish_spack_using_command repo update' -l commit -s c -r -f -a commit complete -c spack -n '__fish_spack_using_command repo update' -l commit -s c -r -d 'name of a commit to change to' +# spack repo show-version-updates +set -g __fish_spack_optspecs_spack_repo_show_version_updates h/help no-manual-packages no-git-versions only-redistributable + +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -s h -l help -f -a help +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -s h -l help -d 'show this help message and exit' +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -l no-manual-packages -f -a no_manual_packages +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -l no-manual-packages -d 'exclude manual packages' +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -l no-git-versions -f -a no_git_versions +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -l no-git-versions -d 'exclude versions from git' +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -l only-redistributable -f -a only_redistributable +complete -c spack -n '__fish_spack_using_command repo show-version-updates' -l only-redistributable -d 'exclude non-redistributable packages' + # spack resource set -g __fish_spack_optspecs_spack_resource h/help complete -c spack -n '__fish_spack_using_command_pos 0 resource' -f -a list -d 'list all resources known to spack (currently just patches)' From e686a9bc9fdc347a5ac67899076527209329d8a8 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Fri, 17 Apr 2026 22:18:28 +0200 Subject: [PATCH 282/337] environment: fix failure to remove + create an environment (#52294) Removing and re-creating the same environment fails if upon removal the environment directory contains leftovers. This is due to the wrong assumption in `exists` that an environment exists if its directory exists. Here we fix the issue by requiring the manifest file to be present so that `spack env create` can recover from orphaned directories. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/environment/environment.py | 2 +- lib/spack/spack/test/cmd/env.py | 50 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index d3e29a384b1f3a..b397231b263d9f 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -302,7 +302,7 @@ def root(name): def exists(name): """Whether an environment with this name exists or not.""" - return valid_env_name(name) and os.path.isdir(_root(name)) + return valid_env_name(name) and os.path.lexists(os.path.join(_root(name), manifest_name)) def active(name): diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index d4b6f6fb905819..58daf4cff9431a 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -4938,3 +4938,53 @@ def test_compiler_target_env(mock_packages, environment_from_manifest): # Sanity check: make sure the target we expect was applied to the # compiler entry assert libdwarf["c"].satisfies("gcc@12.100.100 languages:=c,c++ target=x86_64_v3") + + +@pytest.mark.regression("52247") +def test_create_with_orphaned_directory(mutable_mock_env_path: pathlib.Path): + """Tests that an orphaned environment directory (directory exists, no spack.yaml) must not + prevent 'spack env create' from creating a new environment with that name. + """ + orphaned = mutable_mock_env_path / "test1" + orphaned_subdir = orphaned / ".spack-env" + orphaned_subdir.mkdir(parents=True) + + # The orphaned directory must not be seen as an existing environment + assert not ev.exists("test1") + + # Creating an environment over an orphaned directory must succeed + env("create", "test1") + + assert ev.exists("test1") + assert "test1" in env("list") + + +@pytest.mark.parametrize( + "setup", + [ + # valid environment: spack.yaml is a regular file + pytest.param("valid", id="valid"), + # orphaned directory: no spack.yaml at all + pytest.param("orphaned", id="orphaned"), + # broken manifest symlink: spack.yaml points to a non-existent target + pytest.param("broken_symlink", id="broken_symlink"), + ], +) +@pytest.mark.regression("52247") +def test_exists_consistent_with_all_environment_names( + mutable_mock_env_path: pathlib.Path, setup: str +): + """Tests that exists() and all_environment_names() agree on whether an environment exists.""" + env_dir = mutable_mock_env_path / "myenv" + env_dir.mkdir(parents=True) + manifest = env_dir / ev.manifest_name + + if setup == "valid": + manifest.write_text(ev.default_manifest_yaml()) + elif setup == "orphaned": + pass # no manifest + elif setup == "broken_symlink": + manifest.symlink_to("/nonexistent/spack.yaml") + + listed = "myenv" in ev.all_environment_names() + assert ev.exists("myenv") == listed From 14acd56b5adf552902ebde68485a837f0ff9d197 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:13:08 +0200 Subject: [PATCH 283/337] build(deps): bump sphinxcontrib-programoutput in /lib/spack/docs (#51980) --- lib/spack/docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/docs/requirements.txt b/lib/spack/docs/requirements.txt index a5b63b2de19629..1c70fbee0def04 100644 --- a/lib/spack/docs/requirements.txt +++ b/lib/spack/docs/requirements.txt @@ -1,5 +1,5 @@ sphinx==9.1.0 -sphinxcontrib-programoutput==0.18 +sphinxcontrib-programoutput==0.19 sphinxcontrib-svg2pdfconverter==2.1.0 sphinx-copybutton==0.5.2 sphinx-last-updated-by-git==0.3.8 From 85b1612f642dd0b0468b0b7dba9420a0807a5ad4 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sat, 18 Apr 2026 09:34:36 +0200 Subject: [PATCH 284/337] main.py: deprecate --profile flags (#52301) Suppress `-p/--profile`, `--sorted-profile`, `--profile-file`, and `--lines` from help output and print a deprecation warning with the equivalent `python3 -m cProfile` command, mirroring the `--pdb` deprecation pattern. Update docs to point users to `cProfile` directly. With `spack --profile` we're missing profiling of anything that runs ahead of `main.py`, such as module imports and all the code that runs in the module scope. Signed-off-by: Harmen Stoppels --- lib/spack/docs/developer_guide.rst | 17 +++------- lib/spack/spack/main.py | 50 +++++++++++++++++------------- share/spack/qa/run-unit-tests | 2 -- share/spack/spack-completion.fish | 4 --- 4 files changed, 33 insertions(+), 40 deletions(-) diff --git a/lib/spack/docs/developer_guide.rst b/lib/spack/docs/developer_guide.rst index ee7253e9fa65c3..a62cd9507ed1d5 100644 --- a/lib/spack/docs/developer_guide.rst +++ b/lib/spack/docs/developer_guide.rst @@ -690,21 +690,12 @@ By running this command before and after the change, you can make sure that your Profiling --------- -Spack has some limited built-in support for profiling, and can report statistics using standard Python timing tools. -To use this feature, supply ``--profile`` to Spack on the command line, before any subcommands. +To profile Spack, use Python's built-in `cProfile `_ module directly: -.. _spack-p: - -``spack --profile`` -^^^^^^^^^^^^^^^^^^^ - -``spack --profile`` output looks like this: - -.. command-output:: spack --profile graph hdf5 - :ellipsis: 25 +.. code-block:: console -The bottom of the output shows the most time-consuming functions, slowest on top. -The profiling support is from Python's built-in tool, `cProfile `_. + $ python3 -m cProfile -s cumtime bin/spack find + $ python3 -m cProfile -o profile.out bin/spack find .. _releases: diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index e31b0a8ced77aa..28655ccfa10170 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -537,28 +537,12 @@ def make_argument_parser(**kwargs): help="do not use filesystem locking (unsafe)", ) - profile = parser.add_argument_group("profiling") - profile.add_argument( - "-p", - "--profile", - action="store_true", - dest="spack_profile", - help="profile execution using cProfile", - ) - profile.add_argument("--profile-file", default=None, help="Filename to save profile data to.") - profile.add_argument( - "--sorted-profile", - default=None, - metavar="STAT", - help="profile and sort by STAT, which can be: calls, ncalls,\n" - "cumtime, cumulative, filename, line, module", - ) - profile.add_argument( - "--lines", - default=20, - action="store", - help="lines of profile output or 'all' (default: 20)", + debug.add_argument( + "-p", "--profile", action="store_true", dest="spack_profile", help=argparse.SUPPRESS ) + debug.add_argument("--profile-file", default=None, help=argparse.SUPPRESS) + debug.add_argument("--sorted-profile", default=None, metavar="STAT", help=argparse.SUPPRESS) + debug.add_argument("--lines", default=20, action="store", help=argparse.SUPPRESS) return parser @@ -1083,6 +1067,30 @@ def finish_parse_and_run(parser, cmd_name, main_args, env_format_error): # now we can actually execute the command. if main_args.spack_profile or main_args.sorted_profile or main_args.profile_file: + new_args = [sys.executable, "-m", "cProfile"] + if main_args.sorted_profile: + new_args.extend(["-s", main_args.sorted_profile]) + if main_args.profile_file: + new_args.extend(["-o", main_args.profile_file]) + new_args.append(spack.paths.spack_script) + skip_next = False + for arg in sys.argv[1:]: + if skip_next: + skip_next = False + continue + if arg in ("--sorted-profile", "--profile-file", "--lines"): + skip_next = True + continue + if arg.startswith(("--sorted-profile=", "--profile-file=", "--lines=")): + continue + if arg in ("--profile", "-p"): + continue + new_args.append(arg) + formatted_args = " ".join(shlex.quote(a) for a in new_args) + tty.warn( + "The --profile flag is deprecated and will be removed in Spack v1.3. " + f"Use `{formatted_args}` instead." + ) _profile_wrapper(command, main_args, parser, args, unknown) elif main_args.pdb: new_args = [sys.executable, "-m", "pdb", spack.paths.spack_script] diff --git a/share/spack/qa/run-unit-tests b/share/spack/qa/run-unit-tests index cc86de6f4ef16e..d444bc71188940 100755 --- a/share/spack/qa/run-unit-tests +++ b/share/spack/qa/run-unit-tests @@ -35,8 +35,6 @@ spack config get compilers bin/spack -h bin/spack help -a -# Profile and print top 20 lines for a simple call to spack spec -spack -p --lines 20 spec mpileaks%gcc $coverage_run $(which spack) bootstrap status --dev --optional # Check that we can import Spack packages directly as a first import diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 2d4be897b95a08..f7d11ae931b371 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -471,13 +471,9 @@ complete -c spack -n '__fish_spack_using_command ' -s l -l enable-locks -d 'use complete -c spack -n '__fish_spack_using_command ' -s L -l disable-locks -f -a locks complete -c spack -n '__fish_spack_using_command ' -s L -l disable-locks -d 'do not use filesystem locking (unsafe)' complete -c spack -n '__fish_spack_using_command ' -s p -l profile -f -a spack_profile -complete -c spack -n '__fish_spack_using_command ' -s p -l profile -d 'profile execution using cProfile' complete -c spack -n '__fish_spack_using_command ' -l profile-file -r -f -a profile_file -complete -c spack -n '__fish_spack_using_command ' -l profile-file -r -d 'Filename to save profile data to.' complete -c spack -n '__fish_spack_using_command ' -l sorted-profile -r -f -a sorted_profile -complete -c spack -n '__fish_spack_using_command ' -l sorted-profile -r -d 'profile and sort by STAT, which can be: calls, ncalls,' complete -c spack -n '__fish_spack_using_command ' -l lines -r -f -a lines -complete -c spack -n '__fish_spack_using_command ' -l lines -r -d 'lines of profile output or '"'"'all'"'"' (default: 20)' # spack add set -g __fish_spack_optspecs_spack_add h/help l/list-name= From db368d299a09479bcdc483f021940f5f5f019a62 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sat, 18 Apr 2026 14:25:36 +0200 Subject: [PATCH 285/337] build(deps): bump CI dependencies (#52316) --- .github/dependabot.yml | 4 +++- .github/workflows/bootstrap.yml | 2 +- .github/workflows/import-check.yaml | 2 +- .github/workflows/requirements/style/requirements.txt | 2 +- .github/workflows/requirements/unit_tests/requirements.txt | 5 ++--- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 717d11cbff0e5a..9d8ebd45a6f751 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,9 @@ updates: - package-ecosystem: "pip" directories: - - "/.github/workflows/requirements/*" + - "/.github/workflows/requirements/coverage" + - "/.github/workflows/requirements/style" + - "/.github/workflows/requirements/unit_tests" - "/lib/spack/docs" schedule: interval: "daily" diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 66205e3ed1b72f..e76a3dec4771ca 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -199,7 +199,7 @@ jobs: dnf install -y \ bzip2 curl gcc-c++ gcc gcc-gfortran git gnupg2 gzip \ make patch python3.11 tcl unzip which xz - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup repo and non-root user run: | git --version diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index 1ae2a18cd12bfc..4d5f065eef9de6 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -9,7 +9,7 @@ jobs: continue-on-error: true runs-on: ubuntu-latest steps: - - uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7 + - uses: julia-actions/setup-julia@4a12c5f801ca5ef0458bba44687563ef276522dd # v3.0.0 with: version: '1.10' - uses: julia-actions/cache@9a93c5fb3e9c1c20b60fc80a478cae53e38618a4 # v3.0.2 diff --git a/.github/workflows/requirements/style/requirements.txt b/.github/workflows/requirements/style/requirements.txt index f98de1c9ab517f..ebf80301f57416 100644 --- a/.github/workflows/requirements/style/requirements.txt +++ b/.github/workflows/requirements/style/requirements.txt @@ -8,4 +8,4 @@ pylint==4.0.5 docutils==0.22.4 ruamel.yaml==0.19.1 slotscheck==0.19.1 -ruff==0.15.7 +ruff==0.15.11 diff --git a/.github/workflows/requirements/unit_tests/requirements.txt b/.github/workflows/requirements/unit_tests/requirements.txt index 369ee2aced9661..99f6f4ba7d974d 100644 --- a/.github/workflows/requirements/unit_tests/requirements.txt +++ b/.github/workflows/requirements/unit_tests/requirements.txt @@ -1,6 +1,5 @@ -pytest==9.0.2 +pytest==9.0.3 pytest-cov==7.1.0 -pytest-xdist==3.3.1 +pytest-xdist==3.8.0 coverage[toml]<=7.11.0 clingo==5.8.0 -click==8.1.7 From 7e864787bd15a516314d86002ce1cbabde7cbbe9 Mon Sep 17 00:00:00 2001 From: Victor Brunini Date: Sun, 19 Apr 2026 08:34:30 -0400 Subject: [PATCH 286/337] Allow disabling develop package reference stage links via config. (#52216) By setting the existing config:develop_stage_link option to "None". Signed-off-by: Victor Brunini --- lib/spack/spack/package_base.py | 5 ++++- lib/spack/spack/schema/config.py | 3 ++- lib/spack/spack/stage.py | 33 ++++++++++++++++++-------------- lib/spack/spack/test/stage.py | 15 +++++++++++++++ 4 files changed, 40 insertions(+), 16 deletions(-) diff --git a/lib/spack/spack/package_base.py b/lib/spack/spack/package_base.py index 86ff1cd3dcd4db..586bd6ff02d430 100644 --- a/lib/spack/spack/package_base.py +++ b/lib/spack/spack/package_base.py @@ -1209,7 +1209,10 @@ def _make_stages(self) -> Tuple[stg.StageComposite, List[stg.Stage]]: link_format = spack.config.get("config:develop_stage_link") if not link_format: link_format = "build-{arch}-{hash:7}" - stage_link = self.spec.format_path(link_format) + if link_format == "None": + stage_link = None + else: + stage_link = self.spec.format_path(link_format) source_stage = stg.DevelopStage( stg.compute_stage_name(self.spec), dev_path, stage_link ) diff --git a/lib/spack/spack/schema/config.py b/lib/spack/spack/schema/config.py index d9ab4b1060b79b..745cc1da7e5f95 100644 --- a/lib/spack/spack/schema/config.py +++ b/lib/spack/spack/schema/config.py @@ -90,7 +90,8 @@ }, "develop_stage_link": { "type": "string", - "description": "Name for development spec build stage directories", + "description": "Name for development spec build stage directories. Setting to " + "None will disable develop stage links.", }, "test_stage": { "type": "string", diff --git a/lib/spack/spack/stage.py b/lib/spack/spack/stage.py index 059f11bbc0514f..4addfca7114cf8 100644 --- a/lib/spack/spack/stage.py +++ b/lib/spack/spack/stage.py @@ -974,13 +974,16 @@ def __init__(self, name, dev_path, reference_link): self._source_path = dev_path # The path of a link that will point to this stage - if os.path.isabs(reference_link): - link_path = reference_link + if reference_link: + if os.path.isabs(reference_link): + link_path = reference_link + else: + link_path = os.path.join(self._source_path, reference_link) + if not os.path.isdir(os.path.dirname(link_path)): + raise StageError(f"The directory containing {link_path} must exist") + self.reference_link = link_path else: - link_path = os.path.join(self._source_path, reference_link) - if not os.path.isdir(os.path.dirname(link_path)): - raise StageError(f"The directory containing {link_path} must exist") - self.reference_link = link_path + self.reference_link = None @property def source_path(self): @@ -1007,10 +1010,11 @@ def expanded(self): def create(self): super().create() - try: - symlink(self.path, self.reference_link) - except (AlreadyExistsError, FileExistsError): - pass + if self.reference_link: + try: + symlink(self.path, self.reference_link) + except (AlreadyExistsError, FileExistsError): + pass def destroy(self): # Destroy all files, but do not follow symlinks @@ -1018,10 +1022,11 @@ def destroy(self): shutil.rmtree(self.path) except FileNotFoundError: pass - try: - os.remove(self.reference_link) - except FileNotFoundError: - pass + if self.reference_link: + try: + os.remove(self.reference_link) + except FileNotFoundError: + pass self.created = False def restage(self): diff --git a/lib/spack/spack/test/stage.py b/lib/spack/spack/test/stage.py index 5e1f89b49e4ef4..eb2406e09bc7d5 100644 --- a/lib/spack/spack/test/stage.py +++ b/lib/spack/spack/test/stage.py @@ -872,6 +872,21 @@ def test_develop_stage(self, develop_path, tmp_build_stage_dir): srctree2 = _create_tree_from_dir_recursive(srcdir) assert srctree2 == devtree + def test_develop_stage_without_reference_link(self, develop_path, tmp_build_stage_dir): + """Check that develop stages can be created without creating a reference link""" + devtree, srcdir = develop_path + stage = DevelopStage("test-stage", srcdir, reference_link=None) + stage.create() + srctree1 = _create_tree_from_dir_recursive(stage.source_path) + assert srctree1 == devtree + + stage.destroy() + # Make sure destroying the stage doesn't change anything + # about the path + assert not os.path.exists(stage.path) + srctree2 = _create_tree_from_dir_recursive(srcdir) + assert srctree2 == devtree + def test_stage_create_replace_path(tmp_build_stage_dir): """Ensure stage creation replaces a non-directory path.""" From d40ab302fa8308696ea8c9effe9f1c63efe805b8 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 23 Apr 2026 17:57:16 +0200 Subject: [PATCH 287/337] log parser: optimize regexes by grouping prefixes (#52295) In the log parser, group regex strings by their first character and combine each group into a single pattern using alternation. Python's regex compiler automatically optimizes the combined pattern to share common prefixes, which hits a fast path in cpython. On benchmarks this is a 2.8x speedup. Combined with previous improvements, the sequential version runs at the same speed as the `-j16` version from Spack v1.1. On my macbook it's about 40MB/s. ``` ["foo", "bar", "baz"] -> ["foo", "bar|baz"] ``` Because we iterate over the log file in small chunks (lines), it's in practice much faster to use *multiple* regexes that hit that fast prefix code path, than it is to run a single pass with a combined regex of all patterns `(foo|bar|baz)`. --- lib/spack/spack/test/util/log_parser.py | 53 +++++++++++++++++++++++- lib/spack/spack/util/ctest_log_parser.py | 39 +++++++++++------ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/lib/spack/spack/test/util/log_parser.py b/lib/spack/spack/test/util/log_parser.py index 73df2cf898d3c5..df61ec0e0200e7 100644 --- a/lib/spack/spack/test/util/log_parser.py +++ b/lib/spack/spack/test/util/log_parser.py @@ -4,9 +4,10 @@ import io import pathlib +import re from spack.llnl.util.tty.color import color_when -from spack.util.ctest_log_parser import CTestLogParser +from spack.util.ctest_log_parser import CTestLogParser, _optimize_regexes from spack.util.log_parse import make_log_context @@ -128,3 +129,53 @@ def test_log_parser_non_utf8_bytes(tmp_path: pathlib.Path): parser = CTestLogParser() errors, _ = parser.parse(str(log_file)) assert len(errors) == 1 + + +class TestOptimizeRegexes: + def test_groups_by_first_char(self): + """Regexes sharing a first character are combined into one.""" + result = _optimize_regexes(["bar", "far", "foo"]) + assert len(result) == 2 + assert result == ["bar", "far|foo"] + + def test_singletons_unchanged(self): + """A regex that is the only one with its prefix is kept as-is.""" + result = _optimize_regexes(["^unique pattern"]) + assert result == ["^unique pattern"] + + def test_escaping(self): + """Regexes starting with the same metacharacter are grouped too.""" + result = _optimize_regexes(["\\(foo\\)", "\\(bar\\)", "\\*", "[abc]"]) + assert len(result) == 3 + assert "\\(bar\\)|\\(foo\\)" in result + assert "\\*" in result + assert "[abc]" in result + + def test_semantics_preserved(self): + """Optimized regexes match the same strings as the originals.""" + originals = [ + "^FAIL: ", + "^FATAL: ", + "^failed ", + ": error", + ": warning", + "make: Fatal error", + "make\\[.*\\]: \\*\\*\\*", + ] + test_lines = [ + "FAIL: test_something", + "FATAL: crash", + "failed to build", + "foo.c: error: syntax", + "foo.c: warning: unused", + "make: Fatal error in target", + "make[1]: *** Error 1", + "this matches nothing", + ] + compiled_orig = [re.compile(r) for r in originals] + compiled_opt = [re.compile(r) for r in _optimize_regexes(originals)] + + for line in test_lines: + orig_match = any(r.search(line) for r in compiled_orig) + opt_match = any(r.search(line) for r in compiled_opt) + assert orig_match == opt_match, f"mismatch on {line!r}" diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index 24e81c01ef24f3..06e420e6fc91b8 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -75,7 +75,7 @@ import time from collections import deque from contextlib import contextmanager -from typing import List, TextIO, Tuple, Union +from typing import Dict, List, TextIO, Tuple, Union _error_matches = [ "^FAIL: ", @@ -289,6 +289,23 @@ def _time(times, i): times[i] += end - start +def _optimize_regexes(regex_strings: List[str]) -> List[str]: + """Groups regexes by their first character and combines each group into a single regex using + alternation. Python's regex compiler optimizes the combined pattern to share common prefixes + internally. The result is a shorter list of regexes that all hit a fast path in cpython's regex + engine for prefix matching.""" + groups: Dict[str, List[str]] = {} + for regex in sorted(regex_strings): + key = regex[:1] # empty or single character + if key == "\\": # include escaped character + key = regex[:2] + if key not in groups: + groups[key] = [regex] + else: + groups[key].append(regex) + return ["|".join(entries) for entries in groups.values()] + + def _match(matches, exceptions, line): """True if line matches a regex in matches and none in exceptions.""" return any(m.search(line) for m in matches) and not any(e.search(line) for e in exceptions) @@ -317,14 +334,12 @@ def _profile_match(matches, exceptions, line, match_times, exc_times): def _parse(stream, profile, context): - def compile(regex_array): - return [re.compile(regex) for regex in regex_array] - error_matches = compile(_error_matches) - error_exceptions = compile(_error_exceptions) - warning_matches = compile(_warning_matches) - warning_exceptions = compile(_warning_exceptions) - file_line_matches = compile(_file_line_matches) + error_matches = [re.compile(r) for r in _optimize_regexes(_error_matches)] + error_exceptions = [re.compile(r) for r in _optimize_regexes(_error_exceptions)] + warning_matches = [re.compile(r) for r in _optimize_regexes(_warning_matches)] + warning_exceptions = [re.compile(r) for r in _optimize_regexes(_warning_exceptions)] + file_line_matches = [re.compile(r) for r in _file_line_matches] matcher, _ = _match, [] timings = [] @@ -414,10 +429,10 @@ def stringify(elt): index = 0 for name, arr in [ - ("error_matches", _error_matches), - ("error_exceptions", _error_exceptions), - ("warning_matches", _warning_matches), - ("warning_exceptions", _warning_exceptions), + ("error_matches", _optimize_regexes(_error_matches)), + ("error_exceptions", _optimize_regexes(_error_exceptions)), + ("warning_matches", _optimize_regexes(_warning_matches)), + ("warning_exceptions", _optimize_regexes(_warning_exceptions)), ]: print() print(name) From 9c7e5571bfd29496cd2442891b0fa753161a1c5f Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:28:33 -0400 Subject: [PATCH 288/337] phase logging: fixes for Windows (#51791) * Enable log_output tests on Windows * Add tests for nested log_output instances and fix a bug with this on Windows. In particular the new logic avoids closing sys.stdout in case outside entities are storing a reference to it. * Proper redirection of stdout for subprocesses requires dealing with the file handle interface on Windows, this adds redirection logic that sits alongside redirection of the file descriptor Signed-off-by: John Parent --- lib/spack/spack/llnl/util/tty/log.py | 296 +++++++++++++++------- lib/spack/spack/test/llnl/util/tty/log.py | 33 ++- 2 files changed, 227 insertions(+), 102 deletions(-) diff --git a/lib/spack/spack/llnl/util/tty/log.py b/lib/spack/spack/llnl/util/tty/log.py index a82272094119be..5151193cc45aa4 100644 --- a/lib/spack/spack/llnl/util/tty/log.py +++ b/lib/spack/spack/llnl/util/tty/log.py @@ -14,20 +14,27 @@ import select import signal import sys -import threading import traceback from contextlib import contextmanager from multiprocessing.connection import Connection from threading import Thread -from typing import IO, Callable, Optional, Tuple +from typing import IO, Callable, List, Optional, Tuple import spack.llnl.util.tty as tty +if sys.platform == "win32": + import ctypes.wintypes as wintypes + import msvcrt + + kernel32 = ctypes.windll.kernel32 + try: import termios except ImportError: termios = None # type: ignore[assignment] +# win32api constants +DUPLICATE_SAME_ACCESS = 0x00000002 esc, bell, lbracket, bslash, newline = r"\x1b", r"\x07", r"\[", r"\\", r"\n" # Ansi Control Sequence Introducers (CSI) are a well-defined format @@ -548,67 +555,78 @@ class StreamWrapper: def __init__(self, sys_attr): self.sys_attr = sys_attr self.saved_stream = None - if sys.platform.startswith("win32"): - if hasattr(sys, "gettotalrefcount"): # debug build - libc = ctypes.CDLL("ucrtbased") - else: - libc = ctypes.CDLL("api-ms-win-crt-stdio-l1-1-0") - - kernel32 = ctypes.WinDLL("kernel32") - - # https://docs.microsoft.com/en-us/windows/console/getstdhandle - if self.sys_attr == "stdout": - STD_HANDLE = -11 - elif self.sys_attr == "stderr": - STD_HANDLE = -12 - else: - raise KeyError(self.sys_attr) - - c_stdout = kernel32.GetStdHandle(STD_HANDLE) - self.libc = libc - self.c_stream = c_stdout + + kernel32.SetStdHandle.argtypes = [wintypes.DWORD, wintypes.HANDLE] # nStdHandle # hHandle + + kernel32.GetStdHandle.argtypes = [wintypes.DWORD] + kernel32.GetStdHandle.restype = wintypes.HANDLE + + # https://docs.microsoft.com/en-us/windows/console/getstdhandle + if self.sys_attr == "stdout": + self.STD_HANDLE = -11 + elif self.sys_attr == "stderr": + self.STD_HANDLE = -12 else: - self.libc = ctypes.CDLL(None) - self.c_stream = ctypes.c_void_p.in_dll(self.libc, self.sys_attr) - self.sys_stream = getattr(sys, self.sys_attr) - self.orig_stream_fd = self.sys_stream.fileno() - # Save a copy of the original stdout fd in saved_stream - self.saved_stream = os.dup(self.orig_stream_fd) - - def redirect_stream(self, to_fd): + raise KeyError(self.sys_attr) + + self.saved_stream = getattr(sys, self.sys_attr) + self.std_fd = self.saved_stream.fileno() + self.saved_std_handle = kernel32.GetStdHandle(self.STD_HANDLE) + self.saved_stream_fd = os.dup(self.std_fd) + self.redirect_fd = None + + def redirect_stream(self, write_conn): """Redirect stdout to the given file descriptor.""" - # Flush the C-level buffer stream - if sys.platform.startswith("win32"): - self.libc.fflush(None) - else: - self.libc.fflush(self.c_stream) - # Flush and close sys_stream - also closes the file descriptor (fd) - sys_stream = getattr(sys, self.sys_attr) - sys_stream.flush() - sys_stream.close() - # Make orig_stream_fd point to the same file as to_fd - os.dup2(to_fd, self.orig_stream_fd) - # Set sys_stream to a new stream that points to the redirected fd - new_buffer = open(self.orig_stream_fd, "wb") - new_stream = io.TextIOWrapper(new_buffer) - setattr(sys, self.sys_attr, new_stream) - self.sys_stream = getattr(sys, self.sys_attr) + self.flush() + # Get fd for new stream + redirect_h = write_conn.fileno() + dup_redirect_h = dup_fh(redirect_h) + os.set_handle_inheritable(redirect_h, True) + self.redirect_fd = msvcrt.open_osfhandle(dup_redirect_h, os.O_WRONLY) + kernel32.SetStdHandle(self.STD_HANDLE, wintypes.HANDLE(redirect_h)) + os.dup2(self.redirect_fd, self.std_fd) + setattr( + sys, + self.sys_attr, + os.fdopen( + self.std_fd, + "w", + encoding="utf-8", + buffering=1, + errors="replace", + closefd=False, + newline="\n", + ), + ) def flush(self): - if sys.platform.startswith("win32"): - self.libc.fflush(None) - else: - self.libc.fflush(self.c_stream) - self.sys_stream.flush() + # get current system stream for the standard fd we're redirecting + sys_stream = getattr(sys, self.sys_attr) + try: + if sys_stream: + # Flush the system stream before redirection + sys_stream.flush() + except BaseException as e: + # swallow flush errors + tty.debug(f"Encountered error flushing stream: {e}") + pass def close(self): """Redirect back to the original system stream, and close stream""" try: - if self.saved_stream is not None: - self.redirect_stream(self.saved_stream) + self.flush() + if self.saved_stream_fd is not None: + # restore os handle + kernel32.SetStdHandle(self.STD_HANDLE, self.saved_std_handle) + # restore c fd + os.dup2(self.saved_stream_fd, self.std_fd) + # python level + setattr(sys, self.sys_attr, self.saved_stream) finally: - if self.saved_stream is not None: - os.close(self.saved_stream) + if self.redirect_fd is not None: + os.close(self.redirect_fd) + if self.saved_stream_fd is not None: + os.close(self.saved_stream_fd) class winlog: @@ -631,60 +649,37 @@ def __init__( self.old_stdout = sys.stdout self.old_stderr = sys.stderr self.append = append + self.filter_fn = filter_fn + self.read_p, self.write_p = None, None + self._thread = None def __enter__(self): if self._active: raise RuntimeError("Can't re-enter the same log_output!") - # Open both write and reading on logfile - write_mode = "ab+" if self.append else "wb+" - self.writer = open(self.logfile, mode=write_mode) - self.reader = open(self.logfile, mode="rb+") + self.read_p, self.write_p = multiprocessing.Pipe(duplex=False) # Dup stdout so we can still write to it after redirection - self.echo_writer = open(os.dup(sys.stdout.fileno()), "w", encoding=sys.stdout.encoding) - # Redirect stdout and stderr to write to logfile - self.stderr.redirect_stream(self.writer.fileno()) - self.stdout.redirect_stream(self.writer.fileno()) - self._kill = threading.Event() - - def background_reader(reader, echo_writer, _kill): - # for each line printed to logfile, read it - # if echo: write line to user - try: - while True: - is_killed = _kill.wait(0.1) - # Flush buffered build output to file - # stdout/err fds refer to log file - self.stderr.flush() - self.stdout.flush() - - line = reader.readline() - if self.echo and line: - echo_writer.write("{0}".format(line.decode())) - echo_writer.flush() - - if is_killed: - break - finally: - reader.close() + original_stdout_fd = sys.stdout.fileno() + echo_writer = os.fdopen(os.dup(original_stdout_fd), "w", encoding="utf-8", newline="\n") self._active = True self._thread = Thread( - target=background_reader, args=(self.reader, self.echo_writer, self._kill) + target=self._background_reader, + args=(self.read_p, self.logfile, echo_writer, self.append, self.echo, self.filter_fn), ) self._thread.start() + # Redirect stdout and stderr to write to logfile + self.stderr.redirect_stream(self.write_p) + self.stdout.redirect_stream(self.write_p) + return self def __exit__(self, exc_type, exc_val, exc_tb): - self.writer.close() - self.echo_writer.flush() - self.stdout.flush() - self.stderr.flush() - self._kill.set() - self._thread.join() self.stdout.close() self.stderr.close() + self.write_p.close() + self._thread.join() self._active = False @contextmanager @@ -692,7 +687,65 @@ def force_echo(self): """Context manager to force local echo, even if echo is off.""" if not self._active: raise RuntimeError("Can't call force_echo() outside log_output region!") - yield + sys.stdout.write(xon) + sys.stdout.flush() + try: + yield + finally: + sys.stdout.write(xoff) + sys.stdout.flush() + + @staticmethod + def _background_reader( + read, + logfile: str, + stdout: io.TextIOBase, + append: bool, + echo: bool, + filter_fn: Optional[Callable], + ): + force_echo = False + + write_mode = "ab" if append else "wb" + log_writer = open(logfile, mode=write_mode) + try: + while True: + data = read.recv_bytes(maxlength=4096) + if not data: + # the pipe is closed or otherwise inaccesible + return + norm_data = data.decode(encoding="utf-8", errors="replace") + clean_line, num_controls = control.subn("", norm_data) + + log_writer.write(_strip(clean_line).encode(encoding="utf-8")) + log_writer.flush() + if echo or force_echo: + output = clean_line + if filter_fn: + output = filter_fn(output) + enc = stdout.encoding + if enc != "utf-8": + output = output.encode(enc, "replace").decode(enc) + stdout.write(output) + stdout.flush() + if num_controls > 0: + controls = control.findall(norm_data) + force_echo = force_echo_on(force_echo, controls) + if read.closed: + break + + # swallow valid errors + except EOFError: + pass + except OSError: + pass + except BaseException as e: + tty.error(f"Exception in log writer thread! {e}", stream=stdout) + traceback.print_exc(file=stdout) + finally: + read.close() + log_writer.close() + stdout.close() def _writer_daemon( @@ -836,10 +889,7 @@ def _writer_daemon( if num_controls > 0: controls = control.findall(line) - if xon in controls: - force_echo = True - if xoff in controls: - force_echo = False + force_echo = force_echo_on(force_echo, controls) if not _input_available(read_file): break @@ -863,5 +913,59 @@ def _writer_daemon( control_fd.send(echo) +if sys.platform == "win32": + # dont define this outside windows, otherwise mypy complains + # or we'd have to # type: ignore on basically every line of + # this method + def dup_fh(fh: int) -> int: + """Windows Only + Duplicates Windows file handles. Useful when + we need multiple references to a single file handle + that all can be closed independently + + uses DuplicateHandle from the win32 api + + Arguments: + fh: OS level file handle to be duplicated + + Returns: integer representing the new, identical file handle + """ + # Define function signatures for safety + kernel32.DuplicateHandle.argtypes = [ + wintypes.HANDLE, # hSourceProcessHandle + wintypes.HANDLE, # hSourceHandle + wintypes.HANDLE, # hTargetProcessHandle + ctypes.POINTER(wintypes.HANDLE), # lpTargetHandle + wintypes.DWORD, # dwDesiredAccess + wintypes.BOOL, # bInheritHandle + wintypes.DWORD, # dwOptions + ] + current_process = kernel32.GetCurrentProcess() + target_handle = wintypes.HANDLE() + + success = kernel32.DuplicateHandle( + current_process, + wintypes.HANDLE(fh), + current_process, + ctypes.byref(target_handle), + 0, + True, + DUPLICATE_SAME_ACCESS, + ) + + if not success or not target_handle.value: + raise ctypes.WinError() + + return target_handle.value + + +def force_echo_on(force_echo: bool, controls: List[str]): + if xon in controls: + return True + if xoff in controls: + return False + return force_echo + + def _input_available(f): return f in select.select([f], [], [], 0)[0] diff --git a/lib/spack/spack/test/llnl/util/tty/log.py b/lib/spack/spack/test/llnl/util/tty/log.py index 19872dd812553a..5be2d720b36cc4 100644 --- a/lib/spack/spack/test/llnl/util/tty/log.py +++ b/lib/spack/spack/test/llnl/util/tty/log.py @@ -8,8 +8,6 @@ from types import ModuleType from typing import Optional -import pytest - import spack.llnl.util.tty.log as log from spack.llnl.util.filesystem import working_dir from spack.util.executable import Executable @@ -23,9 +21,6 @@ pass -pytestmark = pytest.mark.not_on_windows("does not run on windows") - - @contextlib.contextmanager def nullcontext(): yield @@ -165,4 +160,30 @@ def test_log_subproc_and_echo_output(capfd, tmp_path: pathlib.Path): # Check captured output (echoed content) # Note: 'logged' is not echoed because force_echo() scope ended - assert capfd.readouterr()[0] == "echo\n" + # Note: "print(echo)" above automatically uses an "\r\n" on Windows + # and will replace any \n with \r\n (so end=\n does not work) + # \r\n is expected and correct here + # Note: the above line ending constraint is an artifact of + # pytest's capfd. This is potentially (however unlikely) + # subject to change with future versions of pytest. + # if this test suddenly starts failing, verifying the line + # endings from capfd is a good starting place. + newline = "\r\n" if sys.platform == "win32" else "\n" + assert capfd.readouterr()[0] == f"echo{newline}" + + +def test_nested_logging_contexts(capfd, tmp_path): + with working_dir(str(tmp_path)): + with log.log_output("foo.txt"): + with log.log_output("bar.txt"): + print("inner") + print("outer") + + with open("foo.txt", "r", encoding="utf-8") as f: + log_captured_out = f.read() + assert "outer\n" in log_captured_out + assert "inner\n" not in log_captured_out + with open("bar.txt", "r", encoding="utf-8") as f: + log_captured_out = f.read() + assert "inner\n" in log_captured_out + assert "outer\n" not in log_captured_out From b7ab4c378f6ea6dcf1a87e3cff51d62e302e131d Mon Sep 17 00:00:00 2001 From: Todd Gamblin Date: Thu, 23 Apr 2026 18:38:50 -0300 Subject: [PATCH 289/337] `CITATION.cff`: use `collection-title` field to help `ruby-cff` (#52319) `collection-title` populates `booktitle` in the BibTeX generated by GitHub's CFF->BibTeX converter (`ruby-cff`). Without it, the generated @inproceedings entry is missing the required `booktitle` field. See https://github.com/citation-file-format/ruby-cff/issues/79 Signed-off-by: Todd Gamblin --- CITATION.cff | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 59888b51ce29e1..a0d855f11b43ec 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -6,7 +6,7 @@ # Todd Gamblin, Matthew P. LeGendre, Michael R. Collette, Gregory L. Lee, # Adam Moody, Bronis R. de Supinski, and W. Scott Futral. # The Spack Package Manager: Bringing Order to HPC Software Chaos. -# In Supercomputing 2015 (SC’15), Austin, Texas, November 15-20 2015. LLNL-CONF-669890. +# In Supercomputing 2015 (SC'15), Austin, Texas, November 15-20 2015. LLNL-CONF-669890. # # Or, in BibTeX: # @@ -15,10 +15,10 @@ # author = {Gamblin, Todd and LeGendre, Matthew and # Collette, Michael R. and Lee, Gregory L. and # Moody, Adam and de Supinski, Bronis R. and Futral, Scott}, +# booktitle = {Supercomputing 2015 (SC'15)}, # doi = {10.1145/2807591.2807623}, # month = {November 15-20}, # note = {LLNL-CONF-669890}, -# series = {Supercomputing 2015 (SC’15)}, # title = {{The Spack Package Manager: Bringing Order to HPC Software Chaos}}, # url = {https://github.com/spack/spack}, # year = {2015} @@ -61,13 +61,26 @@ preferred-citation: given-names: "Bronis R." - family-names: "Futral" given-names: "Scott" + # collection-title populates `booktitle` in the BibTeX generated by + # GitHub's CFF->BibTeX converter (ruby-cff). Without it, the generated + # @inproceedings entry is missing the required booktitle field. + # See https://github.com/citation-file-format/ruby-cff/issues/79 + collection-title: "Supercomputing 2015 (SC'15)" + collection-type: "proceedings" + publisher: + name: "Association for Computing Machinery" + city: "New York" + region: "NY" + country: "US" conference: - name: "Supercomputing 2015 (SC’15)" + name: "Supercomputing 2015 (SC'15)" city: "Austin" region: "Texas" country: "US" date-start: 2015-11-15 date-end: 2015-11-20 + isbn: "9781450337236" + doi: "10.1145/2807591.2807623" month: 11 year: 2015 identifiers: From 1f5251b1b8fb25cacada2f965ce9fbc5581df67d Mon Sep 17 00:00:00 2001 From: Peter Scheibel Date: Thu, 23 Apr 2026 15:30:33 -0700 Subject: [PATCH 290/337] Revert "phase logging: fixes for Windows (#51791)" (#52335) This reverts commit 9c7e5571bfd29496cd2442891b0fa753161a1c5f. Signed-off-by: Peter Scheibel --- lib/spack/spack/llnl/util/tty/log.py | 296 +++++++--------------- lib/spack/spack/test/llnl/util/tty/log.py | 33 +-- 2 files changed, 102 insertions(+), 227 deletions(-) diff --git a/lib/spack/spack/llnl/util/tty/log.py b/lib/spack/spack/llnl/util/tty/log.py index 5151193cc45aa4..a82272094119be 100644 --- a/lib/spack/spack/llnl/util/tty/log.py +++ b/lib/spack/spack/llnl/util/tty/log.py @@ -14,27 +14,20 @@ import select import signal import sys +import threading import traceback from contextlib import contextmanager from multiprocessing.connection import Connection from threading import Thread -from typing import IO, Callable, List, Optional, Tuple +from typing import IO, Callable, Optional, Tuple import spack.llnl.util.tty as tty -if sys.platform == "win32": - import ctypes.wintypes as wintypes - import msvcrt - - kernel32 = ctypes.windll.kernel32 - try: import termios except ImportError: termios = None # type: ignore[assignment] -# win32api constants -DUPLICATE_SAME_ACCESS = 0x00000002 esc, bell, lbracket, bslash, newline = r"\x1b", r"\x07", r"\[", r"\\", r"\n" # Ansi Control Sequence Introducers (CSI) are a well-defined format @@ -555,78 +548,67 @@ class StreamWrapper: def __init__(self, sys_attr): self.sys_attr = sys_attr self.saved_stream = None - - kernel32.SetStdHandle.argtypes = [wintypes.DWORD, wintypes.HANDLE] # nStdHandle # hHandle - - kernel32.GetStdHandle.argtypes = [wintypes.DWORD] - kernel32.GetStdHandle.restype = wintypes.HANDLE - - # https://docs.microsoft.com/en-us/windows/console/getstdhandle - if self.sys_attr == "stdout": - self.STD_HANDLE = -11 - elif self.sys_attr == "stderr": - self.STD_HANDLE = -12 + if sys.platform.startswith("win32"): + if hasattr(sys, "gettotalrefcount"): # debug build + libc = ctypes.CDLL("ucrtbased") + else: + libc = ctypes.CDLL("api-ms-win-crt-stdio-l1-1-0") + + kernel32 = ctypes.WinDLL("kernel32") + + # https://docs.microsoft.com/en-us/windows/console/getstdhandle + if self.sys_attr == "stdout": + STD_HANDLE = -11 + elif self.sys_attr == "stderr": + STD_HANDLE = -12 + else: + raise KeyError(self.sys_attr) + + c_stdout = kernel32.GetStdHandle(STD_HANDLE) + self.libc = libc + self.c_stream = c_stdout else: - raise KeyError(self.sys_attr) - - self.saved_stream = getattr(sys, self.sys_attr) - self.std_fd = self.saved_stream.fileno() - self.saved_std_handle = kernel32.GetStdHandle(self.STD_HANDLE) - self.saved_stream_fd = os.dup(self.std_fd) - self.redirect_fd = None - - def redirect_stream(self, write_conn): + self.libc = ctypes.CDLL(None) + self.c_stream = ctypes.c_void_p.in_dll(self.libc, self.sys_attr) + self.sys_stream = getattr(sys, self.sys_attr) + self.orig_stream_fd = self.sys_stream.fileno() + # Save a copy of the original stdout fd in saved_stream + self.saved_stream = os.dup(self.orig_stream_fd) + + def redirect_stream(self, to_fd): """Redirect stdout to the given file descriptor.""" - self.flush() - # Get fd for new stream - redirect_h = write_conn.fileno() - dup_redirect_h = dup_fh(redirect_h) - os.set_handle_inheritable(redirect_h, True) - self.redirect_fd = msvcrt.open_osfhandle(dup_redirect_h, os.O_WRONLY) - kernel32.SetStdHandle(self.STD_HANDLE, wintypes.HANDLE(redirect_h)) - os.dup2(self.redirect_fd, self.std_fd) - setattr( - sys, - self.sys_attr, - os.fdopen( - self.std_fd, - "w", - encoding="utf-8", - buffering=1, - errors="replace", - closefd=False, - newline="\n", - ), - ) + # Flush the C-level buffer stream + if sys.platform.startswith("win32"): + self.libc.fflush(None) + else: + self.libc.fflush(self.c_stream) + # Flush and close sys_stream - also closes the file descriptor (fd) + sys_stream = getattr(sys, self.sys_attr) + sys_stream.flush() + sys_stream.close() + # Make orig_stream_fd point to the same file as to_fd + os.dup2(to_fd, self.orig_stream_fd) + # Set sys_stream to a new stream that points to the redirected fd + new_buffer = open(self.orig_stream_fd, "wb") + new_stream = io.TextIOWrapper(new_buffer) + setattr(sys, self.sys_attr, new_stream) + self.sys_stream = getattr(sys, self.sys_attr) def flush(self): - # get current system stream for the standard fd we're redirecting - sys_stream = getattr(sys, self.sys_attr) - try: - if sys_stream: - # Flush the system stream before redirection - sys_stream.flush() - except BaseException as e: - # swallow flush errors - tty.debug(f"Encountered error flushing stream: {e}") - pass + if sys.platform.startswith("win32"): + self.libc.fflush(None) + else: + self.libc.fflush(self.c_stream) + self.sys_stream.flush() def close(self): """Redirect back to the original system stream, and close stream""" try: - self.flush() - if self.saved_stream_fd is not None: - # restore os handle - kernel32.SetStdHandle(self.STD_HANDLE, self.saved_std_handle) - # restore c fd - os.dup2(self.saved_stream_fd, self.std_fd) - # python level - setattr(sys, self.sys_attr, self.saved_stream) + if self.saved_stream is not None: + self.redirect_stream(self.saved_stream) finally: - if self.redirect_fd is not None: - os.close(self.redirect_fd) - if self.saved_stream_fd is not None: - os.close(self.saved_stream_fd) + if self.saved_stream is not None: + os.close(self.saved_stream) class winlog: @@ -649,37 +631,60 @@ def __init__( self.old_stdout = sys.stdout self.old_stderr = sys.stderr self.append = append - self.filter_fn = filter_fn - self.read_p, self.write_p = None, None - self._thread = None def __enter__(self): if self._active: raise RuntimeError("Can't re-enter the same log_output!") - self.read_p, self.write_p = multiprocessing.Pipe(duplex=False) + # Open both write and reading on logfile + write_mode = "ab+" if self.append else "wb+" + self.writer = open(self.logfile, mode=write_mode) + self.reader = open(self.logfile, mode="rb+") # Dup stdout so we can still write to it after redirection - original_stdout_fd = sys.stdout.fileno() - echo_writer = os.fdopen(os.dup(original_stdout_fd), "w", encoding="utf-8", newline="\n") + self.echo_writer = open(os.dup(sys.stdout.fileno()), "w", encoding=sys.stdout.encoding) + # Redirect stdout and stderr to write to logfile + self.stderr.redirect_stream(self.writer.fileno()) + self.stdout.redirect_stream(self.writer.fileno()) + self._kill = threading.Event() + + def background_reader(reader, echo_writer, _kill): + # for each line printed to logfile, read it + # if echo: write line to user + try: + while True: + is_killed = _kill.wait(0.1) + # Flush buffered build output to file + # stdout/err fds refer to log file + self.stderr.flush() + self.stdout.flush() + + line = reader.readline() + if self.echo and line: + echo_writer.write("{0}".format(line.decode())) + echo_writer.flush() + + if is_killed: + break + finally: + reader.close() self._active = True self._thread = Thread( - target=self._background_reader, - args=(self.read_p, self.logfile, echo_writer, self.append, self.echo, self.filter_fn), + target=background_reader, args=(self.reader, self.echo_writer, self._kill) ) self._thread.start() - # Redirect stdout and stderr to write to logfile - self.stderr.redirect_stream(self.write_p) - self.stdout.redirect_stream(self.write_p) - return self def __exit__(self, exc_type, exc_val, exc_tb): + self.writer.close() + self.echo_writer.flush() + self.stdout.flush() + self.stderr.flush() + self._kill.set() + self._thread.join() self.stdout.close() self.stderr.close() - self.write_p.close() - self._thread.join() self._active = False @contextmanager @@ -687,65 +692,7 @@ def force_echo(self): """Context manager to force local echo, even if echo is off.""" if not self._active: raise RuntimeError("Can't call force_echo() outside log_output region!") - sys.stdout.write(xon) - sys.stdout.flush() - try: - yield - finally: - sys.stdout.write(xoff) - sys.stdout.flush() - - @staticmethod - def _background_reader( - read, - logfile: str, - stdout: io.TextIOBase, - append: bool, - echo: bool, - filter_fn: Optional[Callable], - ): - force_echo = False - - write_mode = "ab" if append else "wb" - log_writer = open(logfile, mode=write_mode) - try: - while True: - data = read.recv_bytes(maxlength=4096) - if not data: - # the pipe is closed or otherwise inaccesible - return - norm_data = data.decode(encoding="utf-8", errors="replace") - clean_line, num_controls = control.subn("", norm_data) - - log_writer.write(_strip(clean_line).encode(encoding="utf-8")) - log_writer.flush() - if echo or force_echo: - output = clean_line - if filter_fn: - output = filter_fn(output) - enc = stdout.encoding - if enc != "utf-8": - output = output.encode(enc, "replace").decode(enc) - stdout.write(output) - stdout.flush() - if num_controls > 0: - controls = control.findall(norm_data) - force_echo = force_echo_on(force_echo, controls) - if read.closed: - break - - # swallow valid errors - except EOFError: - pass - except OSError: - pass - except BaseException as e: - tty.error(f"Exception in log writer thread! {e}", stream=stdout) - traceback.print_exc(file=stdout) - finally: - read.close() - log_writer.close() - stdout.close() + yield def _writer_daemon( @@ -889,7 +836,10 @@ def _writer_daemon( if num_controls > 0: controls = control.findall(line) - force_echo = force_echo_on(force_echo, controls) + if xon in controls: + force_echo = True + if xoff in controls: + force_echo = False if not _input_available(read_file): break @@ -913,59 +863,5 @@ def _writer_daemon( control_fd.send(echo) -if sys.platform == "win32": - # dont define this outside windows, otherwise mypy complains - # or we'd have to # type: ignore on basically every line of - # this method - def dup_fh(fh: int) -> int: - """Windows Only - Duplicates Windows file handles. Useful when - we need multiple references to a single file handle - that all can be closed independently - - uses DuplicateHandle from the win32 api - - Arguments: - fh: OS level file handle to be duplicated - - Returns: integer representing the new, identical file handle - """ - # Define function signatures for safety - kernel32.DuplicateHandle.argtypes = [ - wintypes.HANDLE, # hSourceProcessHandle - wintypes.HANDLE, # hSourceHandle - wintypes.HANDLE, # hTargetProcessHandle - ctypes.POINTER(wintypes.HANDLE), # lpTargetHandle - wintypes.DWORD, # dwDesiredAccess - wintypes.BOOL, # bInheritHandle - wintypes.DWORD, # dwOptions - ] - current_process = kernel32.GetCurrentProcess() - target_handle = wintypes.HANDLE() - - success = kernel32.DuplicateHandle( - current_process, - wintypes.HANDLE(fh), - current_process, - ctypes.byref(target_handle), - 0, - True, - DUPLICATE_SAME_ACCESS, - ) - - if not success or not target_handle.value: - raise ctypes.WinError() - - return target_handle.value - - -def force_echo_on(force_echo: bool, controls: List[str]): - if xon in controls: - return True - if xoff in controls: - return False - return force_echo - - def _input_available(f): return f in select.select([f], [], [], 0)[0] diff --git a/lib/spack/spack/test/llnl/util/tty/log.py b/lib/spack/spack/test/llnl/util/tty/log.py index 5be2d720b36cc4..19872dd812553a 100644 --- a/lib/spack/spack/test/llnl/util/tty/log.py +++ b/lib/spack/spack/test/llnl/util/tty/log.py @@ -8,6 +8,8 @@ from types import ModuleType from typing import Optional +import pytest + import spack.llnl.util.tty.log as log from spack.llnl.util.filesystem import working_dir from spack.util.executable import Executable @@ -21,6 +23,9 @@ pass +pytestmark = pytest.mark.not_on_windows("does not run on windows") + + @contextlib.contextmanager def nullcontext(): yield @@ -160,30 +165,4 @@ def test_log_subproc_and_echo_output(capfd, tmp_path: pathlib.Path): # Check captured output (echoed content) # Note: 'logged' is not echoed because force_echo() scope ended - # Note: "print(echo)" above automatically uses an "\r\n" on Windows - # and will replace any \n with \r\n (so end=\n does not work) - # \r\n is expected and correct here - # Note: the above line ending constraint is an artifact of - # pytest's capfd. This is potentially (however unlikely) - # subject to change with future versions of pytest. - # if this test suddenly starts failing, verifying the line - # endings from capfd is a good starting place. - newline = "\r\n" if sys.platform == "win32" else "\n" - assert capfd.readouterr()[0] == f"echo{newline}" - - -def test_nested_logging_contexts(capfd, tmp_path): - with working_dir(str(tmp_path)): - with log.log_output("foo.txt"): - with log.log_output("bar.txt"): - print("inner") - print("outer") - - with open("foo.txt", "r", encoding="utf-8") as f: - log_captured_out = f.read() - assert "outer\n" in log_captured_out - assert "inner\n" not in log_captured_out - with open("bar.txt", "r", encoding="utf-8") as f: - log_captured_out = f.read() - assert "inner\n" in log_captured_out - assert "outer\n" not in log_captured_out + assert capfd.readouterr()[0] == "echo\n" From d3ac7d4e2ad634fe2f9d6e5fa807b80c8388e7f0 Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:30:58 -0400 Subject: [PATCH 291/337] phase logging: fixes for Windows (#52336) * Same as #51791 with one change: an intermediary function annotated a stream as TextIOBase, which is not compatible with IO[str] in mypy, even though the concrete object is a TextIOWrapper that is compatible * Remaining points are the same as #51791 * Enable log_output tests on Windows * Add tests for nested log_output instances and fix a bug with this on Windows. In particular the new logic avoids closing sys.stdout in case outside entities are storing a reference to it. * Proper redirection of stdout for subprocesses requires dealing with the file handle interface on Windows, this adds redirection logic that sits alongside redirection of the file descriptor Signed-off-by: John Parent --- lib/spack/spack/llnl/util/tty/log.py | 296 +++++++++++++++------- lib/spack/spack/test/llnl/util/tty/log.py | 33 ++- 2 files changed, 227 insertions(+), 102 deletions(-) diff --git a/lib/spack/spack/llnl/util/tty/log.py b/lib/spack/spack/llnl/util/tty/log.py index a82272094119be..fba3eff45bb849 100644 --- a/lib/spack/spack/llnl/util/tty/log.py +++ b/lib/spack/spack/llnl/util/tty/log.py @@ -14,20 +14,27 @@ import select import signal import sys -import threading import traceback from contextlib import contextmanager from multiprocessing.connection import Connection from threading import Thread -from typing import IO, Callable, Optional, Tuple +from typing import IO, Callable, List, Optional, Tuple import spack.llnl.util.tty as tty +if sys.platform == "win32": + import ctypes.wintypes as wintypes + import msvcrt + + kernel32 = ctypes.windll.kernel32 + try: import termios except ImportError: termios = None # type: ignore[assignment] +# win32api constants +DUPLICATE_SAME_ACCESS = 0x00000002 esc, bell, lbracket, bslash, newline = r"\x1b", r"\x07", r"\[", r"\\", r"\n" # Ansi Control Sequence Introducers (CSI) are a well-defined format @@ -548,67 +555,78 @@ class StreamWrapper: def __init__(self, sys_attr): self.sys_attr = sys_attr self.saved_stream = None - if sys.platform.startswith("win32"): - if hasattr(sys, "gettotalrefcount"): # debug build - libc = ctypes.CDLL("ucrtbased") - else: - libc = ctypes.CDLL("api-ms-win-crt-stdio-l1-1-0") - - kernel32 = ctypes.WinDLL("kernel32") - - # https://docs.microsoft.com/en-us/windows/console/getstdhandle - if self.sys_attr == "stdout": - STD_HANDLE = -11 - elif self.sys_attr == "stderr": - STD_HANDLE = -12 - else: - raise KeyError(self.sys_attr) - - c_stdout = kernel32.GetStdHandle(STD_HANDLE) - self.libc = libc - self.c_stream = c_stdout + + kernel32.SetStdHandle.argtypes = [wintypes.DWORD, wintypes.HANDLE] # nStdHandle # hHandle + + kernel32.GetStdHandle.argtypes = [wintypes.DWORD] + kernel32.GetStdHandle.restype = wintypes.HANDLE + + # https://docs.microsoft.com/en-us/windows/console/getstdhandle + if self.sys_attr == "stdout": + self.STD_HANDLE = -11 + elif self.sys_attr == "stderr": + self.STD_HANDLE = -12 else: - self.libc = ctypes.CDLL(None) - self.c_stream = ctypes.c_void_p.in_dll(self.libc, self.sys_attr) - self.sys_stream = getattr(sys, self.sys_attr) - self.orig_stream_fd = self.sys_stream.fileno() - # Save a copy of the original stdout fd in saved_stream - self.saved_stream = os.dup(self.orig_stream_fd) - - def redirect_stream(self, to_fd): + raise KeyError(self.sys_attr) + + self.saved_stream = getattr(sys, self.sys_attr) + self.std_fd = self.saved_stream.fileno() + self.saved_std_handle = kernel32.GetStdHandle(self.STD_HANDLE) + self.saved_stream_fd = os.dup(self.std_fd) + self.redirect_fd = None + + def redirect_stream(self, write_conn): """Redirect stdout to the given file descriptor.""" - # Flush the C-level buffer stream - if sys.platform.startswith("win32"): - self.libc.fflush(None) - else: - self.libc.fflush(self.c_stream) - # Flush and close sys_stream - also closes the file descriptor (fd) - sys_stream = getattr(sys, self.sys_attr) - sys_stream.flush() - sys_stream.close() - # Make orig_stream_fd point to the same file as to_fd - os.dup2(to_fd, self.orig_stream_fd) - # Set sys_stream to a new stream that points to the redirected fd - new_buffer = open(self.orig_stream_fd, "wb") - new_stream = io.TextIOWrapper(new_buffer) - setattr(sys, self.sys_attr, new_stream) - self.sys_stream = getattr(sys, self.sys_attr) + self.flush() + # Get fd for new stream + redirect_h = write_conn.fileno() + dup_redirect_h = dup_fh(redirect_h) + os.set_handle_inheritable(redirect_h, True) + self.redirect_fd = msvcrt.open_osfhandle(dup_redirect_h, os.O_WRONLY) + kernel32.SetStdHandle(self.STD_HANDLE, wintypes.HANDLE(redirect_h)) + os.dup2(self.redirect_fd, self.std_fd) + setattr( + sys, + self.sys_attr, + os.fdopen( + self.std_fd, + "w", + encoding="utf-8", + buffering=1, + errors="replace", + closefd=False, + newline="\n", + ), + ) def flush(self): - if sys.platform.startswith("win32"): - self.libc.fflush(None) - else: - self.libc.fflush(self.c_stream) - self.sys_stream.flush() + # get current system stream for the standard fd we're redirecting + sys_stream = getattr(sys, self.sys_attr) + try: + if sys_stream: + # Flush the system stream before redirection + sys_stream.flush() + except BaseException as e: + # swallow flush errors + tty.debug(f"Encountered error flushing stream: {e}") + pass def close(self): """Redirect back to the original system stream, and close stream""" try: - if self.saved_stream is not None: - self.redirect_stream(self.saved_stream) + self.flush() + if self.saved_stream_fd is not None: + # restore os handle + kernel32.SetStdHandle(self.STD_HANDLE, self.saved_std_handle) + # restore c fd + os.dup2(self.saved_stream_fd, self.std_fd) + # python level + setattr(sys, self.sys_attr, self.saved_stream) finally: - if self.saved_stream is not None: - os.close(self.saved_stream) + if self.redirect_fd is not None: + os.close(self.redirect_fd) + if self.saved_stream_fd is not None: + os.close(self.saved_stream_fd) class winlog: @@ -631,60 +649,37 @@ def __init__( self.old_stdout = sys.stdout self.old_stderr = sys.stderr self.append = append + self.filter_fn = filter_fn + self.read_p, self.write_p = None, None + self._thread = None def __enter__(self): if self._active: raise RuntimeError("Can't re-enter the same log_output!") - # Open both write and reading on logfile - write_mode = "ab+" if self.append else "wb+" - self.writer = open(self.logfile, mode=write_mode) - self.reader = open(self.logfile, mode="rb+") + self.read_p, self.write_p = multiprocessing.Pipe(duplex=False) # Dup stdout so we can still write to it after redirection - self.echo_writer = open(os.dup(sys.stdout.fileno()), "w", encoding=sys.stdout.encoding) - # Redirect stdout and stderr to write to logfile - self.stderr.redirect_stream(self.writer.fileno()) - self.stdout.redirect_stream(self.writer.fileno()) - self._kill = threading.Event() - - def background_reader(reader, echo_writer, _kill): - # for each line printed to logfile, read it - # if echo: write line to user - try: - while True: - is_killed = _kill.wait(0.1) - # Flush buffered build output to file - # stdout/err fds refer to log file - self.stderr.flush() - self.stdout.flush() - - line = reader.readline() - if self.echo and line: - echo_writer.write("{0}".format(line.decode())) - echo_writer.flush() - - if is_killed: - break - finally: - reader.close() + original_stdout_fd = sys.stdout.fileno() + echo_writer = os.fdopen(os.dup(original_stdout_fd), "w", encoding="utf-8", newline="\n") self._active = True self._thread = Thread( - target=background_reader, args=(self.reader, self.echo_writer, self._kill) + target=self._background_reader, + args=(self.read_p, self.logfile, echo_writer, self.append, self.echo, self.filter_fn), ) self._thread.start() + # Redirect stdout and stderr to write to logfile + self.stderr.redirect_stream(self.write_p) + self.stdout.redirect_stream(self.write_p) + return self def __exit__(self, exc_type, exc_val, exc_tb): - self.writer.close() - self.echo_writer.flush() - self.stdout.flush() - self.stderr.flush() - self._kill.set() - self._thread.join() self.stdout.close() self.stderr.close() + self.write_p.close() + self._thread.join() self._active = False @contextmanager @@ -692,7 +687,65 @@ def force_echo(self): """Context manager to force local echo, even if echo is off.""" if not self._active: raise RuntimeError("Can't call force_echo() outside log_output region!") - yield + sys.stdout.write(xon) + sys.stdout.flush() + try: + yield + finally: + sys.stdout.write(xoff) + sys.stdout.flush() + + @staticmethod + def _background_reader( + read, + logfile: str, + stdout: io.TextIOWrapper, + append: bool, + echo: bool, + filter_fn: Optional[Callable], + ): + force_echo = False + + write_mode = "ab" if append else "wb" + log_writer = open(logfile, mode=write_mode) + try: + while True: + data = read.recv_bytes(maxlength=4096) + if not data: + # the pipe is closed or otherwise inaccesible + return + norm_data = data.decode(encoding="utf-8", errors="replace") + clean_line, num_controls = control.subn("", norm_data) + + log_writer.write(_strip(clean_line).encode(encoding="utf-8")) + log_writer.flush() + if echo or force_echo: + output = clean_line + if filter_fn: + output = filter_fn(output) + enc = stdout.encoding + if enc != "utf-8": + output = output.encode(enc, "replace").decode(enc) + stdout.write(output) + stdout.flush() + if num_controls > 0: + controls = control.findall(norm_data) + force_echo = force_echo_on(force_echo, controls) + if read.closed: + break + + # swallow valid errors + except EOFError: + pass + except OSError: + pass + except BaseException as e: + tty.error(f"Exception in log writer thread! {e}", stream=stdout) + traceback.print_exc(file=stdout) + finally: + read.close() + log_writer.close() + stdout.close() def _writer_daemon( @@ -836,10 +889,7 @@ def _writer_daemon( if num_controls > 0: controls = control.findall(line) - if xon in controls: - force_echo = True - if xoff in controls: - force_echo = False + force_echo = force_echo_on(force_echo, controls) if not _input_available(read_file): break @@ -863,5 +913,59 @@ def _writer_daemon( control_fd.send(echo) +if sys.platform == "win32": + # dont define this outside windows, otherwise mypy complains + # or we'd have to # type: ignore on basically every line of + # this method + def dup_fh(fh: int) -> int: + """Windows Only + Duplicates Windows file handles. Useful when + we need multiple references to a single file handle + that all can be closed independently + + uses DuplicateHandle from the win32 api + + Arguments: + fh: OS level file handle to be duplicated + + Returns: integer representing the new, identical file handle + """ + # Define function signatures for safety + kernel32.DuplicateHandle.argtypes = [ + wintypes.HANDLE, # hSourceProcessHandle + wintypes.HANDLE, # hSourceHandle + wintypes.HANDLE, # hTargetProcessHandle + ctypes.POINTER(wintypes.HANDLE), # lpTargetHandle + wintypes.DWORD, # dwDesiredAccess + wintypes.BOOL, # bInheritHandle + wintypes.DWORD, # dwOptions + ] + current_process = kernel32.GetCurrentProcess() + target_handle = wintypes.HANDLE() + + success = kernel32.DuplicateHandle( + current_process, + wintypes.HANDLE(fh), + current_process, + ctypes.byref(target_handle), + 0, + True, + DUPLICATE_SAME_ACCESS, + ) + + if not success or not target_handle.value: + raise ctypes.WinError() + + return target_handle.value + + +def force_echo_on(force_echo: bool, controls: List[str]): + if xon in controls: + return True + if xoff in controls: + return False + return force_echo + + def _input_available(f): return f in select.select([f], [], [], 0)[0] diff --git a/lib/spack/spack/test/llnl/util/tty/log.py b/lib/spack/spack/test/llnl/util/tty/log.py index 19872dd812553a..5be2d720b36cc4 100644 --- a/lib/spack/spack/test/llnl/util/tty/log.py +++ b/lib/spack/spack/test/llnl/util/tty/log.py @@ -8,8 +8,6 @@ from types import ModuleType from typing import Optional -import pytest - import spack.llnl.util.tty.log as log from spack.llnl.util.filesystem import working_dir from spack.util.executable import Executable @@ -23,9 +21,6 @@ pass -pytestmark = pytest.mark.not_on_windows("does not run on windows") - - @contextlib.contextmanager def nullcontext(): yield @@ -165,4 +160,30 @@ def test_log_subproc_and_echo_output(capfd, tmp_path: pathlib.Path): # Check captured output (echoed content) # Note: 'logged' is not echoed because force_echo() scope ended - assert capfd.readouterr()[0] == "echo\n" + # Note: "print(echo)" above automatically uses an "\r\n" on Windows + # and will replace any \n with \r\n (so end=\n does not work) + # \r\n is expected and correct here + # Note: the above line ending constraint is an artifact of + # pytest's capfd. This is potentially (however unlikely) + # subject to change with future versions of pytest. + # if this test suddenly starts failing, verifying the line + # endings from capfd is a good starting place. + newline = "\r\n" if sys.platform == "win32" else "\n" + assert capfd.readouterr()[0] == f"echo{newline}" + + +def test_nested_logging_contexts(capfd, tmp_path): + with working_dir(str(tmp_path)): + with log.log_output("foo.txt"): + with log.log_output("bar.txt"): + print("inner") + print("outer") + + with open("foo.txt", "r", encoding="utf-8") as f: + log_captured_out = f.read() + assert "outer\n" in log_captured_out + assert "inner\n" not in log_captured_out + with open("bar.txt", "r", encoding="utf-8") as f: + log_captured_out = f.read() + assert "inner\n" in log_captured_out + assert "outer\n" not in log_captured_out From 2229638f2d202d3a61537e90834c857a8b85bf10 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 24 Apr 2026 15:49:34 +0200 Subject: [PATCH 292/337] new_installer.py: blocked by other process message (#52321) When running spack install interactively, and another process is running builds (and taken a prefix write lock) or is doing database write operations, print a message ``` Waiting for other Spack install process... ``` This message is technically not entirely correct, but the most likely explanation for why the current `spack install` is seemingly stuck. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 77eccf9aa63a1d..6b03a319fbb75e 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1155,6 +1155,7 @@ def __init__( self.log_ends_with_newline = True self.actual_jobs: int = 0 self.target_jobs: int = 0 + self.blocked: bool = False self.stdout = stdout self.get_terminal_size = get_terminal_size @@ -1315,6 +1316,13 @@ def next(self, direction: int = 1) -> None: except (KeyError, OSError): pass + def set_blocked(self, blocked: bool) -> None: + """Set whether all pending builds are blocked by another Spack process.""" + if blocked == self.blocked: + return + self.blocked = blocked + self.dirty = True + def set_jobs(self, actual: int, target: int) -> None: """Set the actual and target number of jobs to run concurrently.""" if actual == self.actual_jobs and target == self.target_jobs: @@ -1463,6 +1471,9 @@ def update(self, finalize: bool = False) -> None: else: self._println(buffer, f"{bold}Progress:{reset} {self.completed}/{self.total}") + if self.blocked and not any(pkg.finished_time is None for pkg in self.builds.values()): + self._println(buffer, "Waiting for other Spack install process...") + displayed_builds = ( [b for b in self.builds.values() if self._is_displayed(b)] if self.search_term @@ -2356,6 +2367,7 @@ def _installer(self) -> None: blocked = self._schedule_builds( selector, jobserver, retained_read_locks, database_actions ) + self.build_status.set_blocked(blocked and not self.running_builds) while self.pending_builds or self.running_builds or database_actions: # Monitor the jobserver when we have pending builds, capacity, and at least one @@ -2465,6 +2477,7 @@ def _installer(self) -> None: blocked = self._schedule_builds( selector, jobserver, retained_read_locks, database_actions ) + self.build_status.set_blocked(blocked and not self.running_builds) # Finally update the UI self.build_status.update() From 97642b68991f07d5fed5662da5f2bd7002268262 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 24 Apr 2026 15:51:55 +0200 Subject: [PATCH 293/337] log parser: drop profiler context manager (#52300) The overhead from the `with _time(...):` bits is five times higher than the `re.search` call, so avoid the stack frames and allocations. Signed-off-by: Harmen Stoppels --- lib/spack/spack/util/ctest_log_parser.py | 25 ++++++++++-------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index 06e420e6fc91b8..163df35aae6431 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -74,7 +74,6 @@ import re import time from collections import deque -from contextlib import contextmanager from typing import Dict, List, TextIO, Tuple, Union _error_matches = [ @@ -281,14 +280,6 @@ def chunks(xs, n): return [xs[i : i + chunksize] for i in range(0, len(xs), chunksize)] -@contextmanager -def _time(times, i): - start = time.time() - yield - end = time.time() - times[i] += end - start - - def _optimize_regexes(regex_strings: List[str]) -> List[str]: """Groups regexes by their first character and combines each group into a single regex using alternation. Python's regex compiler optimizes the combined pattern to share common prefixes @@ -319,16 +310,20 @@ def _profile_match(matches, exceptions, line, match_times, exc_times): """ for i, m in enumerate(matches): - with _time(match_times, i): - if m.search(line): - break + start = time.perf_counter() + found = m.search(line) + match_times[i] += time.perf_counter() - start + if found: + break else: return False for i, m in enumerate(exceptions): - with _time(exc_times, i): - if m.search(line): - return False + start = time.perf_counter() + found = m.search(line) + exc_times[i] += time.perf_counter() - start + if found: + return False else: return True From 70a3efe51dbe487e47be5db943f04e92485928ab Mon Sep 17 00:00:00 2001 From: Zack Galbreath Date: Fri, 24 Apr 2026 12:53:39 -0400 Subject: [PATCH 294/337] buildcache_prune updates (#51818) * Prune objects one-at-a-time rather than using `s3 sync --delete` * Move "dry-run" check to within `_delete_object()` * Make sure we're removing the object from the remote mirror (not a local copy) * Restrict `aws s3 ls --recursive` to component prefix * Only download manifests once Signed-off-by: Zack Galbreath Co-authored-by: Ryan Krattiger <80296582+kwryankrattiger@users.noreply.github.com> --- lib/spack/spack/buildcache_prune.py | 320 +++++++++++----------------- lib/spack/spack/url_buildcache.py | 8 +- 2 files changed, 129 insertions(+), 199 deletions(-) diff --git a/lib/spack/spack/buildcache_prune.py b/lib/spack/spack/buildcache_prune.py index e695f10248c333..fab85ed02cb2ea 100644 --- a/lib/spack/spack/buildcache_prune.py +++ b/lib/spack/spack/buildcache_prune.py @@ -1,7 +1,6 @@ # Copyright Spack Project Developers. See COPYRIGHT file for details. # # SPDX-License-Identifier: (Apache-2.0 OR MIT) -import os import pathlib import re import tempfile @@ -18,16 +17,9 @@ import spack.util.parallel import spack.util.url as url_util import spack.util.web as web_util -from spack.util.executable import which from .mirrors.mirror import Mirror -from .url_buildcache import ( - CURRENT_BUILD_CACHE_LAYOUT_VERSION, - BuildcacheComponent, - URLBuildcacheEntry, - get_entries_from_cache, - get_url_buildcache_class, -) +from .url_buildcache import BuildcacheComponent, URLBuildcacheEntry, get_entries_from_cache def _fetch_manifests( @@ -60,65 +52,20 @@ def _fetch_manifests( ) for blob_name in blobs ] + tty.debug(f"Found {len(blobs)} blobs") return manifest_file_to_mtime_mapping, read_fn, blobs -def _delete_manifests_from_cache_aws( - url: str, tmpspecsdir: str, urls_to_delete: Set[str] -) -> Optional[int]: - aws = which("aws") - - if not aws: - tty.warn("AWS CLI not found, skipping deletion of cache entries.") - return None - - cache_class = get_url_buildcache_class(layout_version=CURRENT_BUILD_CACHE_LAYOUT_VERSION) - - include_pattern = cache_class.get_buildcache_component_include_pattern( - BuildcacheComponent.MANIFEST - ) - - file_count_before_deletion = len(list(pathlib.Path(tmpspecsdir).rglob(include_pattern))) - - tty.debug(f"Deleting {len(urls_to_delete)} entries from cache at {url}") - deleted = _delete_entries_from_cache_manual(tmpspecsdir, urls_to_delete) - tty.debug(f"Deleted {deleted} entries from cache at {url}") - - sync_command_args = [ - "s3", - "sync", - "--delete", - "--exclude", - "*", - "--include", - include_pattern, - tmpspecsdir, - url, - ] - - try: - aws(*sync_command_args, output=os.devnull, error=os.devnull) - # `aws s3 sync` doesn't return the number of deleted files, - # but we can calculate it based on the local file count from - # before and after the deletion. - return file_count_before_deletion - len( - list(pathlib.Path(tmpspecsdir).rglob(include_pattern)) - ) - except Exception: - tty.warn( - "Failed to use aws s3 sync to delete manifests, falling back to parallel deletion." - ) - - return None - - -def _delete_entries_from_cache_manual(url: str, urls_to_delete: Set[str]) -> int: +def _delete_entries_from_cache( + manifests_to_delete: Set[str], blobs_to_delete: Set[str], dry_run: bool +) -> int: + urls_to_delete = blobs_to_delete.union(manifests_to_delete) pruned_objects = 0 futures: List[Future] = [] with spack.util.parallel.make_concurrent_executor() as executor: for url in urls_to_delete: - futures.append(executor.submit(_delete_object, url)) + futures.append(executor.submit(_delete_object, url, dry_run)) for manifest_or_blob_future in as_completed(futures): pruned_objects += manifest_or_blob_future.result() @@ -126,36 +73,13 @@ def _delete_entries_from_cache_manual(url: str, urls_to_delete: Set[str]) -> int return pruned_objects -def _delete_entries_from_cache( - mirror: Mirror, tmpspecsdir: str, manifests_to_delete: Set[str], blobs_to_delete: Set[str] -) -> int: - pruned_manifests: Optional[int] = None - - if mirror.fetch_url.startswith("s3://"): - pruned_manifests = _delete_manifests_from_cache_aws( - url=mirror.fetch_url, tmpspecsdir=tmpspecsdir, urls_to_delete=manifests_to_delete - ) - - if pruned_manifests is None: - # If the AWS CLI deletion failed, we fall back to deleting both manifests - # and blobs with the fallback method. - objects_to_delete = blobs_to_delete.union(manifests_to_delete) - pruned_objects = 0 - else: - # If the AWS CLI deletion succeeded, we only need to worry about - # deleting the blobs, since the manifests have already been deleted. - objects_to_delete = blobs_to_delete - pruned_objects = pruned_manifests - - return pruned_objects + _delete_entries_from_cache_manual( - url=mirror.fetch_url, urls_to_delete=objects_to_delete - ) - - -def _delete_object(url: str) -> int: +def _delete_object(url: str, dry_run: bool) -> int: try: - web_util.remove_url(url=url) - tty.info(f"Removed object {url}") + if dry_run: + tty.info(f"Would have removed object {url}") + else: + web_util.remove_url(url=url) + tty.info(f"Removed object {url}") return 1 except Exception as e: tty.warn(f"Unable to remove object {url} due to: {e}") @@ -171,7 +95,7 @@ def _object_has_prunable_mtime(url: str, pruning_started_at: float) -> Tuple[str stat_result = web_util.stat_url(url) assert stat_result is not None if stat_result[1] > pruning_started_at: - tty.verbose(f"Skipping deletion of {url} because it was modified after pruning started") + tty.info(f"Skipping deletion of {url} because it was modified after pruning started") return url, False return url, True @@ -275,24 +199,8 @@ def _prune_orphans( if orphaned_manifests: tty.info(f"Found {len(orphaned_manifests)} manifest(s) that are missing blobs") - # If dry run, just print the manifests and blobs that would be deleted - # and exit early. - if dry_run: - pruned_object_count = len(orphaned_blobs) + len(orphaned_manifests) - for manifest in orphaned_manifests: - manifests.remove(manifest) - tty.info(f" Would prune manifest: {manifest}") - for blob in orphaned_blobs: - blobs.remove(blob) - tty.info(f" Would prune blob: {blob}") - return pruned_object_count - - # Otherwise, perform the deletions. pruned_object_count = _delete_entries_from_cache( - mirror=mirror, - tmpspecsdir=tmpspecsdir, - manifests_to_delete=orphaned_manifests, - blobs_to_delete=orphaned_blobs, + manifests_to_delete=orphaned_manifests, blobs_to_delete=orphaned_blobs, dry_run=dry_run ) for manifest in orphaned_manifests: @@ -304,7 +212,14 @@ def _prune_orphans( def prune_direct( - mirror: Mirror, keeplist_file: pathlib.Path, pruning_started_at: float, dry_run: bool + mirror: Mirror, + keeplist_file: pathlib.Path, + manifest_to_mtime_mapping: Dict[str, float], + read_fn: Callable[[str], URLBuildcacheEntry], + blob_list: List[str], + tmpspecsdir: str, + pruning_started_at: float, + dry_run: bool, ) -> None: """ Execute direct pruning for a given mirror using a keeplist file. @@ -335,67 +250,62 @@ def prune_direct( tty.info(f"Loaded {len(keep_hashes)} hashes to keep from {keeplist_file}") total_pruned: Optional[int] = None - with tempfile.TemporaryDirectory(dir=spack.stage.get_stage_root()) as tmpspecsdir: - try: - manifest_to_mtime_mapping, read_fn, blob_list = _fetch_manifests(mirror, tmpspecsdir) - except Exception as e: - raise BuildcachePruningException("Error getting entries from buildcache") from e + manifests_url = url_util.join( + mirror.fetch_url, + *URLBuildcacheEntry.get_relative_path_components(BuildcacheComponent.MANIFEST), + ) - # Determine which manifests correspond to specs we want to prune - manifests_to_prune: List[str] = [] - specs_to_prune: List[str] = [] + # Determine which manifests correspond to specs we want to prune + manifests_to_prune: List[str] = [] + specs_to_prune: List[str] = [] - for manifest in manifest_to_mtime_mapping.keys(): - if not fnmatch( - manifest, - URLBuildcacheEntry.get_buildcache_component_include_pattern( - BuildcacheComponent.SPEC - ), - ): - tty.info(f"Found a non-spec manifest at {manifest}, skipping...") - continue + tty.info(f"Found {len(manifest_to_mtime_mapping)} total manifests in mirror") - # Attempt to regex match the manifest name in order to extract the name, version, - # and hash for the spec. - manifest_name = manifest.split("/")[-1] # strip off parent directories - regex_match = re.match(r"([^ ]+)-([^- ]+)[-_]([^-_\. ]+)", manifest_name) + for manifest in manifest_to_mtime_mapping.keys(): + # Convert back from local to remote path. + manifest = manifest.replace(tmpspecsdir, manifests_url) + if not fnmatch( + manifest, + URLBuildcacheEntry.get_buildcache_component_include_pattern(BuildcacheComponent.SPEC), + ): + tty.debug(f"Found a non-spec manifest at {manifest}, skipping...") + continue - if regex_match is None: - # This should never happen, unless the buildcache is somehow corrupted - # and/or there is a bug. - raise BuildcachePruningException( - "Unable to extract spec name, version, and hash from " - f'the manifest named "{manifest_name}"' - ) + # Attempt to regex match the manifest name in order to extract the name, version, + # and hash for the spec. + manifest_name = manifest.split("/")[-1] # strip off parent directories + # Schema is -- + regex_match = re.match(r"([^ ]+)-([^- ]+)[-_]([^-_\. ]+)", manifest_name) + + if regex_match is None: + # This should never happen, unless the buildcache is somehow corrupted + # and/or there is a bug. + raise BuildcachePruningException( + "Unable to extract spec name, version, and hash from " + f'the manifest named "{manifest_name}"' + ) - spec_name, spec_version, spec_hash = regex_match.groups() + spec_name, spec_version, spec_hash = regex_match.groups() - # Chop off any prefix/parent file path to get just the name - spec_name = pathlib.Path(spec_name).name + if spec_hash not in keep_hashes: + manifests_to_prune.append(manifest) + specs_to_prune.append(f"{spec_name}/{spec_hash[:7]}") - if spec_hash not in keep_hashes: - manifests_to_prune.append(manifest) - specs_to_prune.append(f"{spec_name}/{spec_hash[:7]}") + if not manifests_to_prune: + tty.info("No specs to prune - all specs are in the keeplist") + return - if not manifests_to_prune: - tty.info("No specs to prune - all specs are in the keeplist") - return + manifests_to_delete = set(_filter_new_specs(manifests_to_prune, pruning_started_at)) - tty.info(f"Found {len(manifests_to_prune)} spec(s) to prune") + tty.info(f"Found {len(manifests_to_delete)} spec(s) to prune") - if dry_run: - for spec_name in specs_to_prune: - tty.info(f" Would prune: {spec_name}") - total_pruned = len(manifests_to_prune) - else: - manifests_to_delete = set(_filter_new_specs(manifests_to_prune, pruning_started_at)) + total_pruned = _delete_entries_from_cache( + manifests_to_delete=manifests_to_delete, blobs_to_delete=set(), dry_run=dry_run + ) - total_pruned = _delete_entries_from_cache( - mirror=mirror, - tmpspecsdir=tmpspecsdir, - manifests_to_delete=manifests_to_delete, - blobs_to_delete=set(), - ) + # Remove pruned specs from manifest_to_mtime_mapping. + for manifest in manifests_to_delete: + manifest_to_mtime_mapping.pop(manifest, None) if dry_run: tty.info(f"Would have pruned {total_pruned} objects from mirror: {mirror.fetch_url}") @@ -408,7 +318,15 @@ def prune_direct( tty.info("Run `spack buildcache update-index` to update the index for this mirror.") -def prune_orphan(mirror: Mirror, pruning_started_at: float, dry_run: bool) -> None: +def prune_orphan( + mirror: Mirror, + manifest_to_mtime_mapping: Dict[str, float], + read_fn: Callable[[str], URLBuildcacheEntry], + blob_list: List[str], + tmpspecsdir: str, + pruning_started_at: float, + dry_run: bool, +) -> None: """ Execute the pruning process for a given mirror. @@ -418,43 +336,36 @@ def prune_orphan(mirror: Mirror, pruning_started_at: float, dry_run: bool) -> No tty.debug(f"Pruning mirror: {mirror.fetch_url}" + (" (dry run)" if dry_run else "")) total_pruned = 0 - with tempfile.TemporaryDirectory(dir=spack.stage.get_stage_root()) as tmpspecsdir: - try: - manifest_to_mtime_mapping, read_fn, blob_list = _fetch_manifests(mirror, tmpspecsdir) - manifests = list(manifest_to_mtime_mapping.keys()) - except Exception as e: - raise BuildcachePruningException("Error getting entries from buildcache") from e - while True: - # Continue pruning until no more orphaned objects are found - pruned = _prune_orphans( - mirror=mirror, - manifests=manifests, - read_fn=read_fn, - blobs=blob_list, - pruning_started_at=pruning_started_at, - tmpspecsdir=tmpspecsdir, - dry_run=dry_run, - ) - if pruned == 0: - break - total_pruned += pruned + manifests = list(manifest_to_mtime_mapping.keys()) + + while True: + # Continue pruning until no more orphaned objects are found + pruned = _prune_orphans( + mirror=mirror, + manifests=manifests, + read_fn=read_fn, + blobs=blob_list, + pruning_started_at=pruning_started_at, + tmpspecsdir=tmpspecsdir, + dry_run=dry_run, + ) + if pruned == 0: + break + total_pruned += pruned - if dry_run: + if dry_run: + tty.info( + f"Would have pruned {total_pruned} orphaned objects from mirror: " + mirror.fetch_url + ) + else: + tty.info(f"Pruned {total_pruned} orphaned objects from mirror: {mirror.fetch_url}") + if total_pruned > 0: + # If we pruned any objects, the buildcache index is likely out of date. + # Inform the user about this. tty.info( - f"Would have pruned {total_pruned} orphaned objects from mirror: " - + mirror.fetch_url + "As a consequence of pruning, the buildcache index is now likely out of date." ) - else: - tty.info(f"Pruned {total_pruned} orphaned objects from mirror: {mirror.fetch_url}") - if total_pruned > 0: - # If we pruned any objects, the buildcache index is likely out of date. - # Inform the user about this. - tty.info( - "As a consequence of pruning, the buildcache index is now likely out of date." - ) - tty.info( - "Run `spack buildcache update-index` to update the index for this mirror." - ) + tty.info("Run `spack buildcache update-index` to update the index for this mirror.") def get_buildcache_normalized_time(mirror: Mirror) -> float: @@ -504,10 +415,27 @@ def prune_buildcache(mirror: Mirror, keeplist: Optional[str] = None, dry_run: bo else: started_at = get_buildcache_normalized_time(mirror) - if keeplist: - prune_direct(mirror, pathlib.Path(keeplist), started_at, dry_run) + with tempfile.TemporaryDirectory(dir=spack.stage.get_stage_root()) as tmpspecsdir: + try: + manifest_to_mtime_mapping, read_fn, blob_list = _fetch_manifests(mirror, tmpspecsdir) + except Exception as e: + raise BuildcachePruningException("Error getting entries from buildcache") from e + + if keeplist: + prune_direct( + mirror, + pathlib.Path(keeplist), + manifest_to_mtime_mapping, + read_fn, + blob_list, + tmpspecsdir, + started_at, + dry_run, + ) - prune_orphan(mirror, started_at, dry_run) + prune_orphan( + mirror, manifest_to_mtime_mapping, read_fn, blob_list, tmpspecsdir, started_at, dry_run + ) class BuildcachePruningException(spack.error.SpackError): diff --git a/lib/spack/spack/url_buildcache.py b/lib/spack/spack/url_buildcache.py index dc7d32dab38d45..5ca9b26cbf0479 100644 --- a/lib/spack/spack/url_buildcache.py +++ b/lib/spack/spack/url_buildcache.py @@ -1109,6 +1109,8 @@ def file_read_method(manifest_path: str) -> URLBuildcacheEntry: include_pattern = cache_class.get_buildcache_component_include_pattern(component_type) component_prefix = cache_class.get_relative_path_components(component_type) + component_url = url_util.join(url, *component_prefix) + sync_command_args = [ "s3", "sync", @@ -1116,17 +1118,17 @@ def file_read_method(manifest_path: str) -> URLBuildcacheEntry: "*", "--include", include_pattern, - url_util.join(url, *component_prefix), + component_url, tmpspecsdir, ] # Use aws s3 ls to get mtimes of manifests - ls_command_args = ["s3", "ls", "--recursive", url] + ls_command_args = ["s3", "ls", "--recursive", component_url] s3_ls_regex = re.compile(r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+\d+\s+(.+)$") filename_to_mtime: Dict[str, float] = {} - tty.debug(f"Using aws s3 sync to download manifests from {url} to {tmpspecsdir}") + tty.debug(f"Using aws s3 sync to download manifests from {component_url} to {tmpspecsdir}") try: aws(*sync_command_args, output=os.devnull, error=os.devnull) From 9172992e11c842e16802cc142f09e1c3d5f0af22 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 24 Apr 2026 19:03:23 +0200 Subject: [PATCH 295/337] reporters: use stage log file in new installer (#52236) The new installer runs every build (including buildcache installs) in a subprocess capturing output to a temp log file. Pass that log_path through to the reporter so it reads the actual install log rather than the historical build log from the install prefix. The child process no longer deletes the temp log file; cleanup is now done by the parent after report_data.finalize() has consumed the logs. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 32 ++++++++++++++++++++------------ lib/spack/spack/report.py | 31 +++++++++++++++++++------------ 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 6b03a319fbb75e..509d3b5003f0d1 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -581,13 +581,6 @@ def handle_sigterm(signum, frame): except Exception: pass # don't fail the build just because log compression failed - # Remove the uncompressed log file from the stage dir on successful install. - if not keep_stage: - try: - os.unlink(log_path) - except OSError: - pass - sys.exit(exit_code) @@ -1967,18 +1960,21 @@ def start_record(self, spec: spack.spec.Spec) -> None: record.start() self.build_records[spec.dag_hash()] = record - def finish_record(self, spec: spack.spec.Spec, exitcode: int) -> None: + def finish_record( + self, spec: spack.spec.Spec, exitcode: int, log_path: Optional[str] = None + ) -> None: """Mark the InstallRecord for a spec as succeeded or failed.""" record = self.build_records.get(spec.dag_hash()) if record is None or spec.external: return if exitcode == 0: - record.succeed() + record.succeed(log_path) else: record.fail( spack.error.InstallError( f"Installation of {spec.name} failed; see log for details" - ) + ), + log_path, ) def finalize( @@ -2027,7 +2023,9 @@ def __init__(self) -> None: def start_record(self, spec: spack.spec.Spec) -> None: pass - def finish_record(self, spec: spack.spec.Spec, exitcode: int) -> None: + def finish_record( + self, spec: spack.spec.Spec, exitcode: int, log_path: Optional[str] = None + ) -> None: pass def finalize( @@ -2545,6 +2543,16 @@ def _installer(self) -> None: except Exception as e: spack.llnl.util.tty.debug(f"[{__name__}]: Failed to finalize reports: {e}]") + # Clean up temp log files now that reports have consumed them. + if not self.keep_stage: + for log_path in self.log_paths.values(): + if log_path == os.devnull: + continue + try: + os.unlink(log_path) + except OSError: + pass + if failures: for s in failures: build_info = self.build_status.builds[s.dag_hash()] @@ -2575,7 +2583,7 @@ def _handle_finished_builds( build.cleanup(selector) exitcode = build.proc.exitcode assert exitcode is not None, "Finished build should have exit code set" - self.report_data.finish_record(build.spec, exitcode) + self.report_data.finish_record(build.spec, exitcode, build.log_path) if exitcode == 0: # Add successful builds for database insertion (after a short delay) database_actions.append(build) diff --git a/lib/spack/spack/report.py b/lib/spack/spack/report.py index bb10a98fbb4707..157e7326322528 100644 --- a/lib/spack/spack/report.py +++ b/lib/spack/spack/report.py @@ -8,6 +8,7 @@ import os import time import traceback +from typing import Optional import spack.error @@ -99,7 +100,11 @@ def skip(self, msg): self.elapsed_time = 0.0 self.message = msg - def fail(self, exc): + def fetch_log(self, log_path: Optional[str] = None) -> str: + """Fetch the log for this spec record. Subclasses should override.""" + return "" + + def fail(self, exc, log_path: Optional[str] = None): """Record failure based on exception type Errors wrapped by spack.error.InstallError are "failures" @@ -113,14 +118,14 @@ def fail(self, exc): self.result = "error" self.message = str(exc) or "Unknown error" self.exception = traceback.format_exc() - self.stdout = self.fetch_log() + self.message + self.stdout = self.fetch_log(log_path) + self.message assert self._start_time, "Start time is None" self.elapsed_time = time.time() - self._start_time - def succeed(self): + def succeed(self, log_path: Optional[str] = None): """Record success for this spec""" self.result = "success" - self.stdout = self.fetch_log() + self.stdout = self.fetch_log(log_path) assert self._start_time, "Start time is None" self.elapsed_time = time.time() - self._start_time @@ -132,10 +137,12 @@ def __init__(self, spec): super().__init__(spec) self.installed_from_binary_cache = None - def fetch_log(self): - """Install log comes from install prefix on success, or stage dir on failure.""" + def fetch_log(self, log_path: Optional[str] = None) -> str: + """Install log comes from log_path if provided, install prefix, or stage dir.""" try: - if os.path.exists(self._package.install_log_path): + if log_path and os.path.exists(log_path): + stream = open(log_path, encoding="utf-8", errors="replace") + elif os.path.exists(self._package.install_log_path): stream = gzip.open( self._package.install_log_path, "rt", encoding="utf-8", errors="replace" ) @@ -146,8 +153,8 @@ def fetch_log(self): except OSError: return f"Cannot open log for {self._spec.cshort_spec}" - def succeed(self): - super().succeed() + def succeed(self, log_path: Optional[str] = None): + super().succeed(log_path) self.installed_from_binary_cache = self._package.installed_from_binary_cache @@ -159,10 +166,10 @@ class NullInstallRecord(InstallRecord): def start(self) -> None: pass - def succeed(self) -> None: + def succeed(self, log_path: Optional[str] = None) -> None: pass - def fail(self, exc) -> None: + def fail(self, exc, log_path: Optional[str] = None) -> None: pass def skip(self, msg: str = "") -> None: @@ -194,7 +201,7 @@ def __init__(self, spec, directory): super().__init__(spec) self.directory = directory - def fetch_log(self): + def fetch_log(self, log_path: Optional[str] = None) -> str: """Get output from test log""" log_file = os.path.join(self.directory, self._package.test_suite.test_log_name(self._spec)) try: From 8c108f3b4d107bdd8942cb7de9abe23684685dc7 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Sat, 25 Apr 2026 14:39:14 +0200 Subject: [PATCH 296/337] log parser: `tail -n` like support (#52279) The log parser doesn't catch everything, and worst case the summary on build failure is empty. That's annoying for people using the new installer. The simplest fix is to include the last 20 lines of the log. I think that's useful whether the log parser detected errors/warnings or not. * The new installer call path is affected only; old installer remains as is * In the new installer, the duplicate header is dropped (one from new installer "log summary", one from write_log_summary) * The flag `spack log-parse --tail` is added. --------- Signed-off-by: Harmen Stoppels --- lib/spack/spack/build_environment.py | 2 +- lib/spack/spack/cmd/log_parse.py | 16 ++++++- lib/spack/spack/new_installer.py | 14 +++--- lib/spack/spack/reporters/cdash.py | 2 +- lib/spack/spack/test/util/log_parser.py | 61 +++++++++++++++++++++--- lib/spack/spack/util/ctest_log_parser.py | 33 ++++++++----- lib/spack/spack/util/log_parse.py | 18 ++++--- share/spack/spack-completion.bash | 2 +- share/spack/spack-completion.fish | 4 +- 9 files changed, 115 insertions(+), 37 deletions(-) diff --git a/lib/spack/spack/build_environment.py b/lib/spack/spack/build_environment.py index 07c74bfc3df5e9..84e1e9030cde2a 100644 --- a/lib/spack/spack/build_environment.py +++ b/lib/spack/spack/build_environment.py @@ -1654,7 +1654,7 @@ def _make_child_error(msg, module, name, traceback, log, log_type, context): def write_log_summary(out, log_type, log, last=None): - errors, warnings = parse_log_events(log) + errors, warnings, _ = parse_log_events(log) nerr = len(errors) nwar = len(warnings) diff --git a/lib/spack/spack/cmd/log_parse.py b/lib/spack/spack/cmd/log_parse.py index 11668c704ae7ed..12eec0883c8270 100644 --- a/lib/spack/spack/cmd/log_parse.py +++ b/lib/spack/spack/cmd/log_parse.py @@ -44,6 +44,15 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: subparser.add_argument( "-j", "--jobs", action="store", type=int, default=None, help=argparse.SUPPRESS ) + subparser.add_argument( + "-t", + "--tail", + metavar="LINES", + action="store", + type=int, + default=0, + help="number of trailing log lines to show (0 to disable)", + ) subparser.add_argument("file", help="a log file containing build output, or - for stdin") @@ -60,7 +69,9 @@ def log_parse(parser, args): if args.jobs is not None: warnings.warn("The --jobs option is deprecated and will be removed in Spack v1.3") - log_errors, log_warnings = parse_log_events(input, args.context, args.profile) + log_errors, log_warnings, tail = parse_log_events( + input, args.context, args.profile, tail=args.tail + ) if args.profile: return @@ -77,4 +88,7 @@ def log_parse(parser, args): events.extend(log_warnings) print("%d warnings" % len(log_warnings)) + if tail: + events.append(tail) + print(make_log_context(events), end="") diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 509d3b5003f0d1..8005ae87c0f960 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -83,6 +83,7 @@ from spack.installer import _do_fake_install, dump_packages from spack.llnl.util.lang import pretty_duration from spack.llnl.util.tty.log import _is_background_tty, ignore_signal +from spack.util.log_parse import make_log_context, parse_log_events from spack.util.path import padding_filter, padding_filter_bytes if TYPE_CHECKING: @@ -1358,13 +1359,12 @@ def parse_log_summary(self, build_id: str) -> None: build_info = self.builds[build_id] if not build_info.log_path or not os.path.exists(build_info.log_path): return - buf = io.StringIO() - spack.build_environment.write_log_summary( - buf, f"{build_info.name}@{build_info.version} build", build_info.log_path - ) - summary = buf.getvalue() - if summary: - build_info.log_summary = summary + errors, warnings, tail_event = parse_log_events(build_info.log_path, tail=20) + events = [*errors, *warnings] + if tail_event is not None: + events.append(tail_event) + if events: + build_info.log_summary = make_log_context(events) def update_progress(self, build_id: str, current: int, total: int) -> None: """Update the progress of a package and mark the display as dirty.""" diff --git a/lib/spack/spack/reporters/cdash.py b/lib/spack/spack/reporters/cdash.py index f7a72561cf4836..22eb88d715d1c1 100644 --- a/lib/spack/spack/reporters/cdash.py +++ b/lib/spack/spack/reporters/cdash.py @@ -203,7 +203,7 @@ def build_report_for_package(self, report_dir, package, duration): for phase in phases_encountered: report_data[phase]["endtime"] = self.endtime report_data[phase]["log"] = "\n".join(report_data[phase]["loglines"]) - errors, warnings = parse_log_events(report_data[phase]["loglines"]) + errors, warnings, _ = parse_log_events(report_data[phase]["loglines"]) # Convert errors to warnings if the package reported success. if package["result"] == "success": diff --git a/lib/spack/spack/test/util/log_parser.py b/lib/spack/spack/test/util/log_parser.py index df61ec0e0200e7..2eedd23a2684b0 100644 --- a/lib/spack/spack/test/util/log_parser.py +++ b/lib/spack/spack/test/util/log_parser.py @@ -7,7 +7,7 @@ import re from spack.llnl.util.tty.color import color_when -from spack.util.ctest_log_parser import CTestLogParser, _optimize_regexes +from spack.util.ctest_log_parser import CTestLogParser, LogEvent, _optimize_regexes from spack.util.log_parse import make_log_context @@ -32,7 +32,7 @@ def test_log_parser(tmp_path: pathlib.Path): ) parser = CTestLogParser() - errors, warnings = parser.parse(str(log_file)) + errors, warnings, _ = parser.parse(str(log_file)) assert len(errors) == 4 assert all(e.text.endswith("E") for e in errors) @@ -49,7 +49,7 @@ def test_log_parser_stream(): "/var/tmp/build/foo.py:60: warning: some weird warning W\n" ) parser = CTestLogParser() - errors, warnings = parser.parse(log) + errors, warnings, _ = parser.parse(log) assert len(errors) == 1 assert errors[0].text.endswith("E") @@ -65,7 +65,7 @@ def test_log_parser_preserves_leading_whitespace(): " ^\n" ) parser = CTestLogParser() - errors, _ = parser.parse(log, context=6) + errors, _, _ = parser.parse(log, context=6) assert len(errors) == 1 assert errors[0].post_context[0] == " int y = x + 1;" @@ -84,7 +84,7 @@ def test_make_log_context_merges_overlapping_events(tmp_path: pathlib.Path): log_file.write_text("".join(lines)) parser = CTestLogParser() - errors, warnings = parser.parse(str(log_file), context=3) + errors, warnings, _ = parser.parse(str(log_file), context=3) log_events = sorted([*errors, *warnings], key=lambda e: e.line_no) output = make_log_context(log_events) @@ -108,7 +108,7 @@ def test_make_log_context_warning_in_error_context_keeps_yellow(tmp_path: pathli log_file.write_text("".join(lines)) parser = CTestLogParser() - errors, warnings = parser.parse(str(log_file), context=3) + errors, warnings, _ = parser.parse(str(log_file), context=3) assert len(errors) == len(warnings) == 1 @@ -127,10 +127,57 @@ def test_log_parser_non_utf8_bytes(tmp_path: pathlib.Path): log_file = tmp_path / "log.bin" log_file.write_bytes(b"checking things...\nerror: \x80\xff something broke\ndone\n") parser = CTestLogParser() - errors, _ = parser.parse(str(log_file)) + errors, _, _ = parser.parse(str(log_file)) assert len(errors) == 1 +def test_tail_renders_as_plain_context(): + """A LogEvent should render all lines as plain context with no highlighting.""" + lines = ["tail line 1", "tail line 2", "tail line 3"] + section = LogEvent(text=lines[-1], line_no=100, pre_context=lines[:-1]) + + with color_when(False): + output = make_log_context([section]) + + assert "-- lines 98 to 100 --" in output + # All lines should be plain context (indented with two spaces, no "> " prefix) + assert " tail line 1\n" in output + assert " tail line 2\n" in output + assert " tail line 3\n" in output + assert "> " not in output + + +def test_tail_overlapping_with_error(): + """Tail lines overlapping with an error's context should not be duplicated.""" + log = io.StringIO("line 1\nline 2\nline 3\nerror: something broke\nline 5\nline 6\nline 7\n") + parser = CTestLogParser() + errors, _, tail = parser.parse(log, context=2, tail=3) + assert len(errors) == 1 + assert tail is not None + + with color_when(False): + output = make_log_context([*errors, tail]) + + # "line 5" and "line 6" appear in both the error context and the tail, + # but should only appear once in the output + assert output.count("line 5") == 1 + assert output.count("line 6") == 1 + assert output.count("line 7") == 1 + + +def test_tail_only(): + """A LogEvent with no errors/warnings renders correctly.""" + lines = ["final line 1", "final line 2"] + section = LogEvent(text=lines[-1], line_no=51, pre_context=lines[:-1]) + + with color_when(False): + output = make_log_context([section]) + + assert "-- lines 50 to 51 --" in output + assert " final line 1\n" in output + assert " final line 2\n" in output + + class TestOptimizeRegexes: def test_groups_by_first_char(self): """Regexes sharing a first character are combined into one.""" diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index 163df35aae6431..51c9e8d261c6fc 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -74,7 +74,7 @@ import re import time from collections import deque -from typing import Dict, List, TextIO, Tuple, Union +from typing import Dict, List, Optional, TextIO, Tuple, Union _error_matches = [ "^FAIL: ", @@ -328,7 +328,7 @@ def _profile_match(matches, exceptions, line, match_times, exc_times): return True -def _parse(stream, profile, context): +def _parse(stream, profile, context, tail=0): error_matches = [re.compile(r) for r in _optimize_regexes(_error_matches)] error_exceptions = [re.compile(r) for r in _optimize_regexes(_error_exceptions)] @@ -350,12 +350,14 @@ def _parse(stream, profile, context): errors = [] warnings = [] # rolling window of recent lines - pre_context = deque(maxlen=context) + pre_context = deque(maxlen=max(context, tail)) # list of (event, remaining_post_context_lines) pending_events: List[Tuple[LogEvent, int]] = [] + last_line_no = 0 for i, line in enumerate(stream): rstripped_line = line.rstrip() + last_line_no = i + 1 # feed this line into every event still collecting post_context if pending_events: @@ -379,7 +381,7 @@ def _parse(stream, profile, context): pre_context.append(rstripped_line) continue - event.pre_context = list(pre_context) + event.pre_context = list(pre_context)[-context:] if context else [] event.post_context = [] # get file/line number for the event, if possible @@ -405,7 +407,14 @@ def _parse(stream, profile, context): else: warnings.append(event) - return errors, warnings, timings + # build tail section from the last N lines of the log, if requested + if tail > 0 and last_line_no > 0: + lines = list(pre_context)[-tail:] + tail_event = LogEvent(text=lines[-1], line_no=last_line_no, pre_context=lines[:-1]) + else: + tail_event = None + + return errors, warnings, tail_event, timings class CTestLogParser: @@ -436,20 +445,22 @@ def stringify(elt): index += 1 def parse( - self, stream: Union[str, TextIO], context: int = 6 - ) -> Tuple[List[BuildError], List[BuildWarning]]: + self, stream: Union[str, TextIO], context: int = 6, tail: int = 0 + ) -> Tuple[List[BuildError], List[BuildWarning], Optional[LogEvent]]: """Parse a log file by searching each line for errors and warnings. Args: stream: filename or stream to read from context: lines of context to extract around each log event + tail: if > 0, also return a :class:`LogEvent` with the last ``tail`` lines Returns: - two lists containing :class:`BuildError` and :class:`BuildWarning` objects. + two lists containing :class:`BuildError` and :class:`BuildWarning` objects, + plus an optional :class:`LogEvent` for the tail (None when ``tail=0``). """ if isinstance(stream, str): with open(stream, encoding="utf-8", errors="replace") as f: - return self.parse(f, context) + return self.parse(f, context, tail) - errors, warnings, self.timings = _parse(stream, self.profile, context) - return errors, warnings + errors, warnings, tail_event, self.timings = _parse(stream, self.profile, context, tail) + return errors, warnings, tail_event diff --git a/lib/spack/spack/util/log_parse.py b/lib/spack/spack/util/log_parse.py index 8331d81f9d4e05..7d9640199e9d8d 100644 --- a/lib/spack/spack/util/log_parse.py +++ b/lib/spack/spack/util/log_parse.py @@ -3,25 +3,29 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import io -from typing import List, TextIO, Union +from typing import List, TextIO, Tuple, Union from spack.llnl.util.tty.color import cescape, colorize -from spack.util.ctest_log_parser import CTestLogParser, LogEvent +from spack.util.ctest_log_parser import BuildError, BuildWarning, CTestLogParser, LogEvent __all__ = ["parse_log_events", "make_log_context"] -def parse_log_events(stream: Union[str, TextIO], context: int = 6, profile: bool = False): - """Extract interesting events from a log file as a list of LogEvent. +def parse_log_events( + stream: Union[str, TextIO], context: int = 6, profile: bool = False, tail: int = 0 +) -> Tuple[List[BuildError], List[BuildWarning], Union[LogEvent, None]]: + """Extract interesting events from a log file. Args: stream: build log name or file object context: lines of context to extract around each log event profile: print out profile information for parsing + tail: if > 0, also return the last ``tail`` lines Returns: two lists containing :class:`~spack.util.ctest_log_parser.BuildError` and - :class:`~spack.util.ctest_log_parser.BuildWarning` objects. + :class:`~spack.util.ctest_log_parser.BuildWarning` objects, plus an optional + :class:`~spack.util.ctest_log_parser.LogEvent` for the tail (None when ``tail=0``). This is a wrapper around :class:`~spack.util.ctest_log_parser.CTestLogParser` that lazily constructs a single ``CTestLogParser`` object. This ensures @@ -32,7 +36,7 @@ def parse_log_events(stream: Union[str, TextIO], context: int = 6, profile: bool parser = CTestLogParser(profile=profile) setattr(parse_log_events, "ctest_parser", parser) - result = parser.parse(stream, context) + result = parser.parse(stream, context, tail) if profile: parser.print_timings() return result @@ -54,7 +58,7 @@ def make_log_context(log_events: List[LogEvent]) -> str: Parses the log file for lines containing errors, and prints them out with context. Errors are highlighted in red and warnings in yellow. Events are sorted by line number. """ - event_colors = {e.line_no: e.color for e in log_events} + event_colors = {e.line_no: e.color for e in log_events if e.color} log_events = sorted(log_events, key=lambda e: e.line_no) out = io.StringIO() diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index bb52561a9d5c7f..26a2980c78308d 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -1415,7 +1415,7 @@ _spack_location() { _spack_log_parse() { if $list_options then - SPACK_COMPREPLY="-h --help --show -c --context -p --profile -w --width -j --jobs" + SPACK_COMPREPLY="-h --help --show -c --context -p --profile -w --width -j --jobs -t --tail" else SPACK_COMPREPLY="" fi diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index f7d11ae931b371..881442e59b179d 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -2284,7 +2284,7 @@ complete -c spack -n '__fish_spack_using_command location' -l first -f -a find_f complete -c spack -n '__fish_spack_using_command location' -l first -d 'use the first match if multiple packages match the spec' # spack log-parse -set -g __fish_spack_optspecs_spack_log_parse h/help show= c/context= p/profile w/width= j/jobs= +set -g __fish_spack_optspecs_spack_log_parse h/help show= c/context= p/profile w/width= j/jobs= t/tail= complete -c spack -n '__fish_spack_using_command log-parse' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command log-parse' -s h -l help -d 'show this help message and exit' @@ -2296,6 +2296,8 @@ complete -c spack -n '__fish_spack_using_command log-parse' -s p -l profile -f - complete -c spack -n '__fish_spack_using_command log-parse' -s p -l profile -d 'print out a profile of time spent in regexes during parse' complete -c spack -n '__fish_spack_using_command log-parse' -s w -l width -r -f -a width complete -c spack -n '__fish_spack_using_command log-parse' -s j -l jobs -r -f -a jobs +complete -c spack -n '__fish_spack_using_command log-parse' -s t -l tail -r -f -a tail +complete -c spack -n '__fish_spack_using_command log-parse' -s t -l tail -r -d 'number of trailing log lines to show (0 to disable)' # spack logs set -g __fish_spack_optspecs_spack_logs h/help From 218f5ad2e28cd9bb19bb7087a5638a6dba06f00e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:06:24 +0200 Subject: [PATCH 297/337] build(deps): bump julia-actions/setup-julia from 3.0.0 to 3.0.1 (#52337) Bumps [julia-actions/setup-julia](https://github.com/julia-actions/setup-julia) from 3.0.0 to 3.0.1. - [Release notes](https://github.com/julia-actions/setup-julia/releases) - [Commits](https://github.com/julia-actions/setup-julia/compare/4a12c5f801ca5ef0458bba44687563ef276522dd...f6f565d9f7cf12f53dc8045742460d6260ad3b39) --- updated-dependencies: - dependency-name: julia-actions/setup-julia dependency-version: 3.0.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/import-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index 4d5f065eef9de6..09af78fa51f817 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -9,7 +9,7 @@ jobs: continue-on-error: true runs-on: ubuntu-latest steps: - - uses: julia-actions/setup-julia@4a12c5f801ca5ef0458bba44687563ef276522dd # v3.0.0 + - uses: julia-actions/setup-julia@f6f565d9f7cf12f53dc8045742460d6260ad3b39 # v3.0.1 with: version: '1.10' - uses: julia-actions/cache@9a93c5fb3e9c1c20b60fc80a478cae53e38618a4 # v3.0.2 From b607dd37b467de89ba23f79bc79464b4d448d63e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 27 Apr 2026 11:32:50 +0200 Subject: [PATCH 298/337] new_installer.py: keep logs on failure (#52338) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 7 ++++--- lib/spack/spack/test/installer.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 8005ae87c0f960..10c4f27be4c9e3 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2543,10 +2543,11 @@ def _installer(self) -> None: except Exception as e: spack.llnl.util.tty.debug(f"[{__name__}]: Failed to finalize reports: {e}]") - # Clean up temp log files now that reports have consumed them. + # Clean up temp log files of successful builds now that reports have consumed them. if not self.keep_stage: - for log_path in self.log_paths.values(): - if log_path == os.devnull: + failed_hashes = {s.dag_hash() for s in failures} + for dag_hash, log_path in self.log_paths.items(): + if log_path == os.devnull or dag_hash in failed_hashes: continue try: os.unlink(log_path) diff --git a/lib/spack/spack/test/installer.py b/lib/spack/spack/test/installer.py index 495c51bff2bcb7..daf952ae2778e6 100644 --- a/lib/spack/spack/test/installer.py +++ b/lib/spack/spack/test/installer.py @@ -1401,3 +1401,13 @@ def test_fallback_to_old_installer_for_splicing(monkeypatch, mock_packages, muta assert isinstance( spack.installer_dispatch.create_installer([out.package]), inst.PackageInstaller ) + + +@pytest.mark.disable_clean_stage_check +def test_log_files_preserved_on_error(install_mockery, mock_fetch, installer_variant): + """Test that the log file is preserved when an install error occurs.""" + pkg = spack.concretize.concretize_one("build-error").package + installer = spack.installer_dispatch.create_installer([pkg]) + with pytest.raises(spack.error.InstallError): + installer.install() + assert os.path.exists(pkg.log_path) From c4b9b9d915a982959bef0cf2e29a2cc898ffb910 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 27 Apr 2026 12:52:56 +0200 Subject: [PATCH 299/337] file_cache.py: use str key (#52345) Allowing `Path | str` is a mistake, since they can be distinct but map to the same file, meaning there could be two lock instances for the same entry. Signed-off-by: Harmen Stoppels --- lib/spack/spack/util/file_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/spack/spack/util/file_cache.py b/lib/spack/spack/util/file_cache.py index 45b46c244a68d3..4c2e4bd960c7be 100644 --- a/lib/spack/spack/util/file_cache.py +++ b/lib/spack/spack/util/file_cache.py @@ -114,7 +114,7 @@ def __init__(self, root: Union[str, pathlib.Path], timeout=120): self.root.mkdir(parents=True, exist_ok=True) self.lock_path = self.root / ".lock" - self._locks: Dict[Union[pathlib.Path, str], Lock] = {} + self._locks: Dict[str, Lock] = {} self.lock_timeout = timeout def destroy(self): From 8fe83543b11485e231befad08cd3abba320bbcff Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 28 Apr 2026 08:43:12 +0200 Subject: [PATCH 300/337] Simplify `BinaryCacheIndex.update` + some fixes (#52344) Modifications: 1. Extract stale cache removal into its own method. 2. Unify two loops over existing mirrors already in cache and existing mirrors not in cache, sharing an identical body, with a single loop that doesn't check if a mirror is or is not in cache. 3. Fix a locking bug for stale cache removal, which resulted in the wrong path for a lock being computed.[^1] 4. Fix a bug introduced in #48713 that made cooldown effectively dead code.[^2] 5. Add a warning when a mirror doesn't have an index that it cannot be used in concretization [^1]: `FileCache.remove` expects a relative path, but gets an absolute one. Using an absolute path instead makes the removal work, but the lock computes a different offset. [^2]: `cached_mirror_url in self._last_fetch_times` is always False, since the item is a `str` and the keys are `MirrorMetadata` Signed-off-by: Massimiliano Culpo --- lib/spack/spack/binary_distribution.py | 268 ++++++++++---------- lib/spack/spack/test/binary_distribution.py | 17 ++ lib/spack/spack/url_buildcache.py | 2 +- 3 files changed, 155 insertions(+), 132 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index 96b08cd649cd42..8060f17ec49b4d 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -26,7 +26,21 @@ import warnings from collections import defaultdict from contextlib import closing -from typing import IO, Callable, Dict, Iterable, List, Mapping, Optional, Set, Tuple, Union, cast +from typing import ( + IO, + Any, + Callable, + Dict, + Iterable, + List, + Mapping, + NamedTuple, + Optional, + Set, + Tuple, + Union, + cast, +) import spack.caches import spack.config @@ -97,6 +111,7 @@ get_url_buildcache_class, get_valid_spec_file, ) +from .vendor.typing_extensions import TypedDict class BuildCacheDatabase(spack.database.Database): @@ -144,6 +159,24 @@ def __init__(self, errors): super().__init__(self.message) +class _MirrorIndexResult(NamedTuple): + succeeded: bool + regenerate: bool + had_cache_entry: bool + error: Optional[Exception] + + +class _LastFetch(NamedTuple): + time: float + succeeded: bool + + +class _LocalIndexCache(TypedDict, total=False): + index_hash: str + index_path: str + etag: str + + class BinaryCacheIndex: """ The BinaryCacheIndex tracks what specs are available on (usually remote) @@ -170,7 +203,7 @@ def __init__(self, cache_root: Optional[str] = None): self._index_file_cache_initialized = False # stores a map of mirror URL and version layout to index hash and cache key (index path) - self._local_index_cache: dict[str, dict] = {} + self._local_index_cache: dict[str, _LocalIndexCache] = {} # hashes of remote indices already ingested into the concrete spec # cache (_mirrors_for_spec) @@ -178,7 +211,7 @@ def __init__(self, cache_root: Optional[str] = None): # mapping from mirror urls to the time.time() of the last index fetch and a bool indicating # whether the fetch succeeded or not. - self._last_fetch_times: Dict[MirrorMetadata, Tuple[float, bool]] = {} + self._last_fetch_times: Dict[MirrorMetadata, _LastFetch] = {} #: Dictionary mapping DAG hashes of specs to Spec objects self._known_specs: Dict[str, spack.spec.Spec] = {} @@ -302,142 +335,115 @@ def update(self, with_cooldown: bool = False) -> None: from each configured mirror and stored locally (both in memory and on disk under ``_index_cache_root``).""" self._init_local_index_cache() - configured_mirrors = [ - MirrorMetadata(m.fetch_url, layout_version, m.fetch_view) + + supported_mirror_versions = { + (m.fetch_url, m.fetch_view): m.supported_layout_versions for m in spack.mirrors.mirror.MirrorCollection(binary=True).values() - for layout_version in m.supported_layout_versions - ] - items_to_remove = [] - spec_cache_clear_needed = False - spec_cache_regenerate_needed = not self._mirrors_for_spec - - # First compare the mirror urls currently present in the cache to the - # configured mirrors. If we have a cached index for a mirror which is - # no longer configured, we should remove it from the cache. For any - # cached indices corresponding to currently configured mirrors, we need - # to check if the cache is still good, or needs to be updated. - # Finally, if there are configured mirrors for which we don't have a - # cache entry, we need to fetch and cache the indices from those - # mirrors. - - # If, during this process, we find that any mirrors for which we - # already have entries have either been removed, or their index - # hash has changed, then our concrete spec cache (_mirrors_for_spec) - # likely has entries that need to be removed, so we will clear it - # and regenerate that data structure. - - # If, during this process, we find that there are new mirrors for - # which do not yet have an entry in our index cache, then we simply - # need to regenerate the concrete spec cache, but do not need to - # clear it first. - - # Otherwise the concrete spec cache should not need to be updated at - # all. - - fetch_errors: List[Exception] = [] - all_methods_failed = True - ttl = spack.config.get("config:binary_index_ttl", 600) - now = time.time() + } - for local_index_cache_key in self._local_index_cache: - urlAndVersion = MirrorMetadata.from_string(local_index_cache_key) - cached_mirror_url = urlAndVersion.url - cache_entry = self._local_index_cache[local_index_cache_key] - cached_index_path = cache_entry["index_path"] - if urlAndVersion in configured_mirrors: - # Only do a fetch if the last fetch was longer than TTL ago - if ( - with_cooldown - and ttl > 0 - and cached_mirror_url in self._last_fetch_times - and now - self._last_fetch_times[urlAndVersion][0] < ttl - ): - # We're in the cooldown period, don't try to fetch again - # If the fetch succeeded last time, consider this update a success, otherwise - # re-report the error here - if self._last_fetch_times[urlAndVersion][1]: - all_methods_failed = False - else: - # May need to fetch the index and update the local caches - needs_regen = False - try: - needs_regen = self._fetch_and_cache_index( - urlAndVersion, cache_entry=cache_entry - ) - self._last_fetch_times[urlAndVersion] = (now, True) - all_methods_failed = False - except FetchIndexError as e: - fetch_errors.append(e) - self._last_fetch_times[urlAndVersion] = (now, False) - except BuildcacheIndexNotExists as e: - fetch_errors.append(e) - self._last_fetch_times[urlAndVersion] = (now, False) - # Binary caches are not required to have an index, don't raise - # if it doesn't exist. - all_methods_failed = False - - # The need to regenerate implies a need to clear as well. - spec_cache_clear_needed |= needs_regen - spec_cache_regenerate_needed |= needs_regen - else: - # No longer have this mirror, cached index should be removed - items_to_remove.append( - { - "url": local_index_cache_key, - "cache_key": os.path.join(self._index_cache_root, cached_index_path), - } + # If we have a cached index for a mirror which is no longer configured, remove it + clear_cache, regenerate_cache = self._remove_stale_cache_entries(supported_mirror_versions) + + # Fetch or update the other indexes + errors, all_failed = [], True + for (url, view), versions in supported_mirror_versions.items(): + result = self._fetch_mirror_index(url, view, versions=versions, cooldown=with_cooldown) + if result.error: + errors.append(result.error) + + if result.succeeded: + all_failed = False + + regenerate_cache |= result.regenerate + clear_cache |= result.regenerate and result.had_cache_entry + + self._write_local_index_cache() + + if supported_mirror_versions and all_failed: + raise FetchCacheError(errors) + + if errors: + warnings.warn( + "The following issues were ignored while updating the indices of binary caches:\n" + + str(FetchCacheError(errors)) + ) + + if regenerate_cache: + self.regenerate_spec_cache(clear_existing=clear_cache) + + def _fetch_mirror_index( + self, url: str, view: str, *, versions: List[int], cooldown: bool + ) -> _MirrorIndexResult: + """Fetches the index of a mirror, using a highest-version first approach, and returning + after the first success. + """ + now = time.time() + ttl = spack.config.CONFIG.get_config("config").get("binary_index_ttl", 600) + for version in versions: + meta = MirrorMetadata(url, version, view) + cache_entry = self._local_index_cache.get(str(meta)) + + if cache_entry is not None and ( + # Cache entry in cooldown + cooldown + and ttl > 0 + and meta in self._last_fetch_times + and now - self._last_fetch_times[meta].time < ttl + ): + return _MirrorIndexResult( + succeeded=self._last_fetch_times[meta].succeeded, + regenerate=False, + had_cache_entry=True, + error=None, ) - if urlAndVersion in self._last_fetch_times: - del self._last_fetch_times[urlAndVersion] - spec_cache_clear_needed = True - spec_cache_regenerate_needed = True - - # Clean up items to be removed, identified above - for item in items_to_remove: - url = item["url"] - cache_key = item["cache_key"] - self._index_file_cache.remove(cache_key) - del self._local_index_cache[url] - - # Iterate the configured mirrors now. Any mirror urls we do not - # already have in our cache must be fetched, stored, and represented - # locally. - for urlAndVersion in configured_mirrors: - if str(urlAndVersion) in self._local_index_cache: - continue - # Need to fetch the index and update the local caches - needs_regen = False try: - needs_regen = self._fetch_and_cache_index(urlAndVersion) - self._last_fetch_times[urlAndVersion] = (now, True) - all_methods_failed = False + regenerate = self._fetch_and_cache_index(meta, cache_entry=cache_entry or {}) + self._last_fetch_times[meta] = _LastFetch(time=now, succeeded=True) + return _MirrorIndexResult( + succeeded=True, + regenerate=regenerate, + had_cache_entry=cache_entry is not None, + error=None, + ) except FetchIndexError as e: - fetch_errors.append(e) - self._last_fetch_times[urlAndVersion] = (now, False) - except BuildcacheIndexNotExists as e: - fetch_errors.append(e) - self._last_fetch_times[urlAndVersion] = (now, False) - # Binary caches are not required to have an index, don't raise - # if it doesn't exist. - all_methods_failed = False - - # Generally speaking, a new mirror wouldn't imply the need to - # clear the spec cache, so leave it as is. - if needs_regen: - spec_cache_regenerate_needed = True + self._last_fetch_times[meta] = _LastFetch(time=now, succeeded=False) + return _MirrorIndexResult( + succeeded=False, + regenerate=False, + had_cache_entry=cache_entry is not None, + error=e, + ) + except BuildcacheIndexNotExists: + # Try next lower layout version + self._last_fetch_times[meta] = _LastFetch(time=now, succeeded=False) + continue - self._write_local_index_cache() + # All versions reported no index found. This is not a failure + warnings.warn(f"the mirror at {url} cannot be used in concretization (no index found)") + return _MirrorIndexResult( + succeeded=True, regenerate=False, had_cache_entry=False, error=None + ) - if configured_mirrors and all_methods_failed: - raise FetchCacheError(fetch_errors) - if fetch_errors: - tty.warn( - "The following issues were ignored while updating the indices of binary caches", - FetchCacheError(fetch_errors), - ) - if spec_cache_regenerate_needed: - self.regenerate_spec_cache(clear_existing=spec_cache_clear_needed) + def _remove_stale_cache_entries( + self, supported_mirror_versions: Dict[Tuple[str, Any], List[int]] + ) -> Tuple[bool, bool]: + items_to_remove = [] + clear, regenerate = False, not self._mirrors_for_spec + + for local_index_key in self._local_index_cache: + meta = MirrorMetadata.from_string(local_index_key) + if meta.version not in supported_mirror_versions.get((meta.url, meta.view), ()): + index_file_key = self._local_index_cache[local_index_key]["index_path"] + items_to_remove.append((local_index_key, index_file_key, meta)) + clear, regenerate = True, True + + for local_index_key, index_file_key, meta in items_to_remove: + self._last_fetch_times.pop(meta, None) + self._index_file_cache.remove(index_file_key) + del self._local_index_cache[local_index_key] + + return clear, regenerate def _fetch_and_cache_index(self, mirror_metadata: MirrorMetadata, cache_entry={}): """Fetch a buildcache index file from a remote mirror and cache it. diff --git a/lib/spack/spack/test/binary_distribution.py b/lib/spack/spack/test/binary_distribution.py index 9d0cb22e0ae59d..5a1417415309a9 100644 --- a/lib/spack/spack/test/binary_distribution.py +++ b/lib/spack/spack/test/binary_distribution.py @@ -1488,3 +1488,20 @@ def test_mirror_metadata_with_view(): with pytest.raises(spack.url_buildcache.MirrorMetadataError, match="Malformed string"): spack.binary_distribution.MirrorMetadata.from_string("https://dummy.io/__v3%asdf__@aview") + + +def test_update_warns_on_mirror_with_no_index(monkeypatch, tmp_path: pathlib.Path, mutable_config): + """Tests that BinaryCacheIndex.update() warns when a mirror has no index for any supported + layout version. + """ + mirror_url = url_util.path_to_file_url(str(tmp_path / "mirror_dir")) + spack.config.set("mirrors", {"test": mirror_url}) + + def no_index(*args, **kwargs): + raise spack.binary_distribution.BuildcacheIndexNotExists("no index") + + binary_index = spack.binary_distribution.BinaryCacheIndex(str(tmp_path / "index_cache")) + monkeypatch.setattr(binary_index, "_fetch_and_cache_index", no_index) + + with pytest.warns(UserWarning, match="cannot be used in concretization"): + binary_index.update() diff --git a/lib/spack/spack/url_buildcache.py b/lib/spack/spack/url_buildcache.py index 5ca9b26cbf0479..2b4d7cf942dda6 100644 --- a/lib/spack/spack/url_buildcache.py +++ b/lib/spack/spack/url_buildcache.py @@ -1380,7 +1380,7 @@ def __hash__(self): return hash((self.url, self.version, self.view)) @classmethod - def from_string(cls, s: str): + def from_string(cls, s: str) -> "MirrorMetadata": m = re.match(r"^(.*)__v([0-9]+)(?:__(.*))?$", s) if not m: raise MirrorMetadataError(f"Malformed string {s}") From 81e1e2500b8505896e82e4fa600f999740b2acee Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 28 Apr 2026 09:22:06 +0200 Subject: [PATCH 301/337] json: fix call sites of SpackJSONError (#52352) Signed-off-by: Harmen Stoppels --- lib/spack/spack/database.py | 2 +- lib/spack/spack/mirrors/mirror.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/database.py b/lib/spack/spack/database.py index d64d342116a94a..2b17da37d88dcd 100644 --- a/lib/spack/spack/database.py +++ b/lib/spack/spack/database.py @@ -689,7 +689,7 @@ def _write_to_file(self, stream): try: sjson.dump(database, stream) except (TypeError, ValueError) as e: - raise sjson.SpackJSONError("error writing JSON database:", str(e)) + raise sjson.SpackJSONError("error writing JSON database:", e) def _read_spec_from_dict(self, spec_reader, hash_key, installs, hash=ht.dag_hash): """Recursively construct a spec from a hash in a YAML database. diff --git a/lib/spack/spack/mirrors/mirror.py b/lib/spack/spack/mirrors/mirror.py index 3ace9ec4743156..014ba78b6e3aac 100644 --- a/lib/spack/spack/mirrors/mirror.py +++ b/lib/spack/spack/mirrors/mirror.py @@ -57,7 +57,7 @@ def from_json(stream, name=None): try: return Mirror(sjson.load(stream), name) except Exception as e: - raise sjson.SpackJSONError("error parsing JSON mirror:", str(e)) from e + raise sjson.SpackJSONError("error parsing JSON mirror:", e) from e @staticmethod def from_local_path(path: str): @@ -465,7 +465,7 @@ def from_json(stream, name=None): d = sjson.load(stream) return MirrorCollection(d) except Exception as e: - raise sjson.SpackJSONError("error parsing JSON mirror collection:", str(e)) from e + raise sjson.SpackJSONError("error parsing JSON mirror collection:", e) from e def to_dict(self, recursive=False): return syaml.syaml_dict( From 2ed678ce30ccb6f8d12981c4c6b38ac99f5503ec Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 28 Apr 2026 10:37:37 +0200 Subject: [PATCH 302/337] new_installer.py: improve error formatting (#52280) Print truncated backtrace (last 4 frames) on general Python errors. Do not print backtrace on ProcessError, just print "exited with ..." and how the command was invoked. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 10c4f27be4c9e3..86227f4bfbf5c8 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -83,6 +83,7 @@ from spack.installer import _do_fake_install, dump_packages from spack.llnl.util.lang import pretty_duration from spack.llnl.util.tty.log import _is_background_tty, ignore_signal +from spack.util.executable import ProcessError from spack.util.log_parse import make_log_context, parse_log_events from spack.util.path import padding_filter, padding_filter_bytes @@ -561,8 +562,11 @@ def handle_sigterm(signum, frame): ) except spack.error.StopPhase: exit_code = EXIT_STOPPED_AT_PHASE + except ProcessError as e: + print(e, file=sys.stderr) + exit_code = 1 except BaseException: - traceback.print_exc() # log the traceback to the log file + traceback.print_exc(limit=-4) exit_code = 1 finally: tee.close() From 1085230341df6bef86484302215a8f66979c5687 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 28 Apr 2026 10:41:56 +0200 Subject: [PATCH 303/337] new_installer.py: only call schedule if pending builds (#52342) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 86227f4bfbf5c8..0896e3e8d7dfc9 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2366,10 +2366,11 @@ def _installer(self) -> None: try: # Try to schedule builds immediately. The first job does not require a token. - blocked = self._schedule_builds( - selector, jobserver, retained_read_locks, database_actions - ) - self.build_status.set_blocked(blocked and not self.running_builds) + if self.pending_builds: + blocked = self._schedule_builds( + selector, jobserver, retained_read_locks, database_actions + ) + self.build_status.set_blocked(blocked and not self.running_builds) while self.pending_builds or self.running_builds or database_actions: # Monitor the jobserver when we have pending builds, capacity, and at least one From acb0df40295de9b1e39c2710108bf241cb4527ec Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 28 Apr 2026 11:10:35 +0200 Subject: [PATCH 304/337] Split `sjson.dump` to avoid ambiguous type-hints (#52350) Now there are two functions, which are wrappers around `json.dump` and `json.dumps`. The return type is clear, and only one of them accepts a stream. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/cmd/diff.py | 2 +- lib/spack/spack/environment/environment.py | 2 +- lib/spack/spack/install_test.py | 3 +-- lib/spack/spack/mirrors/mirror.py | 10 ++++++-- lib/spack/spack/spec.py | 5 +++- lib/spack/spack/test/spec_yaml.py | 4 +-- lib/spack/spack/util/spack_json.py | 29 ++++++++++++++-------- lib/spack/spack/util/timer.py | 2 +- lib/spack/spack/verify.py | 2 +- 9 files changed, 37 insertions(+), 22 deletions(-) diff --git a/lib/spack/spack/cmd/diff.py b/lib/spack/spack/cmd/diff.py index a0269f589b7308..d1d7fde4b62e10 100644 --- a/lib/spack/spack/cmd/diff.py +++ b/lib/spack/spack/cmd/diff.py @@ -228,7 +228,7 @@ def diff(parser, args): attributes = args.attribute or ["all"] if args.dump_json: - print(sjson.dump(c)) + print(sjson.dumps(c)) else: tty.warn("This interface is subject to change.\n") print_difference(c, attributes) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index b397231b263d9f..abd9b18a87585b 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -791,7 +791,7 @@ def content_hash(self, specs): ("specs", [(spec.dag_hash(), spec.prefix) for spec in sorted(specs)]), ] ) - contents = sjson.dump(d) + contents = sjson.dumps(d) return spack.util.hash.b32_hash(contents) def get_projection_for_spec(self, spec): diff --git a/lib/spack/spack/install_test.py b/lib/spack/spack/install_test.py index f240fd243ea12c..5cc542664b255a 100644 --- a/lib/spack/spack/install_test.py +++ b/lib/spack/spack/install_test.py @@ -858,8 +858,7 @@ def name(self) -> str: def content_hash(self) -> str: """The hash used to uniquely identify the test suite.""" if not self._hash: - json_text = sjson.dump(self.to_dict()) - assert json_text is not None, f"{__name__} unexpected value for 'json_text'" + json_text = sjson.dumps(self.to_dict()) sha = hashlib.sha1(json_text.encode("utf-8")) b32_hash = base64.b32encode(sha.digest()).lower() b32_hash = b32_hash.decode("utf-8") diff --git a/lib/spack/spack/mirrors/mirror.py b/lib/spack/spack/mirrors/mirror.py index 014ba78b6e3aac..ede386f2047687 100644 --- a/lib/spack/spack/mirrors/mirror.py +++ b/lib/spack/spack/mirrors/mirror.py @@ -85,7 +85,10 @@ def __repr__(self): return f"Mirror(name={self._name!r}, data={self._data!r})" def to_json(self, stream=None): - return sjson.dump(self.to_dict(), stream) + if stream is None: + return sjson.dumps(self.to_dict()) + sjson.dump(self.to_dict(), stream) + return None def to_yaml(self, stream=None): return syaml.dump(self.to_dict(), stream) @@ -448,7 +451,10 @@ def __eq__(self, other): return self._mirrors == other._mirrors def to_json(self, stream=None): - return sjson.dump(self.to_dict(True), stream) + if stream is None: + return sjson.dumps(self.to_dict(True)) + sjson.dump(self.to_dict(True), stream) + return None def to_yaml(self, stream=None): return syaml.dump(self.to_dict(True), stream) diff --git a/lib/spack/spack/spec.py b/lib/spack/spack/spec.py index 6299dddafc1b20..fab65cba4a46fa 100644 --- a/lib/spack/spack/spec.py +++ b/lib/spack/spack/spec.py @@ -2628,7 +2628,10 @@ def to_yaml(self, stream=None, hash=ht.dag_hash): return syaml.dump(self.to_dict(hash), stream=stream, default_flow_style=False) def to_json(self, stream=None, *, hash=ht.dag_hash, pretty=False): - return sjson.dump(self.to_dict(hash), stream=stream, pretty=pretty) + if stream is None: + return sjson.dumps(self.to_dict(hash), pretty=pretty) + sjson.dump(self.to_dict(hash), stream, pretty=pretty) + return None @staticmethod def from_specfile(path): diff --git a/lib/spack/spack/test/spec_yaml.py b/lib/spack/spack/test/spec_yaml.py index b0561a4f4f2c36..f6ac104dcab7dd 100644 --- a/lib/spack/spack/test/spec_yaml.py +++ b/lib/spack/spack/test/spec_yaml.py @@ -186,8 +186,8 @@ def test_ordered_read_not_required_for_consistent_dag_hash( # Dump to YAML and JSON yaml_string = syaml.dump(spec_dict, default_flow_style=False) yaml_string_rev = syaml.dump(spec_dict_rev, default_flow_style=False) - json_string = sjson.dump(spec_dict) - json_string_rev = sjson.dump(spec_dict_rev) + json_string = sjson.dumps(spec_dict) + json_string_rev = sjson.dumps(spec_dict_rev) # spec yaml is ordered like the spec dict assert yaml_string == spec_yaml diff --git a/lib/spack/spack/util/spack_json.py b/lib/spack/spack/util/spack_json.py index 6b49e14379f155..ad0e17a68fd4f9 100644 --- a/lib/spack/spack/util/spack_json.py +++ b/lib/spack/spack/util/spack_json.py @@ -5,14 +5,16 @@ """Simple wrapper around JSON to guarantee consistent use of load/dump.""" import json -from typing import Any, Dict, Optional +from typing import IO, Any, Dict import spack.error -__all__ = ["load", "dump", "SpackJSONError"] +__all__ = ["load", "dump", "dumps", "SpackJSONError"] -_json_dump_args = {"indent": None, "separators": (",", ":")} -_pretty_dump_args = {"indent": " ", "separators": (", ", ": ")} +_DEFAULT_SEPARATORS = (",", ":") +_DEFAULT_INDENT = None +_PRETTY_SEPARATORS = (", ", ": ") +_PRETTY_INDENT = " " def load(stream: Any) -> Dict: @@ -22,13 +24,18 @@ def load(stream: Any) -> Dict: return json.load(stream) -def dump(data: Dict, stream: Optional[Any] = None, pretty: bool = False) -> Optional[str]: - """Dump JSON with a reasonable amount of indentation and separation.""" - dump_args = _pretty_dump_args if pretty else _json_dump_args - if stream is None: - return json.dumps(data, **dump_args) # type: ignore[arg-type] - json.dump(data, stream, **dump_args) # type: ignore[arg-type] - return None +def dump(data: Any, stream: IO[str], pretty: bool = False) -> None: + """Wrapper around json.dump with different default arguments""" + indent = _PRETTY_INDENT if pretty else _DEFAULT_INDENT + separators = _PRETTY_SEPARATORS if pretty else _DEFAULT_SEPARATORS + json.dump(data, stream, separators=separators, indent=indent) + + +def dumps(data: Any, pretty: bool = False) -> str: + """Wrapper around json.dumps with different default arguments""" + indent = _PRETTY_INDENT if pretty else _DEFAULT_INDENT + separators = _PRETTY_SEPARATORS if pretty else _DEFAULT_SEPARATORS + return json.dumps(data, separators=separators, indent=indent) class SpackJSONError(spack.error.SpackError): diff --git a/lib/spack/spack/util/timer.py b/lib/spack/spack/util/timer.py index 158e6886dae642..e01c60ec20356f 100644 --- a/lib/spack/spack/util/timer.py +++ b/lib/spack/spack/util/timer.py @@ -181,7 +181,7 @@ def write_json(self, out=sys.stdout, extra_attributes={}): if extra_attributes: data.update(extra_attributes) if out: - out.write(sjson.dump(data)) + out.write(sjson.dumps(data)) else: return data diff --git a/lib/spack/spack/verify.py b/lib/spack/spack/verify.py index 105d206e8562a6..33ff521d81e3aa 100644 --- a/lib/spack/spack/verify.py +++ b/lib/spack/spack/verify.py @@ -193,7 +193,7 @@ def has_errors(self): return bool(self.errors) def json_string(self): - return sjson.dump(self.errors) + return sjson.dumps(self.errors) def __str__(self): res = "" From 1942aed2ca255b19378a945393e58c66be292a28 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 28 Apr 2026 16:37:05 +0200 Subject: [PATCH 305/337] mirror.py: add type-hints and minor cleanup (#52347) This PR adds type-hints to classes and functions in `mirror.py`. Besides that: - Some dead code has been removed - Some unused arguments have been removed - A bug in `MirrorCollection.display` when there are no mirrors has been fixed Signed-off-by: Massimiliano Culpo --- lib/spack/spack/binary_distribution.py | 2 +- lib/spack/spack/mirrors/mirror.py | 129 ++++++++++--------------- lib/spack/spack/test/mirror.py | 55 ----------- 3 files changed, 50 insertions(+), 136 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index 8060f17ec49b4d..f15585ecd0d0c5 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -372,7 +372,7 @@ def update(self, with_cooldown: bool = False) -> None: self.regenerate_spec_cache(clear_existing=clear_cache) def _fetch_mirror_index( - self, url: str, view: str, *, versions: List[int], cooldown: bool + self, url: str, view: Optional[str], *, versions: List[int], cooldown: bool ) -> _MirrorIndexResult: """Fetches the index of a mirror, using a highest-version first approach, and returning after the first success. diff --git a/lib/spack/spack/mirrors/mirror.py b/lib/spack/spack/mirrors/mirror.py index ede386f2047687..0a0fb8717e3b00 100644 --- a/lib/spack/spack/mirrors/mirror.py +++ b/lib/spack/spack/mirrors/mirror.py @@ -4,12 +4,11 @@ import operator import os import urllib.parse -from typing import Any, Dict, List, Mapping, Optional, Tuple, Union +from typing import IO, Any, Dict, Iterator, List, Mapping, Optional, Tuple, Union, overload import spack.config import spack.llnl.util.tty as tty import spack.util.path -import spack.util.spack_json as sjson import spack.util.spack_yaml as syaml import spack.util.url as url_util from spack.error import MirrorError @@ -44,27 +43,20 @@ class Mirror: to them. These two URLs are usually the same. """ - def __init__(self, data: Union[str, dict], name: Optional[str] = None): + def __init__(self, data: Union[str, dict], name: Optional[str] = None) -> None: self._data = data self._name = name @staticmethod - def from_yaml(stream, name=None): + def from_yaml(stream: Union[str, IO[str]], name: Optional[str] = None) -> "Mirror": return Mirror(syaml.load(stream), name) @staticmethod - def from_json(stream, name=None): - try: - return Mirror(sjson.load(stream), name) - except Exception as e: - raise sjson.SpackJSONError("error parsing JSON mirror:", e) from e - - @staticmethod - def from_local_path(path: str): + def from_local_path(path: str) -> "Mirror": return Mirror(url_util.path_to_file_url(path)) @staticmethod - def from_url(url: str): + def from_url(url: str) -> "Mirror": """Create an anonymous mirror by URL. This method validates the URL.""" if urllib.parse.urlparse(url).scheme not in supported_url_schemes: raise ValueError( @@ -73,30 +65,31 @@ def from_url(url: str): ) return Mirror(url) - def __eq__(self, other): + def __eq__(self, other: object) -> bool: if not isinstance(other, Mirror): return NotImplemented return self._data == other._data and self._name == other._name - def __str__(self): + def __str__(self) -> str: return f"{self._name}: {self.push_url} {self.fetch_url}" - def __repr__(self): + def __repr__(self) -> str: return f"Mirror(name={self._name!r}, data={self._data!r})" - def to_json(self, stream=None): - if stream is None: - return sjson.dumps(self.to_dict()) - sjson.dump(self.to_dict(), stream) - return None + @overload + def to_yaml(self, stream: None = ...) -> str: ... - def to_yaml(self, stream=None): + @overload + def to_yaml(self, stream: IO[str]) -> None: ... + + def to_yaml(self, stream: Optional[IO[str]] = None) -> Optional[str]: return syaml.dump(self.to_dict(), stream) - def to_dict(self): + def to_dict(self) -> Union[str, dict]: + # Mirrors configured as a plain URL are stored as a string in the config schema return self._data - def display(self, max_len=0): + def display(self, max_len: int = 0) -> None: fetch, push = self.fetch_url, self.push_url # don't print the same URL twice url = fetch if fetch == push else f"fetch: {fetch} push: {push}" @@ -105,15 +98,15 @@ def display(self, max_len=0): print(f"{self.name: <{max_len}} [{source}{binary}] {url}") @property - def name(self): + def name(self) -> str: return self._name or "" @property - def binary(self): + def binary(self) -> bool: return isinstance(self._data, str) or self._data.get("binary", True) @property - def source(self): + def source(self) -> bool: return isinstance(self._data, str) or self._data.get("source", True) @property @@ -132,26 +125,26 @@ def autopush(self) -> bool: return self._data.get("autopush", False) @property - def fetch_url(self): + def fetch_url(self) -> str: """Get the valid, canonicalized fetch URL""" return self.get_url("fetch") @property - def push_url(self): - """Get the valid, canonicalized fetch URL""" + def push_url(self) -> str: + """Get the valid, canonicalized push URL""" return self.get_url("push") @property - def fetch_view(self): - """Get the valid, canonicalized fetch URL""" - return self.get_view("fetch") + def fetch_view(self) -> Optional[str]: + """Get the fetch view""" + return self._get_value("view", direction="fetch") @property - def push_view(self): - """Get the valid, canonicalized fetch URL""" - return self.get_view("push") + def push_view(self) -> Optional[str]: + """Get the push view""" + return self._get_value("view", direction="push") - def ensure_mirror_usable(self, direction: str = "push"): + def ensure_mirror_usable(self, direction: str = "push") -> None: access_pair = self._get_value("access_pair", direction) access_token_variable = self._get_value("access_token_variable", direction) @@ -199,7 +192,7 @@ def supported_layout_versions(self) -> List[int]: return supported_versions - def _update_connection_dict(self, current_data: dict, new_data: dict, top_level: bool): + def _update_connection_dict(self, current_data: dict, new_data: dict, top_level: bool) -> bool: # Only allow one to exist in the config if "access_token" in current_data and "access_token_variable" in new_data: current_data.pop("access_token") @@ -239,7 +232,7 @@ def _update_connection_dict(self, current_data: dict, new_data: dict, top_level: changed = True return changed - def update(self, data: dict, direction: Optional[str] = None) -> bool: + def update(self, data: Dict[str, Any], direction: Optional[str] = None) -> bool: """Modify the mirror with the given data. This takes care of expanding trivial mirror definitions by URL to something more rich with a dict if necessary @@ -303,7 +296,7 @@ def update(self, data: dict, direction: Optional[str] = None) -> bool: return self._update_connection_dict(self._data[direction], data, top_level=False) - def _get_value(self, attribute: str, direction: str): + def _get_value(self, attribute: str, direction: str) -> Any: """Returns the most specific value for a given attribute (either push/fetch or global)""" if direction not in ("fetch", "push"): raise ValueError(f"direction must be either 'fetch' or 'push', not {direction}") @@ -345,9 +338,6 @@ def get_url(self, direction: str) -> str: return _url_or_path_to_url(url) - def get_view(self, direction: str): - return self._get_value("view", direction) - def get_credentials(self, direction: str) -> Dict[str, Any]: """Get the mirror credentials from the mirror config @@ -382,9 +372,7 @@ def get_access_token(self, direction: str) -> Optional[str]: tok = self._get_value("access_token_variable", direction) if tok: return os.environ.get(tok) - else: - return self._get_value("access_token", direction) - return None + return self._get_value("access_token", direction) def get_access_pair(self, direction: str) -> Optional[Tuple[str, str]]: pair = self._get_value("access_pair", direction) @@ -409,8 +397,8 @@ class MirrorCollection(Mapping[str, Mirror]): def __init__( self, - mirrors=None, - scope=None, + mirrors: Optional[Mapping[str, Any]] = None, + scope: Optional[str] = None, binary: Optional[bool] = None, source: Optional[bool] = None, autopush: Optional[bool] = None, @@ -447,33 +435,12 @@ def _filter(m: Mirror): self._mirrors = {m.name: m for m in mirrors if _filter(m)} - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, MirrorCollection): + return NotImplemented return self._mirrors == other._mirrors - def to_json(self, stream=None): - if stream is None: - return sjson.dumps(self.to_dict(True)) - sjson.dump(self.to_dict(True), stream) - return None - - def to_yaml(self, stream=None): - return syaml.dump(self.to_dict(True), stream) - - # TODO: this isn't called anywhere - @staticmethod - def from_yaml(stream, name=None): - data = syaml.load(stream) - return MirrorCollection(data) - - @staticmethod - def from_json(stream, name=None): - try: - d = sjson.load(stream) - return MirrorCollection(d) - except Exception as e: - raise sjson.SpackJSONError("error parsing JSON mirror collection:", e) from e - - def to_dict(self, recursive=False): + def to_dict(self, recursive: bool = False) -> Dict[str, Any]: return syaml.syaml_dict( sorted( ((k, (v.to_dict() if recursive else v)) for (k, v) in self._mirrors.items()), @@ -482,18 +449,20 @@ def to_dict(self, recursive=False): ) @staticmethod - def from_dict(d): + def from_dict(d: Mapping[str, Any]) -> "MirrorCollection": return MirrorCollection(d) - def __getitem__(self, item): + def __getitem__(self, item: str) -> Mirror: return self._mirrors[item] - def display(self): + def display(self) -> None: + if not self._mirrors: + return max_len = max(len(mirror.name) for mirror in self._mirrors.values()) for mirror in self._mirrors.values(): mirror.display(max_len) - def lookup(self, name_or_url): + def lookup(self, name_or_url: str) -> Mirror: """Looks up and returns a Mirror. If this MirrorCollection contains a named Mirror under the name @@ -504,12 +473,12 @@ def lookup(self, name_or_url): result = self.get(name_or_url) if result is None: - result = Mirror(fetch=name_or_url) + result = Mirror(name_or_url) return result - def __iter__(self): + def __iter__(self) -> Iterator[str]: return iter(self._mirrors) - def __len__(self): + def __len__(self) -> int: return len(self._mirrors) diff --git a/lib/spack/spack/test/mirror.py b/lib/spack/spack/test/mirror.py index bf4219aeadfccf..188a98e5638fa2 100644 --- a/lib/spack/spack/test/mirror.py +++ b/lib/spack/spack/test/mirror.py @@ -18,7 +18,6 @@ import spack.mirrors.utils import spack.patch import spack.stage -import spack.util.spack_json as sjson import spack.util.url as url_util from spack.cmd.common.arguments import mirror_name_or_url from spack.llnl.util.filesystem import resolve_link_target_relative_to_the_link, working_dir @@ -146,8 +145,6 @@ def test_all_mirror(mock_git_repository, mock_svn_repository, mock_hg_repository def test_roundtrip_mirror(mirror: spack.mirrors.mirror.Mirror): mirror_yaml = mirror.to_yaml() assert spack.mirrors.mirror.Mirror.from_yaml(mirror_yaml) == mirror - mirror_json = mirror.to_json() - assert spack.mirrors.mirror.Mirror.from_json(mirror_json) == mirror @pytest.mark.parametrize( @@ -159,58 +156,6 @@ def test_invalid_yaml_mirror(invalid_yaml): assert invalid_yaml in str(e.value) -@pytest.mark.parametrize("invalid_json, error_message", [("{13:", "Expecting property name")]) -def test_invalid_json_mirror(invalid_json, error_message): - with pytest.raises(sjson.SpackJSONError) as e: - spack.mirrors.mirror.Mirror.from_json(invalid_json) - exc_msg = str(e.value) - assert exc_msg.startswith("error parsing JSON mirror:") - assert error_message in exc_msg - - -@pytest.mark.parametrize( - "mirror_collection", - [ - spack.mirrors.mirror.MirrorCollection( - mirrors={ - "example-mirror": spack.mirrors.mirror.Mirror( - "https://example.com/fetch", "https://example.com/push" - ).to_dict() - } - ) - ], -) -def test_roundtrip_mirror_collection(mirror_collection): - mirror_collection_yaml = mirror_collection.to_yaml() - assert ( - spack.mirrors.mirror.MirrorCollection.from_yaml(mirror_collection_yaml) - == mirror_collection - ) - mirror_collection_json = mirror_collection.to_json() - assert ( - spack.mirrors.mirror.MirrorCollection.from_json(mirror_collection_json) - == mirror_collection - ) - - -@pytest.mark.parametrize( - "invalid_yaml", ["playing_playlist: {{ action }} playlist {{ playlist_name }}"] -) -def test_invalid_yaml_mirror_collection(invalid_yaml): - with pytest.raises(SpackYAMLError, match="error parsing YAML") as e: - spack.mirrors.mirror.MirrorCollection.from_yaml(invalid_yaml) - assert invalid_yaml in str(e.value) - - -@pytest.mark.parametrize("invalid_json, error_message", [("{13:", "Expecting property name")]) -def test_invalid_json_mirror_collection(invalid_json, error_message): - with pytest.raises(sjson.SpackJSONError) as e: - spack.mirrors.mirror.MirrorCollection.from_json(invalid_json) - exc_msg = str(e.value) - assert exc_msg.startswith("error parsing JSON mirror collection:") - assert error_message in exc_msg - - def test_mirror_archive_paths_no_version(mock_packages, mock_archive): spec = spack.concretize.concretize_one( Spec("trivial-install-test-package@=nonexistingversion") From 77de25ee85bb870c7be655d96a9e7f24cf884024 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 28 Apr 2026 17:34:21 +0200 Subject: [PATCH 306/337] ctest_log_parser: type hints, bug fixes, refactor (#52346) * Fix a trailing-comma typo in `LogEvent` that stored two members as tuples instead of plain values * Fix the `log_parse` singleton so the profile flag is respected on every call, not just the first. * Compile all regexes once in `CTestLogParser.__init__` * Add matcher objects to abstract away the profile stuff * Remove unused `chunks`. * Add typehints Signed-off-by: Harmen Stoppels --- lib/spack/spack/reporters/cdash.py | 4 +- lib/spack/spack/util/ctest_log_parser.py | 199 ++++++++++++----------- lib/spack/spack/util/log_parse.py | 25 ++- 3 files changed, 117 insertions(+), 111 deletions(-) diff --git a/lib/spack/spack/reporters/cdash.py b/lib/spack/spack/reporters/cdash.py index 22eb88d715d1c1..cd3ac31a3724f2 100644 --- a/lib/spack/spack/reporters/cdash.py +++ b/lib/spack/spack/reporters/cdash.py @@ -229,9 +229,7 @@ def clean_log_event(event): event["post_context"] = xml.sax.saxutils.escape( "\n".join(event["post_context"]) ) - # source_file and source_line_no are either strings or - # the tuple (None,). Distinguish between these two cases. - if event["source_file"][0] is None: + if event["source_file"] is None: event["source_file"] = "" event["source_line_no"] = "" else: diff --git a/lib/spack/spack/util/ctest_log_parser.py b/lib/spack/spack/util/ctest_log_parser.py index 51c9e8d261c6fc..421d68121aff98 100644 --- a/lib/spack/spack/util/ctest_log_parser.py +++ b/lib/spack/spack/util/ctest_log_parser.py @@ -70,11 +70,12 @@ """ import io -import math import re import time from collections import deque -from typing import Dict, List, Optional, TextIO, Tuple, Union +from typing import Dict, Iterable, List, Optional, TextIO, Tuple, Union + +from spack.llnl.util.lang import PatternStr _error_matches = [ "^FAIL: ", @@ -217,41 +218,41 @@ class LogEvent: def __init__( self, - text, - line_no, - source_file=None, - source_line_no=None, - pre_context=None, - post_context=None, - ): + text: str, + line_no: int, + source_file: Optional[str] = None, + source_line_no: Optional[str] = None, + pre_context: Optional[List[str]] = None, + post_context: Optional[List[str]] = None, + ) -> None: self.text = text self.line_no = line_no - self.source_file = (source_file,) - self.source_line_no = (source_line_no,) + self.source_file = source_file + self.source_line_no = source_line_no self.pre_context = pre_context if pre_context is not None else [] self.post_context = post_context if post_context is not None else [] self.repeat_count = 0 @property - def start(self): + def start(self) -> int: """First line in the log with text for the event or its context.""" return self.line_no - len(self.pre_context) @property - def end(self): + def end(self) -> int: """Last line in the log with text for event or its context.""" return self.line_no + len(self.post_context) + 1 - def __getitem__(self, line_no): + def __getitem__(self, line_no: int) -> str: """Index event text and context by actual line number in file.""" if line_no == self.line_no: return self.text elif line_no < self.line_no: return self.pre_context[line_no - self.line_no] - elif line_no > self.line_no: + else: return self.post_context[line_no - self.line_no - 1] - def __str__(self): + def __str__(self) -> str: """Returns event lines and context.""" out = io.StringIO() for i in range(self.start, self.end): @@ -274,12 +275,6 @@ class BuildWarning(LogEvent): color = "Y" -def chunks(xs, n): - """Divide xs into n approximately-even chunks.""" - chunksize = int(math.ceil(len(xs) / n)) - return [xs[i : i + chunksize] for i in range(0, len(xs), chunksize)] - - def _optimize_regexes(regex_strings: List[str]) -> List[str]: """Groups regexes by their first character and combines each group into a single regex using alternation. Python's regex compiler optimizes the combined pattern to share common prefixes @@ -297,62 +292,78 @@ def _optimize_regexes(regex_strings: List[str]) -> List[str]: return ["|".join(entries) for entries in groups.values()] -def _match(matches, exceptions, line): - """True if line matches a regex in matches and none in exceptions.""" - return any(m.search(line) for m in matches) and not any(e.search(line) for e in exceptions) - +class _Matcher: + """Tests a log line against match/exception regex lists.""" -def _profile_match(matches, exceptions, line, match_times, exc_times): - """Profiled version of match(). + def __init__(self, matches: List[PatternStr], exceptions: List[PatternStr]) -> None: + self.matches = matches + self.exceptions = exceptions - Timing is expensive so we have two whole functions. This is much - longer because we have to break up the ``any()`` calls. - - """ - for i, m in enumerate(matches): - start = time.perf_counter() - found = m.search(line) - match_times[i] += time.perf_counter() - start - if found: - break - else: - return False - - for i, m in enumerate(exceptions): - start = time.perf_counter() - found = m.search(line) - exc_times[i] += time.perf_counter() - start - if found: + def __call__(self, line: str) -> bool: + """Returns True if line matches any regex in self.matches and none in self.exceptions.""" + for match in self.matches: + if match.search(line): + break + else: return False - else: + for exc in self.exceptions: + if exc.search(line): + return False return True -def _parse(stream, profile, context, tail=0): +class _ProfileMatcher(_Matcher): + """Variant of _Matcher that records time spent in each regex.""" - error_matches = [re.compile(r) for r in _optimize_regexes(_error_matches)] - error_exceptions = [re.compile(r) for r in _optimize_regexes(_error_exceptions)] - warning_matches = [re.compile(r) for r in _optimize_regexes(_warning_matches)] - warning_exceptions = [re.compile(r) for r in _optimize_regexes(_warning_exceptions)] - file_line_matches = [re.compile(r) for r in _file_line_matches] + def __init__(self, matches: List[PatternStr], exceptions: List[PatternStr]) -> None: + super().__init__(matches, exceptions) + self.match_times = [0.0] * len(matches) + self.exc_times = [0.0] * len(exceptions) - matcher, _ = _match, [] - timings = [] - if profile: - matcher = _profile_match - timings = [ - [0.0] * len(error_matches), - [0.0] * len(error_exceptions), - [0.0] * len(warning_matches), - [0.0] * len(warning_exceptions), - ] + def __call__(self, line: str) -> bool: + for i, m in enumerate(self.matches): + start = time.perf_counter() + found = m.search(line) + self.match_times[i] += time.perf_counter() - start + if found: + break + else: + return False - errors = [] - warnings = [] + for i, m in enumerate(self.exceptions): + start = time.perf_counter() + found = m.search(line) + self.exc_times[i] += time.perf_counter() - start + if found: + return False + return True + + def print_timings(self, kind: str) -> None: + print() + print(f"{kind}_matches") + for pattern, t in zip(self.matches, self.match_times): + print("%16.2f %s" % (t * 1e6, pattern.pattern)) + print() + print(f"{kind}_exceptions") + for pattern, t in zip(self.exceptions, self.exc_times): + print("%16.2f %s" % (t * 1e6, pattern.pattern)) + + +def _parse( + stream: Iterable[str], + error_matcher: _Matcher, + warning_matcher: _Matcher, + file_line_matches: List[PatternStr], + context: int, + tail: int = 0, +) -> Tuple[List[BuildError], List[BuildWarning], Optional[LogEvent]]: + + errors: List[BuildError] = [] + warnings: List[BuildWarning] = [] # rolling window of recent lines - pre_context = deque(maxlen=max(context, tail)) + pre_context: deque[str] = deque(maxlen=max(context, tail)) # list of (event, remaining_post_context_lines) - pending_events: List[Tuple[LogEvent, int]] = [] + pending_events: List[Tuple[Union[BuildError, BuildWarning], int]] = [] last_line_no = 0 for i, line in enumerate(stream): @@ -373,9 +384,9 @@ def _parse(stream, profile, context, tail=0): pending_events = active_events # use CTest's regular expressions to scrape the log for events - if matcher(error_matches, error_exceptions, line, *timings[:2]): + if error_matcher(line): event = BuildError(rstripped_line, i + 1) - elif matcher(warning_matches, warning_exceptions, line, *timings[2:]): + elif warning_matcher(line): event = BuildWarning(rstripped_line, i + 1) else: pre_context.append(rstripped_line) @@ -414,38 +425,32 @@ def _parse(stream, profile, context, tail=0): else: tail_event = None - return errors, warnings, tail_event, timings + return errors, warnings, tail_event class CTestLogParser: """Log file parser that extracts errors and warnings.""" - def __init__(self, profile=False): - # whether to record timing information - self.timings = [] - self.profile = profile + def __init__(self, profile: bool = False) -> None: + error_matches = [re.compile(r) for r in _optimize_regexes(_error_matches)] + error_exceptions = [re.compile(r) for r in _optimize_regexes(_error_exceptions)] + warning_matches = [re.compile(r) for r in _optimize_regexes(_warning_matches)] + warning_exceptions = [re.compile(r) for r in _optimize_regexes(_warning_exceptions)] - def print_timings(self): - """Print out profile of time spent in different regular expressions.""" + cls = _ProfileMatcher if profile else _Matcher + self._error_matcher = cls(error_matches, error_exceptions) + self._warning_matcher = cls(warning_matches, warning_exceptions) + self._file_line_matches = [re.compile(r) for r in _file_line_matches] - def stringify(elt): - return elt if isinstance(elt, str) else elt.pattern - - index = 0 - for name, arr in [ - ("error_matches", _optimize_regexes(_error_matches)), - ("error_exceptions", _optimize_regexes(_error_exceptions)), - ("warning_matches", _optimize_regexes(_warning_matches)), - ("warning_exceptions", _optimize_regexes(_warning_exceptions)), - ]: - print() - print(name) - for i, elt in enumerate(arr): - print("%16.2f %s" % (self.timings[index][i] * 1e6, stringify(elt))) - index += 1 + def print_timings(self) -> None: + """Print out profile of time spent in different regular expressions.""" + assert isinstance(self._error_matcher, _ProfileMatcher) + assert isinstance(self._warning_matcher, _ProfileMatcher) + self._error_matcher.print_timings("error") + self._warning_matcher.print_timings("warning") def parse( - self, stream: Union[str, TextIO], context: int = 6, tail: int = 0 + self, stream: Union[str, TextIO, List[str]], context: int = 6, tail: int = 0 ) -> Tuple[List[BuildError], List[BuildWarning], Optional[LogEvent]]: """Parse a log file by searching each line for errors and warnings. @@ -462,5 +467,11 @@ def parse( with open(stream, encoding="utf-8", errors="replace") as f: return self.parse(f, context, tail) - errors, warnings, tail_event, self.timings = _parse(stream, self.profile, context, tail) - return errors, warnings, tail_event + return _parse( + stream, + self._error_matcher, + self._warning_matcher, + self._file_line_matches, + context, + tail, + ) diff --git a/lib/spack/spack/util/log_parse.py b/lib/spack/spack/util/log_parse.py index 7d9640199e9d8d..91218513e59ca4 100644 --- a/lib/spack/spack/util/log_parse.py +++ b/lib/spack/spack/util/log_parse.py @@ -3,7 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import io -from typing import List, TextIO, Tuple, Union +from typing import List, Optional, TextIO, Tuple, Union from spack.llnl.util.tty.color import cescape, colorize from spack.util.ctest_log_parser import BuildError, BuildWarning, CTestLogParser, LogEvent @@ -11,6 +11,9 @@ __all__ = ["parse_log_events", "make_log_context"] +_PARSER: Optional[CTestLogParser] = None + + def parse_log_events( stream: Union[str, TextIO], context: int = 6, profile: bool = False, tail: int = 0 ) -> Tuple[List[BuildError], List[BuildWarning], Union[LogEvent, None]]: @@ -26,26 +29,20 @@ def parse_log_events( two lists containing :class:`~spack.util.ctest_log_parser.BuildError` and :class:`~spack.util.ctest_log_parser.BuildWarning` objects, plus an optional :class:`~spack.util.ctest_log_parser.LogEvent` for the tail (None when ``tail=0``). - - This is a wrapper around :class:`~spack.util.ctest_log_parser.CTestLogParser` that - lazily constructs a single ``CTestLogParser`` object. This ensures - that all the regex compilation is only done once. """ - parser = getattr(parse_log_events, "ctest_parser", None) - if parser is None: - parser = CTestLogParser(profile=profile) - setattr(parse_log_events, "ctest_parser", parser) - + global _PARSER + if profile: + parser = CTestLogParser(profile=True) + elif _PARSER is None: + _PARSER = parser = CTestLogParser() + else: + parser = _PARSER result = parser.parse(stream, context, tail) if profile: parser.print_timings() return result -#: lazily constructed CTest log parser -parse_log_events.ctest_parser = None # type: ignore[attr-defined] - - def make_log_context(log_events: List[LogEvent]) -> str: """Get error context from a log file. From fad6103fff3673438f993f6ae1cb4dc43eb1b36a Mon Sep 17 00:00:00 2001 From: Ryan Krattiger <80296582+kwryankrattiger@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:19:08 -0500 Subject: [PATCH 307/337] Rename a few classes in binary_distribution.py (#52351) This makes the naming more consistent with the role of the class (e.g. `*Fetcher` is not just fetching etc.) Signed-off-by: Ryan Krattiger --- lib/spack/spack/binary_distribution.py | 46 +++++++------- lib/spack/spack/solver/input_analysis.py | 2 +- lib/spack/spack/test/binary_distribution.py | 68 ++++++++++++--------- lib/spack/spack/test/cmd/buildcache.py | 2 +- lib/spack/spack/test/cmd/ci.py | 2 +- lib/spack/spack/test/conftest.py | 4 +- lib/spack/spack/url_buildcache.py | 2 +- 7 files changed, 68 insertions(+), 58 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index f15585ecd0d0c5..87f1d0e9cc9d8f 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -177,9 +177,9 @@ class _LocalIndexCache(TypedDict, total=False): etag: str -class BinaryCacheIndex: +class BinaryIndexCache: """ - The BinaryCacheIndex tracks what specs are available on (usually remote) + The BinaryIndexCache tracks what specs are available on (usually remote) binary caches. This index is "best effort", in the sense that whenever we don't find @@ -476,7 +476,7 @@ def _fetch_and_cache_index(self, mirror_metadata: MirrorMetadata, cache_entry={} if not web_util.url_exists(index_url): raise BuildcacheIndexNotExists(f"Index not found in cache {index_url}") - fetcher: IndexFetcher = get_index_fetcher(scheme, mirror_metadata, cache_entry) + fetcher: IndexHandler = get_index_fetcher(scheme, mirror_metadata, cache_entry) result = fetcher.conditional_fetch() # Nothing to do @@ -506,13 +506,13 @@ def _fetch_and_cache_index(self, mirror_metadata: MirrorMetadata, cache_entry={} def binary_index_location(): - """Set up a BinaryCacheIndex for remote buildcache dbs in the user's homedir.""" + """Set up a BinaryIndexCache for remote buildcache dbs in the user's homedir.""" cache_root = os.path.join(spack.caches.misc_cache_location(), "indices") return spack.util.path.canonicalize_path(cache_root) #: Default binary cache index instance -BINARY_INDEX = cast(BinaryCacheIndex, spack.llnl.util.lang.Singleton(BinaryCacheIndex)) +BINARY_INDEX = cast(BinaryIndexCache, spack.llnl.util.lang.Singleton(BinaryIndexCache)) def compute_hash(data): @@ -2570,7 +2570,7 @@ class BuildcacheIndexNotExists(Exception): FetchIndexResult = collections.namedtuple("FetchIndexResult", "etag hash data fresh") -class IndexFetcher: +class IndexHandler: def conditional_fetch(self) -> FetchIndexResult: raise NotImplementedError(f"{self.__class__.__name__} is abstract") @@ -2616,11 +2616,11 @@ def fetch_index_blob( return (computed_hash, blob_result) -class DefaultIndexFetcherV2(IndexFetcher): +class DefaultIndexHandlerV2(IndexHandler): """Fetcher for index.json, using separate index.json.hash as cache invalidation strategy""" - def __init__(self, url, local_hash, urlopen=web_util.urlopen): - self.url = url + def __init__(self, mirror_metadata, local_hash, urlopen=web_util.urlopen): + self.url = mirror_metadata.url self.local_hash = local_hash self.urlopen = urlopen self.headers = {"User-Agent": web_util.SPACK_USER_AGENT} @@ -2687,11 +2687,11 @@ def conditional_fetch(self) -> FetchIndexResult: return FetchIndexResult(etag=etag, hash=computed_hash, data=result, fresh=False) -class EtagIndexFetcherV2(IndexFetcher): +class EtagIndexHandlerV2(IndexHandler): """Fetcher for index.json, using ETags headers as cache invalidation strategy""" - def __init__(self, url, etag, urlopen=web_util.urlopen): - self.url = url + def __init__(self, mirror_metadata, etag, urlopen=web_util.urlopen): + self.url = mirror_metadata.url self.etag = etag self.urlopen = urlopen @@ -2730,7 +2730,7 @@ def conditional_fetch(self) -> FetchIndexResult: ) -class OCIIndexFetcher(IndexFetcher): +class OCIIndexHandler(IndexHandler): def __init__(self, mirror_metadata: MirrorMetadata, local_hash, urlopen=None) -> None: self.local_hash = local_hash self.ref = spack.oci.image.ImageReference.from_url(mirror_metadata.url) @@ -2784,7 +2784,7 @@ def conditional_fetch(self) -> FetchIndexResult: return FetchIndexResult(etag=None, hash=index_digest.digest, data=result, fresh=False) -class DefaultIndexFetcher(IndexFetcher): +class DefaultIndexHandler(IndexHandler): """Fetcher for buildcache index, cache invalidation via manifest contents""" def __init__(self, mirror_metadata: MirrorMetadata, local_hash, urlopen=web_util.urlopen): @@ -2832,10 +2832,10 @@ def conditional_fetch(self) -> FetchIndexResult: return FetchIndexResult(etag=etag, hash=computed_hash, data=result, fresh=False) -class EtagIndexFetcher(IndexFetcher): +class EtagIndexHandler(IndexHandler): """Fetcher for buildcache index, cache invalidation via ETags headers - This class differs from the :class:`DefaultIndexFetcher` in the following ways: + This class differs from the :class:`DefaultIndexHandler` in the following ways: 1. It is provided with an etag value on creation, rather than an index checksum value. Note that since we never start out with an etag, the default fetcher must have been used initially @@ -2892,23 +2892,23 @@ def conditional_fetch(self) -> FetchIndexResult: def get_index_fetcher( scheme: str, mirror_metadata: MirrorMetadata, cache_entry: Dict[str, str] -) -> IndexFetcher: +) -> IndexHandler: if scheme == "oci": # TODO: Actually etag and OCI are not mutually exclusive... - return OCIIndexFetcher(mirror_metadata, cache_entry.get("index_hash", None)) + return OCIIndexHandler(mirror_metadata, cache_entry.get("index_hash", None)) elif cache_entry.get("etag"): if mirror_metadata.version < 3: - return EtagIndexFetcherV2(mirror_metadata.url, cache_entry["etag"]) + return EtagIndexHandlerV2(mirror_metadata, cache_entry["etag"]) else: - return EtagIndexFetcher(mirror_metadata, cache_entry["etag"]) + return EtagIndexHandler(mirror_metadata, cache_entry["etag"]) else: if mirror_metadata.version < 3: - return DefaultIndexFetcherV2( - mirror_metadata.url, local_hash=cache_entry.get("index_hash", None) + return DefaultIndexHandlerV2( + mirror_metadata, local_hash=cache_entry.get("index_hash", None) ) else: - return DefaultIndexFetcher( + return DefaultIndexHandler( mirror_metadata, local_hash=cache_entry.get("index_hash", None) ) diff --git a/lib/spack/spack/solver/input_analysis.py b/lib/spack/spack/solver/input_analysis.py index 821d2d48e6cacc..77075ae65a6841 100644 --- a/lib/spack/spack/solver/input_analysis.py +++ b/lib/spack/spack/solver/input_analysis.py @@ -269,7 +269,7 @@ def __init__( configuration: spack.config.Configuration, repo: spack.repo.RepoPath, store: spack.store.Store, - binary_index: spack.binary_distribution.BinaryCacheIndex, + binary_index: spack.binary_distribution.BinaryIndexCache, ): self.store = store self.binary_index = binary_index diff --git a/lib/spack/spack/test/binary_distribution.py b/lib/spack/spack/test/binary_distribution.py index 5a1417415309a9..26b2769cc9b3ec 100644 --- a/lib/spack/spack/test/binary_distribution.py +++ b/lib/spack/spack/test/binary_distribution.py @@ -277,7 +277,7 @@ def test_use_bin_index(monkeypatch, tmp_path: pathlib.Path, mutable_config): monkeypatch.setattr( spack.binary_distribution, "BINARY_INDEX", - spack.binary_distribution.BinaryCacheIndex(index_cache_root), + spack.binary_distribution.BinaryIndexCache(index_cache_root), ) # Create a mirror, configure us to point at it, install a spec, and @@ -292,7 +292,7 @@ def test_use_bin_index(monkeypatch, tmp_path: pathlib.Path, mutable_config): # Now the test buildcache_cmd("list", "-al") - spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex( + spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryIndexCache( index_cache_root ) cache_list = buildcache_cmd("list", "-al") @@ -310,7 +310,7 @@ def test_use_bin_index_active_env_with_view( monkeypatch.setattr( spack.binary_distribution, "BINARY_INDEX", - spack.binary_distribution.BinaryCacheIndex(index_cache_root), + spack.binary_distribution.BinaryIndexCache(index_cache_root), ) # Create a mirror, configure us to point at it, install a spec, and @@ -329,7 +329,7 @@ def test_use_bin_index_active_env_with_view( # Now the test buildcache_cmd("list", "-al") - spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex( + spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryIndexCache( index_cache_root ) cache_list = buildcache_cmd("list", "-al") @@ -347,7 +347,7 @@ def test_use_bin_index_with_view( monkeypatch.setattr( spack.binary_distribution, "BINARY_INDEX", - spack.binary_distribution.BinaryCacheIndex(index_cache_root), + spack.binary_distribution.BinaryIndexCache(index_cache_root), ) # Create a mirror, configure us to point at it, install a spec, and @@ -367,7 +367,7 @@ def test_use_bin_index_with_view( # Now the test buildcache_cmd("list", "-al") - spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryCacheIndex( + spack.binary_distribution.BINARY_INDEX = spack.binary_distribution.BinaryIndexCache( index_cache_root ) cache_list = buildcache_cmd("list", "-al") @@ -580,8 +580,8 @@ def response_304(request: urllib.request.Request): ) assert False, "Should not fetch {}".format(url) - fetcher = spack.binary_distribution.EtagIndexFetcherV2( - url="https://www.example.com", + fetcher = spack.binary_distribution.EtagIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), etag="112a8bbc1b3f7f185621c1ee335f0502", urlopen=response_304, ) @@ -605,8 +605,8 @@ def response_200(request: urllib.request.Request): ) assert False, "Should not fetch {}".format(url) - fetcher = spack.binary_distribution.EtagIndexFetcherV2( - url="https://www.example.com", + fetcher = spack.binary_distribution.EtagIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), etag="112a8bbc1b3f7f185621c1ee335f0502", urlopen=response_200, ) @@ -630,8 +630,8 @@ def response_404(request: urllib.request.Request): fp=None, ) - fetcher = spack.binary_distribution.EtagIndexFetcherV2( - url="https://www.example.com", + fetcher = spack.binary_distribution.EtagIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), etag="112a8bbc1b3f7f185621c1ee335f0502", urlopen=response_404, ) @@ -664,8 +664,10 @@ def urlopen(request: urllib.request.Request): assert False, "Unexpected request {}".format(url) - fetcher = spack.binary_distribution.DefaultIndexFetcherV2( - url="https://www.example.com", local_hash="outdated", urlopen=urlopen + fetcher = spack.binary_distribution.DefaultIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), + local_hash="outdated", + urlopen=urlopen, ) result = fetcher.conditional_fetch() @@ -695,8 +697,10 @@ def urlopen(request: urllib.request.Request): assert False, "Unexpected request {}".format(url) - fetcher = spack.binary_distribution.DefaultIndexFetcherV2( - url="https://www.example.com", local_hash=None, urlopen=urlopen + fetcher = spack.binary_distribution.DefaultIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), + local_hash=None, + urlopen=urlopen, ) result = fetcher.conditional_fetch() @@ -725,8 +729,10 @@ def urlopen(request: urllib.request.Request): # No request to index.json should be made. assert False, "Unexpected request {}".format(url) - fetcher = spack.binary_distribution.DefaultIndexFetcherV2( - url="https://www.example.com", local_hash=index_json_hash, urlopen=urlopen + fetcher = spack.binary_distribution.DefaultIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), + local_hash=index_json_hash, + urlopen=urlopen, ) assert fetcher.conditional_fetch().fresh @@ -745,8 +751,10 @@ def urlopen(request: urllib.request.Request): code=200, ) - fetcher = spack.binary_distribution.DefaultIndexFetcherV2( - url="https://www.example.com", local_hash=index_json_hash, urlopen=urlopen + fetcher = spack.binary_distribution.DefaultIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), + local_hash=index_json_hash, + urlopen=urlopen, ) assert fetcher.get_remote_hash() is None @@ -778,8 +786,10 @@ def urlopen(request: urllib.request.Request): assert False, "Unexpected fetch {}".format(url) - fetcher = spack.binary_distribution.DefaultIndexFetcherV2( - url="https://www.example.com", local_hash="invalid", urlopen=urlopen + fetcher = spack.binary_distribution.DefaultIndexHandlerV2( + spack.binary_distribution.MirrorMetadata("https://www.example.com", 2), + local_hash="invalid", + urlopen=urlopen, ) with pytest.raises(spack.binary_distribution.FetchIndexError, match="Could not fetch index"): @@ -1267,7 +1277,7 @@ def response_304(request: urllib.request.Request): ) assert False, "Unexpected request {}".format(url) - fetcher = spack.binary_distribution.EtagIndexFetcher( + fetcher = spack.binary_distribution.EtagIndexHandler( spack.binary_distribution.MirrorMetadata( "https://www.example.com", spack.binary_distribution.CURRENT_BUILD_CACHE_LAYOUT_VERSION ), @@ -1294,7 +1304,7 @@ def response_200(request: urllib.request.Request): ) assert False, "Unexpected request {}".format(url) - fetcher = spack.binary_distribution.EtagIndexFetcher( + fetcher = spack.binary_distribution.EtagIndexHandler( spack.binary_distribution.MirrorMetadata( "https://www.example.com", spack.binary_distribution.CURRENT_BUILD_CACHE_LAYOUT_VERSION ), @@ -1322,7 +1332,7 @@ def response_404(request: urllib.request.Request): fp=None, ) - fetcher = spack.binary_distribution.EtagIndexFetcher( + fetcher = spack.binary_distribution.EtagIndexHandler( spack.binary_distribution.MirrorMetadata( "https://www.example.com", spack.binary_distribution.CURRENT_BUILD_CACHE_LAYOUT_VERSION ), @@ -1348,7 +1358,7 @@ def urlopen(request: urllib.request.Request): assert False, "Unexpected request {}".format(url) - fetcher = spack.binary_distribution.DefaultIndexFetcher( + fetcher = spack.binary_distribution.DefaultIndexHandler( spack.binary_distribution.MirrorMetadata( "https://www.example.com", spack.binary_distribution.CURRENT_BUILD_CACHE_LAYOUT_VERSION ), @@ -1377,7 +1387,7 @@ def urlopen(request: urllib.request.Request): fp=None, ) - fetcher = spack.binary_distribution.DefaultIndexFetcher( + fetcher = spack.binary_distribution.DefaultIndexHandler( spack.binary_distribution.MirrorMetadata( "https://www.example.com", spack.binary_distribution.CURRENT_BUILD_CACHE_LAYOUT_VERSION ), @@ -1404,7 +1414,7 @@ def urlopen(request: urllib.request.Request): # No other request should be made. assert False, "Unexpected request {}".format(url) - fetcher = spack.binary_distribution.DefaultIndexFetcher( + fetcher = spack.binary_distribution.DefaultIndexHandler( spack.binary_distribution.MirrorMetadata( "https://www.example.com", spack.binary_distribution.CURRENT_BUILD_CACHE_LAYOUT_VERSION ), @@ -1500,7 +1510,7 @@ def test_update_warns_on_mirror_with_no_index(monkeypatch, tmp_path: pathlib.Pat def no_index(*args, **kwargs): raise spack.binary_distribution.BuildcacheIndexNotExists("no index") - binary_index = spack.binary_distribution.BinaryCacheIndex(str(tmp_path / "index_cache")) + binary_index = spack.binary_distribution.BinaryIndexCache(str(tmp_path / "index_cache")) monkeypatch.setattr(binary_index, "_fetch_and_cache_index", no_index) with pytest.warns(UserWarning, match="cannot be used in concretization"): diff --git a/lib/spack/spack/test/cmd/buildcache.py b/lib/spack/spack/test/cmd/buildcache.py index 776841a6791a85..d4e93806bbfd34 100644 --- a/lib/spack/spack/test/cmd/buildcache.py +++ b/lib/spack/spack/test/cmd/buildcache.py @@ -1026,7 +1026,7 @@ def read_specs_in_index(mirror_directory, view): mirror_metadata = spack.binary_distribution.MirrorMetadata( f"file://{mirror_directory}", spack.mirrors.mirror.SUPPORTED_LAYOUT_VERSIONS[0], view ) - fetcher = spack.binary_distribution.DefaultIndexFetcher(mirror_metadata, None) + fetcher = spack.binary_distribution.DefaultIndexHandler(mirror_metadata, None) result = fetcher.conditional_fetch() db_dict = json.loads(result.data) return set([h for h in db_dict["database"]["installs"]]) diff --git a/lib/spack/spack/test/cmd/ci.py b/lib/spack/spack/test/cmd/ci.py index 53edde5b46443b..c111bbba4728e5 100644 --- a/lib/spack/spack/test/cmd/ci.py +++ b/lib/spack/spack/test/cmd/ci.py @@ -878,7 +878,7 @@ def test_push_to_build_cache( # Validate resulting buildcache (database) index layout_version = spack.binary_distribution.CURRENT_BUILD_CACHE_LAYOUT_VERSION mirror_metadata = spack.binary_distribution.MirrorMetadata(mirror_url, layout_version) - index_fetcher = spack.binary_distribution.DefaultIndexFetcher(mirror_metadata, None) + index_fetcher = spack.binary_distribution.DefaultIndexHandler(mirror_metadata, None) result = index_fetcher.conditional_fetch() spack.vendor.jsonschema.validate(json.loads(result.data), db_idx_schema) diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index 3db5345150794e..a1ff0074f70e8f 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -632,7 +632,7 @@ def mock_binary_index(monkeypatch, tmp_path_factory: pytest.TempPathFactory): """ tmpdir = tmp_path_factory.mktemp("mock_binary_index") index_path = tmpdir / "binary_index" - mock_index = spack.binary_distribution.BinaryCacheIndex(str(index_path)) + mock_index = spack.binary_distribution.BinaryIndexCache(str(index_path)) monkeypatch.setattr(spack.binary_distribution, "BINARY_INDEX", mock_index) yield @@ -2093,7 +2093,7 @@ def inode_cache(): def brand_new_binary_cache(): yield spack.binary_distribution.BINARY_INDEX = spack.llnl.util.lang.Singleton( - spack.binary_distribution.BinaryCacheIndex + spack.binary_distribution.BinaryIndexCache ) diff --git a/lib/spack/spack/url_buildcache.py b/lib/spack/spack/url_buildcache.py index 2b4d7cf942dda6..561733e15f012f 100644 --- a/lib/spack/spack/url_buildcache.py +++ b/lib/spack/spack/url_buildcache.py @@ -1354,7 +1354,7 @@ def try_verify(specfile_path): class MirrorMetadata: """Simple class to hold a mirror url and a buildcache layout version - This class is used by BinaryCacheIndex to produce a key used to keep + This class is used by BinaryIndexCache to produce a key used to keep track of downloaded/processed buildcache index files from remote mirrors in some layout version.""" From a26ef0d71cc85d6d8200aec1ad28d3ba65e8cfc2 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 29 Apr 2026 13:03:08 +0200 Subject: [PATCH 308/337] solver: pre-check if unknown virtuals are in the input specs (#52317) * solver: pre-check if unknown virtuals are in the input specs fixes #51364 closes #51323 Error early with a clear message if unknown virtuals are in the input specs or in a requirement Signed-off-by: Massimiliano Culpo * Raise the same exception type for invalid virtuals Signed-off-by: Massimiliano Culpo * Use BFS instead of DFS Signed-off-by: Massimiliano Culpo --------- Signed-off-by: Massimiliano Culpo --- lib/spack/spack/error.py | 4 +++ lib/spack/spack/solver/asp.py | 31 +++++++++++++++++++ lib/spack/spack/solver/requirements.py | 21 +++++++++++++ lib/spack/spack/test/concretization/errors.py | 29 +++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/lib/spack/spack/error.py b/lib/spack/spack/error.py index de90ed053dfdf7..1ab2f95076d545 100644 --- a/lib/spack/spack/error.py +++ b/lib/spack/spack/error.py @@ -116,6 +116,10 @@ class SpecError(SpackError): """Superclass for all errors that occur while constructing specs.""" +class InvalidVirtualOnEdgeError(SpecError): + """Raised when an edge requires a virtual that does not exist in the repository.""" + + class UnsatisfiableSpecError(SpecError): """ Raised when a spec conflicts with package constraints. diff --git a/lib/spack/spack/solver/asp.py b/lib/spack/spack/solver/asp.py index 3d17bf2d61e167..4b9dd8d54a7c9e 100644 --- a/lib/spack/spack/solver/asp.py +++ b/lib/spack/spack/solver/asp.py @@ -3917,6 +3917,8 @@ def __init__(self, *, specs_factory: Optional[SpecFiltersFactory] = None): def _check_input_and_extract_concrete_specs( specs: Sequence[spack.spec.Spec], ) -> List[spack.spec.Spec]: + _check_unknown_virtuals_in_input_specs(specs) + reusable: List[spack.spec.Spec] = [] analyzer = create_graph_analyzer() for root in specs: @@ -4065,6 +4067,35 @@ def solve_in_rounds( self._conc_cache.cleanup() +class _SkipConcreteVisitor(traverse.BaseVisitor): + """Visitor that trims edges between two concrete nodes.""" + + def neighbors(self, item): + if item.edge.spec.concrete: + return [] + return super().neighbors(item) + + +def _check_unknown_virtuals_in_input_specs(specs: Sequence[spack.spec.Spec]) -> None: + """Raise if any edge in *specs* requires a virtual that does not exist in the repository.""" + errors = [] + for root in specs: + root_edges = traverse.with_artificial_edges([root]) + visitor = traverse.CoverNodesVisitor(_SkipConcreteVisitor()) + for edge in traverse.traverse_breadth_first_edges_generator(root_edges, visitor): + for virtual in edge.virtuals: + if not spack.repo.PATH.is_virtual(virtual): + errors.append(f"'{virtual}' in '{root}' is not a known virtual package") + if not errors: + return + if len(errors) == 1: + raise spack.error.InvalidVirtualOnEdgeError(errors[0]) + details = "\n".join(f" {idx}. {msg}" for idx, msg in enumerate(errors, 1)) + raise spack.error.InvalidVirtualOnEdgeError( + f"unknown virtuals have been found in input specs:\n{details}" + ) + + class UnsatisfiableSpecError(spack.error.UnsatisfiableSpecError): """There was an issue with the spec that was requested (i.e. a user error).""" diff --git a/lib/spack/spack/solver/requirements.py b/lib/spack/spack/solver/requirements.py index 8e0c4ab6d9539e..5014d57c671e82 100644 --- a/lib/spack/spack/solver/requirements.py +++ b/lib/spack/spack/solver/requirements.py @@ -26,6 +26,26 @@ def _mark_str(raw) -> str: return f"{mark.name}:{mark.line + 1}: " if mark else "" +def _check_unknown_virtuals_on_edges(raw_strs: List[str], specs: List["spack.spec.Spec"]) -> None: + """Raise if any edge in *specs* requires a virtual that does not exist in the repository.""" + errors = [] + for raw, spec in zip(raw_strs, specs): + for edge in spack.traverse.traverse_edges([spec], root=False): + for virtual in edge.virtuals: + if not spack.repo.PATH.is_virtual(virtual): + errors.append( + f"{_mark_str(raw)}'{virtual}' in '{raw}' is not a known virtual package" + ) + if not errors: + return + if len(errors) == 1: + raise spack.error.InvalidVirtualOnEdgeError(errors[0]) + details = "\n".join(f" {idx}. {msg}" for idx, msg in enumerate(errors, 1)) + raise spack.error.InvalidVirtualOnEdgeError( + f"unknown virtuals have been detected in requirements:\n{details}" + ) + + def _check_unknown_targets( raw_strs: List[str], specs: List["spack.spec.Spec"], *, always_warn: bool = False ) -> None: @@ -316,6 +336,7 @@ def _rules_from_requirements( for constraint in raw_strs ] _check_unknown_targets(raw_strs, constraints) + _check_unknown_virtuals_on_edges(raw_strs, constraints) when_str = requirement.get("when") when = self._parse_and_expand(when_str) if when_str else spack.spec.Spec() diff --git a/lib/spack/spack/test/concretization/errors.py b/lib/spack/spack/test/concretization/errors.py index 66c2c9c6ec4d91..ab25523c049dc8 100644 --- a/lib/spack/spack/test/concretization/errors.py +++ b/lib/spack/spack/test/concretization/errors.py @@ -174,6 +174,19 @@ def assert_actionable_error(exc_info, *required_part: str) -> None: ["mvapich2", "file_systems", "the value 'auto' is mutually exclusive"], id="variant_disjoint_sets", ), + # "fortan" is not a known virtual (typo of "fortran"). The error must name the + # unknown virtual and quote the originating spec, and must not be a generic internal error. + pytest.param( + "zlib %c,cxx,fortan=gcc", + ["fortan", "zlib %c,cxx,fortan=gcc", "not a known virtual"], + id="unknown_virtual_on_edge", + ), + # Two unknown virtuals on the same edge: both must appear in the single error raised. + pytest.param( + "zlib %c,fortan,cxxxx=gcc", + ["fortan", "cxxxx", "zlib %c,cxxxx,fortan=gcc"], + id="two_unknown_virtuals_on_edge", + ), ], ) def test_input_spec_driven_errors( @@ -225,6 +238,22 @@ def test_input_spec_driven_errors( ["libelf"], id="requirement_unsatisfied_generic", ), + # A `require:` entry names a virtual that does not exist. The error must name the + # unknown virtual and quote the originating spec so the user can find and fix the + # config entry. + pytest.param( + {"packages:zlib": {"require": ["%[virtuals=fortan]gcc"]}}, + "zlib", + ["fortan", "%[virtuals=fortan]gcc"], + id="unknown_virtual_in_requirement", + ), + # Two unknown virtuals in a single requirement spec: both must appear in the error. + pytest.param( + {"packages:zlib": {"require": ["%[virtuals=fortan,cxxxx]gcc"]}}, + "zlib", + ["fortan", "cxxxx", "%[virtuals=fortan,cxxxx]gcc"], + id="two_unknown_virtuals_in_requirement", + ), ], ) def test_config_driven_errors( From 7d9f2093afd98ac9d2232a5915e6211802329bca Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 29 Apr 2026 13:05:57 +0200 Subject: [PATCH 309/337] config edit: create the scope directory if it does not exist (#52297) fixes #52152 Signed-off-by: Massimiliano Culpo --- lib/spack/spack/cmd/config.py | 1 + lib/spack/spack/test/cmd/config.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index 2d86c60ff56dbc..f559c3a0f164b1 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -288,6 +288,7 @@ def config_edit(args): if args.print_file: print(config_file) else: + fs.mkdirp(os.path.dirname(config_file)) editor(config_file) diff --git a/lib/spack/spack/test/cmd/config.py b/lib/spack/spack/test/cmd/config.py index a07f9b027364aa..f051c10968fafa 100644 --- a/lib/spack/spack/test/cmd/config.py +++ b/lib/spack/spack/test/cmd/config.py @@ -6,9 +6,11 @@ import os import pathlib import re +import shutil import pytest +import spack.cmd.config as config_cmd import spack.concretize import spack.config import spack.database @@ -767,3 +769,25 @@ def test_config_with_unknown_group_gives_clear_error(cmd_str, tmp_path, mutable_ output = config(cmd_str, "--group=nonexistent", "packages", fail_on_error=False) assert config.returncode != 0 assert "'nonexistent' not found in" in output + + +@pytest.mark.regression("52152") +def test_config_edit_creates_scope_dir(mutable_config, working_env, monkeypatch): + """Tests that `spack config edit` can create the scope directory if it does not exist.""" + scope_name = spack.config.default_modify_scope("config") + scope_dir = pathlib.Path(mutable_config.scopes[scope_name].path) + + # Remove the scope directory to simulate a "fresh start" with no ~/.spack + shutil.rmtree(scope_dir) + assert not scope_dir.exists() + + editor_called = [] + + def fake_editor(*args, **kwargs): + editor_called.extend(args) + + monkeypatch.setattr(config_cmd, "editor", fake_editor) + config("edit", "config") + + assert scope_dir.exists(), "scope directory should be created before invoking the editor" + assert editor_called, "editor should have been called" From 078f8fac8b84b42d053b5b23e9bb1c796bc58b84 Mon Sep 17 00:00:00 2001 From: Victor Brunini Date: Wed, 29 Apr 2026 07:50:12 -0400 Subject: [PATCH 310/337] environment: Make mutate capable of handling multiple selectors/mutators in one pass. (#51948) * environment: Make mutate capable of handling multiple selectors/mutators in one pass. To amortize the cost of recomputing hashes, updating views, etc. when making different changes to multiple specs at once. For example starting with an installed environment with a view and multiple develop packages and running 'spack undevelop -a' would previously run ~10 view regenerations and take ~15s on my machine. It now runs 1 view regeneration and takes ~3s. Signed-off-by: Victor Brunini * Add a test for multiple simultaneous mutations. Signed-off-by: Victor Brunini --------- Signed-off-by: Victor Brunini Signed-off-by: Victor Brunini Co-authored-by: vbrunini --- lib/spack/spack/cmd/change.py | 7 +- lib/spack/spack/cmd/develop.py | 2 +- lib/spack/spack/cmd/undevelop.py | 3 +- lib/spack/spack/environment/environment.py | 84 +++++++++++++++------- lib/spack/spack/test/environment/mutate.py | 45 +++++++++++- 5 files changed, 110 insertions(+), 31 deletions(-) diff --git a/lib/spack/spack/cmd/change.py b/lib/spack/spack/cmd/change.py index 91ac5729fbce84..89b7eaff12f1f2 100644 --- a/lib/spack/spack/cmd/change.py +++ b/lib/spack/spack/cmd/change.py @@ -80,7 +80,12 @@ def change(parser, args): raise ValueError(msg) from e if args.concrete or args.concrete_only: + selectors = [] + mutators = [] for spec in specs: - env.mutate(selector=match_spec or spack.spec.Spec(spec.name), mutator=spec) + selectors.append(match_spec or spack.spec.Spec(spec.name)) + mutators.append(spec) + + env.mutate(selectors=selectors, mutators=mutators) env.write() diff --git a/lib/spack/spack/cmd/develop.py b/lib/spack/spack/cmd/develop.py index b1604f62eb17ee..6ab0c7cef6262e 100644 --- a/lib/spack/spack/cmd/develop.py +++ b/lib/spack/spack/cmd/develop.py @@ -183,7 +183,7 @@ def update_env( # If we are automatically mutating the concrete specs for dev provenance, do so if apply_changes: - env.apply_develop(spec, _abs_code_path(env, spec, specified_path)) + env.apply_develop([spec], [_abs_code_path(env, spec, specified_path)]) def _clone(spec: spack.spec.Spec, abspath: str, force: bool = False): diff --git a/lib/spack/spack/cmd/undevelop.py b/lib/spack/spack/cmd/undevelop.py index 373f832c8720f6..c2b3ec1c0eeb59 100644 --- a/lib/spack/spack/cmd/undevelop.py +++ b/lib/spack/spack/cmd/undevelop.py @@ -59,8 +59,7 @@ def undevelop(parser, args): with env.write_transaction(): _update_config(remove_specs) if args.apply_changes: - for spec in remove_specs: - env.apply_develop(spec, path=None) + env.apply_develop(remove_specs, paths=None) updated_all_dev_specs = set(spack.config.get("develop")) diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index abd9b18a87585b..57299872b9ba24 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -13,6 +13,7 @@ import stat import warnings from collections.abc import KeysView +from itertools import zip_longest from typing import ( Any, Callable, @@ -1573,34 +1574,45 @@ def is_develop(self, spec): """Returns true when the spec is built from local sources""" return spec.name in self.dev_specs - def apply_develop(self, spec: spack.spec.Spec, path: Optional[str] = None): + def apply_develop(self, specs: List[spack.spec.Spec], paths: Optional[List[str]] = None): """Mutate concrete specs to include dev_path provenance pointing to path. This will fail if any existing concrete spec for the same package does not satisfy the given develop spec.""" - selector = spack.spec.Spec(spec.name) + selectors = [] + mutators = [] + msgs = [] + + assert not paths or len(specs) == len(paths) + for spec, path in zip_longest(specs, paths or [], fillvalue=None): + assert spec + selector = spack.spec.Spec(spec.name) + + mutator = spack.spec.Spec() + if path: + variant = vt.SingleValuedVariant("dev_path", path) + else: + variant = vt.VariantValueRemoval("dev_path") + mutator.variants["dev_path"] = variant - mutator = spack.spec.Spec() - if path: - variant = vt.SingleValuedVariant("dev_path", path) - else: - variant = vt.VariantValueRemoval("dev_path") - mutator.variants["dev_path"] = variant + msg = ( + f"Develop spec '{spec}' conflicts with concrete specs in environment." + " Try again with 'spack develop --no-modify-concrete-specs'" + " and run 'spack concretize --force' to apply your changes." + ) + selectors.append(selector) + mutators.append(mutator) + msgs.append(msg) - msg = ( - f"Develop spec '{spec}' conflicts with concrete specs in environment." - " Try again with 'spack develop --no-modify-concrete-specs'" - " and run 'spack concretize --force' to apply your changes." - ) - self.mutate(selector, mutator, validator=spec, msg=msg) + self.mutate(selectors, mutators, validators=specs, msgs=msgs) def mutate( self, - selector: spack.spec.Spec, - mutator: spack.spec.Spec, - validator: Optional[spack.spec.Spec] = None, - msg: Optional[str] = None, + selectors: List[spack.spec.Spec], + mutators: List[spack.spec.Spec], + validators: Optional[List[spack.spec.Spec]] = None, + msgs: Optional[List[str]] = None, ): """Mutate concrete specs of an environment @@ -1611,17 +1623,37 @@ def mutate( # Find all specs that this mutation applies to modify_specs = [] modified_specs = [] + if len(selectors) != len(mutators): + raise ValueError( + f"Length mismatch: selectors ({len(selectors)}) != mutators ({len(mutators)})" + ) + + if validators and len(validators) != len(selectors): + raise ValueError( + f"Length mismatch: validators ({len(validators)}) != selectors ({len(selectors)})" + ) + + if msgs and len(msgs) != len(selectors): + raise ValueError( + f"Length mismatch: msgs ({len(msgs)}) != selectors ({len(selectors)})" + ) + for dep in self.all_specs_generator(): - if dep.satisfies(selector): - if not dep.satisfies(validator or selector): - if not msg: - msg = f"spec {dep} satisfies selector {selector}" - msg += f" but not validator {validator}" - raise SpackEnvironmentDevelopError(msg) - modify_specs.append(dep) + for selector, mutator, validator, msg in zip_longest( + selectors, mutators, validators or [], msgs or [], fillvalue=None + ): + assert selector + assert mutator + if dep.satisfies(selector): + if not dep.satisfies(validator or selector): + if not msg: + msg = f"spec {dep} satisfies selector {selector}" + msg += f" but not validator {validator}" + raise SpackEnvironmentDevelopError(msg) + modify_specs.append((dep, mutator)) # Manipulate selected specs - for s in modify_specs: + for s, mutator in modify_specs: modified = s.mutate(mutator, rehash=False) if modified: modified_specs.append(s) diff --git a/lib/spack/spack/test/environment/mutate.py b/lib/spack/spack/test/environment/mutate.py index bc4677ab635b44..d9adf6a6273fdb 100644 --- a/lib/spack/spack/test/environment/mutate.py +++ b/lib/spack/spack/test/environment/mutate.py @@ -63,7 +63,7 @@ def test_mutate_internals(dep, orig_constraint, mutated_constraint): selector = spack.spec.Spec("cmake") mutator = spack.spec.Spec(mutated_constraint) - env.mutate(selector=selector, mutator=mutator) + env.mutate(selectors=[selector], mutators=[mutator]) cmake_spec.mutate(mutator) for spec in env.all_specs_generator(): @@ -81,6 +81,49 @@ def test_mutate_internals(dep, orig_constraint, mutated_constraint): assert root_spec.dag_hash() == new_hash +def test_mutate_internals_multiple_mutations(): + """ + Check that Environment.mutate correctly applies multiple mutations to different selected Specs. + """ + ev.create("test") + env = ev.read("test") + + root = "cmake-client+truthy os=debian6 %cmake@3.23.1 os=debian6" + env.add(root) + env.concretize() + + planned_mutations = [ + ("cmake", "@3.4.3"), + ("cmake-client", "~truthy"), + ("platform=test", "os=redhat6"), + ] + + orig_hash = next(env.roots()).dag_hash() + + selectors, mutators = zip( + *[(spack.spec.Spec(s), spack.spec.Spec(m)) for s, m in planned_mutations] + ) + + with pytest.raises(ValueError, match="Length mismatch: selectors"): + env.mutate(selectors=[], mutators=mutators) + + with pytest.raises(ValueError, match="Length mismatch: validators"): + env.mutate(selectors=selectors, mutators=mutators, validators=["cmake@3.4.3"]) + + with pytest.raises(ValueError, match="Length mismatch: msgs"): + env.mutate(selectors=selectors, mutators=mutators, msgs=["A message"]) + + env.mutate(selectors=selectors, mutators=mutators) + + for selector, mutated_constraint in planned_mutations: + for spec in env.all_specs_generator(): + if spec.satisfies(selector): + assert spec.satisfies(mutated_constraint) + + new_hash = next(env.roots()).dag_hash() + assert new_hash != orig_hash + + @pytest.mark.parametrize("constraint", ["foo", "foo.bar", "foo%cmake@1.0", "foo@1.1:", "foo/abc"]) def test_mutate_spec_invalid(constraint): spec = spack.concretize.concretize_one("cmake-client") From 243f50f4025b2e96d6c849009a3d182554385b03 Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 29 Apr 2026 18:41:29 +0200 Subject: [PATCH 311/337] documentation: faq on how to debug concretization errors (#52315) Signed-off-by: Massimiliano Culpo --- lib/spack/docs/developer_guide.rst | 66 +++++++++++++++++++ lib/spack/docs/frequently_asked_questions.rst | 24 +++++++ 2 files changed, 90 insertions(+) diff --git a/lib/spack/docs/developer_guide.rst b/lib/spack/docs/developer_guide.rst index a62cd9507ed1d5..a4f0ce070f68e1 100644 --- a/lib/spack/docs/developer_guide.rst +++ b/lib/spack/docs/developer_guide.rst @@ -697,6 +697,72 @@ To profile Spack, use Python's built-in `cProfile zlib.lp + +The resulting file contains both the package facts (versions, variants, dependencies) and the problem-specific facts derived from the user's configuration. +It can be fed directly to clingo alongside the solver rules. + +Running clingo directly +^^^^^^^^^^^^^^^^^^^^^^^ + +Once you have the facts file, you can invoke clingo directly. +This bypasses Spack's Python layer and lets you iterate on ``.lp`` rule files quickly. + +On Linux (includes libc compatibility rules): + +.. code-block:: console + + $ LP_FILES="lib/spack/spack/solver/concretize.lp \ + lib/spack/spack/solver/heuristic.lp \ + lib/spack/spack/solver/display.lp \ + lib/spack/spack/solver/libc_compatibility.lp \ + lib/spack/spack/solver/direct_dependency.lp" + $ clingo --verbose=3 --stats=2 --quiet=1,0,0 [--project-anonymous] \ + --configuration=tweety --opt-strategy=usc,one \ + --heuristic=Domain $LP_FILES zlib.lp + +On macOS, replace ``libc_compatibility.lp`` with ``os_compatibility.lp``. + +Reading the output +^^^^^^^^^^^^^^^^^^ + +``--quiet=1,0,0`` suppresses intermediate models and shows only the optimal answer. +``--stats=2`` appends a detailed statistics block at the end of the output. +The most useful fields are: + +* **Grounding**: total number of ground rules; a sudden increase usually indicates a rule is producing a combinatorial blowup. +* **Solve time**: wall-clock time spent in the search phase alone, excluding grounding. +* **Optimization**: the vector of objective values at each priority level, useful for verifying that the solver is minimizing the right criteria. + +``--verbose=3`` prints each rule as it is grounded, which helps identify which rule is responsible for an unexpected grounding explosion. +Because the output is very large, redirect it to a file and search for the rule body of interest. + +If a solve takes a long time to finish, you can interrupt it with ``Ctrl+C``. +The partial statistics printed on interrupt are still useful for diagnosing the bottleneck. + +Running the concretization test suite +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +After modifying any solver ``.lp`` file, verify correctness with: + +.. code-block:: console + + $ pytest -n 8 lib/spack/spack/test/concretization + .. _releases: Releases diff --git a/lib/spack/docs/frequently_asked_questions.rst b/lib/spack/docs/frequently_asked_questions.rst index ada520fc06c630..8edcdfb6e79751 100644 --- a/lib/spack/docs/frequently_asked_questions.rst +++ b/lib/spack/docs/frequently_asked_questions.rst @@ -151,6 +151,30 @@ You can also be more specific about what compiler to use for a particular langua These input specs can be simplified using :doc:`toolchains_yaml`. See also :ref:`pitfalls-without-toolchains` for common mistakes to avoid. +.. _faq-concretization-errors: + +How do I debug unexpected or failing concretization? +----------------------------------------------------- + +``spack install`` and ``spack concretize`` may fail with a concretization error when the solver cannot find a package configuration that satisfies all constraints. + +Most of the time, the error message is structured and contains information about which requirements could not be met. +It typically identifies the conflicting constraints and the files where they are defined (e.g., a ``packages.yaml`` entry or a ``conflicts()`` directive in a ``package.py``). +If the cause is clear from the error, you can fix the offending entry directly. + +If it is not obvious *why* the solver made a particular decision -- for example, why it chose a specific version or variant -- run :ref:`spack-solve` to see the full optimization breakdown: + +.. code-block:: console + + $ spack solve + +The output shows the optimization criteria and the weights assigned to each choice. +This makes it possible to trace which preference or requirement is driving an unexpected result. +See also :ref:`faq-concretizer-precedence` for an overview of how criteria are prioritized. + +For a deeper investigation of solver internals, see :ref:`debugging-concretization` in the developer guide. + .. rubric:: Footnotes .. [#f1] The exact list of criteria can be retrieved with the :ref:`spack-solve` command. + See :ref:`faq-concretization-errors` for more information. From 79c9381eae27f40eec5fe85b4d3c44a7eb1d40d3 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 30 Apr 2026 13:56:36 +0200 Subject: [PATCH 312/337] new_installer.py: schedule build deps dynamically (#52222) Instead of scheduling the full dependency DAG up front, the installer initially schedules only the link/run sub-DAG. Each spec is attempted from the binary cache first. On a cache miss, pure build dependencies are scheduled. This repeats recursively, so build deps are only installed when they are actually needed. There is no overhead for users who don't have binary mirrors configured. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 340 +++++++++++++----- lib/spack/spack/test/cmd/install.py | 28 +- lib/spack/spack/test/installer_build_graph.py | 170 +++++++++ lib/spack/spack/test/installer_tui.py | 24 ++ lib/spack/spack/test/new_installer.py | 90 +++++ 5 files changed, 560 insertions(+), 92 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 0896e3e8d7dfc9..6d6066d5085029 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -70,6 +70,7 @@ import spack.llnl.util.filesystem as fs import spack.llnl.util.tty import spack.llnl.util.tty.color +import spack.mirrors.mirror import spack.paths import spack.report import spack.spec @@ -114,8 +115,14 @@ #: Suffix for temporary cleanup during failed install OVERWRITE_GARBAGE_SUFFIX = ".garbage" -#: Exit code used by the child process to signal that the build was stopped at a phase boundary -EXIT_STOPPED_AT_PHASE = 3 + +class ExitCode: + SUCCESS = 0 + BUILD_ERROR = 1 + #: Exit code used by the child process to signal that the build was stopped at a phase boundary + STOPPED_AT_PHASE = 3 + #: Exit code used by the child process to signal a binary cache miss (no source fallback) + BUILD_CACHE_MISS = 4 class DatabaseAction: @@ -128,13 +135,13 @@ class DatabaseAction: def save_to_db(self, db: spack.database.Database) -> None: ... - def release_lock(self) -> None: + def release_prefix_lock(self) -> None: if self.prefix_lock is not None: try: self.prefix_lock.release_write() except Exception: pass - self.prefix_lock = None + self.prefix_lock = None class MarkExplicitAction(DatabaseAction): @@ -225,7 +232,7 @@ def tee(control_r: int, log_r: int, log_path: str, parent_w: int) -> None: selector.register(control_r, selectors.EVENT_READ) try: - with open(log_path, "wb") as log_file, open(parent_w, "wb", closefd=False) as parent: + with open(log_path, "ab") as log_file, open(parent_w, "wb", closefd=False) as parent: while True: for key, _ in selector.select(): if key.fd == log_r: @@ -396,8 +403,9 @@ def __exit__( return # Failure handling: - if self.keep_prefix: - # Leave the failed prefix in place, discard the backup + if self.keep_prefix and not issubclass(exc_type, BinaryCacheMiss): + # Leave the failed prefix in place, discard the backup. Except for binary cache misses, + # which is a scheduling failure and not a build failure. if self.tmp_prefix is not None: self._rmtree_ignore_errors(self.tmp_prefix) elif self.tmp_prefix is not None: @@ -537,7 +545,7 @@ def handle_sigterm(signum, frame): # Use closedfd=false because of the connection objects. Use line buffering. state_stream = os.fdopen(state.fileno(), "w", buffering=1, closefd=False) - exit_code = 0 + exit_code = ExitCode.SUCCESS try: with PrefixPivoter(spec.prefix, keep_prefix): @@ -561,18 +569,20 @@ def handle_sigterm(signum, frame): stop_at, ) except spack.error.StopPhase: - exit_code = EXIT_STOPPED_AT_PHASE + exit_code = ExitCode.STOPPED_AT_PHASE except ProcessError as e: print(e, file=sys.stderr) - exit_code = 1 + exit_code = ExitCode.BUILD_ERROR + except BinaryCacheMiss: + exit_code = ExitCode.BUILD_CACHE_MISS except BaseException: traceback.print_exc(limit=-4) - exit_code = 1 + exit_code = ExitCode.BUILD_ERROR finally: tee.close() state_stream.close() - if exit_code == 0: + if exit_code == ExitCode.SUCCESS: # Try to install the compressed log file if not os.path.lexists(spec.package.install_log_path): try: @@ -695,9 +705,8 @@ def _install( spack.hooks.post_install(spec, explicit) return elif install_policy == "cache_only": - # Binary required but not available send_state("no binary available", state_stream) - raise spack.error.InstallError(f"No binary available for {spec}") + raise BinaryCacheMiss(f"No binary available for {spec}") unmodified_env = os.environ.copy() env_mods = spack.build_environment.setup_package(pkg, dirty=dirty) @@ -933,6 +942,7 @@ def start_build( install_source: bool, run_tests: bool, jobserver: JobServer, + log_path: str, stop_before: Optional[str] = None, stop_at: Optional[str] = None, ) -> ChildInfo: @@ -948,17 +958,6 @@ def start_build( makeflags = jobserver.makeflags(gmake) fifo = "--jobserver-auth=fifo:" in makeflags - # TODO: remove once external specs do not create a build process - if spec.external: - log_path = os.devnull - else: - log_fd, log_path = tempfile.mkstemp( - prefix=f"spack-stage-{spec.name}-{spec.version}-{spec.dag_hash()}-", - suffix=".log", - dir=spack.stage.get_stage_root(), - ) - os.close(log_fd) # child will open it - proc = Process( target=worker_function, args=( @@ -1116,7 +1115,7 @@ def __init__( self.duration: Optional[float] = None self.progress_percent: Optional[int] = None self.control_w_conn = control_w_conn - self.log_path: Optional[str] = log_path + self.log_path = log_path self.log_summary: Optional[str] = None @@ -1197,6 +1196,15 @@ def add_build( except OSError: pass + def remove_build(self, build_id: str) -> None: + """Remove a build from the display (e.g. after a binary cache miss before retry).""" + self.builds.pop(build_id, None) + if self.tracked_build_id == build_id: + self.tracked_build_id = "" + if not self.overview_mode: + self.overview_mode = True + self.dirty = True + def toggle(self) -> None: """Toggle between overview mode and following a specific build.""" if self.overview_mode: @@ -1650,10 +1658,14 @@ def __init__( overwrite_set = overwrite_set or set() explicit_set = explicit_set or set() self.pruned: Set[str] = set() + self.done: Set[str] = set() + self.force_source: Set[str] = set() stack: List[Tuple[spack.spec.Spec, InstallPolicy]] = [ (s, root_policy) for s in self.nodes.values() ] + self.tests = tests + with database.read_transaction(): # Set the install prefix for each spec based on the db record or store layout for s in spack.traverse.traverse_nodes(specs): @@ -1668,23 +1680,18 @@ def __init__( spec, install_policy = stack.pop() key = spec.dag_hash() _, record = database.query_by_spec_hash(key) + depflag = self._base_deptypes(spec) # Conditionally include build dependencies. Don't prune installed specs # that need to be marked explicit so they flow through the DB write path. if record and record.installed and key not in overwrite_set: - # Installed spec only needs link/run deps traversed. - dependencies = spec.dependencies(deptype=dt.LINK | dt.RUN) # If it needs to be marked explicit, keep it in the graph (don't prune). - if not (key in explicit_set and not record.explicit): + if key not in explicit_set or record.explicit: self.pruned.add(key) - elif install_policy == "cache_only" and not include_build_deps: - dependencies = spec.dependencies(deptype=dt.LINK | dt.RUN) - else: - deptype = dt.BUILD | dt.LINK | dt.RUN - if tests is True or (tests and spec.name in tests): - deptype |= dt.TEST - dependencies = spec.dependencies(deptype=deptype) + elif install_policy == "source_only" or include_build_deps: + depflag |= dt.BUILD + dependencies = spec.dependencies(deptype=depflag) self.parent_to_child[key] = {d.dag_hash() for d in dependencies} # Enqueue new dependencies @@ -1738,6 +1745,15 @@ def __init__( "installed" ) + def _base_deptypes(self, spec: spack.spec.Spec) -> dt.DepFlag: + """Returns the dependency types that are always eagerly traversed. These are LINK, RUN, and + conditionally TEST, but excludes BUILD. Build deps are deferred until after a build cache + miss.""" + deptypes = dt.LINK | dt.RUN + if self.tests is True or (self.tests and spec.name in self.tests): + deptypes |= dt.TEST + return deptypes + def enqueue_parents(self, dag_hash: str, pending_builds: List[str]) -> None: """After a spec is installed, remove it from the graph and enqueue any parents that are now ready to install. @@ -1746,6 +1762,7 @@ def enqueue_parents(self, dag_hash: str, pending_builds: List[str]) -> None: dag_hash: The dag_hash of the spec that was just installed pending_builds: List to append parent specs that are ready to build """ + self.done.add(dag_hash) # Remove node and edges from the node in the build graph self.parent_to_child.pop(dag_hash, None) self.nodes.pop(dag_hash, None) @@ -1761,6 +1778,85 @@ def enqueue_parents(self, dag_hash: str, pending_builds: List[str]) -> None: if not children: pending_builds.append(parent) + def has_unexpanded_build_deps(self, dag_hash: str) -> bool: + return bool(self.get_unexpanded_build_deps(dag_hash)) + + def get_unexpanded_build_deps(self, dag_hash: str) -> List["spack.spec.Spec"]: + """Returns a list of unprocessed build deps for a spec.""" + spec = self.nodes[dag_hash] + base_deptypes = self._base_deptypes(spec) + unexpanded = [] + for edge in spec.edges_to_dependencies(depflag=dt.BUILD): + if (edge.depflag & base_deptypes) == 0: + unexpanded.append(edge.spec) + return unexpanded + + def expand_build_deps( + self, + spec_hashes: List[str], + pending_builds: List[str], + database: "spack.database.Database", + dependencies_policy: InstallPolicy = "auto", + ) -> List[str]: + """Expand build dependencies for a list of specs after binary cache misses. + + Adds the spec's build deps and their transitive runtime deps to the graph. When + ``dependencies_policy`` is ``"source_only"``, build deps of newly added specs are included + immediately. Installed deps are skipped without adding edges. + + The caller must hold the database read lock and have called ``db._read()``. + + Returns the list of newly added dag hashes.""" + # Seed with tuples of (parent_hash, dep) for each to-be-expanded build dep + stack = [(h, dep) for h in spec_hashes for dep in self.get_unexpanded_build_deps(h)] + newly_added: List[str] = [] + + while stack: + parent_hash, dep = stack.pop() + dep_hash = dep.dag_hash() + + # Skip installed deps + if dep_hash in self.pruned or dep_hash in self.done: + continue + + # If already in the graph (e.g. overwrite build in progress), add edge but don't + # re-add node. This must be checked before the DB installed check, because an + # overwrite build is installed in the DB but not yet done. + if dep_hash in self.nodes: + self.parent_to_child.setdefault(parent_hash, set()).add(dep_hash) + self.child_to_parent.setdefault(dep_hash, set()).add(parent_hash) + continue + + _, record = database.query_by_spec_hash(dep_hash) + if record and record.installed: + self.done.add(dep_hash) + continue + + # Add forward/reverse edge + self.parent_to_child.setdefault(parent_hash, set()).add(dep_hash) + self.child_to_parent.setdefault(dep_hash, set()).add(parent_hash) + + # New node: add to graph and recurse into its link/run/test deps + self.nodes[dep_hash] = dep + self.parent_to_child.setdefault(dep_hash, set()) + newly_added.append(dep_hash) + + deptype = self._base_deptypes(dep) + if dependencies_policy == "source_only": + deptype |= dt.BUILD + for child in dep.dependencies(deptype=deptype): + stack.append((dep_hash, child)) + + # Enqueue nodes that are ready (no uninstalled children) + for h in newly_added: + if not self.parent_to_child[h]: + pending_builds.append(h) + for dag_hash in spec_hashes: + if not self.parent_to_child[dag_hash]: + pending_builds.append(dag_hash) + + return newly_added + class ScheduleResult(NamedTuple): """Return value of :func:`schedule_builds`.""" @@ -1971,7 +2067,7 @@ def finish_record( record = self.build_records.get(spec.dag_hash()) if record is None or spec.external: return - if exitcode == 0: + if exitcode == ExitCode.SUCCESS: record.succeed(log_path) else: record.fail( @@ -2249,6 +2345,12 @@ def __init__( specs = [pkg.spec for pkg in packages] + # No point trying cache when there are no binary mirrors configured. + if not spack.mirrors.mirror.MirrorCollection(binary=True): + if root_policy == "auto": + root_policy = "source_only" + if dependencies_policy == "auto": + dependencies_policy = "source_only" self.root_policy: InstallPolicy = root_policy self.dependencies_policy: InstallPolicy = dependencies_policy self.include_build_deps = include_build_deps @@ -2305,6 +2407,9 @@ def __init__( parent for parent, children in self.build_graph.parent_to_child.items() if not children ] + #: specs awaiting build-dep expansion (deferred until DB read lock is available) + self.pending_expansions: List[str] = [] + self.verbose = verbose self.running_builds: Dict[int, ChildInfo] = {} self.log_paths: Dict[str, str] = {} @@ -2372,7 +2477,12 @@ def _installer(self) -> None: ) self.build_status.set_blocked(blocked and not self.running_builds) - while self.pending_builds or self.running_builds or database_actions: + while ( + self.pending_builds + or self.running_builds + or database_actions + or self.pending_expansions + ): # Monitor the jobserver when we have pending builds, capacity, and at least one # spec is not locked by another process. Also listen if the target parallelism is # reduced. @@ -2429,9 +2539,10 @@ def _installer(self) -> None: jobserver.maybe_discard_tokens() self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) - if finished_pids: - self._handle_finished_builds( - finished_pids, selector, jobserver, database_actions, failures + current_time = time.monotonic() + for pid in finished_pids: + self._handle_finished_build( + pid, current_time, jobserver, selector, failures, database_actions ) if failures and self.fail_fast: @@ -2468,13 +2579,19 @@ def _installer(self) -> None: if ( database_actions and ( - time.monotonic() >= self.next_database_write + current_time >= self.next_database_write or not (self.pending_builds or self.running_builds) ) and self._save_to_db(database_actions, retained_read_locks) ): database_actions.clear() + # Try to expand build deps for cache-miss specs. This requires a read lock on the + # database, meaning that it can take several iterations of the event loop in case + # of contention with other processes. + if self.pending_expansions: + self._try_expand_build_deps() + # Try to schedule more builds, acquiring per-spec locks and jobserver tokens. if self.capacity and self.pending_builds: blocked = self._schedule_builds( @@ -2521,7 +2638,7 @@ def _installer(self) -> None: # Release all held locks best-effort, so that one failure does not prevent the others # from being released. for child in self.running_builds.values(): - child.release_lock() + child.release_prefix_lock() for lock in retained_read_locks: try: @@ -2529,7 +2646,7 @@ def _installer(self) -> None: except Exception: pass for action in database_actions: - action.release_lock() + action.release_prefix_lock() try: self.build_status.overview_mode = True @@ -2569,45 +2686,81 @@ def _installer(self) -> None: "The following packages failed to install:\n" + "\n".join(lines) ) - def _handle_finished_builds( + def _handle_finished_build( self, - finished_pids: List[int], - selector: selectors.BaseSelector, + pid: int, + current_time: float, jobserver: JobServer, - database_actions: List[DatabaseAction], + selector: selectors.BaseSelector, failures: List[spack.spec.Spec], + database_actions: List[DatabaseAction], ) -> None: - """Handle builds that finished since the last event loop iteration.""" - current_time = time.monotonic() - for pid in finished_pids: - build = self.running_builds.pop(pid) - self.capacity += 1 - jobserver.release() - self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) - self._drain_child_output(build, selector) - self._drain_child_state(build, selector) - build.cleanup(selector) - exitcode = build.proc.exitcode - assert exitcode is not None, "Finished build should have exit code set" - self.report_data.finish_record(build.spec, exitcode, build.log_path) - if exitcode == 0: - # Add successful builds for database insertion (after a short delay) - database_actions.append(build) - self.build_graph.enqueue_parents(build.spec.dag_hash(), self.pending_builds) - self.next_database_write = current_time + DATABASE_WRITE_INTERVAL - self.build_status.update_state(build.spec.dag_hash(), "finished") - elif exitcode == EXIT_STOPPED_AT_PHASE: - # Partial build: neither failure nor success. Should not be persisted in - # the database, but also not treated as a failure in the UI. Just release - # locks and move on. - build.release_lock() - elif not self.fail_fast or not failures: - # In fail-fast mode, only record the first failure. Subsequent failures may - # be a consequence of us terminating other builds, and should not be - # reported as failures in the UI. - failures.append(build.spec) - self.build_status.update_state(build.spec.dag_hash(), "failed") - self.build_status.parse_log_summary(build.spec.dag_hash()) + """Handle a build that has finished. Remove from running_builds; release jobserver token; + update UI state; defer database insertion if successful; possibly reschedule if failed with + cache miss; register failures.""" + build = self.running_builds.pop(pid) + dag_hash = build.spec.dag_hash() + self.capacity += 1 + jobserver.release() + self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) + self._drain_child_output(build, selector) + self._drain_child_state(build, selector) + build.cleanup(selector) + exitcode = build.proc.exitcode + assert exitcode is not None, "Finished build should have exit code set" + self.report_data.finish_record(build.spec, exitcode, build.log_path) + + if exitcode == ExitCode.SUCCESS: + # Schedule successful builds for batched database insertion. We don't release the + # prefix lock here; that strictly happens after a successful db write. + database_actions.append(build) + self.build_graph.enqueue_parents(dag_hash, self.pending_builds) + self.next_database_write = current_time + DATABASE_WRITE_INTERVAL + self.build_status.update_state(dag_hash, "finished") + return + + # When we don't have to do a db write, we can release the lock immediately. + build.release_prefix_lock() + + is_root = dag_hash in self.build_graph.roots + user_policy = self.root_policy if is_root else self.dependencies_policy + + if exitcode == ExitCode.STOPPED_AT_PHASE: + return # the user requested early stopping; don't treat as failure + elif exitcode == ExitCode.BUILD_CACHE_MISS and user_policy == "auto": + # Check if we can reschedule this as a source build after a build cache miss. If so, + # return early without recording a failure. + self.build_graph.force_source.add(dag_hash) + self.build_status.remove_build(dag_hash) + if self.build_graph.has_unexpanded_build_deps(dag_hash): + self.pending_expansions.append(dag_hash) + else: + self.pending_builds.append(dag_hash) + elif not failures or not self.fail_fast: + # Record a failure. In fail-fast mode, only record the first failure; subsequent + # failures may be a consequence of us terminating other builds. + failures.append(build.spec) + self.build_status.update_state(dag_hash, "failed") + self.build_status.parse_log_summary(dag_hash) + + def _try_expand_build_deps(self) -> None: + """Try to expand build deps for specs with cache misses. Non-blocking: returns immediately + if the DB read lock is unavailable.""" + if not self.db.lock.try_acquire_read(): + return + try: + self.db._read() + newly_added = self.build_graph.expand_build_deps( + self.pending_expansions, self.pending_builds, self.db, self.dependencies_policy + ) + for h in newly_added: + self.binary_cache_for_spec[h] = ( + spack.binary_distribution.BINARY_INDEX.find_by_hash(h) + ) + self.build_status.total += len(newly_added) + self.pending_expansions.clear() + finally: + self.db.lock.release_read() def _save_to_db( self, @@ -2682,6 +2835,14 @@ def _schedule_builds( self._start(selector, jobserver, dag_hash, lock) return blocked + def _install_policy(self, dag_hash: str, is_root: bool) -> InstallPolicy: + if dag_hash in self.build_graph.force_source: + return "source_only" + policy = self.root_policy if is_root else self.dependencies_policy + if policy == "auto" and not self.include_build_deps: + return "cache_only" + return policy + def _start( self, selector: selectors.BaseSelector, @@ -2696,12 +2857,25 @@ def _start( tests = self.tests run_tests = tests is True or bool(tests and spec.name in tests) is_root = dag_hash in self.build_graph.roots + # Both possible sub-processes (cache install, source build) append to the same log file. + if dag_hash not in self.log_paths: + if spec.external: + self.log_paths[dag_hash] = os.devnull + else: + log_fd, log_path = tempfile.mkstemp( + prefix=f"spack-stage-{spec.name}-{spec.version}-{spec.dag_hash()}-", + suffix=".log", + dir=spack.stage.get_stage_root(), + ) + os.close(log_fd) + self.log_paths[dag_hash] = log_path + child_info = start_build( spec, explicit=explicit, mirrors=self.binary_cache_for_spec[dag_hash], unsigned=self.unsigned, - install_policy=self.root_policy if is_root else self.dependencies_policy, + install_policy=self._install_policy(dag_hash, is_root), dirty=self.dirty, # keep_stage/restage logic taken from installer.py keep_stage=self.keep_stage or is_develop, @@ -2712,10 +2886,10 @@ def _start( install_source=self.install_source, run_tests=run_tests, jobserver=jobserver, + log_path=self.log_paths[dag_hash], stop_before=self.stop_before if is_root else None, stop_at=self.stop_at if is_root else None, ) - self.log_paths[dag_hash] = child_info.log_path child_info.prefix_lock = prefix_lock pid = child_info.proc.pid assert type(pid) is int @@ -2813,3 +2987,7 @@ def _handle_child_state( ) elif "installed_from_binary_cache" in message: child_info.spec.package.installed_from_binary_cache = True + + +class BinaryCacheMiss(spack.error.SpackError): + pass diff --git a/lib/spack/spack/test/cmd/install.py b/lib/spack/spack/test/cmd/install.py index 9dbaae65e59481..ebcbbd36c38e59 100644 --- a/lib/spack/spack/test/cmd/install.py +++ b/lib/spack/spack/test/cmd/install.py @@ -681,21 +681,27 @@ def test_build_warning_output(mock_fetch, install_mockery): assert "foo.c:89: warning: some weird warning!" in e.value.long_message -def test_cache_only_fails(mock_fetch, install_mockery): +@pytest.mark.disable_clean_stage_check # new installer keeps a log for build cache installs +def test_cache_only_fails(mock_fetch, install_mockery, installer_variant): # libelf from cache fails to install, which automatically removes the # the libdwarf build task out = install("--cache-only", "libdwarf", fail_on_error=False) + assert isinstance(install.error, spack.error.InstallError) + assert not spack.store.STORE.db.query_local("libdwarf") + assert not spack.store.STORE.db.query_local("libelf") - assert "Failed to install gcc-runtime" in out - assert "Skipping build of libdwarf" in out - assert "was not installed" in out - - # Check that failure prefix locks are still cached - failed_packages = [ - pkg_name for dag_hash, pkg_name in spack.store.STORE.failure_tracker.locker.locks.keys() - ] - assert "libelf" in failed_packages - assert "libdwarf" in failed_packages + if installer_variant == "old": + assert "Failed to install gcc-runtime" in out + assert "Skipping build of libdwarf" in out + assert "was not installed" in out + + # Check that failure prefix locks are still cached + failed_packages = [ + pkg_name + for dag_hash, pkg_name in spack.store.STORE.failure_tracker.locker.locks.keys() + ] + assert "libelf" in failed_packages + assert "libdwarf" in failed_packages def test_install_only_dependencies(mock_fetch, install_mockery, installer_variant): diff --git a/lib/spack/spack/test/installer_build_graph.py b/lib/spack/spack/test/installer_build_graph.py index 28719a10552943..c8c50ccf81c50a 100644 --- a/lib/spack/spack/test/installer_build_graph.py +++ b/lib/spack/spack/test/installer_build_graph.py @@ -688,3 +688,173 @@ def test_mark_explicit_spec_excludes_build_only_deps( assert root.dag_hash() in graph.nodes # build-only dep should NOT be pulled in since root is already installed. assert specs_with_build_deps["build_dep"].dag_hash() not in graph.nodes + + +class TestExpandBuildDeps: + """Tests for BuildGraph.expand_build_deps after a binary cache miss.""" + + def _make_graph(self, specs, root, temporary_store): + """Helper to create a BuildGraph with include_build_deps=False (auto policy).""" + return BuildGraph( + specs=[specs[root]], + root_policy="auto", + dependencies_policy="auto", + include_build_deps=False, + install_package=True, + install_deps=True, + database=temporary_store.db, + ) + + def _expand(self, graph, dag_hash, pending, db, tests=False): + """Call expand_build_deps under the DB read lock (as the real caller would).""" + with db.read_transaction(): + return graph.expand_build_deps([dag_hash], pending, db, tests) + + def test_expand_build_deps_adds_missing_deps(self, temporary_store: Store): + """A --build--> C --link--> D, A --link--> B. + Initial graph (auto, no build deps): A, B. + After expand: C, D added. D is leaf -> in pending_builds.""" + specs = create_dag( + nodes=["a", "b", "c", "d"], + edges=[("a", "b", "link"), ("a", "c", "build"), ("c", "d", "link")], + ) + graph = self._make_graph(specs, "a", temporary_store) + assert specs["c"].dag_hash() not in graph.nodes + assert specs["d"].dag_hash() not in graph.nodes + + pending: List[str] = [] + newly_added = self._expand(graph, specs["a"].dag_hash(), pending, temporary_store.db) + + assert specs["c"].dag_hash() in newly_added + assert specs["d"].dag_hash() in newly_added + assert specs["c"].dag_hash() in graph.nodes + assert specs["d"].dag_hash() in graph.nodes + # D is a leaf (no children), so it should be enqueued + assert specs["d"].dag_hash() in pending + # C waits on D, so it should NOT be enqueued + assert specs["c"].dag_hash() not in pending + + def test_expand_build_deps_shared_dep_already_in_graph(self, temporary_store: Store): + """A --link--> B, A --build--> C --link--> B. + Initial graph: A, B. After expand: C added with edge C->B.""" + specs = create_dag( + nodes=["a", "b", "c"], + edges=[("a", "b", "link"), ("a", "c", "build"), ("c", "b", "link")], + ) + graph = self._make_graph(specs, "a", temporary_store) + assert specs["b"].dag_hash() in graph.nodes + assert specs["c"].dag_hash() not in graph.nodes + + pending: List[str] = [] + newly_added = self._expand(graph, specs["a"].dag_hash(), pending, temporary_store.db) + + assert specs["c"].dag_hash() in newly_added + # C depends on B, so C->B edge should exist + assert specs["b"].dag_hash() in graph.parent_to_child[specs["c"].dag_hash()] + # B should list C as a parent + assert specs["c"].dag_hash() in graph.child_to_parent[specs["b"].dag_hash()] + # C waits on B, so not in pending + assert specs["c"].dag_hash() not in pending + + def test_expand_build_deps_skips_installed_in_db(self, temporary_store: Store): + """A --build--> C --link--> D. D installed in DB. + After expand: C added, D NOT added. No edge C->D. C in pending.""" + specs = create_dag(nodes=["a", "c", "d"], edges=[("a", "c", "build"), ("c", "d", "link")]) + install_spec_in_db(specs["d"], temporary_store) + graph = self._make_graph(specs, "a", temporary_store) + + pending: List[str] = [] + newly_added = self._expand(graph, specs["a"].dag_hash(), pending, temporary_store.db) + + assert specs["c"].dag_hash() in newly_added + assert specs["d"].dag_hash() not in newly_added + assert specs["d"].dag_hash() not in graph.nodes + # No edge from C to D (installed dep) + assert specs["d"].dag_hash() not in graph.parent_to_child.get(specs["c"].dag_hash(), set()) + # C has no uninstalled children, so it should be enqueued + assert specs["c"].dag_hash() in pending + + def test_expand_build_deps_skips_installed_in_session(self, temporary_store: Store): + """Same as above, but D in graph.done instead of DB.""" + specs = create_dag(nodes=["a", "c", "d"], edges=[("a", "c", "build"), ("c", "d", "link")]) + graph = self._make_graph(specs, "a", temporary_store) + graph.done.add(specs["d"].dag_hash()) + + pending: List[str] = [] + newly_added = self._expand(graph, specs["a"].dag_hash(), pending, temporary_store.db) + + assert specs["c"].dag_hash() in newly_added + assert specs["d"].dag_hash() not in newly_added + assert specs["d"].dag_hash() not in graph.nodes + assert specs["c"].dag_hash() in pending + + def test_expand_build_deps_reenqueues_original_when_all_deps_installed( + self, temporary_store: Store + ): + """A --build--> C. C installed in DB. + After expand: C NOT added. A re-enqueued (no uninstalled children).""" + specs = create_dag(nodes=["a", "c"], edges=[("a", "c", "build")]) + install_spec_in_db(specs["c"], temporary_store) + graph = self._make_graph(specs, "a", temporary_store) + + pending: List[str] = [] + newly_added = self._expand(graph, specs["a"].dag_hash(), pending, temporary_store.db) + + assert len(newly_added) == 0 + assert specs["a"].dag_hash() in pending + + def test_expand_build_deps_no_deadlock_on_installed_dep(self, temporary_store: Store): + """A --build--> C --link--> D. D installed in DB. + No edge C->D in parent_to_child. C in pending.""" + specs = create_dag(nodes=["a", "c", "d"], edges=[("a", "c", "build"), ("c", "d", "link")]) + install_spec_in_db(specs["d"], temporary_store) + graph = self._make_graph(specs, "a", temporary_store) + + pending: List[str] = [] + self._expand(graph, specs["a"].dag_hash(), pending, temporary_store.db) + + # No edge from C to D: installed deps get no edge, otherwise C is never scheduled + assert specs["d"].dag_hash() not in graph.parent_to_child.get(specs["c"].dag_hash(), set()) + assert specs["c"].dag_hash() in pending + + def test_has_unexpanded_build_deps_true(self, temporary_store: Store): + """A --build--> C, A --link--> B. With include_build_deps=False, C is not in the graph, + so has_unexpanded_build_deps returns True.""" + specs = create_dag(nodes=["a", "b", "c"], edges=[("a", "b", "link"), ("a", "c", "build")]) + graph = self._make_graph(specs, "a", temporary_store) + assert graph.has_unexpanded_build_deps(specs["a"].dag_hash()) + + def test_has_unexpanded_build_deps_false_shared(self, temporary_store: Store): + """A --(build,link)--> B. B is already in graph as link dep, + so has_unexpanded_build_deps returns False.""" + specs = create_dag(nodes=["a", "b"], edges=[("a", "b", ("build", "link"))]) + graph = self._make_graph(specs, "a", temporary_store) + assert not graph.has_unexpanded_build_deps(specs["a"].dag_hash()) + + def test_expand_build_deps_does_not_mark_in_graph_spec_as_done(self, temporary_store: Store): + """A --link--> B, A --link--> C, B --build--> C. + C is in the graph (link dep of A) and installed in DB (simulating an overwrite build + in progress). Expanding B's build deps should add edge B->C and NOT mark C as done.""" + specs = create_dag( + nodes=["a", "b", "c"], + edges=[("a", "b", "link"), ("a", "c", "link"), ("b", "c", "build")], + ) + graph = self._make_graph(specs, "a", temporary_store) + # C should be in graph as a link dep of A + assert specs["c"].dag_hash() in graph.nodes + + # Simulate overwrite: install C in DB after graph creation + install_spec_in_db(specs["c"], temporary_store) + + pending: List[str] = [] + self._expand(graph, specs["b"].dag_hash(), pending, temporary_store.db) + + c_hash = specs["c"].dag_hash() + b_hash = specs["b"].dag_hash() + # C must NOT be marked as done (it's still being overwrite-built) + assert c_hash not in graph.done + # Edge B->C must exist + assert c_hash in graph.parent_to_child[b_hash] + assert b_hash in graph.child_to_parent[c_hash] + # B should NOT be in pending (it still waits on C) + assert b_hash not in pending diff --git a/lib/spack/spack/test/installer_tui.py b/lib/spack/spack/test/installer_tui.py index 4a1fa8ccdc4f7c..d398cd91706024 100644 --- a/lib/spack/spack/test/installer_tui.py +++ b/lib/spack/spack/test/installer_tui.py @@ -179,6 +179,30 @@ def test_update_state_failed(self): assert status.completed == 1 assert status.builds[build_id].finished_time == fake_time[0] + inst.CLEANUP_TIMEOUT + def test_remove_build(self): + """Test that remove_build removes the build from the display.""" + status, _, _ = create_build_status(total=2) + specs = add_mock_builds(status, 2) + build_id = specs[0].dag_hash() + + status.dirty = False + status.remove_build(build_id) + assert build_id not in status.builds + assert len(status.builds) == 1 + assert status.dirty is True + + def test_remove_build_resets_tracked(self): + """Test that removing the tracked build resets tracking to overview mode.""" + status, _, _ = create_build_status(total=1) + (spec,) = add_mock_builds(status, 1) + build_id = spec.dag_hash() + + status.tracked_build_id = build_id + status.overview_mode = False + status.remove_build(build_id) + assert status.tracked_build_id == "" + assert status.overview_mode is True + def test_parse_log_summary(self, tmp_path): """Test that parse_log_summary parses the build log and stores the summary.""" status, _, _ = create_build_status() diff --git a/lib/spack/spack/test/new_installer.py b/lib/spack/spack/test/new_installer.py index e8d286fa251b24..96a3ba867498a8 100644 --- a/lib/spack/spack/test/new_installer.py +++ b/lib/spack/spack/test/new_installer.py @@ -15,6 +15,8 @@ import spack.spec from spack.new_installer import ( OVERWRITE_GARBAGE_SUFFIX, + BinaryCacheMiss, + BuildGraph, JobServer, PackageInstaller, PrefixPivoter, @@ -162,6 +164,19 @@ def test_failure_no_prefix_created(self, tmp_path: pathlib.Path): # Nothing should remain assert len(list(tmp_path.iterdir())) == 0 + def test_binary_cache_miss_with_keep_prefix_and_existing_prefix_restores_original( + self, tmp_path: pathlib.Path, existing_prefix: pathlib.Path + ): + """BinaryCacheMiss bypasses keep_prefix: original prefix is restored.""" + with pytest.raises(BinaryCacheMiss), PrefixPivoter(str(existing_prefix), keep_prefix=True): + existing_prefix.mkdir() + (existing_prefix / "partial_file").write_text("partial content") + raise BinaryCacheMiss("cache miss") + + assert (existing_prefix / "old_file").read_text() == "old content" + assert not (existing_prefix / "partial_file").exists() + assert len(list(tmp_path.iterdir())) == 1 + class FailingPrefixPivoter(PrefixPivoter): """Test subclass that can simulate filesystem failures.""" @@ -256,6 +271,28 @@ def test_capacity_from_config_non_zero(self, temporary_store, mock_packages, mut spec._mark_concrete() assert PackageInstaller([spec.package]).capacity == 1 + def test_no_binary_mirrors_forces_source_only( + self, temporary_store, mock_packages, mutable_config + ): + """With no binary mirrors configured, auto is overridden to source_only.""" + spec = spack.spec.Spec("trivial-install-test-package") + spec._mark_concrete() + installer = PackageInstaller([spec.package], root_policy="auto") + assert installer.root_policy == "source_only" + assert installer.dependencies_policy == "source_only" + + def test_no_binary_mirrors_preserves_cache_only( + self, temporary_store, mock_packages, mutable_config + ): + """Without binary mirrors, an explicit cache_only shouldn't turn into source_only.""" + spec = spack.spec.Spec("trivial-install-test-package") + spec._mark_concrete() + installer = PackageInstaller( + [spec.package], root_policy="cache_only", dependencies_policy="cache_only" + ) + assert installer.root_policy == "cache_only" + assert installer.dependencies_policy == "cache_only" + class _FakeBuildGraph: """Minimal stand-in for BuildGraph in schedule_builds unit tests. @@ -622,3 +659,56 @@ def test_nodes_to_roots_shared_dependency(): assert node_to_roots[a.dag_hash()] == frozenset([a.dag_hash()]) assert node_to_roots[b.dag_hash()] == frozenset([b.dag_hash()]) assert node_to_roots[c.dag_hash()] == frozenset([a.dag_hash(), b.dag_hash()]) + + +def test_expand_build_deps_source_only_includes_nested_build_deps(temporary_store): + """When dependencies_policy is source_only, expand_build_deps must include BUILD deps of + dynamically added specs, not just LINK|RUN. Otherwise those specs attempt to build from source + without their build tools in the graph.""" + # root --[build]--> build_tool --[build]--> nested_build_tool + # --[link]--> lib_dep + specs = create_dag( + nodes=["root", "build_tool", "nested_build_tool", "lib_dep"], + edges=[ + ("root", "build_tool", "build"), + ("build_tool", "nested_build_tool", "build"), + ("build_tool", "lib_dep", "link"), + ], + ) + root = specs["root"] + for s in specs.values(): + s._mark_concrete() + + # Construct a BuildGraph with root_policy="auto" so root's build deps are deferred. + bg = BuildGraph( + specs=[root], + root_policy="auto", + dependencies_policy="source_only", + include_build_deps=False, + install_package=True, + install_deps=True, + database=temporary_store.db, + ) + + # The initial graph should contain only root (build deps deferred for "auto" policy). + assert root.dag_hash() in bg.nodes + assert specs["build_tool"].dag_hash() not in bg.nodes + + # Simulate a cache miss: expand build deps for root. + pending = [] + with temporary_store.db.read_transaction(): + newly_added = bg.expand_build_deps( + [root.dag_hash()], pending, temporary_store.db, dependencies_policy="source_only" + ) + + added_hashes = set(newly_added) + + # build_tool must be added (direct BUILD dep of root) + assert specs["build_tool"].dag_hash() in added_hashes + + # lib_dep must be added (LINK dep of build_tool) + assert specs["lib_dep"].dag_hash() in added_hashes + + # nested_build_tool must also be added (BUILD dep of build_tool). This is the bug: without the + # fix, expand_build_deps only traverses LINK|RUN, so nested_build_tool is missing. + assert specs["nested_build_tool"].dag_hash() in added_hashes From 9513d26584d96695329958d560c5c7b79e115fb2 Mon Sep 17 00:00:00 2001 From: Angelica Date: Thu, 30 Apr 2026 10:48:47 -0600 Subject: [PATCH 313/337] buildcache: --allow-missing flag for partial pushes (#52354) Signed-off-by: Angelica --- lib/spack/spack/cmd/buildcache.py | 14 +++++++++++++- lib/spack/spack/test/cmd/buildcache.py | 20 ++++++++++++++++++++ share/spack/spack-completion.bash | 4 ++-- share/spack/spack-completion.fish | 8 ++++++-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/cmd/buildcache.py b/lib/spack/spack/cmd/buildcache.py index 312b9de8262543..c3fd32c04f7717 100644 --- a/lib/spack/spack/cmd/buildcache.py +++ b/lib/spack/spack/cmd/buildcache.py @@ -119,6 +119,12 @@ def setup_parser(subparser: argparse.ArgumentParser): action="store_true", help="stop pushing on first failure (default is best effort)", ) + push.add_argument( + "--allow-missing", + action="store_true", + help="allow not installed specs to continue without failure (default fails on missing " + "specs)", + ) push.add_argument( "--base-image", default=None, help="specify the base image for the buildcache" ) @@ -515,8 +521,14 @@ def push_fn(args): with spack.store.STORE.db.read_transaction(): if any(not s.installed for s in specs): specs, not_installed = stable_partition(specs, lambda s: s.installed) - if args.fail_fast: + if args.fail_fast and not args.allow_missing: raise PackagesAreNotInstalledError(not_installed) + elif args.allow_missing: + tty.warn( + f"The following {len(not_installed)} specs are not installed and will be " + "skipped: \n" + + "\n".join(elide_list([f" {_format_spec(s)}" for s in not_installed], 5)) + ) else: failed.extend( (s, PackageNotInstalledError("package not installed")) for s in not_installed diff --git a/lib/spack/spack/test/cmd/buildcache.py b/lib/spack/spack/test/cmd/buildcache.py index d4e93806bbfd34..4e30908694a3b5 100644 --- a/lib/spack/spack/test/cmd/buildcache.py +++ b/lib/spack/spack/test/cmd/buildcache.py @@ -515,6 +515,26 @@ def test_best_effort_vs_fail_fast_when_dep_not_installed(tmp_path: pathlib.Path, assert set(specs) == {s for s in mpileaks.traverse() if s.name != "mpich"} +def test_allow_missing_when_dep_not_installed(tmp_path: pathlib.Path, mutable_database): + """When --allow-missing is passed, the push command should push installed specs and skip specs + that are not installed without raising an error.""" + + mirror("add", "--unsigned", "my-mirror", str(tmp_path)) + + # Uninstall mpich so that its dependent mpileaks can't be pushed + for s in mutable_database.query_local("mpich"): + s.package.do_uninstall(force=True) + + # There should be warnings but no errors + buildcache("push", "--update-index", "--allow-missing", "my-mirror", "mpileaks^mpich") + + specs = spack.binary_distribution.update_cache_and_get_specs() + + # Everything but mpich should be pushed + mpileaks = mutable_database.query_local("mpileaks^mpich")[0] + assert set(specs) == {s for s in mpileaks.traverse() if s.name != "mpich"} + + def test_push_without_build_deps( tmp_path: pathlib.Path, temporary_store, mock_packages, mutable_config ): diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 26a2980c78308d..694453c2e3d999 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -566,7 +566,7 @@ _spack_buildcache() { _spack_buildcache_push() { if $list_options then - SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --base-image --tag -t --private --group -j --jobs" + SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --allow-missing --base-image --tag -t --private --group -j --jobs" else _mirrors fi @@ -575,7 +575,7 @@ _spack_buildcache_push() { _spack_buildcache_create() { if $list_options then - SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --base-image --tag -t --private --group -j --jobs" + SPACK_COMPREPLY="-h --help -f --force --unsigned -u --signed --key -k --update-index --rebuild-index --only --with-build-dependencies --without-build-dependencies --fail-fast --allow-missing --base-image --tag -t --private --group -j --jobs" else _mirrors fi diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 881442e59b179d..7a394e547a01de 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -702,7 +702,7 @@ complete -c spack -n '__fish_spack_using_command buildcache' -s h -l help -f -a complete -c spack -n '__fish_spack_using_command buildcache' -s h -l help -d 'show this help message and exit' # spack buildcache push -set -g __fish_spack_optspecs_spack_buildcache_push h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast base-image= t/tag= private group= j/jobs= +set -g __fish_spack_optspecs_spack_buildcache_push h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast allow-missing base-image= t/tag= private group= j/jobs= complete -c spack -n '__fish_spack_using_command_pos_remainder 1 buildcache push' -f -k -a '(__fish_spack_specs)' complete -c spack -n '__fish_spack_using_command buildcache push' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command buildcache push' -s h -l help -d 'show this help message and exit' @@ -724,6 +724,8 @@ complete -c spack -n '__fish_spack_using_command buildcache push' -l without-bui complete -c spack -n '__fish_spack_using_command buildcache push' -l without-build-dependencies -d 'exclude build dependencies from the buildcache' complete -c spack -n '__fish_spack_using_command buildcache push' -l fail-fast -f -a fail_fast complete -c spack -n '__fish_spack_using_command buildcache push' -l fail-fast -d 'stop pushing on first failure (default is best effort)' +complete -c spack -n '__fish_spack_using_command buildcache push' -l allow-missing -f -a allow_missing +complete -c spack -n '__fish_spack_using_command buildcache push' -l allow-missing -d 'allow not installed specs to continue without failure (default fails on missing specs)' complete -c spack -n '__fish_spack_using_command buildcache push' -l base-image -r -f -a base_image complete -c spack -n '__fish_spack_using_command buildcache push' -l base-image -r -d 'specify the base image for the buildcache' complete -c spack -n '__fish_spack_using_command buildcache push' -l tag -s t -r -f -a tag @@ -736,7 +738,7 @@ complete -c spack -n '__fish_spack_using_command buildcache push' -s j -l jobs - complete -c spack -n '__fish_spack_using_command buildcache push' -s j -l jobs -r -d 'explicitly set number of parallel jobs' # spack buildcache create -set -g __fish_spack_optspecs_spack_buildcache_create h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast base-image= t/tag= private group= j/jobs= +set -g __fish_spack_optspecs_spack_buildcache_create h/help f/force u/unsigned signed k/key= update-index only= with-build-dependencies without-build-dependencies fail-fast allow-missing base-image= t/tag= private group= j/jobs= complete -c spack -n '__fish_spack_using_command_pos_remainder 1 buildcache create' -f -k -a '(__fish_spack_specs)' complete -c spack -n '__fish_spack_using_command buildcache create' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command buildcache create' -s h -l help -d 'show this help message and exit' @@ -758,6 +760,8 @@ complete -c spack -n '__fish_spack_using_command buildcache create' -l without-b complete -c spack -n '__fish_spack_using_command buildcache create' -l without-build-dependencies -d 'exclude build dependencies from the buildcache' complete -c spack -n '__fish_spack_using_command buildcache create' -l fail-fast -f -a fail_fast complete -c spack -n '__fish_spack_using_command buildcache create' -l fail-fast -d 'stop pushing on first failure (default is best effort)' +complete -c spack -n '__fish_spack_using_command buildcache create' -l allow-missing -f -a allow_missing +complete -c spack -n '__fish_spack_using_command buildcache create' -l allow-missing -d 'allow not installed specs to continue without failure (default fails on missing specs)' complete -c spack -n '__fish_spack_using_command buildcache create' -l base-image -r -f -a base_image complete -c spack -n '__fish_spack_using_command buildcache create' -l base-image -r -d 'specify the base image for the buildcache' complete -c spack -n '__fish_spack_using_command buildcache create' -l tag -s t -r -f -a tag From e0a74b0eff5b8926247cfa6759adbd2e2c39a00e Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Thu, 30 Apr 2026 19:05:32 +0200 Subject: [PATCH 314/337] views: collapse unique subtrees in symlink case (#52135) When a subdir is unique to a single spec's prefix, do not symlink each file recursively but just symlink the dir. Using simultaneous DFS on all prefixes this avoids many filesystem operations. Tried it on `py-black` with default views config. Before: ``` % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ------------------ 81.21 0.445178 28 15610 symlink 5.64 0.030944 26 1183 mkdir 2.96 0.016201 5 3231 getdents64 ... ------ ----------- ----------- --------- --------- ------------------ 100.00 0.548202 14 36978 1647 total ``` After: ``` % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ------------------ 42.61 0.055549 23 2410 symlink 10.51 0.013707 22 599 mkdir 7.16 0.009340 4 2061 getdents64 ... ------ ----------- ----------- --------- --------- ------------------ 100.00 0.130372 6 20138 1645 total ``` So an 84% reduction in symlinks, 50% reduction in mkdirs, and 36% reduction in reading dir contents. It's enabled by default, you can opt out with `view::link_dirs:false`. --- lib/spack/docs/environments.rst | 8 + lib/spack/spack/environment/environment.py | 24 +- lib/spack/spack/filesystem_view.py | 57 ++-- lib/spack/spack/llnl/util/link_tree.py | 319 ++++++++++++-------- lib/spack/spack/schema/view.py | 6 + lib/spack/spack/test/conftest.py | 33 +- lib/spack/spack/test/env.py | 9 +- lib/spack/spack/test/llnl/util/link_tree.py | 167 +++++++--- lib/spack/spack/test/views.py | 84 ++++++ 9 files changed, 498 insertions(+), 209 deletions(-) diff --git a/lib/spack/docs/environments.rst b/lib/spack/docs/environments.rst index 09a69ce07d680c..aaf3e0c69cce81 100644 --- a/lib/spack/docs/environments.rst +++ b/lib/spack/docs/environments.rst @@ -1133,6 +1133,7 @@ The root specs with their (transitive) link and run type dependencies will be pu all: "{name}/{version}-{compiler.name}" link: all link_type: symlink + link_dirs: true The default for the ``select`` and ``exclude`` values is to select everything and exclude nothing. The default projection is the default view projection (``{}``). @@ -1144,6 +1145,13 @@ The ``link`` attribute allows the following values: The ``link_type`` defaults to ``symlink`` but can also take the value of ``hardlink`` or ``copy``. +.. versionadded:: 1.2 + + The ``link_dirs`` option controls whether directories are symlinked. This is the default + behavior in Spack v1.2 and later. This is an optimization that significantly reduces the time + to create views, and reduces the inode usage of the view. It only applies when ``link_type`` + is set to ``symlink``. If you want to link only non-directory files, set ``link_dirs: false``. + .. tip:: The option ``link: run`` can be used to create small environment views for Python packages. diff --git a/lib/spack/spack/environment/environment.py b/lib/spack/spack/environment/environment.py index 57299872b9ba24..9d53c34c0a438b 100644 --- a/lib/spack/spack/environment/environment.py +++ b/lib/spack/spack/environment/environment.py @@ -704,6 +704,7 @@ def __init__( exclude: Optional[List[str]] = None, link: str = default_view_link, link_type: fsv.LinkType = "symlink", + link_dirs: bool = True, groups: Optional[Union[str, List[str]]] = None, ) -> None: self.base = base_path @@ -713,6 +714,7 @@ def __init__( self.select = select or [] self.exclude = exclude or [] self.link_type: fsv.LinkType = fsv.canonicalize_link_type(link_type) + self.link_dirs: bool = link_type == "symlink" and link_dirs self.link = link if isinstance(groups, str): groups = [groups] @@ -729,15 +731,15 @@ def update_root(self, new_path: str) -> None: self.root = spack.util.path.canonicalize_path(new_path, default_wd=self.base) def __eq__(self, other: object) -> bool: - return isinstance(other, ViewDescriptor) and all( - [ - self.root == other.root, - self.projections == other.projections, - self.select == other.select, - self.exclude == other.exclude, - self.link == other.link, - self.link_type == other.link_type, - ] + return ( + isinstance(other, ViewDescriptor) + and self.root == other.root + and self.projections == other.projections + and self.select == other.select + and self.exclude == other.exclude + and self.link == other.link + and self.link_type == other.link_type + and self.link_dirs == other.link_dirs ) def to_dict(self): @@ -750,6 +752,8 @@ def to_dict(self): ret["exclude"] = self.exclude if self.link_type: ret["link_type"] = self.link_type + if self.link_dirs: + ret["link_dirs"] = self.link_dirs if self.link != default_view_link: ret["link"] = self.link return ret @@ -764,6 +768,7 @@ def from_dict(base_path: str, d) -> "ViewDescriptor": exclude=d.get("exclude", []), link=d.get("link", default_view_link), link_type=d.get("link_type", "symlink"), + link_dirs=d.get("link_dirs", True), groups=d.get("group", None), ) @@ -828,6 +833,7 @@ def _view(self, root: str) -> fsv.SimpleFilesystemView: ignore_conflicts=True, projections=self.projections, link_type=self.link_type, + link_dirs=self.link_dirs, ) def __contains__(self, spec): diff --git a/lib/spack/spack/filesystem_view.py b/lib/spack/spack/filesystem_view.py index c65bc01d273f71..c5b9674abfa5a3 100644 --- a/lib/spack/spack/filesystem_view.py +++ b/lib/spack/spack/filesystem_view.py @@ -3,7 +3,6 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import functools as ft -import itertools import os import re import shutil @@ -39,8 +38,8 @@ DestinationMergeVisitor, LinkTree, MergeConflictSummary, + MultiPrefixMerger, SingleMergeConflictError, - SourceMergeVisitor, ) from spack.llnl.util.tty.color import colorize @@ -165,6 +164,7 @@ def __init__( ignore_conflicts: bool = False, verbose: bool = False, link_type: LinkType = "symlink", + link_dirs: bool = False, ): """ Initialize a filesystem view under the given ``root`` directory with @@ -182,6 +182,7 @@ def __init__( # Setup link function to include view self.link_type = link_type self._link = function_for_link_type(link_type) + self.link_dirs = link_dirs and link_type == "symlink" def link(self, src: str, dst: str, spec: Optional[spack.spec.Spec] = None) -> None: self._link(src, dst, self, spec) @@ -714,13 +715,16 @@ def skip_list(file): # Determine if the root is on a case-insensitive filesystem normalize_paths = is_folder_on_case_insensitive_filesystem(self._root) - visitor = SourceMergeVisitor(ignore=skip_list, normalize_paths=normalize_paths) - - # Gather all the directories to be made and files to be linked - for spec in specs: - src_prefix = spec.package.view_source() - visitor.set_projection(self.get_relative_projection_for_spec(spec)) - visit_directory_tree(src_prefix, visitor) + sources = [ + (spec.package.view_source(), self.get_relative_projection_for_spec(spec)) + for spec in specs + ] + visitor = MultiPrefixMerger( + sources, + ignore=skip_list, + normalize_paths=normalize_paths, + dir_symlink_optimization=self.link_dirs, + ) # Check for conflicts in destination dir. visit_directory_tree(self._root, DestinationMergeVisitor(visitor)) @@ -754,21 +758,17 @@ def skip_list(file): # Finally create the metadata dirs. self.link_metadata(specs) - def _source_merge_visitor_to_merge_map(self, visitor: SourceMergeVisitor): + def _source_merge_visitor_to_merge_map(self, visitor: MultiPrefixMerger): # For compatibility with add_files_to_view, we have to create a # merge_map of the form join(src_root, src_rel) => join(dst_root, dst_rel), # but our visitor.files format is dst_rel => (src_root, src_rel). - # We exploit that visitor.files is an ordered dict, and files per source - # prefix are contiguous. - source_root = lambda item: item[1][0] - per_source = itertools.groupby(visitor.files.items(), key=source_root) - return { - src_root: { - os.path.join(src_root, src_rel): os.path.join(self._root, dst_rel) - for dst_rel, (_, src_rel) in group - } - for src_root, group in per_source - } + merge_map: Dict[str, Dict[str, str]] = {} + for dst_rel, (src_root, src_rel) in visitor.files.items(): + per_source = merge_map.get(src_root) + if per_source is None: + per_source = merge_map[src_root] = {} + per_source[os.path.join(src_root, src_rel)] = os.path.join(self._root, dst_rel) + return merge_map def relative_metadata_dir_for_spec(self, spec): return os.path.join( @@ -778,15 +778,14 @@ def relative_metadata_dir_for_spec(self, spec): ) def link_metadata(self, specs): - metadata_visitor = SourceMergeVisitor() - - for spec in specs: - src_prefix = os.path.join( - spec.package.view_source(), spack.store.STORE.layout.metadata_dir + prefix_and_projection = [ + ( + os.path.join(spec.package.view_source(), spack.store.STORE.layout.metadata_dir), + self.relative_metadata_dir_for_spec(spec), ) - proj = self.relative_metadata_dir_for_spec(spec) - metadata_visitor.set_projection(proj) - visit_directory_tree(src_prefix, metadata_visitor) + for spec in specs + ] + metadata_visitor = MultiPrefixMerger(prefix_and_projection) # Check for conflicts in destination dir. visit_directory_tree(self._root, DestinationMergeVisitor(metadata_visitor)) diff --git a/lib/spack/spack/llnl/util/link_tree.py b/lib/spack/spack/llnl/util/link_tree.py index bb302ef5d1add3..4eb738e0eea7c6 100644 --- a/lib/spack/spack/llnl/util/link_tree.py +++ b/lib/spack/spack/llnl/util/link_tree.py @@ -7,7 +7,8 @@ import filecmp import os import shutil -from typing import Callable, Dict, List, Optional, Tuple +from pathlib import Path +from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union import spack.llnl.util.filesystem as fs import spack.llnl.util.tty as tty @@ -51,22 +52,41 @@ def _samefile(a: str, b: str): return False -class SourceMergeVisitor(fs.BaseDirectoryVisitor): - """ - Visitor that produces actions: - - An ordered list of directories to create in dst - - A list of files to link in dst - - A list of merge conflicts in dst/ - """ +#: (index, src_root, rel_path, is_symlink) +FileEntry = Tuple[int, str, str, bool] + +#: (index, src_root, rel_path) +DirEntry = Tuple[int, str, str] + +PrefixAndProjection = Union[Union[str, Path], Tuple[Union[str, Path], Union[str, Path]]] + + +class MultiPrefixMerger: + """Class that takes multiple pairs of prefixes and projections, and produces a list of + directories to create, files to link, and conflicts when merging them together.""" def __init__( - self, ignore: Optional[Callable[[str], bool]] = None, normalize_paths: bool = False + self, + sources: Sequence[PrefixAndProjection], + ignore: Optional[Callable[[str], bool]] = None, + normalize_paths: bool = False, + dir_symlink_optimization: bool = False, ): + """ + Args: + sources: list of source directories, or tuples of (source directory, projection) pairs + ignore: optional callable(rel_path) -> bool to skip entries + normalize_paths: whether to normalize paths for case-insensitive filesystems + dir_symlink_optimization: whether to enable directory-level symlink optimization + """ self.ignore = ignore if ignore is not None else lambda f: False # On case-insensitive filesystems, normalize paths to detect duplications self.normalize_paths = normalize_paths + #: Whether to symlink directories unique to one source + self._dir_symlink_optimization = dir_symlink_optimization + # When mapping to /, we need to prepend the # bit to the relative path in the destination dir. self.projection: str = "" @@ -97,6 +117,21 @@ def __init__( # normalized path to: original path, root directory + relative path self._files_normalized: Dict[str, Tuple[str, str, str]] = {} + # Group sources by projection + projection_groups: Dict[str, List[str]] = {} + for src in sources: + if isinstance(src, tuple): + src_root, projection = src + else: + src_root, projection = src, "" + projection_groups.setdefault(str(projection), []).append(str(src_root)) + + # Process each projection group + for projection, roots in projection_groups.items(): + self.set_projection(projection) + active = [(i, root, "") for i, root in enumerate(roots)] + self._simultaneous_recurse(active, 0) + def _in_directories(self, proj_rel_path: str) -> bool: """ Check if a path is already in the directory list @@ -150,14 +185,6 @@ def _file(self, proj_rel_path: str) -> Tuple[str, str, str]: else: return (proj_rel_path, *self.files[proj_rel_path]) - def _del_file(self, proj_rel_path: str): - """ - Remove a file from the list of files - """ - del self.files[proj_rel_path] - if self.normalize_paths: - del self._files_normalized[proj_rel_path.lower()] - def _add_file(self, proj_rel_path: str, root: str, rel_path: str): """ Add a file to the list of files @@ -167,113 +194,6 @@ def _add_file(self, proj_rel_path: str, root: str, rel_path: str): if self.normalize_paths: self._files_normalized[proj_rel_path.lower()] = (proj_rel_path, root, rel_path) - def before_visit_dir(self, root: str, rel_path: str, depth: int) -> bool: - """ - Register a directory if dst / rel_path is not blocked by a file or ignored. - """ - proj_rel_path = os.path.join(self.projection, rel_path) - - if self.ignore(rel_path): - # Don't recurse when dir is ignored. - return False - elif self._in_files(proj_rel_path): - # A file-dir conflict is fatal except if they're the same file (symlinked dir). - src_a = os.path.join(*self._file(proj_rel_path)) - src_b = os.path.join(root, rel_path) - - if not _samefile(src_a, src_b): - self.fatal_conflicts.append( - MergeConflict(dst=proj_rel_path, src_a=src_a, src_b=src_b) - ) - return False - - # Remove the link in favor of the dir. - existing_proj_rel_path, _, _ = self._file(proj_rel_path) - self._del_file(existing_proj_rel_path) - self._add_directory(proj_rel_path, root, rel_path) - return True - elif self._in_directories(proj_rel_path): - # No new directory, carry on. - return True - else: - # Register new directory. - self._add_directory(proj_rel_path, root, rel_path) - return True - - def before_visit_symlinked_dir(self, root: str, rel_path: str, depth: int) -> bool: - """ - Replace symlinked dirs with actual directories when possible in low depths, - otherwise handle it as a file (i.e. we link to the symlink). - - Transforming symlinks into dirs makes it more likely we can merge directories, - e.g. when /lib -> /subdir/lib. - - We only do this when the symlink is pointing into a subdirectory from the - symlink's directory, to avoid potential infinite recursion; and only at a - constant level of nesting, to avoid potential exponential blowups in file - duplication. - """ - if self.ignore(rel_path): - return False - - # Only follow symlinked dirs in /**/**/* - if depth > 1: - handle_as_dir = False - else: - # Only follow symlinked dirs when pointing deeper - src = os.path.join(root, rel_path) - real_parent = os.path.realpath(os.path.dirname(src)) - real_child = os.path.realpath(src) - handle_as_dir = real_child.startswith(real_parent) - - if handle_as_dir: - return self.before_visit_dir(root, rel_path, depth) - - self.visit_file(root, rel_path, depth, symlink=True) - return False - - def visit_file(self, root: str, rel_path: str, depth: int, *, symlink: bool = False) -> None: - proj_rel_path = os.path.join(self.projection, rel_path) - - if self.ignore(rel_path): - pass - elif self._in_directories(proj_rel_path): - # Can't create a file where a dir is, unless they are the same file (symlinked dir), - # in which case we simply drop the symlink in favor of the actual dir. - src_a = os.path.join(*self._directory(proj_rel_path)) - src_b = os.path.join(root, rel_path) - if not symlink or not _samefile(src_a, src_b): - self.fatal_conflicts.append( - MergeConflict(dst=proj_rel_path, src_a=src_a, src_b=src_b) - ) - elif self._in_files(proj_rel_path): - # When two files project to the same path, they conflict iff they are distinct. - # If they are the same (i.e. one links to the other), register regular files rather - # than symlinks. The reason is that in copy-type views, we need a copy of the actual - # file, not the symlink. - src_a = os.path.join(*self._file(proj_rel_path)) - src_b = os.path.join(root, rel_path) - if not _samefile(src_a, src_b): - # Distinct files produce a conflict. - self.file_conflicts.append( - MergeConflict(dst=proj_rel_path, src_a=src_a, src_b=src_b) - ) - return - - if not symlink: - # Remove the link in favor of the actual file. The del is necessary to maintain the - # order of the files dict, which is grouped by root. - existing_proj_rel_path, _, _ = self._file(proj_rel_path) - self._del_file(existing_proj_rel_path) - self._add_file(proj_rel_path, root, rel_path) - else: - # Otherwise register this file to be linked. - self._add_file(proj_rel_path, root, rel_path) - - def visit_symlinked_file(self, root: str, rel_path: str, depth: int) -> None: - # Treat symlinked files as ordinary files (without "dereferencing") - self.visit_file(root, rel_path, depth, symlink=True) - def set_projection(self, projection: str) -> None: self.projection = os.path.normpath(projection) @@ -300,9 +220,156 @@ def set_projection(self, projection: str) -> None: ) ) + def _simultaneous_recurse(self, active: List[Tuple[int, str, str]], depth: int) -> None: + """Recursively scan active sources simultaneously. + + Args: + depth: current depth from source root (for symlinked dir handling) + active: list of (index, src_root, rel_path) tuples that have this directory + """ + # Mapping of normalized entry names to their corresponding directory and file entries + entry_map: Dict[str, Tuple[List[DirEntry], List[FileEntry]]] = {} + + for idx, src_root, rel_path in active: + scan_path = os.path.join(src_root, rel_path) if rel_path else src_root + try: + scanner = os.scandir(scan_path) + except OSError: + continue # skip if we cannot list directory entries. + + with scanner: + for dir_entry in scanner: + name = dir_entry.name + child_rel = os.path.join(rel_path, name) if rel_path else name + + if self.ignore(child_rel): + continue + + is_link = dir_entry.is_symlink() + try: + is_dir = dir_entry.is_dir(follow_symlinks=True) + except OSError: + is_dir = False # broken symlink is not a dir. + + norm_name = name.lower() if self.normalize_paths else name + dirs, files = entry_map.setdefault(norm_name, ([], [])) + + if is_dir and not is_link: + dirs.append((idx, src_root, child_rel)) + elif is_dir and is_link: + if self._should_follow_symlinked_dir(src_root, child_rel, depth): + dirs.append((idx, src_root, child_rel)) + else: + files.append((idx, src_root, child_rel, True)) + else: + files.append((idx, src_root, child_rel, is_link)) + + # Process collected entries in sorted order + for norm_name in sorted(entry_map): + dirs, files = entry_map[norm_name] + + # When dirs and files project to the same path, we have a potential fatal conflict. + if dirs and files: + rel_path = dirs[0][2] + dir_proj = os.path.join(self.projection, rel_path) if self.projection else rel_path + conflicts = self._dir_file_conflicts(dir_proj, dirs, files) + + if not conflicts: + # all files were symlinks to a dir at the same projected location, ignore them. + files.clear() + else: + # actual dir-file conflicts we cannot resolve. + self.fatal_conflicts.extend(conflicts) + continue + + # Note: no elif. We now have either files or dirs. + if files: + self._handle_files(files) + elif dirs and self._handle_dirs(dirs, depth): + self._simultaneous_recurse(dirs, depth + 1) + + def _should_follow_symlinked_dir(self, src_root: str, rel_path: str, depth: int) -> bool: + """Determine if a symlinked directory should be followed (treated as real dir) + or treated as a file.""" + if depth > 1: + return False + src = os.path.join(src_root, rel_path) + real_parent = os.path.realpath(os.path.dirname(src)) + real_child = os.path.realpath(src) + return real_child.startswith(real_parent) + + def _handle_files(self, files: List[FileEntry]) -> None: + """Handle file entries that all map to the same projected path.""" + # In case of resolvable conflicts (conflicting files are links to the same file) + # the best candidate for the source is the non-symlink file. + + _, root, rel_path, is_symlink = files[0] + dst = os.path.join(self.projection, rel_path) if self.projection else rel_path + for _, other_root, other_rel_path, other_is_symlink in files[1:]: + first_path = os.path.join(root, rel_path) + other_path = os.path.join(other_root, other_rel_path) + if not _samefile(first_path, other_path): + # two distinct files project to the same path; this is a conflict. + self.file_conflicts.append( + MergeConflict(dst=dst, src_a=first_path, src_b=other_path) + ) + elif not other_is_symlink and is_symlink: + # if they are the same, prefer the non-symlink as the source. + root, rel_path, is_symlink = other_root, other_rel_path, other_is_symlink + dst = os.path.join(self.projection, rel_path) if self.projection else rel_path + + self._add_file(dst, root, rel_path) + + def _handle_dirs(self, dirs: List[DirEntry], depth: int) -> bool: + """Handle directory entries that all map to the same projected path. + + Returns True if the caller should recurse deeper into this directory. + """ + _, src_root, rel_path = dirs[0] + proj_child = os.path.join(self.projection, rel_path) if self.projection else rel_path + if self._dir_symlink_optimization and depth > 0 and len(dirs) == 1: + # Unique subtree optimization: if this directory is unique to one source, and we're + # using symlinks, and we're not at the root level, we simply symlink the directory + # rather than creating it in the view and recursing into it. + self._add_file(proj_child, src_root, rel_path) + return False + else: + # Subtree optimization not possible, register make dirs operations and recurse. + self._add_directory(proj_child, src_root, rel_path) + return True + + def _dir_file_conflicts( + self, proj_child_rel: str, dirs: List[DirEntry], files: List[FileEntry] + ) -> Optional[List[MergeConflict]]: + """Handle dir-file conflicts at the same projected path.""" + # We drop all symlinks that resolve to any of the directories that project to the same path + # For example the symlink `/include -> /include` is a resolvable + # conflict as we just keep `/include` in the view. Notice that this is a very rare + # occurrence. + remaining_files = [ + os.path.join(file_root, file_rel_path) + for _, file_root, file_rel_path, is_sym in files + if not is_sym + or not any( + _samefile( + os.path.join(file_root, file_rel_path), os.path.join(dir_root, dir_rel_path) + ) + for _, dir_root, dir_rel_path in dirs + ) + ] + if not remaining_files: + return None + # Use the first dir is the representative dir to register conflicts. + _, src_root, rel_path = dirs[0] + dir_src = os.path.join(src_root, rel_path) + return [ + MergeConflict(dst=proj_child_rel, src_a=dir_src, src_b=file_path) + for file_path in remaining_files + ] + class DestinationMergeVisitor(fs.BaseDirectoryVisitor): - """DestinationMergeVisitor takes a SourceMergeVisitor and: + """DestinationMergeVisitor takes a MultiPrefixMerger and: a. registers additional conflicts when merging to the destination prefix b. removes redundant mkdir operations when directories already exist in the destination prefix. @@ -311,7 +378,7 @@ class DestinationMergeVisitor(fs.BaseDirectoryVisitor): directories in the sources directories. """ - def __init__(self, source_merge_visitor: SourceMergeVisitor): + def __init__(self, source_merge_visitor: MultiPrefixMerger): self.src = source_merge_visitor def before_visit_dir(self, root: str, rel_path: str, depth: int) -> bool: diff --git a/lib/spack/spack/schema/view.py b/lib/spack/spack/schema/view.py index 08b5e6489ded31..14efe293f40251 100644 --- a/lib/spack/spack/schema/view.py +++ b/lib/spack/spack/schema/view.py @@ -63,6 +63,12 @@ "description": "How files are linked in the view: 'symlink' " "(default), 'hardlink', or 'copy'", }, + "link_dirs": { + "type": "boolean", + "description": "Whether to link directories in the view, or only files" + " (default: true, only applicable when link_type is 'symlink')", + "default": True, + }, "select": { "type": "array", "items": {"type": "string"}, diff --git a/lib/spack/spack/test/conftest.py b/lib/spack/spack/test/conftest.py index a1ff0074f70e8f..126698b453627f 100644 --- a/lib/spack/spack/test/conftest.py +++ b/lib/spack/spack/test/conftest.py @@ -21,7 +21,7 @@ import textwrap import xml.etree.ElementTree from pathlib import Path -from typing import Callable, List, Optional, Tuple +from typing import Callable, List, Optional, Tuple, Union import pytest @@ -2580,3 +2580,34 @@ def installer_variant(request): pytest.skip("New installer not supported on Windows") with spack.config.override("config:installer", request.param): yield request.param + + +class FsTree: + class symlink: + def __init__(self, target): + self.target = target + + class file: + def __init__(self, content: Union[bytes, str] = b""): + self.content = content + + class dir: + pass + + def __init__(self, base_path: Path, layout: dict): + for rel_path, content in layout.items(): + p = base_path / rel_path + p.parent.mkdir(parents=True, exist_ok=True) + + assert isinstance(content, (self.symlink, self.file, self.dir)) + + if isinstance(content, self.dir): + p.mkdir(exist_ok=True) + elif isinstance(content, self.symlink): + p.symlink_to(content.target) + elif isinstance(content, self.file): + assert isinstance(content.content, (bytes, str)) + if isinstance(content.content, bytes): + p.write_bytes(content.content) + elif isinstance(content.content, str): + p.write_text(content.content, encoding="utf-8") diff --git a/lib/spack/spack/test/env.py b/lib/spack/spack/test/env.py index 1fedd238bc0449..a25fffde003691 100644 --- a/lib/spack/spack/test/env.py +++ b/lib/spack/spack/test/env.py @@ -291,7 +291,12 @@ def test_update_default_view(init_view, update_value, tmp_path: pathlib.Path, co link_type: symlink """, "./another-view", - {"root": "./another-view", "select": ["%gcc"], "link_type": "symlink"}, + { + "root": "./another-view", + "select": ["%gcc"], + "link_type": "symlink", + "link_dirs": True, + }, ), ( """ @@ -305,7 +310,7 @@ def test_update_default_view(init_view, update_value, tmp_path: pathlib.Path, co link_type: symlink """, True, - {"root": "./view-gcc", "select": ["%gcc"], "link_type": "symlink"}, + {"root": "./view-gcc", "select": ["%gcc"], "link_type": "symlink", "link_dirs": True}, ), ], ) diff --git a/lib/spack/spack/test/llnl/util/link_tree.py b/lib/spack/spack/test/llnl/util/link_tree.py index d6e248b3d88a2d..9481e9e329336b 100644 --- a/lib/spack/spack/test/llnl/util/link_tree.py +++ b/lib/spack/spack/test/llnl/util/link_tree.py @@ -19,7 +19,8 @@ visit_directory_tree, working_dir, ) -from spack.llnl.util.link_tree import DestinationMergeVisitor, LinkTree, SourceMergeVisitor +from spack.llnl.util.link_tree import DestinationMergeVisitor, LinkTree, MultiPrefixMerger +from spack.test.conftest import FsTree @pytest.fixture @@ -219,8 +220,7 @@ def test_source_merge_visitor_does_not_follow_symlinked_dirs_at_depth(tmp_path: with open(j("a", "b", "c", "d", "file"), "wb"): pass - visitor = SourceMergeVisitor() - visit_directory_tree(str(tmp_path), visitor) + visitor = MultiPrefixMerger([tmp_path]) assert [p for p in visitor.files.keys()] == [ j("a", "b", "c", "d", "file"), j("a", "b", "c", "symlink_d"), # treated as a file, not expanded @@ -263,8 +263,7 @@ def test_source_merge_visitor_cant_be_cyclical(tmp_path: pathlib.Path): symlink(j("symlink_b"), j("a", "symlink_b_b")) symlink(j("..", "a"), j("b", "symlink_a")) - visitor = SourceMergeVisitor() - visit_directory_tree(str(tmp_path), visitor) + visitor = MultiPrefixMerger([tmp_path]) assert [p for p in visitor.files.keys()] == [ j("a", "symlink_b"), j("a", "symlink_b_b"), @@ -297,8 +296,7 @@ def test_destination_merge_visitor_always_errors_on_symlinked_dirs(tmp_path: pat pass os.symlink("..", "example_b") - visitor = SourceMergeVisitor() - visit_directory_tree(str(src_path), visitor) + visitor = MultiPrefixMerger([src_path]) visit_directory_tree(str(dst_path), DestinationMergeVisitor(visitor)) assert visitor.fatal_conflicts @@ -321,14 +319,12 @@ def test_destination_merge_visitor_file_dir_clashes(tmp_path: pathlib.Path): with open("example", "wb"): pass - a_to_b = SourceMergeVisitor() - visit_directory_tree(str(a_path), a_to_b) + a_to_b = MultiPrefixMerger([a_path]) visit_directory_tree(str(b_path), DestinationMergeVisitor(a_to_b)) assert a_to_b.fatal_conflicts assert a_to_b.fatal_conflicts[0].dst == "example" - b_to_a = SourceMergeVisitor() - visit_directory_tree(str(b_path), b_to_a) + b_to_a = MultiPrefixMerger([b_path]) visit_directory_tree(str(a_path), DestinationMergeVisitor(b_to_a)) assert b_to_a.fatal_conflicts assert b_to_a.fatal_conflicts[0].dst == "example" @@ -355,15 +351,15 @@ def u(path: str) -> str: (tmp_path / "b" / u("dir")).symlink_to(tmp_path / "a" / "dir") (tmp_path / "b" / "bar").write_bytes(b"hello") - visitor_1 = SourceMergeVisitor(normalize_paths=normalize) - visitor_1.set_projection(str(tmp_path / "view")) - for p in ("a", "b"): - visit_directory_tree(str(tmp_path / p), visitor_1) + visitor_1 = MultiPrefixMerger( + [(tmp_path / "a", tmp_path / "view"), (tmp_path / "b", tmp_path / "view")], + normalize_paths=normalize, + ) - visitor_2 = SourceMergeVisitor(normalize_paths=normalize) - visitor_2.set_projection(str(tmp_path / "view")) - for p in ("b", "a"): - visit_directory_tree(str(tmp_path / p), visitor_2) + visitor_2 = MultiPrefixMerger( + [(tmp_path / "b", tmp_path / "view"), (tmp_path / "a", tmp_path / "view")], + normalize_paths=normalize, + ) assert not visitor_1.file_conflicts and not visitor_2.file_conflicts assert not visitor_1.fatal_conflicts and not visitor_2.fatal_conflicts @@ -388,11 +384,9 @@ def test_source_merge_visitor_deals_with_dangling_symlinks(tmp_path: pathlib.Pat (tmp_path / "dir_b").mkdir() (tmp_path / "dir_b" / "file").write_bytes(b"data") - visitor = SourceMergeVisitor() - visitor.set_projection(str(tmp_path / "view")) - - visit_directory_tree(str(tmp_path / "dir_a"), visitor) - visit_directory_tree(str(tmp_path / "dir_b"), visitor) + visitor = MultiPrefixMerger( + [(tmp_path / "dir_a", tmp_path / "view"), (tmp_path / "dir_b", tmp_path / "view")] + ) # Check that a conflict was registered. assert len(visitor.file_conflicts) == 1 @@ -412,9 +406,7 @@ def test_source_visitor_file_file(tmp_path: pathlib.Path, normalize: bool): (tmp_path / "a" / "file").write_bytes(b"") (tmp_path / "b" / "FILE").write_bytes(b"") - v = SourceMergeVisitor(normalize_paths=normalize) - for p in ("a", "b"): - visit_directory_tree(str(tmp_path / p), v) + v = MultiPrefixMerger([tmp_path / "a", tmp_path / "b"], normalize_paths=normalize) if normalize: assert len(v.files) == 1 @@ -435,12 +427,8 @@ def test_source_visitor_file_dir(tmp_path: pathlib.Path, normalize: bool): (tmp_path / "a" / "file").write_bytes(b"") (tmp_path / "b").mkdir() (tmp_path / "b" / "FILE").mkdir() - v1 = SourceMergeVisitor(normalize_paths=normalize) - for p in ("a", "b"): - visit_directory_tree(str(tmp_path / p), v1) - v2 = SourceMergeVisitor(normalize_paths=normalize) - for p in ("b", "a"): - visit_directory_tree(str(tmp_path / p), v2) + v1 = MultiPrefixMerger([tmp_path / "a", tmp_path / "b"], normalize_paths=normalize) + v2 = MultiPrefixMerger([tmp_path / "b", tmp_path / "a"], normalize_paths=normalize) assert not v1.file_conflicts and not v2.file_conflicts @@ -460,9 +448,7 @@ def test_source_visitor_dir_dir(tmp_path: pathlib.Path, normalize: bool): (tmp_path / "a" / "dir").mkdir() (tmp_path / "b").mkdir() (tmp_path / "b" / "DIR").mkdir() - v = SourceMergeVisitor(normalize_paths=normalize) - for p in ("a", "b"): - visit_directory_tree(str(tmp_path / p), v) + v = MultiPrefixMerger([tmp_path / "a", tmp_path / "b"], normalize_paths=normalize) assert not v.files assert not v.fatal_conflicts @@ -483,8 +469,7 @@ def test_dst_visitor_file_file(tmp_path: pathlib.Path, normalize: bool): (tmp_path / "a" / "file").write_bytes(b"") (tmp_path / "b" / "FILE").write_bytes(b"") - src = SourceMergeVisitor(normalize_paths=normalize) - visit_directory_tree(str(tmp_path / "a"), src) + src = MultiPrefixMerger([tmp_path / "a"], normalize_paths=normalize) visit_directory_tree(str(tmp_path / "b"), DestinationMergeVisitor(src)) assert len(src.files) == 1 @@ -505,11 +490,9 @@ def test_dst_visitor_file_dir(tmp_path: pathlib.Path, normalize: bool): (tmp_path / "a" / "file").write_bytes(b"") (tmp_path / "b").mkdir() (tmp_path / "b" / "FILE").mkdir() - src1 = SourceMergeVisitor(normalize_paths=normalize) - visit_directory_tree(str(tmp_path / "a"), src1) + src1 = MultiPrefixMerger([tmp_path / "a"], normalize_paths=normalize) visit_directory_tree(str(tmp_path / "b"), DestinationMergeVisitor(src1)) - src2 = SourceMergeVisitor(normalize_paths=normalize) - visit_directory_tree(str(tmp_path / "b"), src2) + src2 = MultiPrefixMerger([tmp_path / "b"], normalize_paths=normalize) visit_directory_tree(str(tmp_path / "a"), DestinationMergeVisitor(src2)) assert len(src1.files) == 1 @@ -527,3 +510,103 @@ def test_dst_visitor_file_dir(tmp_path: pathlib.Path, normalize: bool): else: assert not src1.fatal_conflicts and not src2.fatal_conflicts assert not src1.file_conflicts and not src2.file_conflicts + + +def test_unique_subdir_optimization(tmp_path: pathlib.Path): + """A subdirectory at depth > 0 unique to one prefix should be registered as a single file + entry (to be symlinked as a directory) in symlink mode, not recursed into. Top-level (depth 0) + dirs are always recursed into.""" + src_a = tmp_path / "a" + src_b = tmp_path / "b" + + FsTree( + tmp_path, + { + # shared dir: lib (exists in both) -- depth 0, shared + "a/lib/liba.so": FsTree.file(b"a"), + "b/lib/libb.so": FsTree.file(b"b"), + # shared dir: share (exists in both) -- depth 0, shared + # but unique subdirs at depth 1 + "a/share/app_a/data.txt": FsTree.file(b"a"), + "a/share/app_a/sub/deep.txt": FsTree.file(b"deep"), + "b/share/app_b/info.txt": FsTree.file(b"b"), + # unique dir: include (only in a) -- depth 0, unique but NOT collapsed + "a/include/a.h": FsTree.file(b"a"), + # unique dir: bin (only in b) -- depth 0, unique but NOT collapsed + "b/bin/prog": FsTree.file(b"p"), + }, + ) + + visitor = MultiPrefixMerger(sources=[src_a, src_b], dir_symlink_optimization=True) + + assert not visitor.fatal_conflicts + assert not visitor.file_conflicts + + # depth 0 unique dirs should be recursed into (directories), not collapsed + assert "include" in visitor.directories + assert "include" not in visitor.files + assert os.path.join("include", "a.h") in visitor.files + assert "bin" in visitor.directories + assert "bin" not in visitor.files + assert os.path.join("bin", "prog") in visitor.files + + # "lib" should be a directory (shared), with individual files inside + assert "lib" in visitor.directories + assert os.path.join("lib", "liba.so") in visitor.files + assert os.path.join("lib", "libb.so") in visitor.files + + # depth 1 unique subdirs under shared parent should be collapsed (dir-level symlinks) + assert "share" in visitor.directories + assert os.path.join("share", "app_a") in visitor.files + assert visitor.files[os.path.join("share", "app_a")] == ( + str(src_a), + os.path.join("share", "app_a"), + ) + assert os.path.join("share", "app_b") in visitor.files + assert visitor.files[os.path.join("share", "app_b")] == ( + str(src_b), + os.path.join("share", "app_b"), + ) + + # Subdirs of collapsed dirs should NOT appear in directories + assert os.path.join("share", "app_a") not in visitor.directories + assert os.path.join("share", "app_a", "sub") not in visitor.directories + assert os.path.join("share", "app_b") not in visitor.directories + + +def test_unique_subdir_optimization_disabled(tmp_path: pathlib.Path): + """For hardlink/copy views, unique subdirs should NOT be dir-level symlinks; + individual files should be registered instead.""" + src_a = tmp_path / "a" + src_b = tmp_path / "b" + + FsTree(tmp_path, {"a/lib/a/liba.so": FsTree.file(b"a"), "b/lib/b/libb.so": FsTree.file(b"b")}) + + visitor = MultiPrefixMerger(sources=[src_a, src_b], dir_symlink_optimization=False) + + assert not visitor.fatal_conflicts + assert not visitor.file_conflicts + + # Check that all files are there + assert "lib" in visitor.directories + assert os.path.join("lib", "a") in visitor.directories + assert os.path.join("lib", "b") in visitor.directories + assert os.path.join("lib", "a", "liba.so") in visitor.files + assert os.path.join("lib", "b", "libb.so") in visitor.files + + # No dirs are symlinked. + assert os.path.join("lib", "a") not in visitor.files + assert os.path.join("lib", "b") not in visitor.files + + +def test_projection_dirs_created(tmp_path: pathlib.Path): + """Projection directories should be registered.""" + src_a = tmp_path / "a" + + FsTree(tmp_path, {"a/file.txt": FsTree.file(b"a")}) + + visitor = MultiPrefixMerger(sources=[(src_a, "proj/sub")], dir_symlink_optimization=True) + + assert "proj" in visitor.directories + assert os.path.join("proj", "sub") in visitor.directories + assert os.path.join("proj", "sub", "file.txt") in visitor.files diff --git a/lib/spack/spack/test/views.py b/lib/spack/spack/test/views.py index 0728e04b2dd792..d6ffdd289a108f 100644 --- a/lib/spack/spack/test/views.py +++ b/lib/spack/spack/test/views.py @@ -12,6 +12,7 @@ from spack.filesystem_view import SimpleFilesystemView, YamlFilesystemView from spack.installer import PackageInstaller from spack.spec import Spec +from spack.test.conftest import FsTree def test_remove_extensions_ordered(install_mockery, mock_fetch, tmp_path: pathlib.Path): @@ -66,3 +67,86 @@ def pkg_a_add_files_to_view(view, merge_map, skip_if_exists=True): view.add_specs(a, b) assert os.path.lexists(os.path.join(view_dir, "file")) assert os.path.lexists(os.path.join(view_dir, "subdir", "file")) + + +def test_view_unique_subdir_becomes_dir_symlink(mock_packages, tmp_path: pathlib.Path): + """With link_dirs=True, if a directory is only contributed to by a single spec, the view + should create a symlink to that directory instead of linking individual files.""" + view_dir = str(tmp_path / "view") + os.mkdir(view_dir) + + layout = DirectoryLayout(view_dir) + view = SimpleFilesystemView(view_dir, layout, link_type="symlink", link_dirs=True) + + a = Spec("pkg-a") + b = Spec("pkg-b") + a.set_prefix(str(tmp_path / "a")) + b.set_prefix(str(tmp_path / "b")) + a._mark_concrete() + b._mark_concrete() + + FsTree( + tmp_path, + { + # metadata dirs for both + "a/.spack": FsTree.dir(), + "b/.spack": FsTree.dir(), + # shared dir "lib" with different files in each + "a/lib/liba.so": FsTree.file(), + "b/lib/libb.so": FsTree.file(), + # unique dir "include/a" and "include/b" with nested content + "a/include/a/a.h": FsTree.file(), + "b/include/b/b.h": FsTree.file(), + # unique dir "bin" but at depth 0, so not deep enough to be symlinked + "a/bin/a": FsTree.file(), + }, + ) + + view.add_specs(a, b) + + # Shared dir "lib" should be a real directory with individual file symlinks + lib_dir = os.path.join(view_dir, "lib") + assert os.path.isdir(lib_dir) and not os.path.islink(lib_dir) + assert os.path.islink(os.path.join(lib_dir, "liba.so")) + assert os.path.islink(os.path.join(lib_dir, "libb.so")) + + # Unique dir "include/a" should be a directory symlink + include_link = os.path.join(view_dir, "include", "a") + assert os.path.islink(include_link) + assert os.path.isdir(include_link) + assert os.path.isfile(os.path.join(include_link, "a.h")) + + # Unique dir "include/b" should be a directory symlink pointing to b's include + include_b_link = os.path.join(view_dir, "include", "b") + assert os.path.islink(include_b_link) + assert os.path.isdir(include_b_link) + assert os.path.isfile(os.path.join(include_b_link, "b.h")) + + # Unique dir "bin/" is too shallow to be symlinked, so should be an actual dir. + assert os.path.islink(os.path.join(view_dir, "bin")) is False + assert os.path.isdir(os.path.join(view_dir, "bin")) + assert os.path.islink(os.path.join(view_dir, "bin", "a")) + + +def test_view_no_dir_symlinks(mock_packages, tmp_path: pathlib.Path): + """With link_dirs=False, no directies are symlinked.""" + view_dir = str(tmp_path / "view") + os.mkdir(view_dir) + + layout = DirectoryLayout(view_dir) + view = SimpleFilesystemView(view_dir, layout, link_type="symlink", link_dirs=False) + + a = Spec("pkg-a") + a.set_prefix(str(tmp_path / "a")) + a._mark_concrete() + + FsTree(tmp_path, {"a/.spack": FsTree.dir(), "a/include/a/a.h": FsTree.file("header")}) + + view.add_specs(a) + + # "include/a" should be a real directory, not a symlink + include_dir = os.path.join(view_dir, "include", "a") + assert os.path.isdir(include_dir) and not os.path.islink(include_dir) + # File should be a symlink. + ah_path = os.path.join(include_dir, "a.h") + assert os.path.islink(ah_path) From 92f56c664f500b74c03ddb069891a2ff68148b52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 12:32:33 +0200 Subject: [PATCH 315/337] build(deps): bump julia-actions/cache from 3.0.2 to 3.1.0 (#52362) Bumps [julia-actions/cache](https://github.com/julia-actions/cache) from 3.0.2 to 3.1.0. - [Release notes](https://github.com/julia-actions/cache/releases) - [Commits](https://github.com/julia-actions/cache/compare/9a93c5fb3e9c1c20b60fc80a478cae53e38618a4...a45e8fa8be21c18a06b7177052533149e61e9b38) --- updated-dependencies: - dependency-name: julia-actions/cache dependency-version: 3.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/import-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index 09af78fa51f817..d8b886a6e7d8d0 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -12,7 +12,7 @@ jobs: - uses: julia-actions/setup-julia@f6f565d9f7cf12f53dc8045742460d6260ad3b39 # v3.0.1 with: version: '1.10' - - uses: julia-actions/cache@9a93c5fb3e9c1c20b60fc80a478cae53e38618a4 # v3.0.2 + - uses: julia-actions/cache@a45e8fa8be21c18a06b7177052533149e61e9b38 # v3.1.0 # PR: use the base of the PR as the old commit - name: Checkout PR base commit From 4254eca6464ca9c4bb3b2309aec29c202bc6562f Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 5 May 2026 18:06:36 +0200 Subject: [PATCH 316/337] sbang: install ahead of time (#52369) Move the responsibility for installing `sbang` from the post-install hook to the installer. This ensures that sbang is present before the build begins, eliminates unnecessary races to install it in parallel in the build sub-processes, and allows the build subprocesses to run with more restricted write access, necessary for sandboxing. Other than that: * Use O_EXCL to prevent race conditions where multiple processes create a temporary file in `$store/bin` * Use fchmod/fchown to eliminate TOCTOU * Due to use of fd, we now have to chmod with 0o111 to make sbang executable. * Eliminate a few redundant syscalls. --- lib/spack/spack/hooks/sbang.py | 57 ------------------------------- lib/spack/spack/installer.py | 1 + lib/spack/spack/new_installer.py | 1 + lib/spack/spack/store.py | 58 ++++++++++++++++++++++++++++++++ lib/spack/spack/test/sbang.py | 6 ++-- 5 files changed, 63 insertions(+), 60 deletions(-) diff --git a/lib/spack/spack/hooks/sbang.py b/lib/spack/spack/hooks/sbang.py index a41688ecf12820..39b754fee60cde 100644 --- a/lib/spack/spack/hooks/sbang.py +++ b/lib/spack/spack/hooks/sbang.py @@ -2,7 +2,6 @@ # # SPDX-License-Identifier: (Apache-2.0 OR MIT) -import filecmp import os import re import shutil @@ -11,13 +10,8 @@ import tempfile import spack.error -import spack.llnl.util.filesystem as fs import spack.llnl.util.tty as tty -import spack.package_prefs -import spack.paths -import spack.spec import spack.store -from spack.util.socket import _gethostname #: OS-imposed character limit for shebang line: 127 for Linux; 511 for Mac. #: Different Linux distributions have different limits, but 127 is the @@ -39,11 +33,6 @@ # ignore any error a sane default is set already pass -#: Groupdb does not exist on Windows, prevent imports -#: on supported systems -if sys.platform != "win32": - import grp - #: Spack itself also limits the shebang line to at most 4KB, which should be plenty. spack_shebang_limit = 4096 @@ -189,50 +178,6 @@ def filter_shebangs_in_directory(directory, filenames=None): tty.debug("Patched overlong shebang in %s" % path) -def install_sbang(): - """Ensure that ``sbang`` is installed in the root of Spack's install_tree. - - This is the shortest known publicly accessible path, and installing - ``sbang`` here ensures that users can access the script and that - ``sbang`` itself is in a short path. - """ - # copy in a new version of sbang if it differs from what's in spack - sbang_path = sbang_install_path() - if os.path.exists(sbang_path) and filecmp.cmp(spack.paths.sbang_script, sbang_path): - return - - # make $install_tree/bin - sbang_bin_dir = os.path.dirname(sbang_path) - fs.mkdirp(sbang_bin_dir) - - # get permissions for bin dir from configuration files - group_name = spack.package_prefs.get_package_group(spack.spec.Spec("all")) - config_mode = spack.package_prefs.get_package_dir_permissions(spack.spec.Spec("all")) - - if group_name: - os.chmod(sbang_bin_dir, config_mode) # Use package directory permissions - else: - fs.set_install_permissions(sbang_bin_dir) - - # set group on sbang_bin_dir if not already set (only if set in configuration) - # TODO: after we drop python2 support, use shutil.chown to avoid gid lookups that - # can fail for remote groups - if group_name and os.stat(sbang_bin_dir).st_gid != grp.getgrnam(group_name).gr_gid: - os.chown(sbang_bin_dir, os.stat(sbang_bin_dir).st_uid, grp.getgrnam(group_name).gr_gid) - - # copy over the fresh copy of `sbang` - sbang_tmp_path = os.path.join(sbang_bin_dir, f".sbang.{_gethostname()}.{os.getpid()}.tmp") - shutil.copy(spack.paths.sbang_script, sbang_tmp_path) - - # set permissions on `sbang` (including group if set in configuration) - os.chmod(sbang_tmp_path, config_mode) - if group_name: - os.chown(sbang_tmp_path, os.stat(sbang_tmp_path).st_uid, grp.getgrnam(group_name).gr_gid) - - # Finally, move the new `sbang` into place atomically - os.rename(sbang_tmp_path, sbang_path) - - def post_install(spec, explicit=None): """This hook edits scripts so that they call /bin/bash $spack_prefix/bin/sbang instead of something longer than the @@ -244,8 +189,6 @@ def post_install(spec, explicit=None): tty.debug("SKIP: shebang filtering [external package]") return - install_sbang() - for directory, _, filenames in os.walk(spec.prefix): filter_shebangs_in_directory(directory, filenames) diff --git a/lib/spack/spack/installer.py b/lib/spack/spack/installer.py index fad9c3a16c3477..1c455aa63469bf 100644 --- a/lib/spack/spack/installer.py +++ b/lib/spack/spack/installer.py @@ -2461,6 +2461,7 @@ def _install(self) -> None: """ + spack.store.STORE.install_sbang() self._init_queue() failed_build_requests = [] install_status = InstallStatus(len(self.build_pq)) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 6d6066d5085029..79682020b90dfb 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -2445,6 +2445,7 @@ def install(self) -> None: self._installer() def _installer(self) -> None: + spack.store.STORE.install_sbang() jobserver = JobServer(self.jobs) selector = selectors.DefaultSelector() diff --git a/lib/spack/spack/store.py b/lib/spack/spack/store.py index f4e13fef0e5907..e8f07e70f19bb4 100644 --- a/lib/spack/spack/store.py +++ b/lib/spack/spack/store.py @@ -17,9 +17,13 @@ """ import contextlib +import filecmp import os import pathlib import re +import secrets +import shutil +import sys import uuid from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union, cast @@ -28,9 +32,11 @@ import spack.directory_layout import spack.error import spack.llnl.util.lang +import spack.package_prefs import spack.paths import spack.spec import spack.util.path +from spack.llnl.util import filesystem as fs from spack.llnl.util import tty #: default installation root, relative to the Spack install path @@ -194,6 +200,58 @@ def reindex(self) -> None: """Convenience function to reindex the store DB with its own layout.""" return self.db.reindex() + def install_sbang(self) -> None: + """Install the sbang script in this store's bin directory. + + sbang is a short shell script that Spack prepends to scripts with shebangs that are too + long for the OS. It must live in the store so its path is short enough to fit on a + shebang line. + """ + + if sys.platform == "win32": + return + + import grp # unix only, hence the import here + + sbang_path = os.path.join(self.unpadded_root, "bin", "sbang") + try: + if filecmp.cmp(sbang_path, spack.paths.sbang_script): + return # installed and up to date + except FileNotFoundError: + pass + + bin_dir = os.path.dirname(sbang_path) + os.makedirs(bin_dir, exist_ok=True) + + all_spec = spack.spec.Spec("all") + group_name = spack.package_prefs.get_package_group(all_spec) + config_mode = spack.package_prefs.get_package_dir_permissions(all_spec) + gid = grp.getgrnam(group_name).gr_gid if group_name else -1 + + if group_name: + os.chmod(bin_dir, config_mode) + os.chown(bin_dir, -1, gid) + else: + fs.set_install_permissions(bin_dir) + + sbang_tmp_path = os.path.join(bin_dir, f".sbang.{secrets.token_hex(8)}.tmp") + # Open a randomized temporary file with O_EXCL to error on races. Outside the try-except + # to ensure we don't delete a file created by another process in the except block. + sbang_tmp_file = open(sbang_tmp_path, "xb") + try: + with open(spack.paths.sbang_script, "rb") as src, sbang_tmp_file as dst: + shutil.copyfileobj(src, dst) + os.fchmod(dst.fileno(), config_mode | 0o111) # ensure executable + if group_name: + os.fchown(dst.fileno(), -1, gid) + os.rename(sbang_tmp_path, sbang_path) + except BaseException: + try: + os.unlink(sbang_tmp_path) + except OSError: + pass + raise + def __reduce__(self): return Store, ( self.root, diff --git a/lib/spack/spack/test/sbang.py b/lib/spack/spack/test/sbang.py index 165bb20bb1a978..deda0bb38747da 100644 --- a/lib/spack/spack/test/sbang.py +++ b/lib/spack/spack/test/sbang.py @@ -333,7 +333,7 @@ def run_test_install_sbang(group): assert sbang_path.startswith(spack.store.STORE.unpadded_root) assert not os.path.exists(sbang_bin_dir) - sbang.install_sbang() + spack.store.STORE.install_sbang() check_sbang_installation(group) # put an invalid file in for sbang @@ -341,11 +341,11 @@ def run_test_install_sbang(group): with open(sbang_path, "w", encoding="utf-8") as f: f.write("foo") - sbang.install_sbang() + spack.store.STORE.install_sbang() check_sbang_installation(group) # install again and make sure sbang is still fine - sbang.install_sbang() + spack.store.STORE.install_sbang() check_sbang_installation(group) From dc375fdc45ba1edfbadc514ea2e08a8127d113ef Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Tue, 5 May 2026 19:26:19 +0200 Subject: [PATCH 317/337] Bump the package API version to v2.5 (#52359) This change includes adding hip-lang and cuda-lang, and was overlooked in #52145 Signed-off-by: Massimiliano Culpo --- NEWS.md | 3 +++ lib/spack/docs/package_api.rst | 5 +++++ lib/spack/spack/__init__.py | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 0224d3779d3648..89944b5cbbde0a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,6 @@ +## Package API v2.5 +- Added `cuda-lang` and `hip-lang` as language virtuals (analogous to `c`, `cxx`, `fortran`). + ## Package API v2.4 - Added the `%%` sigil to spec syntax, to propagate compiler preferences. diff --git a/lib/spack/docs/package_api.rst b/lib/spack/docs/package_api.rst index 9d18100304b88f..287285b43bc20f 100644 --- a/lib/spack/docs/package_api.rst +++ b/lib/spack/docs/package_api.rst @@ -37,6 +37,11 @@ Spack version |spack_version| supports package repositories with a Package API v Changelog --------- +**v2.5** *(Spack v1.2.0)* + +* Added ``cuda-lang`` and ``hip-lang`` as language virtuals, analogous to ``c``, ``cxx``, and ``fortran``. + Packages that use CUDA or HIP can now declare explicit language dependencies on these virtuals. + **v2.4** *(Spack v1.0.3)* * The ``%%`` operator can be used on input specs to set propagated preferences, which is particularly useful for ``unify: false`` environments. diff --git a/lib/spack/spack/__init__.py b/lib/spack/spack/__init__.py index da762a5f909190..ae772db4099df4 100644 --- a/lib/spack/spack/__init__.py +++ b/lib/spack/spack/__init__.py @@ -19,7 +19,7 @@ #: version is incremented when the package API is extended in a backwards-compatible way. The major #: version is incremented upon breaking changes. This version is changed independently from the #: Spack version. -package_api_version = (2, 4) +package_api_version = (2, 5) #: The minimum Package API version that this version of Spack is compatible with. This should #: always be a tuple of the form ``(major, 0)``, since compatibility with vX.Y implies From 7c3216c205692c815f807e2b6878a3c044495d90 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Tue, 5 May 2026 22:04:36 +0200 Subject: [PATCH 318/337] download cache: fix race (#52365) If two builds `foo/hash1` and `foo/hash2` cache sources at the same time, there's a race, resulting in a broken archive. This is particularly bad for git sources where we ~can't~ don't do source verification This fix does not rule out races entirely, but does give an exclusive temporary file to write to, followed by a rename. Signed-off-by: Harmen Stoppels --- lib/spack/spack/caches.py | 16 ++++++---------- lib/spack/spack/fetch_strategy.py | 26 ++++++++++++++++++++++---- lib/spack/spack/test/mirror.py | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/lib/spack/spack/caches.py b/lib/spack/spack/caches.py index 66f44952726943..528969295f6673 100644 --- a/lib/spack/spack/caches.py +++ b/lib/spack/spack/caches.py @@ -4,7 +4,6 @@ """Caches used by Spack to store data""" -import os from typing import cast import spack.config @@ -13,7 +12,6 @@ import spack.paths import spack.util.file_cache import spack.util.path -from spack.llnl.util.filesystem import mkdirp def misc_cache_location(): @@ -53,19 +51,17 @@ def _fetch_cache(): return spack.fetch_strategy.FsCache(path) -class MirrorCache: +class MirrorCache(spack.fetch_strategy.FsCacheBase): def __init__(self, root, skip_unstable_versions): - self.root = os.path.abspath(root) + super().__init__(root) self.skip_unstable_versions = skip_unstable_versions def store(self, fetcher, relative_dest): - """Fetch and relocate the fetcher's target into our mirror cache.""" + """Fetch and relocate the fetcher's target into our mirror cache. - # Note this will archive package sources even if they would not - # normally be cached (e.g. the current tip of an hg/git branch) - dst = os.path.join(self.root, relative_dest) - mkdirp(os.path.dirname(dst)) - fetcher.archive(dst) + Note: archives package sources even if not normally cached (e.g. tip of hg/git branch). + """ + super().store(fetcher, relative_dest) #: Spack's local cache for downloaded source archives diff --git a/lib/spack/spack/fetch_strategy.py b/lib/spack/spack/fetch_strategy.py index 39dff1e338c410..d07ccc21b4babb 100644 --- a/lib/spack/spack/fetch_strategy.py +++ b/lib/spack/spack/fetch_strategy.py @@ -32,6 +32,7 @@ import http.client import os import re +import secrets import shutil import sys import time @@ -1760,10 +1761,29 @@ def from_list_url(pkg): tty.msg("Could not determine url from list_url.") -class FsCache: +class FsCacheBase: def __init__(self, root): self.root = os.path.abspath(root) + def store(self, fetcher, relative_dest): + dst = os.path.join(self.root, relative_dest) + mkdirp(os.path.dirname(dst)) + tmp = os.path.join( + os.path.dirname(dst), ".tmp." + secrets.token_hex(6) + "." + os.path.basename(dst) + ) + open(tmp, "xb").close() + try: + fetcher.archive(tmp) + os.replace(tmp, dst) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +class FsCache(FsCacheBase): def store(self, fetcher, relative_dest): # skip fetchers that aren't cachable if not fetcher.cachable: @@ -1773,9 +1793,7 @@ def store(self, fetcher, relative_dest): if isinstance(fetcher, CacheURLFetchStrategy): return - dst = os.path.join(self.root, relative_dest) - mkdirp(os.path.dirname(dst)) - fetcher.archive(dst) + super().store(fetcher, relative_dest) def fetcher(self, target_path: str, digest: Optional[str], **kwargs) -> CacheURLFetchStrategy: path = os.path.join(self.root, target_path) diff --git a/lib/spack/spack/test/mirror.py b/lib/spack/spack/test/mirror.py index 188a98e5638fa2..e268a608dcc55c 100644 --- a/lib/spack/spack/test/mirror.py +++ b/lib/spack/spack/test/mirror.py @@ -218,6 +218,27 @@ def archive(dst): pass +def test_cache_store_atomic_on_failure(tmp_path: pathlib.Path): + """A failed archive() must not leave a partial file at the final destination.""" + + class FailingFetcher: + cachable = True + + @staticmethod + def archive(dst): + with open(dst, "wb") as f: + f.write(b"partial") + raise RuntimeError("simulated failure mid-archive") + + for cache in [ + spack.caches.MirrorCache(root=str(tmp_path), skip_unstable_versions=False), + spack.fetch_strategy.FsCache(str(tmp_path)), + ]: + with pytest.raises(RuntimeError, match="simulated failure"): + cache.store(FailingFetcher(), "pkg/pkg-1.0.tar.gz") + assert not (tmp_path / "pkg" / "pkg-1.0.tar.gz").exists() + + @pytest.mark.regression("14067") def test_mirror_layout_make_alias(tmp_path: pathlib.Path): """Confirm that the cosmetic symlink created in the mirror cache (which may From 2e495411e37f33f3a988b44b62fddc1050eb44d1 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 6 May 2026 16:13:33 +0200 Subject: [PATCH 319/337] commands: consistency in exit codes, improved cli errors (#52284) Exit codes now carry meaning: * `1`: runtime "no" / "cannot" (package not found, install failure, etc.) * `2`: invalid user input (wrong flags, bad argument combinations, missing required arguments); produced by `parser.error()` To that end: * Replace `tty.die` and `SpackError` with `parser.error` for command-line argument validation errors across all commands * Replace `raise RuntimeError` with `parser.error` in `spack find` for conflicting `--only-missing`/`--deprecated`/`--missing` flags * Inject the active subparser into the arguments namespace using `set_defaults(subparser=...)` to make it accessible to command functions * Refactor `require_active_env` to accept a parser object instead of a command name string * Adjust command error messages to align with standard argparse output formatting (lowercase, no repeated command name) * Update CLI unit tests to assert specific exit codes (2 for bad input, 1 for runtime failures) rather than just non-zero --- lib/spack/spack/cmd/__init__.py | 23 ++++++-------- lib/spack/spack/cmd/add.py | 2 +- lib/spack/spack/cmd/audit.py | 2 +- lib/spack/spack/cmd/buildcache.py | 46 ++++++++++++++------------- lib/spack/spack/cmd/change.py | 2 +- lib/spack/spack/cmd/ci.py | 16 +++++----- lib/spack/spack/cmd/commands.py | 4 +-- lib/spack/spack/cmd/concretize.py | 2 +- lib/spack/spack/cmd/config.py | 22 ++++++++++--- lib/spack/spack/cmd/deconcretize.py | 8 ++--- lib/spack/spack/cmd/dependencies.py | 2 +- lib/spack/spack/cmd/dependents.py | 2 +- lib/spack/spack/cmd/deprecate.py | 3 +- lib/spack/spack/cmd/dev_build.py | 9 +++--- lib/spack/spack/cmd/develop.py | 10 +++--- lib/spack/spack/cmd/diff.py | 2 +- lib/spack/spack/cmd/env.py | 5 +-- lib/spack/spack/cmd/extensions.py | 2 +- lib/spack/spack/cmd/fetch.py | 7 ++-- lib/spack/spack/cmd/find.py | 8 ++--- lib/spack/spack/cmd/gpg.py | 18 +++++------ lib/spack/spack/cmd/graph.py | 4 +-- lib/spack/spack/cmd/info.py | 4 +-- lib/spack/spack/cmd/install.py | 10 +++--- lib/spack/spack/cmd/location.py | 6 ++-- lib/spack/spack/cmd/log_parse.py | 3 +- lib/spack/spack/cmd/logs.py | 4 +-- lib/spack/spack/cmd/maintainers.py | 5 ++- lib/spack/spack/cmd/mirror.py | 20 ++++++------ lib/spack/spack/cmd/patch.py | 2 +- lib/spack/spack/cmd/pkg.py | 13 ++++++-- lib/spack/spack/cmd/python.py | 4 +-- lib/spack/spack/cmd/remove.py | 2 +- lib/spack/spack/cmd/restage.py | 3 +- lib/spack/spack/cmd/solve.py | 2 +- lib/spack/spack/cmd/spec.py | 3 +- lib/spack/spack/cmd/stage.py | 4 +-- lib/spack/spack/cmd/tags.py | 2 +- lib/spack/spack/cmd/undevelop.py | 2 +- lib/spack/spack/cmd/uninstall.py | 6 ++-- lib/spack/spack/cmd/unload.py | 5 ++- lib/spack/spack/cmd/verify.py | 22 ++++++------- lib/spack/spack/main.py | 3 +- lib/spack/spack/test/cmd/audit.py | 3 +- lib/spack/spack/test/cmd/config.py | 4 +-- lib/spack/spack/test/cmd/create.py | 2 +- lib/spack/spack/test/cmd/dev_build.py | 3 +- lib/spack/spack/test/cmd/env.py | 6 ++++ lib/spack/spack/test/cmd/location.py | 20 ++++++++---- lib/spack/spack/test/cmd/logs.py | 4 ++- lib/spack/spack/test/cmd/mirror.py | 23 ++++---------- lib/spack/spack/test/cmd/python.py | 3 +- lib/spack/spack/test/cmd/spec.py | 2 +- lib/spack/spack/test/cmd/style.py | 8 ++--- 54 files changed, 212 insertions(+), 190 deletions(-) diff --git a/lib/spack/spack/cmd/__init__.py b/lib/spack/spack/cmd/__init__.py index e2f5340a5c3429..92f97e7b9b7e69 100644 --- a/lib/spack/spack/cmd/__init__.py +++ b/lib/spack/spack/cmd/__init__.py @@ -639,29 +639,26 @@ def extant_file(f): return f -def require_active_env(cmd_name): - """Used by commands to get the active environment +def require_active_env(parser): + """Used by commands to get the active environment. - If an environment is not found, print an error message that says the calling - command *needs* an active environment. + If an environment is not found, calls ``parser.error()`` which prints usage and exits. Arguments: - cmd_name (str): name of calling command + parser: the subparser for the command (typically ``args.subparser``) Returns: (spack.environment.Environment): the active environment """ env = ev.active_environment() - if env: return env - - tty.die( - "`spack %s` requires an environment" % cmd_name, - "activate an environment first:", - " spack env activate ENV", - "or use:", - " spack -e ENV %s ..." % cmd_name, + parser.error( + "requires an active environment\n" + " activate an environment first:\n" + " spack env activate ENV\n" + " or use:\n" + " spack -e ENV %s ..." % parser.prog.partition(" ")[2] ) diff --git a/lib/spack/spack/cmd/add.py b/lib/spack/spack/cmd/add.py index 05ac69b65a5ae7..30ffa4a163be36 100644 --- a/lib/spack/spack/cmd/add.py +++ b/lib/spack/spack/cmd/add.py @@ -25,7 +25,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def add(parser, args): - env = spack.cmd.require_active_env(cmd_name="add") + env = spack.cmd.require_active_env(args.subparser) with env.write_transaction(): for spec in spack.cmd.parse_specs(args.specs): diff --git a/lib/spack/spack/cmd/audit.py b/lib/spack/spack/cmd/audit.py index cd7359af2670e0..d9d0d29c16e710 100644 --- a/lib/spack/spack/cmd/audit.py +++ b/lib/spack/spack/cmd/audit.py @@ -68,7 +68,7 @@ def packages(parser, args): def packages_https(parser, args): # Since packages takes a long time, --all is required without name if not args.check_all and not args.name: - tty.die("Please specify one or more packages to audit, or --all.") + args.subparser.error("please specify one or more packages to audit, or --all") pkgs = args.name or spack.repo.PATH.all_package_names() reports = spack.audit.run_group(args.subcommand, pkgs=pkgs) diff --git a/lib/spack/spack/cmd/buildcache.py b/lib/spack/spack/cmd/buildcache.py index c3fd32c04f7717..80fb3250dd2de3 100644 --- a/lib/spack/spack/cmd/buildcache.py +++ b/lib/spack/spack/cmd/buildcache.py @@ -150,7 +150,7 @@ def setup_parser(subparser: argparse.ArgumentParser): "(can be specified multiple times, requires an active environment)", ) arguments.add_common_arguments(push, ["specs", "jobs"]) - push.set_defaults(func=push_fn) + push.set_defaults(func=push_fn, subparser=push) install = subparsers.add_parser("install", help=install_fn.__doc__) install.add_argument( @@ -173,7 +173,7 @@ def setup_parser(subparser: argparse.ArgumentParser): ) arguments.add_common_arguments(install, ["specs"]) - install.set_defaults(func=install_fn) + install.set_defaults(func=install_fn, subparser=install) listcache = subparsers.add_parser("list", help=list_fn.__doc__) arguments.add_common_arguments(listcache, ["long", "very_long", "namespaces"]) @@ -191,7 +191,7 @@ def setup_parser(subparser: argparse.ArgumentParser): help="list specs for all available architectures instead of default platform and OS", ) arguments.add_common_arguments(listcache, ["specs"]) - listcache.set_defaults(func=list_fn) + listcache.set_defaults(func=list_fn, subparser=listcache) keys = subparsers.add_parser("keys", help=keys_fn.__doc__) keys.add_argument( @@ -199,7 +199,7 @@ def setup_parser(subparser: argparse.ArgumentParser): ) keys.add_argument("-t", "--trust", action="store_true", help="trust all downloaded keys") keys.add_argument("-f", "--force", action="store_true", help="force new download of keys") - keys.set_defaults(func=keys_fn) + keys.set_defaults(func=keys_fn, subparser=keys) # Check if binaries need to be rebuilt on remote mirror check = subparsers.add_parser("check", help=check_fn.__doc__) @@ -225,7 +225,7 @@ def setup_parser(subparser: argparse.ArgumentParser): arguments.add_common_arguments(check, ["specs"]) - check.set_defaults(func=check_fn) + check.set_defaults(func=check_fn, subparser=check) # Download tarball and specfile download = subparsers.add_parser("download", help=download_fn.__doc__) @@ -237,7 +237,7 @@ def setup_parser(subparser: argparse.ArgumentParser): default=None, help="path to directory where tarball should be downloaded", ) - download.set_defaults(func=download_fn) + download.set_defaults(func=download_fn, subparser=download) prune = subparsers.add_parser("prune", help=prune_fn.__doc__) prune.add_argument( @@ -254,7 +254,7 @@ def setup_parser(subparser: argparse.ArgumentParser): action="store_true", help="do not actually delete anything from the buildcache, but log what would be deleted", ) - prune.set_defaults(func=prune_fn) + prune.set_defaults(func=prune_fn, subparser=prune) # Given the root spec, save the yaml of the dependent spec to a file savespecfile = subparsers.add_parser("save-specfile", help=save_specfile_fn.__doc__) @@ -269,7 +269,7 @@ def setup_parser(subparser: argparse.ArgumentParser): savespecfile.add_argument( "--specfile-dir", required=True, help="path to directory where spec yamls should be saved" ) - savespecfile.set_defaults(func=save_specfile_fn) + savespecfile.set_defaults(func=save_specfile_fn, subparser=savespecfile) # Sync buildcache entries from one mirror to another sync = subparsers.add_parser("sync", help=sync_fn.__doc__) @@ -304,7 +304,7 @@ def setup_parser(subparser: argparse.ArgumentParser): help="destination mirror name, path, or URL", ) - sync.set_defaults(func=sync_fn) + sync.set_defaults(func=sync_fn, subparser=sync) # Check the validity of a buildcache check_index = subparsers.add_parser("check-index", help=check_index_fn.__doc__) @@ -324,7 +324,7 @@ def setup_parser(subparser: argparse.ArgumentParser): check_index.add_argument( "mirror", type=arguments.mirror_name_or_url, help="mirror name, path, or URL" ) - check_index.set_defaults(func=check_index_fn) + check_index.set_defaults(func=check_index_fn, subparser=check_index) # Update buildcache index without copying any additional packages update_index = subparsers.add_parser( @@ -365,7 +365,7 @@ def setup_parser(subparser: argparse.ArgumentParser): help="if provided, key index will be updated as well as package index", ) arguments.add_common_arguments(update_index, ["yes_to_all"]) - update_index.set_defaults(func=update_index_fn) + update_index.set_defaults(func=update_index_fn, subparser=update_index) # Migrate a buildcache from layout_version 2 to version 3 migrate = subparsers.add_parser("migrate", help=migrate_fn.__doc__) @@ -386,7 +386,7 @@ def setup_parser(subparser: argparse.ArgumentParser): ) arguments.add_common_arguments(migrate, ["yes_to_all"]) # TODO: add -y argument to prompt if user really means to delete existing - migrate.set_defaults(func=migrate_fn) + migrate.set_defaults(func=migrate_fn, subparser=migrate) def _matching_specs(specs: List[Spec]) -> List[Spec]: @@ -462,10 +462,10 @@ def _specs_to_be_packaged( def push_fn(args): """create a binary package and push it to a mirror""" if args.specs and args.groups: - tty.die("--group and explicit specs are mutually exclusive") + args.subparser.error("--group and explicit specs are mutually exclusive") if args.groups: - env = spack.cmd.require_active_env(cmd_name="buildcache push") + env = spack.cmd.require_active_env(args.subparser) available_groups = env.manifest.groups() if any(g not in available_groups for g in args.groups): tty.die( @@ -477,7 +477,7 @@ def push_fn(args): elif args.specs: roots = _matching_specs(spack.cmd.parse_specs(args.specs)) else: - roots = spack.cmd.require_active_env(cmd_name="buildcache push").concrete_roots() + roots = spack.cmd.require_active_env(args.subparser).concrete_roots() mirror = args.mirror assert isinstance(mirror, spack.mirrors.mirror.Mirror) @@ -589,7 +589,7 @@ def push_fn(args): def install_fn(args): """install from a binary package""" if not args.specs: - tty.die("a spec argument is required to install from a buildcache") + args.subparser.error("a spec argument is required to install from a buildcache") query = spack.binary_distribution.BinaryCacheQuery(all_architectures=args.otherarch) matches = spack.store.find(args.specs, multiple=args.multiple, query_fn=query) @@ -640,7 +640,7 @@ def check_fn(args: argparse.Namespace): if specs_arg: specs = _matching_specs(spack.cmd.parse_specs(specs_arg)) else: - specs = spack.cmd.require_active_env("buildcache check").all_specs() + specs = spack.cmd.require_active_env(args.subparser).all_specs() if not specs: tty.msg("No specs provided, exiting.") @@ -677,7 +677,7 @@ def download_fn(args): specs = _matching_specs(spack.cmd.parse_specs(args.spec)) if len(specs) != 1: - tty.die("a single spec argument is required to download from a buildcache") + args.subparser.error("requires a single spec argument") spack.binary_distribution.download_single_spec(specs[0], args.path) @@ -692,7 +692,7 @@ def save_specfile_fn(args): specs = spack.cmd.parse_specs(args.root_spec) if len(specs) != 1: - tty.die("a single spec argument is required to save specfile") + args.subparser.error("requires a single spec argument") root = specs[0] @@ -798,7 +798,7 @@ def sync_fn(args): return 0 if args.src_mirror is None or args.dest_mirror is None: - tty.die("Provide mirrors to sync from and to.") + args.subparser.error("provide mirrors to sync from and to") src_mirror = args.src_mirror dest_mirror = args.dest_mirror @@ -807,7 +807,7 @@ def sync_fn(args): dest_mirror_url = dest_mirror.push_url # Get the active environment - env = spack.cmd.require_active_env(cmd_name="buildcache sync") + env = spack.cmd.require_active_env(args.subparser) tty.msg( "Syncing environment buildcache files from {0} to {1}".format( @@ -904,6 +904,7 @@ def update_view( name: Optional[str] = None, update_keys: bool = False, yes_to_all: bool = False, + parser, ): """update a buildcache view index""" # OCI images do not support views. @@ -963,7 +964,7 @@ def update_view( hashes.extend(env.all_hashes()) else: # Get hashes in the current active environment - hashes = spack.cmd.require_active_env(cmd_name="buildcache update-view").all_hashes() + hashes = spack.cmd.require_active_env(parser).all_hashes() if not hashes: tty.warn("No specs found for view, creating an empty index") @@ -1142,6 +1143,7 @@ def update_index_fn(args): name=args.name, update_keys=args.keys, yes_to_all=args.yes_to_all, + parser=args.subparser, ) else: update_index(args.mirror, update_keys=args.keys, timer=t) diff --git a/lib/spack/spack/cmd/change.py b/lib/spack/spack/cmd/change.py index 89b7eaff12f1f2..33658f58f3a04f 100644 --- a/lib/spack/spack/cmd/change.py +++ b/lib/spack/spack/cmd/change.py @@ -57,7 +57,7 @@ def change(parser, args): if args.list_name != "specs" and args.concrete_only: warnings.warn("'spack change --list-name' argument is ignored with '--concrete-only'") - env = spack.cmd.require_active_env(cmd_name="change") + env = spack.cmd.require_active_env(args.subparser) match_spec = None if args.match_spec: diff --git a/lib/spack/spack/cmd/ci.py b/lib/spack/spack/cmd/ci.py index 99b27235566870..76265a1025b91c 100644 --- a/lib/spack/spack/cmd/ci.py +++ b/lib/spack/spack/cmd/ci.py @@ -149,7 +149,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: help="Environment variables to forward from the generate environment " "to the generated jobs.", ) - generate.set_defaults(func=ci_generate) + generate.set_defaults(func=ci_generate, subparser=generate) spack.cmd.common.arguments.add_concretizer_args(generate) spack.cmd.common.arguments.add_common_arguments(generate, ["jobs"]) @@ -159,7 +159,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: index = subparsers.add_parser( "rebuild-index", description=doc_dedented(ci_reindex), help=doc_first_line(ci_reindex) ) - index.set_defaults(func=ci_reindex) + index.set_defaults(func=ci_reindex, subparser=index) # Handle steps of a ci build/rebuild rebuild = subparsers.add_parser( @@ -192,7 +192,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: default=None, help="maximum time (in seconds) that tests are allowed to run", ) - rebuild.set_defaults(func=ci_rebuild) + rebuild.set_defaults(func=ci_rebuild, subparser=rebuild) spack.cmd.common.arguments.add_common_arguments(rebuild, ["jobs"]) # Facilitate reproduction of a failed CI build job @@ -231,7 +231,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: "--gpg-url", help="URL to public GPG key for validating binary cache installs" ) - reproduce.set_defaults(func=ci_reproduce) + reproduce.set_defaults(func=ci_reproduce, subparser=reproduce) # Verify checksums inside of ci workflows verify_versions = subparsers.add_parser( @@ -241,7 +241,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: ) verify_versions.add_argument("from_ref", help="git ref from which start looking at changes") verify_versions.add_argument("to_ref", help="git ref to end looking at changes") - verify_versions.set_defaults(func=ci_verify_versions) + verify_versions.set_defaults(func=ci_verify_versions, subparser=verify_versions) def ci_generate(args): @@ -252,7 +252,7 @@ def ci_generate(args): before invoking this command. the value must be the CDash authorization token needed to create a build group and register all generated jobs under it """ - env = spack.cmd.require_active_env(cmd_name="ci generate") + env = spack.cmd.require_active_env(args.subparser) spack_ci.generate_pipeline(env, args) @@ -263,7 +263,7 @@ def ci_reindex(args): use the active, gitlab-enabled environment to rebuild the buildcache index for the associated mirror """ - env = spack.cmd.require_active_env(cmd_name="ci rebuild-index") + env = spack.cmd.require_active_env(args.subparser) yaml_root = env.manifest[ev.TOP_LEVEL_KEY] if "mirrors" not in yaml_root or len(yaml_root["mirrors"].values()) < 1: @@ -286,7 +286,7 @@ def ci_rebuild(args): """ rebuild_timer = timer.Timer() - env = spack.cmd.require_active_env(cmd_name="ci rebuild") + env = spack.cmd.require_active_env(args.subparser) # Make sure the environment is "gitlab-enabled", or else there's nothing # to do. diff --git a/lib/spack/spack/cmd/commands.py b/lib/spack/spack/cmd/commands.py index 726ae9c3ecf7af..4ebf3fa2691381 100644 --- a/lib/spack/spack/cmd/commands.py +++ b/lib/spack/spack/cmd/commands.py @@ -846,7 +846,7 @@ def _commands(parser: ArgumentParser, args: Namespace) -> None: # check header first so we don't open out files unnecessarily if args.header and not os.path.exists(args.header): - tty.die(f"No such file: '{args.header}'") + args.subparser.error(f"no such file: '{args.header}'") if args.update: tty.msg(f"Updating file: {args.update}") @@ -884,7 +884,7 @@ def commands(parser: ArgumentParser, args: Namespace) -> None: """ if args.update_completion: if args.format != "names" or any([args.aliases, args.update, args.header]): - tty.die("--update-completion can only be specified alone.") + args.subparser.error("--update-completion can only be specified alone") # this runs the command multiple times with different arguments update_completion(parser, args) diff --git a/lib/spack/spack/cmd/concretize.py b/lib/spack/spack/cmd/concretize.py index 1d8884f7408616..10cde4f7aa57c6 100644 --- a/lib/spack/spack/cmd/concretize.py +++ b/lib/spack/spack/cmd/concretize.py @@ -31,7 +31,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def concretize(parser, args): - env = spack.cmd.require_active_env(cmd_name="concretize") + env = spack.cmd.require_active_env(args.subparser) if args.test == "all": tests = True diff --git a/lib/spack/spack/cmd/config.py b/lib/spack/spack/cmd/config.py index f559c3a0f164b1..32d21a5cc8cbfb 100644 --- a/lib/spack/spack/cmd/config.py +++ b/lib/spack/spack/cmd/config.py @@ -52,6 +52,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: default=None, help="show configuration as seen by this environment spec group (requires active env)", ) + get_parser.set_defaults(subparser=get_parser) blame_parser = sp.add_parser( "blame", help="print configuration annotated with source file:line" @@ -69,6 +70,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: default=None, help="show configuration as seen by this environment spec group (requires active env)", ) + blame_parser.set_defaults(subparser=blame_parser) edit_parser = sp.add_parser("edit", help="edit configuration file") edit_parser.add_argument( @@ -81,8 +83,10 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: edit_parser.add_argument( "--print-file", action="store_true", help="print the file name that would be edited" ) + edit_parser.set_defaults(subparser=edit_parser) - sp.add_parser("list", help="list configuration sections") + list_parser = sp.add_parser("list", help="list configuration sections") + list_parser.set_defaults(subparser=list_parser) scopes_parser = sp.add_parser( "scopes", help="list defined scopes in descending order of precedence" @@ -119,6 +123,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: nargs="?", choices=spack.config.SECTION_SCHEMAS, ) + scopes_parser.set_defaults(subparser=scopes_parser) add_parser = sp.add_parser("add", help="add configuration parameters") add_parser.add_argument( @@ -127,10 +132,12 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: help="colon-separated path to config that should be added, e.g. 'config:default:true'", ) add_parser.add_argument("-f", "--file", help="file from which to set all config values") + add_parser.set_defaults(subparser=add_parser) change_parser = sp.add_parser("change", help="swap variants etc. on specs in config") change_parser.add_argument("path", help="colon-separated path to config section with specs") change_parser.add_argument("--match-spec", help="only change constraints that match this") + change_parser.set_defaults(subparser=change_parser) prefer_upstream_parser = sp.add_parser( "prefer-upstream", help="set package preferences from upstream" @@ -142,12 +149,14 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: default=False, help="set packages preferences based on local installs, rather than upstream", ) + prefer_upstream_parser.set_defaults(subparser=prefer_upstream_parser) remove_parser = sp.add_parser("remove", aliases=["rm"], help="remove configuration parameters") remove_parser.add_argument( "path", help="colon-separated path to config that should be removed, e.g. 'config:default:true'", ) + remove_parser.set_defaults(subparser=remove_parser) # Make the add parser available later setattr(setup_parser, "add_parser", add_parser) @@ -155,12 +164,14 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: update = sp.add_parser("update", help="update configuration files to the latest format") arguments.add_common_arguments(update, ["yes_to_all"]) update.add_argument("section", help="section to update") + update.set_defaults(subparser=update) revert = sp.add_parser( "revert", help="revert configuration files to their state before update" ) arguments.add_common_arguments(revert, ["yes_to_all"]) revert.add_argument("section", help="section to update") + revert.set_defaults(subparser=revert) def _get_scope_and_section(args): @@ -190,15 +201,16 @@ def _get_scope_and_section(args): def print_configuration(args, *, blame: bool) -> None: if args.scope and args.scope not in spack.config.existing_scope_names(): - tty.die(f"the argument --scope={args.scope} must refer to an existing scope.") + args.subparser.error(f"the argument --scope={args.scope} must refer to an existing scope") if args.scope and args.section is None: - tty.die(f"the argument --scope={args.scope} requires specifying a section.") + args.subparser.error(f"the argument --scope={args.scope} requires specifying a section") group = getattr(args, "group", None) if group is not None: env = ev.active_environment() if env is None: - tty.die("the argument --group requires an active environment") + args.subparser.error("the argument --group requires an active environment") + return # parser.error exits, but help mypy understand this is unreachable try: with env.config_override_for_group(group=group): _print_configuration_helper(args, blame=blame) @@ -282,7 +294,7 @@ def config_edit(args): # If we aren't editing a spack.yaml file, get config path from scope. scope, section = _get_scope_and_section(args) if not scope and not section: - tty.die("`spack config edit` requires a section argument or an active environment.") + args.subparser.error("requires a section argument or an active environment") config_file = spack.config.CONFIG.get_config_filename(scope, section) if args.print_file: diff --git a/lib/spack/spack/cmd/deconcretize.py b/lib/spack/spack/cmd/deconcretize.py index a0b899d1736507..78c087cb532d50 100644 --- a/lib/spack/spack/cmd/deconcretize.py +++ b/lib/spack/spack/cmd/deconcretize.py @@ -74,7 +74,7 @@ def get_deconcretize_list( def deconcretize_specs(args, specs): - env = spack.cmd.require_active_env(cmd_name="deconcretize") + env = spack.cmd.require_active_env(args.subparser) if args.specs: deconcretize_list = get_deconcretize_list(args, specs, env) @@ -92,9 +92,9 @@ def deconcretize_specs(args, specs): def deconcretize(parser, args): if not args.specs and not args.all: - tty.die( - "deconcretize requires at least one spec argument.", - " Use `spack deconcretize --all` to deconcretize ALL specs.", + args.subparser.error( + "requires at least one spec argument\n" + " use `spack deconcretize --all` to deconcretize ALL specs" ) specs = spack.cmd.parse_specs(args.specs) if args.specs else [None] diff --git a/lib/spack/spack/cmd/dependencies.py b/lib/spack/spack/cmd/dependencies.py index 5a6055f396131f..dc066b2ecfbbca 100644 --- a/lib/spack/spack/cmd/dependencies.py +++ b/lib/spack/spack/cmd/dependencies.py @@ -49,7 +49,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def dependencies(parser, args): specs = spack.cmd.parse_specs(args.spec) if len(specs) != 1: - tty.die("spack dependencies takes only one spec.") + args.subparser.error("takes only one spec") if args.installed: env = ev.active_environment() diff --git a/lib/spack/spack/cmd/dependents.py b/lib/spack/spack/cmd/dependents.py index 4591489404e808..9b8fc70a42381d 100644 --- a/lib/spack/spack/cmd/dependents.py +++ b/lib/spack/spack/cmd/dependents.py @@ -87,7 +87,7 @@ def get_dependents(pkg_name, ideps, transitive=False, dependents=None): def dependents(parser, args): specs = spack.cmd.parse_specs(args.spec) if len(specs) != 1: - tty.die("spack dependents takes only one spec.") + args.subparser.error("takes only one spec") if args.installed: env = ev.active_environment() diff --git a/lib/spack/spack/cmd/deprecate.py b/lib/spack/spack/cmd/deprecate.py index 2fc95d26ebe537..5bb55bb994b3f6 100644 --- a/lib/spack/spack/cmd/deprecate.py +++ b/lib/spack/spack/cmd/deprecate.py @@ -22,7 +22,6 @@ import spack.llnl.util.tty as tty import spack.store from spack.cmd.common import arguments -from spack.error import SpackError from spack.llnl.util.filesystem import symlink from ..enums import InstallRecordStatus @@ -94,7 +93,7 @@ def deprecate(parser, args): specs = spack.cmd.parse_specs(args.specs) if len(specs) != 2: - raise SpackError("spack deprecate requires exactly two specs") + args.subparser.error("requires exactly two specs") deprecate = spack.cmd.disambiguate_spec( specs[0], diff --git a/lib/spack/spack/cmd/dev_build.py b/lib/spack/spack/cmd/dev_build.py index 68f676e1c50eed..3bd89ea0098862 100644 --- a/lib/spack/spack/cmd/dev_build.py +++ b/lib/spack/spack/cmd/dev_build.py @@ -92,20 +92,19 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def dev_build(self, args): if not args.spec: - tty.die("spack dev-build requires a package spec argument.") + args.subparser.error("requires a package spec argument") specs = spack.cmd.parse_specs(args.spec) if len(specs) > 1: - tty.die("spack dev-build only takes one spec.") + args.subparser.error("only takes one spec") spec = specs[0] if not spack.repo.PATH.exists(spec.name): raise spack.repo.UnknownPackageError(spec.name) if not spec.versions.concrete_range_as_version: - tty.die( - "spack dev-build spec must have a single, concrete version. " - "Did you forget a package version number?" + args.subparser.error( + "spec must have a single, concrete version. Did you forget a package version number?" ) source_path = args.source_path diff --git a/lib/spack/spack/cmd/develop.py b/lib/spack/spack/cmd/develop.py index 6ab0c7cef6262e..025c690fa91fe3 100644 --- a/lib/spack/spack/cmd/develop.py +++ b/lib/spack/spack/cmd/develop.py @@ -215,7 +215,7 @@ def _dev_spec_generator(args, env): """ if not args.spec: if args.clone is False: - raise SpackError("No spec provided to spack develop command") + args.subparser.error("no spec provided") for name, entry in env.dev_specs.items(): path = entry.get("path", name) @@ -227,9 +227,9 @@ def _dev_spec_generator(args, env): else: specs = spack.cmd.parse_specs(args.spec) if (args.path or args.build_directory) and len(specs) > 1: - raise SpackError( - "spack develop requires at most one named spec when using the --path or" - " --build-directory arguments" + args.subparser.error( + "requires at most one named spec when using the --path or --build-directory " + "arguments" ) for spec in specs: @@ -252,7 +252,7 @@ def _dev_spec_generator(args, env): def develop(parser, args): - env = spack.cmd.require_active_env(cmd_name="develop") + env = spack.cmd.require_active_env(args.subparser) for spec, abspath in _dev_spec_generator(args, env): assure_concrete_spec(env, spec) diff --git a/lib/spack/spack/cmd/diff.py b/lib/spack/spack/cmd/diff.py index d1d7fde4b62e10..fa889284205f44 100644 --- a/lib/spack/spack/cmd/diff.py +++ b/lib/spack/spack/cmd/diff.py @@ -209,7 +209,7 @@ def diff(parser, args): env = ev.active_environment() if len(args.specs) != 2: - tty.die("You must provide two specs to diff.") + args.subparser.error("you must provide two specs to diff") specs = [] for spec in spack.cmd.parse_specs(args.specs): diff --git a/lib/spack/spack/cmd/env.py b/lib/spack/spack/cmd/env.py index f8b3ad722b775c..e324a0a0088c00 100644 --- a/lib/spack/spack/cmd/env.py +++ b/lib/spack/spack/cmd/env.py @@ -859,7 +859,7 @@ def env_loads_setup_parser(subparser): def env_loads(args): - env = spack.cmd.require_active_env(cmd_name="env loads") + env = spack.cmd.require_active_env(args.subparser) # Set the module types that have been selected module_type = args.module_type @@ -1033,7 +1033,7 @@ def env_depfile_setup_parser(subparser): def env_depfile(args): # Currently only make is supported. - spack.cmd.require_active_env(cmd_name="env depfile") + spack.cmd.require_active_env(args.subparser) env = ev.active_environment() @@ -1098,6 +1098,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: description=spack.cmd.doc_dedented(setup_parser_cmd), help=spack.cmd.doc_first_line(setup_parser_cmd), ) + subsubparser.set_defaults(subparser=subsubparser) setup_parser_cmd(subsubparser) diff --git a/lib/spack/spack/cmd/extensions.py b/lib/spack/spack/cmd/extensions.py index b8d80fa0f9d95b..9b0df83dec7840 100644 --- a/lib/spack/spack/cmd/extensions.py +++ b/lib/spack/spack/cmd/extensions.py @@ -66,7 +66,7 @@ def extensions(parser, args): # Checks spec = cmd.parse_specs(args.spec) if len(spec) > 1: - tty.die("Can only list extensions for one package.") + args.subparser.error("can only list extensions for one package") env = ev.active_environment() spec = cmd.disambiguate_spec(spec[0], env) diff --git a/lib/spack/spack/cmd/fetch.py b/lib/spack/spack/cmd/fetch.py index 66d3c7a01b8934..634b90f0f2d1bd 100644 --- a/lib/spack/spack/cmd/fetch.py +++ b/lib/spack/spack/cmd/fetch.py @@ -7,7 +7,6 @@ import spack.cmd import spack.config import spack.environment as ev -import spack.llnl.util.tty as tty import spack.traverse from spack.cmd.common import arguments @@ -54,9 +53,11 @@ def fetch(parser, args): else: specs = env.all_specs() if specs == []: - tty.die("No uninstalled specs in environment. Did you run `spack concretize` yet?") + args.subparser.error( + "no uninstalled specs in environment. Did you run `spack concretize` yet?" + ) else: - tty.die("fetch requires at least one spec argument") + args.subparser.error("requires at least one spec argument") if args.dependencies or args.missing: to_be_fetched = spack.traverse.traverse_nodes(specs, key=spack.traverse.by_dag_hash) diff --git a/lib/spack/spack/cmd/find.py b/lib/spack/spack/cmd/find.py index 9f30fc57b92937..682f0a21f7b509 100644 --- a/lib/spack/spack/cmd/find.py +++ b/lib/spack/spack/cmd/find.py @@ -189,10 +189,10 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def query_arguments(args): if args.only_missing and (args.deprecated or args.missing): - raise RuntimeError("cannot use --only-missing with --deprecated, or --missing") + args.subparser.error("cannot use --only-missing with --deprecated, or --missing") if args.only_deprecated and (args.deprecated or args.missing): - raise RuntimeError("cannot use --only-deprecated with --deprecated, or --missing") + args.subparser.error("cannot use --only-deprecated with --deprecated, or --missing") installed = InstallRecordStatus.INSTALLED if args.only_missing: @@ -402,9 +402,9 @@ def find(parser, args): env = ev.active_environment() if not env and args.only_roots: - tty.die("-r / --only-roots requires an active environment") + args.subparser.error("-r / --only-roots requires an active environment") if not env and args.show_concretized: - tty.die("-c / --show-concretized requires an active environment") + args.subparser.error("-c / --show-concretized requires an active environment") try: results, concretized_but_not_installed = _find_query(args, env) diff --git a/lib/spack/spack/cmd/gpg.py b/lib/spack/spack/cmd/gpg.py index 66c9d9a41a64f4..3f1f5233f6cde5 100644 --- a/lib/spack/spack/cmd/gpg.py +++ b/lib/spack/spack/cmd/gpg.py @@ -26,16 +26,16 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: verify = subparsers.add_parser("verify", help=gpg_verify.__doc__) arguments.add_common_arguments(verify, ["installed_spec"]) verify.add_argument("signature", type=str, nargs="?", help="the signature file") - verify.set_defaults(func=gpg_verify) + verify.set_defaults(func=gpg_verify, subparser=verify) trust = subparsers.add_parser("trust", help=gpg_trust.__doc__) trust.add_argument("keyfile", type=str, help="add a key to the trust store") - trust.set_defaults(func=gpg_trust) + trust.set_defaults(func=gpg_trust, subparser=trust) untrust = subparsers.add_parser("untrust", help=gpg_untrust.__doc__) untrust.add_argument("--signing", action="store_true", help="allow untrusting signing keys") untrust.add_argument("keys", nargs="+", type=str, help="remove keys from the trust store") - untrust.set_defaults(func=gpg_untrust) + untrust.set_defaults(func=gpg_untrust, subparser=untrust) sign = subparsers.add_parser("sign", help=gpg_sign.__doc__) sign.add_argument( @@ -46,7 +46,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: "--clearsign", action="store_true", help="if specified, create a clearsign signature" ) arguments.add_common_arguments(sign, ["installed_spec"]) - sign.set_defaults(func=gpg_sign) + sign.set_defaults(func=gpg_sign, subparser=sign) create = subparsers.add_parser("create", help=gpg_create.__doc__) create.add_argument("name", type=str, help="the name to use for the new key") @@ -71,18 +71,18 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: dest="secret", help="export the private key to a file", ) - create.set_defaults(func=gpg_create) + create.set_defaults(func=gpg_create, subparser=create) list = subparsers.add_parser("list", help=gpg_list.__doc__) list.add_argument("--trusted", action="store_true", default=True, help="list trusted keys") list.add_argument( "--signing", action="store_true", help="list keys which may be used for signing" ) - list.set_defaults(func=gpg_list) + list.set_defaults(func=gpg_list, subparser=list) init = subparsers.add_parser("init", help=gpg_init.__doc__) init.add_argument("--from", metavar="DIR", type=str, dest="import_dir", help=argparse.SUPPRESS) - init.set_defaults(func=gpg_init) + init.set_defaults(func=gpg_init, subparser=init) export = subparsers.add_parser("export", help=gpg_export.__doc__) export.add_argument("location", type=str, help="where to export keys") @@ -90,7 +90,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: "keys", nargs="*", help="the keys to export (all public keys if unspecified)" ) export.add_argument("--secret", action="store_true", help="export secret keys") - export.set_defaults(func=gpg_export) + export.set_defaults(func=gpg_export, subparser=export) publish = subparsers.add_parser("publish", help=gpg_publish.__doc__) @@ -125,7 +125,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: publish.add_argument( "keys", nargs="*", help="keys to publish (all public keys if unspecified)" ) - publish.set_defaults(func=gpg_publish) + publish.set_defaults(func=gpg_publish, subparser=publish) def gpg_create(args): diff --git a/lib/spack/spack/cmd/graph.py b/lib/spack/spack/cmd/graph.py index de310e72b0b3fb..f63b2b561f8cc4 100644 --- a/lib/spack/spack/cmd/graph.py +++ b/lib/spack/spack/cmd/graph.py @@ -57,10 +57,10 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def graph(parser, args): env = ev.active_environment() if args.installed and env: - tty.die("cannot use --installed with an active environment") + args.subparser.error("cannot use --installed with an active environment") if args.color and not args.dot: - tty.die("the --color option can be used only with --dot") + args.subparser.error("the --color option can be used only with --dot") if args.installed: if not args.specs: diff --git a/lib/spack/spack/cmd/info.py b/lib/spack/spack/cmd/info.py index 9ea09d38114c3f..51a42f14b2f558 100644 --- a/lib/spack/spack/cmd/info.py +++ b/lib/spack/spack/cmd/info.py @@ -639,9 +639,9 @@ def print_virtuals(pkg: PackageBase, args: Namespace) -> None: def info(parser: argparse.ArgumentParser, args: Namespace) -> None: specs = spack.cmd.parse_specs(args.spec) if len(specs) > 1: - tty.die(f"`spack info` requires exactly one spec. Parsed {len(specs)}") + args.subparser.error(f"requires exactly one spec, got {len(specs)}") if len(specs) == 0: - tty.die("`spack info` requires a spec.") + args.subparser.error("requires a spec") spec = specs[0] pkg_cls = spack.repo.PATH.get_pkg_class(spec.fullname) diff --git a/lib/spack/spack/cmd/install.py b/lib/spack/spack/cmd/install.py index cbdef814b14aed..d610e090ebe086 100644 --- a/lib/spack/spack/cmd/install.py +++ b/lib/spack/spack/cmd/install.py @@ -287,8 +287,8 @@ def _dump_log_on_error(e: InstallError): shutil.copyfileobj(log, sys.stderr) -def _die_require_env(): - msg = "install requires a package argument or active environment" +def _die_require_env(parser): + msg = "requires a package argument or active environment" if "spack.yaml" in os.listdir(os.getcwd()): # There's a spack.yaml file in the working dir, the user may # have intended to use that @@ -301,7 +301,7 @@ def _die_require_env(): " OR\n" " spack --env . install" ) - tty.die(msg) + parser.error(msg) def install(parser, args): @@ -326,7 +326,7 @@ def install(parser, args): env = ev.active_environment() if not env and not args.spec: - _die_require_env() + _die_require_env(args.subparser) try: if env: @@ -431,7 +431,7 @@ def install_without_active_env(args, install_kwargs, reporter): concrete_specs = concrete_specs_from_cli(args, install_kwargs) if len(concrete_specs) == 0: - tty.die("The `spack install` command requires a spec to install.") + args.subparser.error("requires a spec") if args.overwrite: require_user_confirmation_for_overwrite(concrete_specs, args) diff --git a/lib/spack/spack/cmd/location.py b/lib/spack/spack/cmd/location.py index 9d23aa6d9427e9..6d953e344495d1 100644 --- a/lib/spack/spack/cmd/location.py +++ b/lib/spack/spack/cmd/location.py @@ -104,7 +104,7 @@ def location(parser, args): if args.location_env is not False: if args.location_env is None: # Get current environment path - spack.cmd.require_active_env("location -e") + spack.cmd.require_active_env(args.subparser) path = ev.active_environment().path else: # Get path of requested environment @@ -131,10 +131,10 @@ def location(parser, args): specs = spack.cmd.parse_specs(args.spec) if not specs: - tty.die("You must supply a spec.") + args.subparser.error("requires a spec") if len(specs) != 1: - tty.die("Too many specs. Supply only one.") + args.subparser.error("too many specs, supply only one") # install_dir command matches against installed specs. if args.install_dir: diff --git a/lib/spack/spack/cmd/log_parse.py b/lib/spack/spack/cmd/log_parse.py index 12eec0883c8270..c4ddf0321563f9 100644 --- a/lib/spack/spack/cmd/log_parse.py +++ b/lib/spack/spack/cmd/log_parse.py @@ -7,7 +7,6 @@ import sys import warnings -import spack.llnl.util.tty as tty from spack.util.log_parse import make_log_context, parse_log_events description = "filter errors and warnings from build logs" @@ -78,7 +77,7 @@ def log_parse(parser, args): types = [s.strip() for s in args.show.split(",")] for e in types: if e not in event_types: - tty.die("Invalid event type: %s" % e) + args.subparser.error("invalid event type: %s" % e) events = [] if "errors" in types: diff --git a/lib/spack/spack/cmd/logs.py b/lib/spack/spack/cmd/logs.py index 94db8798bd3dec..0a91c4bc5e9af6 100644 --- a/lib/spack/spack/cmd/logs.py +++ b/lib/spack/spack/cmd/logs.py @@ -61,10 +61,10 @@ def logs(parser, args): specs = spack.cmd.parse_specs(args.spec) if not specs: - raise SpackError("You must supply a spec.") + args.subparser.error("requires a spec") if len(specs) != 1: - raise SpackError("Too many specs. Supply only one.") + args.subparser.error("too many specs, supply only one") concrete_spec = spack.cmd.matching_spec_from_env(specs[0]) diff --git a/lib/spack/spack/cmd/maintainers.py b/lib/spack/spack/cmd/maintainers.py index 7d6afa4b891e54..4d2974c08e8335 100644 --- a/lib/spack/spack/cmd/maintainers.py +++ b/lib/spack/spack/cmd/maintainers.py @@ -5,7 +5,6 @@ import argparse from collections import defaultdict -import spack.llnl.util.tty as tty import spack.llnl.util.tty.color as color import spack.repo from spack.llnl.util.tty.colify import colify @@ -123,7 +122,7 @@ def maintainers(parser, args): if args.by_user: if not args.package_or_user: - tty.die("spack maintainers --by-user requires a user or --all") + args.subparser.error("--by-user requires a user or --all") packages = union_values(maintainers_to_packages(args.package_or_user)) colify(packages) @@ -131,7 +130,7 @@ def maintainers(parser, args): else: if not args.package_or_user: - tty.die("spack maintainers requires a package or --all") + args.subparser.error("requires a package or --all") users = union_values(packages_to_maintainers(args.package_or_user)) colify(users) diff --git a/lib/spack/spack/cmd/mirror.py b/lib/spack/spack/cmd/mirror.py index 32c1f46d80128c..5f156e2fb1803e 100644 --- a/lib/spack/spack/cmd/mirror.py +++ b/lib/spack/spack/cmd/mirror.py @@ -587,7 +587,7 @@ def versions_per_spec(args): try: num_versions = int(args.versions_per_spec) except ValueError: - raise SpackError( + args.subparser.error( "'--versions-per-spec' must be a number or 'all', got '{0}'".format( args.versions_per_spec ) @@ -612,24 +612,24 @@ def process_mirror_stats(present, mirrored, error): def mirror_create(args): """create a directory to be used as a spack mirror, and fill it with package archives""" if args.file and args.all: - raise SpackError( + args.subparser.error( "cannot specify specs with a file if you chose to mirror all specs with '--all'" ) if args.file and args.specs: - raise SpackError("cannot specify specs with a file AND on command line") + args.subparser.error("cannot specify specs with a file AND on command line") if not args.specs and not args.file and not args.all: - raise SpackError( - "no packages were specified.", - "To mirror all packages, use the '--all' option " - "(this will require significant time and space).", + args.subparser.error( + "no packages were specified\n" + " to mirror all packages, use the '--all' option" + " (this will require significant time and space)" ) if args.versions_per_spec and args.all: - raise SpackError( - "cannot specify '--versions_per-spec' and '--all' together", - "The option '--all' already implies mirroring all versions for each package.", + args.subparser.error( + "cannot specify '--versions_per-spec' and '--all' together\n" + " '--all' already implies mirroring all versions for each package" ) # When no directory is provided, the source dir is used diff --git a/lib/spack/spack/cmd/patch.py b/lib/spack/spack/cmd/patch.py index 8f6522560f83bb..31ad6f9cf20deb 100644 --- a/lib/spack/spack/cmd/patch.py +++ b/lib/spack/spack/cmd/patch.py @@ -26,7 +26,7 @@ def patch(parser, args): if not args.specs: env = ev.active_environment() if not env: - tty.die("`spack patch` requires a spec or an active environment") + args.subparser.error("requires a spec or an active environment") return _patch_env(env) if args.no_checksum: diff --git a/lib/spack/spack/cmd/pkg.py b/lib/spack/spack/cmd/pkg.py index 82a8eb40849553..dbb1bd14650482 100644 --- a/lib/spack/spack/cmd/pkg.py +++ b/lib/spack/spack/cmd/pkg.py @@ -24,11 +24,13 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: add_parser = sp.add_parser("add", help=pkg_add.__doc__) arguments.add_common_arguments(add_parser, ["packages"]) + add_parser.set_defaults(subparser=add_parser) list_parser = sp.add_parser("list", help=pkg_list.__doc__) list_parser.add_argument( "rev", default="HEAD", nargs="?", help="revision to list packages for" ) + list_parser.set_defaults(subparser=list_parser) diff_parser = sp.add_parser("diff", help=pkg_diff.__doc__) diff_parser.add_argument( @@ -37,12 +39,14 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: diff_parser.add_argument( "rev2", nargs="?", default="HEAD", help="revision to compare to rev1 (default is HEAD)" ) + diff_parser.set_defaults(subparser=diff_parser) add_parser = sp.add_parser("added", help=pkg_added.__doc__) add_parser.add_argument("rev1", nargs="?", default="HEAD^", help="revision to compare against") add_parser.add_argument( "rev2", nargs="?", default="HEAD", help="revision to compare to rev1 (default is HEAD)" ) + add_parser.set_defaults(subparser=add_parser) add_parser = sp.add_parser("changed", help=pkg_changed.__doc__) add_parser.add_argument("rev1", nargs="?", default="HEAD^", help="revision to compare against") @@ -56,12 +60,14 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: default="C", help="types of changes to show (A: added, R: removed, C: changed); default is 'C'", ) + add_parser.set_defaults(subparser=add_parser) rm_parser = sp.add_parser("removed", help=pkg_removed.__doc__) rm_parser.add_argument("rev1", nargs="?", default="HEAD^", help="revision to compare against") rm_parser.add_argument( "rev2", nargs="?", default="HEAD", help="revision to compare to rev1 (default is HEAD)" ) + rm_parser.set_defaults(subparser=rm_parser) # explicitly add help for `spack pkg grep` with just `--help` and NOT `-h`. This is so # that the very commonly used -h (no filename) argument can be passed through to grep @@ -70,6 +76,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: "grep_args", nargs=argparse.REMAINDER, default=None, help="arguments for grep" ) grep_parser.add_argument("--help", action="help", help="show this help message and exit") + grep_parser.set_defaults(subparser=grep_parser) source_parser = sp.add_parser("source", help=pkg_source.__doc__) source_parser.add_argument( @@ -80,9 +87,11 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: help="dump canonical source as used by package hash", ) arguments.add_common_arguments(source_parser, ["spec"]) + source_parser.set_defaults(subparser=source_parser) hash_parser = sp.add_parser("hash", help=pkg_hash.__doc__) arguments.add_common_arguments(hash_parser, ["spec"]) + hash_parser.set_defaults(subparser=hash_parser) def pkg_add(args): @@ -138,7 +147,7 @@ def pkg_source(args): """dump source code for a package""" specs = spack.cmd.parse_specs(args.spec, concretize=False) if len(specs) != 1: - tty.die("spack pkg source requires exactly one spec") + args.subparser.error("requires exactly one spec") spec = specs[0] filename = spack.repo.PATH.filename_for_package_name(spec.name) @@ -252,6 +261,6 @@ def pkg(parser, args, unknown_args): if args.pkg_command == "grep": return pkg_grep(args, unknown_args) elif unknown_args: - tty.die("unrecognized arguments: %s" % " ".join(unknown_args)) + args.subparser.error("unrecognized arguments: %s" % " ".join(unknown_args)) else: return action[args.pkg_command](args) diff --git a/lib/spack/spack/cmd/python.py b/lib/spack/spack/cmd/python.py index 1f99f0b9b199dc..d6a68fb152131c 100644 --- a/lib/spack/spack/cmd/python.py +++ b/lib/spack/spack/cmd/python.py @@ -71,11 +71,11 @@ def python(parser, args, unknown_args): return if unknown_args: - tty.die("Unknown arguments:", " ".join(unknown_args)) + args.subparser.error("unrecognized arguments: %s" % " ".join(unknown_args)) # Unexpected behavior from supplying both if args.python_command and args.python_args: - tty.die("You can only specify a command OR script, but not both.") + args.subparser.error("you can only specify a command OR script, but not both") # Ensure that spack.repo.PATH is initialized spack.repo.PATH.repos diff --git a/lib/spack/spack/cmd/remove.py b/lib/spack/spack/cmd/remove.py index a73077e62492d4..d15d1250c28b4c 100644 --- a/lib/spack/spack/cmd/remove.py +++ b/lib/spack/spack/cmd/remove.py @@ -31,7 +31,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def remove(parser, args): - env = spack.cmd.require_active_env(cmd_name="remove") + env = spack.cmd.require_active_env(args.subparser) with env.write_transaction(): if args.all: diff --git a/lib/spack/spack/cmd/restage.py b/lib/spack/spack/cmd/restage.py index 5337f39f285929..0d53e581eea48c 100644 --- a/lib/spack/spack/cmd/restage.py +++ b/lib/spack/spack/cmd/restage.py @@ -5,7 +5,6 @@ import argparse import spack.cmd -import spack.llnl.util.tty as tty from spack.cmd.common import arguments description = "revert checked out package source code" @@ -19,7 +18,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def restage(parser, args): if not args.specs: - tty.die("spack restage requires at least one package spec.") + args.subparser.error("requires at least one package spec") specs = spack.cmd.parse_specs(args.specs, concretize=True) for spec in specs: diff --git a/lib/spack/spack/cmd/solve.py b/lib/spack/spack/cmd/solve.py index 4063f1c64ea109..e43c21e46e119c 100644 --- a/lib/spack/spack/cmd/solve.py +++ b/lib/spack/spack/cmd/solve.py @@ -148,7 +148,7 @@ def solve(parser, args): elif env: specs = list(env.user_specs) else: - tty.die("spack solve requires at least one spec or an active environment") + args.subparser.error("requires at least one spec or an active environment") solver = asp.Solver() output = sys.stdout if "asp" in show else None diff --git a/lib/spack/spack/cmd/spec.py b/lib/spack/spack/cmd/spec.py index aeedb3e37a3b78..d59cc010253e76 100644 --- a/lib/spack/spack/cmd/spec.py +++ b/lib/spack/spack/cmd/spec.py @@ -10,7 +10,6 @@ import spack.environment as ev import spack.hash_types as ht import spack.llnl.util.lang as lang -import spack.llnl.util.tty as tty import spack.package_base import spack.spec import spack.store @@ -98,7 +97,7 @@ def spec(parser, args): env.concretize() concrete_specs = env.concrete_roots() else: - tty.die("spack spec requires at least one spec or an active environment") + args.subparser.error("requires at least one spec or an active environment") # With --yaml, --json, or --format, just print the raw specs to output if args.format: diff --git a/lib/spack/spack/cmd/stage.py b/lib/spack/spack/cmd/stage.py index 2dbdee6b79a314..b65b5c71bef51d 100644 --- a/lib/spack/spack/cmd/stage.py +++ b/lib/spack/spack/cmd/stage.py @@ -73,7 +73,7 @@ def stage(parser, args): if not args.specs: env = ev.active_environment() if not env: - tty.die("`spack stage` requires a spec or an active environment") + args.subparser.error("requires a spec or an active environment") return _stage_env(env, filter) specs = spack.cmd.parse_specs(args.specs, concretize=False) @@ -84,7 +84,7 @@ def stage(parser, args): # prevent multiple specs from extracting in the same folder if len(specs) > 1 and custom_path: - tty.die("`--path` requires a single spec, but multiple were provided") + args.subparser.error("--path requires a single spec, but multiple were provided") specs = spack.cmd.matching_specs_from_env(specs) for spec in specs: diff --git a/lib/spack/spack/cmd/tags.py b/lib/spack/spack/cmd/tags.py index 7b71376ca62768..75694883be1cae 100644 --- a/lib/spack/spack/cmd/tags.py +++ b/lib/spack/spack/cmd/tags.py @@ -60,7 +60,7 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def tags(parser, args): # Disallow combining all option with (positional) tags to avoid confusion if args.all and args.tag: - tty.die("Use the '--all' option OR provide tag(s) on the command line") + args.subparser.error("use the '--all' option OR provide tag(s) on the command line") # Provide a nice, simple message if database is empty if args.installed and not spack.environment.installed_specs(): diff --git a/lib/spack/spack/cmd/undevelop.py b/lib/spack/spack/cmd/undevelop.py index c2b3ec1c0eeb59..b78001debe1b8d 100644 --- a/lib/spack/spack/cmd/undevelop.py +++ b/lib/spack/spack/cmd/undevelop.py @@ -49,7 +49,7 @@ def change_fn(dev_config): def undevelop(parser, args): # TODO: when https://github.com/spack/spack/pull/35307 is merged, # an active env is not required if a scope is specified - env = spack.cmd.require_active_env(cmd_name="undevelop") + env = spack.cmd.require_active_env(args.subparser) if args.all: remove_specs = [spack.spec.Spec(s) for s in env.dev_specs] diff --git a/lib/spack/spack/cmd/uninstall.py b/lib/spack/spack/cmd/uninstall.py index aadc4282801719..566d60534a797b 100644 --- a/lib/spack/spack/cmd/uninstall.py +++ b/lib/spack/spack/cmd/uninstall.py @@ -295,9 +295,9 @@ def uninstall_specs(args, specs): def uninstall(parser, args): if not args.specs and not args.all: - tty.die( - "uninstall requires at least one package argument.", - " Use `spack uninstall --all` to uninstall ALL packages.", + args.subparser.error( + "requires at least one package argument\n" + " use `spack uninstall --all` to uninstall ALL packages" ) # [None] here handles the --all case by forcing all specs to be returned diff --git a/lib/spack/spack/cmd/unload.py b/lib/spack/spack/cmd/unload.py index 3cc50cfba37e1c..50837e65105164 100644 --- a/lib/spack/spack/cmd/unload.py +++ b/lib/spack/spack/cmd/unload.py @@ -8,7 +8,6 @@ import spack.cmd import spack.cmd.common -import spack.error import spack.store import spack.user_environment as uenv from spack.cmd.common import arguments @@ -68,8 +67,8 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: def unload(parser, args): """unload spack packages from the user environment""" if args.specs and args.all: - raise spack.error.SpackError( - "Cannot specify specs on command line when unloading all specs with '--all'" + args.subparser.error( + "cannot specify specs on command line when unloading all specs with '--all'" ) hashes = os.environ.get(uenv.spack_loaded_hashes_var, "").split(os.pathsep) diff --git a/lib/spack/spack/cmd/verify.py b/lib/spack/spack/cmd/verify.py index cd2ebb1a51c2f4..13c1df05543dae 100644 --- a/lib/spack/spack/cmd/verify.py +++ b/lib/spack/spack/cmd/verify.py @@ -20,28 +20,26 @@ section = "admin" level = "long" -MANIFEST_SUBPARSER: Optional[argparse.ArgumentParser] = None - def setup_parser(subparser: argparse.ArgumentParser): - global MANIFEST_SUBPARSER sp = subparser.add_subparsers(metavar="SUBCOMMAND", dest="verify_command") - MANIFEST_SUBPARSER = sp.add_parser( + manifest_subparser = sp.add_parser( "manifest", help=verify_manifest.__doc__, description=verify_manifest.__doc__ ) - MANIFEST_SUBPARSER.add_argument( + manifest_subparser.set_defaults(subparser=manifest_subparser) + manifest_subparser.add_argument( "-l", "--local", action="store_true", help="verify only locally installed packages" ) - MANIFEST_SUBPARSER.add_argument( + manifest_subparser.add_argument( "-j", "--json", action="store_true", help="output json-formatted errors" ) - MANIFEST_SUBPARSER.add_argument("-a", "--all", action="store_true", help="verify all packages") - MANIFEST_SUBPARSER.add_argument( + manifest_subparser.add_argument("-a", "--all", action="store_true", help="verify all packages") + manifest_subparser.add_argument( "specs_or_files", nargs=argparse.REMAINDER, help="specs or files to verify" ) - manifest_sp_type = MANIFEST_SUBPARSER.add_mutually_exclusive_group() + manifest_sp_type = manifest_subparser.add_mutually_exclusive_group() manifest_sp_type.add_argument( "-s", "--specs", @@ -64,12 +62,14 @@ def setup_parser(subparser: argparse.ArgumentParser): libraries_subparser = sp.add_parser( "libraries", help=verify_libraries.__doc__, description=verify_libraries.__doc__ ) + libraries_subparser.set_defaults(subparser=libraries_subparser) arguments.add_common_arguments(libraries_subparser, ["constraint"]) versions_subparser = sp.add_parser( "versions", help=verify_versions.__doc__, description=verify_versions.__doc__ ) + versions_subparser.set_defaults(subparser=versions_subparser) arguments.add_common_arguments(versions_subparser, ["constraint"]) @@ -186,7 +186,7 @@ def verify_manifest(args): if args.type == "files": if args.all: - MANIFEST_SUBPARSER.error("cannot use --all with --files") + args.subparser.error("cannot use --all with --files") for file in args.specs_or_files: results = spack.verify.check_file_manifest(file) @@ -217,7 +217,7 @@ def verify_manifest(args): env = ev.active_environment() specs = list(map(lambda x: spack.cmd.disambiguate_spec(x, env, local=local), spec_args)) else: - MANIFEST_SUBPARSER.error("use --all or specify specs to verify") + args.subparser.error("use --all or specify specs to verify") for spec in specs: tty.debug("Verifying package %s") diff --git a/lib/spack/spack/main.py b/lib/spack/spack/main.py index 28655ccfa10170..cb634fedf0f399 100644 --- a/lib/spack/spack/main.py +++ b/lib/spack/spack/main.py @@ -369,6 +369,7 @@ def add_command(self, cmd_name): help=module.description, description=module.description, ) + subparser.set_defaults(subparser=subparser) module.setup_parser(subparser) # return the callable function for the command @@ -628,7 +629,7 @@ def _invoke_command(command, parser, args, unknown_args): return_val = command(parser, args, unknown_args) else: if unknown_args: - tty.die("unrecognized arguments: %s" % " ".join(unknown_args)) + args.subparser.error("unrecognized arguments: %s" % " ".join(unknown_args)) return_val = command(parser, args) # Allow commands to return and error code if they want diff --git a/lib/spack/spack/test/cmd/audit.py b/lib/spack/spack/test/cmd/audit.py index 2bea59f9f46928..73bd534eead0bc 100644 --- a/lib/spack/spack/test/cmd/audit.py +++ b/lib/spack/spack/test/cmd/audit.py @@ -41,8 +41,7 @@ def test_audit_packages_https(mutable_config, mock_packages, monkeypatch): # Without providing --all should fail audit("packages-https", fail_on_error=False) - # The mock configuration has duplicate definitions of some compilers - assert audit.returncode == 1 + assert audit.returncode == 2 # This uses http and should fail audit("packages-https", "test-dependency", fail_on_error=False) diff --git a/lib/spack/spack/test/cmd/config.py b/lib/spack/spack/test/cmd/config.py index f051c10968fafa..c84fc45ce8275c 100644 --- a/lib/spack/spack/test/cmd/config.py +++ b/lib/spack/spack/test/cmd/config.py @@ -757,7 +757,7 @@ def test_config_with_group_shows_override_packages(cmd_str, tmp_path, mutable_co def test_config_with_group_requires_active_environment(cmd_str, mutable_config): """Tests that using groups outside an environment should give a clear error.""" output = config(cmd_str, "--group=mygroup", "packages", fail_on_error=False) - assert config.returncode != 0 + assert config.returncode == 2 assert "--group requires an active environment" in output @@ -767,7 +767,7 @@ def test_config_with_unknown_group_gives_clear_error(cmd_str, tmp_path, mutable_ (tmp_path / "spack.yaml").write_text("spack:\n specs:\n - zlib\n") with ev.Environment(str(tmp_path)): output = config(cmd_str, "--group=nonexistent", "packages", fail_on_error=False) - assert config.returncode != 0 + assert config.returncode == 1 assert "'nonexistent' not found in" in output diff --git a/lib/spack/spack/test/cmd/create.py b/lib/spack/spack/test/cmd/create.py index d521d47e6ce9cd..ae619c03eb8ea6 100644 --- a/lib/spack/spack/test/cmd/create.py +++ b/lib/spack/spack/test/cmd/create.py @@ -150,7 +150,7 @@ def test_create_template_bad_name(mock_test_repo, name, expected): """Test template creation with bad name options.""" output = create("--skip-editor", "-n", name, fail_on_error=False) assert expected in output - assert create.returncode != 0 + assert create.returncode == 1 def test_build_system_guesser_no_stage(): diff --git a/lib/spack/spack/test/cmd/dev_build.py b/lib/spack/spack/test/cmd/dev_build.py index 04751f510da597..4a01074a1fddea 100644 --- a/lib/spack/spack/test/cmd/dev_build.py +++ b/lib/spack/spack/test/cmd/dev_build.py @@ -161,7 +161,8 @@ def test_dev_build_fails_nonexistent_package_name(mock_packages): def test_dev_build_fails_no_version(mock_packages): output = dev_build("dev-build-test-install", fail_on_error=False) - assert "dev-build spec must have a single, concrete version" in output + assert "spec must have a single, concrete version" in output + assert dev_build.returncode == 2 def test_dev_build_can_parse_path_with_at_symbol(tmp_path: pathlib.Path, install_mockery): diff --git a/lib/spack/spack/test/cmd/env.py b/lib/spack/spack/test/cmd/env.py index 58daf4cff9431a..327f2778703094 100644 --- a/lib/spack/spack/test/cmd/env.py +++ b/lib/spack/spack/test/cmd/env.py @@ -254,6 +254,12 @@ def template_combinatorial_env(tmp_path: pathlib.Path): """ +def test_add_requires_active_env(): + """Test that spack add exits with code 2 when no environment is active.""" + add("hdf5", fail_on_error=False) + assert add.returncode == 2 + + def test_add(): e = ev.create("test") e.add("mpileaks") diff --git a/lib/spack/spack/test/cmd/location.py b/lib/spack/spack/test/cmd/location.py index 4a9f5830a39c16..3103d0f364224d 100644 --- a/lib/spack/spack/test/cmd/location.py +++ b/lib/spack/spack/test/cmd/location.py @@ -71,14 +71,19 @@ def test_location_source_dir_missing(): @pytest.mark.parametrize( - "options", - [([]), (["--source-dir", "mpileaks"]), (["--env", "missing-env"]), (["spec1", "spec2"])], + "options,expected_code", + [ + ([], 2), + (["--source-dir", "mpileaks"], 1), + (["--env", "missing-env"], 1), + (["spec1", "spec2"], 2), + ], ) -def test_location_cmd_error(options): +def test_location_cmd_error(options, expected_code): """Ensure the proper error is raised with problematic location options.""" with pytest.raises(spack.main.SpackCommandError) as e: location(*options) - assert e.value.code == 1 + assert e.value.code == expected_code def test_location_env_exists(mutable_mock_env_path): @@ -135,12 +140,13 @@ def test_location_paths_options(option, expected): @pytest.mark.parametrize( "specs,expected", - [([], "You must supply a spec."), (["spec1", "spec2"], "Too many specs. Supply only one.")], + [([], "requires a spec"), (["spec1", "spec2"], "too many specs, supply only one")], ) def test_location_spec_errors(specs, expected): """Tests spack location with bad spec options.""" - error = "==> Error: %s" % expected - assert location(*specs, fail_on_error=False).strip() == error + output = location(*specs, fail_on_error=False) + assert expected in output + assert location.returncode == 2 @pytest.mark.db diff --git a/lib/spack/spack/test/cmd/logs.py b/lib/spack/spack/test/cmd/logs.py index 1004306c24e715..8b0b7ddb752d56 100644 --- a/lib/spack/spack/test/cmd/logs.py +++ b/lib/spack/spack/test/cmd/logs.py @@ -14,6 +14,7 @@ import spack.cmd.logs import spack.concretize import spack.error +import spack.main import spack.spec from spack.main import SpackCommand @@ -53,8 +54,9 @@ def test_logs_cmd_errors(install_mockery, mock_fetch, mock_archive, mock_package with pytest.raises(spack.error.SpackError, match="is not installed or staged"): logs("pkg-c") - with pytest.raises(spack.error.SpackError, match="Too many specs"): + with pytest.raises(spack.main.SpackCommandError) as e: logs("pkg-c mpi") + assert e.value.code == 2 install("pkg-c") os.remove(spec.package.install_log_path) diff --git a/lib/spack/spack/test/cmd/mirror.py b/lib/spack/spack/test/cmd/mirror.py index 2afb0df2ca84a1..083b3d23b0ef6f 100644 --- a/lib/spack/spack/test/cmd/mirror.py +++ b/lib/spack/spack/test/cmd/mirror.py @@ -12,7 +12,6 @@ import spack.concretize import spack.config import spack.environment as ev -import spack.error import spack.mirrors.utils import spack.package_base import spack.spec @@ -521,27 +520,19 @@ def test_all_specs_with_all_versions_dont_concretize(self): @pytest.mark.parametrize( "cli_args,error_str", [ - # Passed more than one among -f --all + (["create", "--file", "input.txt", "--all"], "cannot specify specs with a file if"), + (["create", "--file", "input.txt", "hdf5"], "cannot specify specs with a file AND"), + (["create"], "no packages were specified"), ( - {"specs": None, "file": "input.txt", "all": True}, - "cannot specify specs with a file if", - ), - ( - {"specs": "hdf5", "file": "input.txt", "all": False}, - "cannot specify specs with a file AND", - ), - ({"specs": None, "file": None, "all": False}, "no packages were specified"), - # Passed -n along with --all - ( - {"specs": None, "file": None, "all": True, "versions_per_spec": 2}, + ["create", "--all", "--versions-per-spec", "2"], "cannot specify '--versions_per-spec'", ), ], ) def test_error_conditions(self, cli_args, error_str): - args = MockMirrorArgs(**cli_args) - with pytest.raises(spack.error.SpackError, match=error_str): - spack.cmd.mirror.mirror_create(args) + output = mirror(*cli_args, fail_on_error=False) + assert error_str in output + assert mirror.returncode == 2 @pytest.mark.parametrize( "cli_args,not_expected", diff --git a/lib/spack/spack/test/cmd/python.py b/lib/spack/spack/test/cmd/python.py index 883fe8c42ad58c..9d56d9a9c3c88a 100644 --- a/lib/spack/spack/test/cmd/python.py +++ b/lib/spack/spack/test/cmd/python.py @@ -39,4 +39,5 @@ def test_python_with_module(): def test_python_raises(): out = python("--foobar", fail_on_error=False) - assert "Error: Unknown arguments" in out + assert python.returncode == 2 + assert "--foobar" in out diff --git a/lib/spack/spack/test/cmd/spec.py b/lib/spack/spack/test/cmd/spec.py index 28a7d64b5671a8..ed04784f2ef9d3 100644 --- a/lib/spack/spack/test/cmd/spec.py +++ b/lib/spack/spack/test/cmd/spec.py @@ -140,7 +140,7 @@ def test_spec_deptypes_edges(): def test_spec_returncode(): with pytest.raises(SpackCommandError): spec() - assert spec.returncode == 1 + assert spec.returncode == 2 def test_spec_parse_error(): diff --git a/lib/spack/spack/test/cmd/style.py b/lib/spack/spack/test/cmd/style.py index f47ad503a5a16d..8b78e9287773d8 100644 --- a/lib/spack/spack/test/cmd/style.py +++ b/lib/spack/spack/test/cmd/style.py @@ -155,7 +155,7 @@ def test_bad_root(tmp_path: pathlib.Path): """Ensure that `spack style` doesn't run on non-spack directories.""" output = style("--root", str(tmp_path), fail_on_error=False) assert "This does not look like a valid spack root" in output - assert style.returncode != 0 + assert style.returncode == 1 @pytest.fixture @@ -223,7 +223,7 @@ def test_external_root(external_style_root): output = style("--root-relative", "--root", str(tmp_path), fail_on_error=False) # make sure it failed - assert style.returncode != 0 + assert style.returncode == 1 # ruff-check error assert "Import block is un-sorted or un-formatted\n --> lib/spack/spack/dummy.py" in output @@ -272,7 +272,7 @@ def test_style_with_errors(ruff_package_with_errors): "--tool", "ruff-check", "--root-relative", ruff_package_with_errors, fail_on_error=False ) assert root_relative in output - assert style.returncode != 0 + assert style.returncode == 1 assert "spack style found errors" in output @@ -280,7 +280,7 @@ def test_style_with_errors(ruff_package_with_errors): def test_style_with_ruff_format(ruff_package_with_errors): output = style("--tool", "ruff-format", ruff_package_with_errors, fail_on_error=False) assert "ruff-format found errors" in output - assert style.returncode != 0 + assert style.returncode == 1 assert "spack style found errors" in output From 7590ac7d38f8f68ecbf4c8013d1add01b40139aa Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 6 May 2026 18:47:50 +0200 Subject: [PATCH 320/337] Warn about missing index only during concretization (#52376) The warning for missing index was unconditionally emitted for every update, which is noisy. Since the warning is only helpful during concretization, restrict it to just that code path Signed-off-by: Massimiliano Culpo --- lib/spack/spack/binary_distribution.py | 12 +++++++++--- lib/spack/spack/solver/reuse.py | 6 +++++- lib/spack/spack/test/binary_distribution.py | 19 ++++++++++++++----- lib/spack/spack/test/concretization/core.py | 13 +++++++++++++ 4 files changed, 41 insertions(+), 9 deletions(-) diff --git a/lib/spack/spack/binary_distribution.py b/lib/spack/spack/binary_distribution.py index 87f1d0e9cc9d8f..d42f6129a1130a 100644 --- a/lib/spack/spack/binary_distribution.py +++ b/lib/spack/spack/binary_distribution.py @@ -164,6 +164,7 @@ class _MirrorIndexResult(NamedTuple): regenerate: bool had_cache_entry: bool error: Optional[Exception] + no_index: bool = False class _LastFetch(NamedTuple): @@ -217,6 +218,8 @@ def __init__(self, cache_root: Optional[str] = None): self._known_specs: Dict[str, spack.spec.Spec] = {} #: Dictionary mapping DAG hashes of specs to a list of mirrors where they can be found self._mirrors_for_spec: Dict[str, Set[MirrorMetadata]] = defaultdict(set) + #: URLs of binary mirrors that had no buildcache index during the last update() + self.mirrors_without_index: Set[str] = set() def _init_local_index_cache(self): if not self._index_file_cache_initialized: @@ -335,6 +338,7 @@ def update(self, with_cooldown: bool = False) -> None: from each configured mirror and stored locally (both in memory and on disk under ``_index_cache_root``).""" self._init_local_index_cache() + self.mirrors_without_index = set() supported_mirror_versions = { (m.fetch_url, m.fetch_view): m.supported_layout_versions @@ -354,6 +358,9 @@ def update(self, with_cooldown: bool = False) -> None: if result.succeeded: all_failed = False + if result.no_index: + self.mirrors_without_index.add(url) + regenerate_cache |= result.regenerate clear_cache |= result.regenerate and result.had_cache_entry @@ -419,10 +426,9 @@ def _fetch_mirror_index( self._last_fetch_times[meta] = _LastFetch(time=now, succeeded=False) continue - # All versions reported no index found. This is not a failure - warnings.warn(f"the mirror at {url} cannot be used in concretization (no index found)") + # All versions reported no index found. Record it for concretization callers to warn. return _MirrorIndexResult( - succeeded=True, regenerate=False, had_cache_entry=False, error=None + succeeded=True, regenerate=False, had_cache_entry=False, error=None, no_index=True ) def _remove_stale_cache_entries( diff --git a/lib/spack/spack/solver/reuse.py b/lib/spack/spack/solver/reuse.py index ab18b16e1c9b44..8917a44904855a 100644 --- a/lib/spack/spack/solver/reuse.py +++ b/lib/spack/spack/solver/reuse.py @@ -4,6 +4,7 @@ import enum import functools import typing +import warnings from typing import Any, Callable, List, Mapping, Optional import spack.binary_distribution @@ -129,12 +130,15 @@ def _specs_from_store(configuration): def _specs_from_mirror(): try: - return spack.binary_distribution.update_cache_and_get_specs() + specs = spack.binary_distribution.update_cache_and_get_specs() except (spack.binary_distribution.FetchCacheError, IndexError): # this is raised when no mirrors had indices. # TODO: update mirror configuration so it can indicate that the # TODO: source cache (or any mirror really) doesn't have binaries. return [] + for url in sorted(spack.binary_distribution.BINARY_INDEX.mirrors_without_index): + warnings.warn(f"the mirror at {url} cannot be used in concretization (no index found)") + return specs def _specs_from_environment(env): diff --git a/lib/spack/spack/test/binary_distribution.py b/lib/spack/spack/test/binary_distribution.py index 26b2769cc9b3ec..e35631e1c35655 100644 --- a/lib/spack/spack/test/binary_distribution.py +++ b/lib/spack/spack/test/binary_distribution.py @@ -13,6 +13,7 @@ import urllib.error import urllib.request import urllib.response +import warnings from pathlib import Path, PurePath from typing import Any, Callable, Dict, NamedTuple, Optional @@ -1500,12 +1501,13 @@ def test_mirror_metadata_with_view(): spack.binary_distribution.MirrorMetadata.from_string("https://dummy.io/__v3%asdf__@aview") -def test_update_warns_on_mirror_with_no_index(monkeypatch, tmp_path: pathlib.Path, mutable_config): - """Tests that BinaryCacheIndex.update() warns when a mirror has no index for any supported - layout version. +def test_update_does_not_warn_on_mirror_with_no_index(monkeypatch, tmp_path, mutable_config): + """Tests that BinaryIndexCache.update() does NOT warn when a mirror has no index but records + that information for later use. """ mirror_url = url_util.path_to_file_url(str(tmp_path / "mirror_dir")) - spack.config.set("mirrors", {"test": mirror_url}) + mirror_url2 = url_util.path_to_file_url(str(tmp_path / "mirror_dir2")) + mutable_config.set("mirrors", {"test1": mirror_url, "test2": mirror_url2}) def no_index(*args, **kwargs): raise spack.binary_distribution.BuildcacheIndexNotExists("no index") @@ -1513,5 +1515,12 @@ def no_index(*args, **kwargs): binary_index = spack.binary_distribution.BinaryIndexCache(str(tmp_path / "index_cache")) monkeypatch.setattr(binary_index, "_fetch_and_cache_index", no_index) - with pytest.warns(UserWarning, match="cannot be used in concretization"): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") binary_index.update() + + concretization_warnings = [ + w for w in caught if "cannot be used in concretization" in str(w.message) + ] + assert not concretization_warnings, "update() must not warn about concretization" + assert binary_index.mirrors_without_index == {mirror_url, mirror_url2} diff --git a/lib/spack/spack/test/concretization/core.py b/lib/spack/spack/test/concretization/core.py index de3b73e2b211df..41f4241b83d2e3 100644 --- a/lib/spack/spack/test/concretization/core.py +++ b/lib/spack/spack/test/concretization/core.py @@ -5077,3 +5077,16 @@ def test_preferring_different_compilers_for_different_languages(mutable_config, assert mpileaks.satisfies("%c,cxx=llvm %fortran=gcc"), mpileaks.tree() assert mpileaks.satisfies("%mpi=mpich") assert mpileaks["mpich"].satisfies("%c,cxx=llvm %fortran=gcc") + + +def test_specs_from_mirror_warns_when_index_missing(monkeypatch): + """Tests that we get a warning when a binary mirror has no index.""" + + def fake_update_cache(): + spack.binary_distribution.BINARY_INDEX.mirrors_without_index = {"file:///fake-mirror"} + return [] + + monkeypatch.setattr(spack.binary_distribution, "update_cache_and_get_specs", fake_update_cache) + + with pytest.warns(UserWarning, match="cannot be used in concretization"): + spack.solver.reuse._specs_from_mirror() From 2f9910ee1cb30a5db31f398fae38f51b9aa9ca11 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 6 May 2026 21:11:43 +0200 Subject: [PATCH 321/337] new_installer.py: do not add token if decrease is pending (#52378) If you do `-` to decrease parallelism, and the decrease is pending because no job token is returned, then `+` should only update the target value, without adding a new token the jobserver. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 5 ++++- lib/spack/spack/test/jobserver.py | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index 79682020b90dfb..a86a99f257054f 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -850,8 +850,11 @@ def increase_parallelism(self) -> None: """Add one token to the jobserver to increase parallelism; this should always work.""" if not self.created: return - os.write(self.w, b"+") self.target_jobs += 1 + # If a decrease was pending, don't add a token. + if self.target_jobs <= self.num_jobs: + return + os.write(self.w, b"+") self.num_jobs += 1 def decrease_parallelism(self) -> None: diff --git a/lib/spack/spack/test/jobserver.py b/lib/spack/spack/test/jobserver.py index fcabd2ddf3ddb3..49c4299fbe6630 100644 --- a/lib/spack/spack/test/jobserver.py +++ b/lib/spack/spack/test/jobserver.py @@ -366,7 +366,8 @@ def test_decrease_parallelism_token_available(self): js.close() def test_decrease_parallelism_no_token_available(self): - """When all tokens are held, decrease_parallelism defers the discard.""" + """When all tokens are held, decrease_parallelism defers the discard. + A subsequent increase cancels the pending decrease instead of adding a token.""" js = JobServer(3) try: # Drain the pipe so no tokens are available for immediate discard. @@ -376,6 +377,10 @@ def test_decrease_parallelism_no_token_available(self): # target_jobs decremented but num_jobs unchanged (no token to discard yet). assert js.target_jobs == original_num - 1 assert js.num_jobs == original_num + # increase should cancel the pending decrease, not write a new token. + js.increase_parallelism() + assert js.target_jobs == original_num + assert js.num_jobs == original_num finally: js.close() From 67df5f93bfb9625dd17d79c4846da0c5868af45a Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Thu, 7 May 2026 19:04:17 -0400 Subject: [PATCH 322/337] bootstrap env: cleanup environment lockfile on failure (#52304) * bootstrap env: cleanup environment lockfile on failure If we fail to bootstrap the dev dependencies, cleanup the environment so we try again on a re-run. Otherwise, the subsequent concretize command will return no specs, Spack assumes the env is installed, and tries to use the "installed" python, producing hard to trace errors. Signed-off-by: John Parent --- lib/spack/spack/bootstrap/environment.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/bootstrap/environment.py b/lib/spack/spack/bootstrap/environment.py index a5d311dbe5d1e0..50746f96415f5c 100644 --- a/lib/spack/spack/bootstrap/environment.py +++ b/lib/spack/spack/bootstrap/environment.py @@ -7,6 +7,7 @@ import hashlib import os import pathlib +import shutil import sys from typing import Iterable, List @@ -101,9 +102,16 @@ def update_installations(self) -> None: if not spack.config.get("bootstrap:dev:enable_source", False) else "auto" ) - self.install_all( - fail_fast=True, root_policy=fetch_policy, dependencies_policy=fetch_policy - ) + try: + self.install_all( + fail_fast=True, + root_policy=fetch_policy, + dependencies_policy=fetch_policy, + ) + except BaseException: + # catch any exception as we always want to clean up + shutil.rmtree(self.environment_root()) + raise self.write(regenerate=True) def load(self) -> None: From 86305d08f100296c1cde5a0798f7cf68f5634e9c Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Fri, 8 May 2026 17:42:43 +0200 Subject: [PATCH 323/337] new_installer.py: bold, not bold white (#52383) Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index a86a99f257054f..bb1a1a6877214c 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -1587,10 +1587,10 @@ def _generate_line_components( yield "\033[0m" # reset yield " " - # Package name in bold white if explicit, default otherwise + # Package name in bold if explicit, default otherwise if build_info.explicit: if self.color: - yield "\033[1;37m" # bold white + yield "\033[1m" yield build_info.name if self.color: yield "\033[0m" # reset From 7aa4d909975094035b983a312277d9792bb26834 Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Mon, 11 May 2026 12:34:33 -0400 Subject: [PATCH 324/337] pyproject: configure ruff check to implicitly fix (#52387) * pyproject: configure ruff to implicitly fix Signed-off-by: John Parent * Leave --fix Signed-off-by: John Parent --------- Signed-off-by: John Parent --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index bf937a121a0a9e..abd0de2db0c311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ test = "./bin/spack unit-test" features = ["dev", "ci"] [tool.ruff] +fix = true target-version = "py37" line-length = 99 extend-include = ["bin/spack"] From 83d07ab0079c16e7ca874466a41aac4978bd4cd3 Mon Sep 17 00:00:00 2001 From: dabele Date: Mon, 11 May 2026 18:49:00 +0200 Subject: [PATCH 325/337] add command 'location --view' (#52177) Signed-off-by: Abele, Daniel --- lib/spack/spack/cmd/location.py | 26 ++++++++++ lib/spack/spack/test/cmd/location.py | 74 ++++++++++++++++++++++++++++ share/spack/spack-completion.bash | 4 +- share/spack/spack-completion.fish | 8 ++- 4 files changed, 108 insertions(+), 4 deletions(-) diff --git a/lib/spack/spack/cmd/location.py b/lib/spack/spack/cmd/location.py index 6d953e344495d1..96571ff20341c9 100644 --- a/lib/spack/spack/cmd/location.py +++ b/lib/spack/spack/cmd/location.py @@ -79,6 +79,16 @@ def setup_parser(subparser: argparse.ArgumentParser) -> None: default=False, help="location of the named or current environment", ) + directories.add_argument( + "-v", + "--view", + action="store", + nargs="?", + metavar="name", + dest="location_view", + default=False, + help="location of the named or active environment view", + ) subparser.add_argument( "--first", @@ -114,6 +124,22 @@ def location(parser, args): print(path) return + # no -v corresponds to False, -v without arg to None, -v name to the string name. + if args.location_view is not False: + env = spack.cmd.require_active_env("location -v") + view_name = args.location_view + if view_name is None: + # get active view name + view_name = os.getenv(ev.spack_env_view_var) + if view_name is None: + tty.die("no active view in the current environment") + # print the view location + if env.has_view(view_name): + print(f"{env.views[view_name].root}\n") + else: + tty.die("no such view in the current environment: '%s'" % view_name) + return + if args.repo is not False: if args.repo is None: print(spack.repo.PATH.first_repo().root) diff --git a/lib/spack/spack/test/cmd/location.py b/lib/spack/spack/test/cmd/location.py index 3103d0f364224d..cef2e7ac37b4d2 100644 --- a/lib/spack/spack/test/cmd/location.py +++ b/lib/spack/spack/test/cmd/location.py @@ -109,6 +109,80 @@ def test_location_env_missing(): assert out == error +def test_location_active_view(mutable_mock_env_path, monkeypatch): + """Tests spack location --view for the active view.""" + mutable_mock_env_path.mkdir() + view_path = os.path.abspath(mutable_mock_env_path / "path" / "to" / "view") + spack_yaml = mutable_mock_env_path / ev.manifest_name + spack_yaml.write_text( + f"""spack: + specs: [] + view: + viewname: + root: {view_path} + concretizer: + unify: True + """ + ) + e = ev.Environment(mutable_mock_env_path) + monkeypatch.setenv(ev.spack_env_view_var, "viewname") + with e: + assert location("--view").strip() == view_path + + +def test_location_no_active_view(mutable_mock_env_path): + """Tests spack location --env without active view.""" + mutable_mock_env_path.mkdir() + view_path = os.path.abspath(mutable_mock_env_path / "path" / "to" / "view") + spack_yaml = mutable_mock_env_path / ev.manifest_name + spack_yaml.write_text( + f"""spack: + specs: [] + view: + viewname: + root: {view_path} + concretizer: + unify: True + """ + ) + e = ev.Environment(mutable_mock_env_path) + error = "==> Error: no active view in the current environment" + with e: + out = location("--view", fail_on_error=False).strip() + assert out == error + + +def test_location_view_exists(mutable_mock_env_path): + """Tests spack location --view for an existing view.""" + mutable_mock_env_path.mkdir() + view_path = os.path.abspath(mutable_mock_env_path / "path" / "to" / "view") + spack_yaml = mutable_mock_env_path / ev.manifest_name + spack_yaml.write_text( + f"""spack: + specs: [] + view: + viewname: + root: {view_path} + concretizer: + unify: True + """ + ) + e = ev.Environment(mutable_mock_env_path) + with e: + assert location("--view", "viewname").strip() == view_path + + +def test_location_view_missing(mutable_mock_env_path): + """Tests spack location --env with missing view.""" + e = ev.create("example", with_view=True) + e.write() + missing_view_name = "missing-view" + error = "==> Error: no such view in the current environment: '%s'" % missing_view_name + with e: + out = location("--view", missing_view_name, fail_on_error=False).strip() + assert out == error + + @pytest.mark.db @pytest.mark.not_on_windows("Broken on Windows") def test_location_install_dir(mock_spec): diff --git a/share/spack/spack-completion.bash b/share/spack/spack-completion.bash index 694453c2e3d999..315bc0edf75237 100644 --- a/share/spack/spack-completion.bash +++ b/share/spack/spack-completion.bash @@ -677,7 +677,7 @@ _spack_buildcache_migrate() { _spack_cd() { if $list_options then - SPACK_COMPREPLY="-h --help -m --module-dir -r --spack-root -i --install-dir -p --package-dir --repo --packages -P -s --stage-dir -S --stages -c --source-dir -b --build-dir -e --env --first" + SPACK_COMPREPLY="-h --help -m --module-dir -r --spack-root -i --install-dir -p --package-dir --repo --packages -P -s --stage-dir -S --stages -c --source-dir -b --build-dir -e --env -v --view --first" else _all_packages fi @@ -1406,7 +1406,7 @@ _spack_load() { _spack_location() { if $list_options then - SPACK_COMPREPLY="-h --help -m --module-dir -r --spack-root -i --install-dir -p --package-dir --repo --packages -P -s --stage-dir -S --stages -c --source-dir -b --build-dir -e --env --first" + SPACK_COMPREPLY="-h --help -m --module-dir -r --spack-root -i --install-dir -p --package-dir --repo --packages -P -s --stage-dir -S --stages -c --source-dir -b --build-dir -e --env -v --view --first" else _all_packages fi diff --git a/share/spack/spack-completion.fish b/share/spack/spack-completion.fish index 7a394e547a01de..5a0a496923cdf7 100644 --- a/share/spack/spack-completion.fish +++ b/share/spack/spack-completion.fish @@ -921,7 +921,7 @@ complete -c spack -n '__fish_spack_using_command buildcache migrate' -s y -l yes complete -c spack -n '__fish_spack_using_command buildcache migrate' -s y -l yes-to-all -d 'assume "yes" is the answer to every confirmation request' # spack cd -set -g __fish_spack_optspecs_spack_cd h/help m/module-dir r/spack-root i/install-dir p/package-dir repo= s/stage-dir S/stages c/source-dir b/build-dir e/env= first +set -g __fish_spack_optspecs_spack_cd h/help m/module-dir r/spack-root i/install-dir p/package-dir repo= s/stage-dir S/stages c/source-dir b/build-dir e/env= v/view= first complete -c spack -n '__fish_spack_using_command_pos_remainder 0 cd' -f -k -a '(__fish_spack_specs)' complete -c spack -n '__fish_spack_using_command cd' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command cd' -s h -l help -d 'show this help message and exit' @@ -945,6 +945,8 @@ complete -c spack -n '__fish_spack_using_command cd' -s b -l build-dir -f -a bui complete -c spack -n '__fish_spack_using_command cd' -s b -l build-dir -d 'build directory for a spec (requires it to be staged first)' complete -c spack -n '__fish_spack_using_command cd' -s e -l env -r -f -a location_env complete -c spack -n '__fish_spack_using_command cd' -s e -l env -r -d 'location of the named or current environment' +complete -c spack -n '__fish_spack_using_command cd' -s v -l view -r -f -a location_view +complete -c spack -n '__fish_spack_using_command cd' -s v -l view -r -d 'location of the named or active environment view' complete -c spack -n '__fish_spack_using_command cd' -l first -f -a find_first complete -c spack -n '__fish_spack_using_command cd' -l first -d 'use the first match if multiple packages match the spec' @@ -2260,7 +2262,7 @@ complete -c spack -n '__fish_spack_using_command load' -l list -f -a list complete -c spack -n '__fish_spack_using_command load' -l list -d 'show loaded packages: same as ``spack find --loaded``' # spack location -set -g __fish_spack_optspecs_spack_location h/help m/module-dir r/spack-root i/install-dir p/package-dir repo= s/stage-dir S/stages c/source-dir b/build-dir e/env= first +set -g __fish_spack_optspecs_spack_location h/help m/module-dir r/spack-root i/install-dir p/package-dir repo= s/stage-dir S/stages c/source-dir b/build-dir e/env= v/view= first complete -c spack -n '__fish_spack_using_command_pos_remainder 0 location' -f -k -a '(__fish_spack_specs)' complete -c spack -n '__fish_spack_using_command location' -s h -l help -f -a help complete -c spack -n '__fish_spack_using_command location' -s h -l help -d 'show this help message and exit' @@ -2284,6 +2286,8 @@ complete -c spack -n '__fish_spack_using_command location' -s b -l build-dir -f complete -c spack -n '__fish_spack_using_command location' -s b -l build-dir -d 'build directory for a spec (requires it to be staged first)' complete -c spack -n '__fish_spack_using_command location' -s e -l env -r -f -a location_env complete -c spack -n '__fish_spack_using_command location' -s e -l env -r -d 'location of the named or current environment' +complete -c spack -n '__fish_spack_using_command location' -s v -l view -r -f -a location_view +complete -c spack -n '__fish_spack_using_command location' -s v -l view -r -d 'location of the named or active environment view' complete -c spack -n '__fish_spack_using_command location' -l first -f -a find_first complete -c spack -n '__fish_spack_using_command location' -l first -d 'use the first match if multiple packages match the spec' From d2e004dce30c779c57bf45a1b4cc54daa0443c5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:06:44 +0200 Subject: [PATCH 326/337] build(deps): bump julia-actions/setup-julia from 3.0.1 to 3.0.2 (#52392) Bumps [julia-actions/setup-julia](https://github.com/julia-actions/setup-julia) from 3.0.1 to 3.0.2. - [Release notes](https://github.com/julia-actions/setup-julia/releases) - [Commits](https://github.com/julia-actions/setup-julia/compare/f6f565d9f7cf12f53dc8045742460d6260ad3b39...fa02766e078afaaf09b14210362cee14137e6a32) --- updated-dependencies: - dependency-name: julia-actions/setup-julia dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/import-check.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/import-check.yaml b/.github/workflows/import-check.yaml index d8b886a6e7d8d0..641a26dca88da6 100644 --- a/.github/workflows/import-check.yaml +++ b/.github/workflows/import-check.yaml @@ -9,7 +9,7 @@ jobs: continue-on-error: true runs-on: ubuntu-latest steps: - - uses: julia-actions/setup-julia@f6f565d9f7cf12f53dc8045742460d6260ad3b39 # v3.0.1 + - uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 with: version: '1.10' - uses: julia-actions/cache@a45e8fa8be21c18a06b7177052533149e61e9b38 # v3.1.0 From ae7f79f466d63c91124974466750dd9aa6ace861 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 10:07:18 +0200 Subject: [PATCH 327/337] build(deps): bump coverage in /.github/workflows/requirements/coverage (#52393) Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.13.5 to 7.14.0. - [Release notes](https://github.com/coveragepy/coveragepy/releases) - [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst) - [Commits](https://github.com/coveragepy/coveragepy/compare/7.13.5...7.14.0) --- updated-dependencies: - dependency-name: coverage dependency-version: 7.14.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/requirements/coverage/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/requirements/coverage/requirements.txt b/.github/workflows/requirements/coverage/requirements.txt index 92a66e3f04f19c..ede5d290b580ff 100644 --- a/.github/workflows/requirements/coverage/requirements.txt +++ b/.github/workflows/requirements/coverage/requirements.txt @@ -1 +1 @@ -coverage==7.13.5 +coverage==7.14.0 From 74faab1ed54c731cc371a3ea41651a3659284391 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 13 May 2026 10:47:56 +0200 Subject: [PATCH 328/337] new_installer.py: landlock sandbox (#52334) Adds support for sandboxing builds in the new installer. Works on Linux using Landlock. The goal is primarily build isolation and reproducibility. This sandbox does not guard against malicious python code in `package.py`, which runs before the build on module import. When enabled in config, the following dirs are readable and executable: * Prefixes of Spack installed dependencies (excluding externals) * User configured dirs The following are writeable and executable: * Install prefix * Stage dir * System temp dir (POSIX `/tmp` or `TMPDIR`) * `/dev/null` (POSIX) * User configured dirs The sandbox applies to the build process (and sub-processes) after sources are fetched and extracted, and before running the install phases. Signed-off-by: Harmen Stoppels --- etc/spack/defaults/base/config.yaml | 15 ++ lib/spack/docs/installing.rst | 30 +++ lib/spack/spack/installer_dispatch.py | 9 + lib/spack/spack/new_installer.py | 48 ++++- lib/spack/spack/sandbox.py | 269 ++++++++++++++++++++++++++ lib/spack/spack/schema/config.py | 25 +++ lib/spack/spack/test/sandbox.py | 205 ++++++++++++++++++++ 7 files changed, 598 insertions(+), 3 deletions(-) create mode 100644 lib/spack/spack/sandbox.py create mode 100644 lib/spack/spack/test/sandbox.py diff --git a/etc/spack/defaults/base/config.yaml b/etc/spack/defaults/base/config.yaml index ee4401b50fc3db..80d8deb88e70e4 100644 --- a/etc/spack/defaults/base/config.yaml +++ b/etc/spack/defaults/base/config.yaml @@ -170,6 +170,21 @@ config: # Which installer to use: "old" or "new". installer: new + # Restrict filesystem and network access during builds. The stage directory, + # install prefix, and system temp directory are always writable. + # Spack-installed dependency prefixes are always readable. Use the allow_read + # and allow_write lists to grant additional permissions to other paths. + # This feature is only available on Linux kernels with Landlock support, and + # only works with the new installer. + sandbox: + enable: false + # Allow TCP network access during the build phase. + allow_network: true + # Additional paths with read and execute permissions. + allow_read: [] + # Additional paths with write and execute permissions. + allow_write: [] + # If set to true, Spack will use ccache to cache C compiles. ccache: false diff --git a/lib/spack/docs/installing.rst b/lib/spack/docs/installing.rst index bceae733e22024..9d7460f2bf21ac 100644 --- a/lib/spack/docs/installing.rst +++ b/lib/spack/docs/installing.rst @@ -146,3 +146,33 @@ Failed builds show ``[x]`` in the overview. Navigate to a failed build and press ``v`` to see a parsed error summary and the path to the full log. See :ref:`spack install ` for the full set of flags related to debugging and controlling build behavior. + + +Build isolation and sandboxing (Linux) +-------------------------------------- + +Spack can run builds in an unprivileged sandbox to restrict filesystem and network access. +This opt-in feature requires Linux 5.13+ with Landlock support (network restrictions require Linux 6.7+). +Sandboxing is meant for build reproducibility and bug containment rather than acting as a strict security boundary, as package recipes still execute outside the sandbox ahead of the build. + +When enabled, the stage directory, install prefix, system temp directory and ``/dev/null`` are implicitly writable. +Spack-installed dependencies (excluding externals) are implicitly readable. +All other paths must be explicitly allowed in configuration: + +.. code-block:: yaml + + config: + sandbox: + enable: true # Enable for all builds + allow_network: false # Disable TCP network access during the build phase + allow_read: # Additional paths with read and execute permissions + - /usr + allow_write: # Additional paths with write and execute permissions + - /scratch + +The sandbox activates immediately after source extraction and prefix creation. +Note that network restrictions only apply during the build phases, leaving Spack's own fetch operations unaffected. + +File system restrictions are complementary to existing file permissions and ACLs; they cannot grant access to files the user does not already have permission to read or write. + +Spack's sandboxing complements external containerization tools like Podman or Bubblewrap: while a container must grant the main Spack process write access to the entire software store, Landlock dynamically confines each build subprocess strictly to its exact, package-specific install prefix. diff --git a/lib/spack/spack/installer_dispatch.py b/lib/spack/spack/installer_dispatch.py index dc29f1ec3d2752..0d48a38aa07ede 100644 --- a/lib/spack/spack/installer_dispatch.py +++ b/lib/spack/spack/installer_dispatch.py @@ -8,6 +8,7 @@ from spack.vendor.typing_extensions import Literal import spack.config +import spack.sandbox import spack.traverse if TYPE_CHECKING: @@ -54,6 +55,14 @@ def create_installer( if s.build_spec is not s: use_old_installer = True break + if spack.config.get("config:sandbox:enable", False): + if use_old_installer: + raise spack.sandbox.SandboxError( + "config:sandbox:enable is only supported with config:installer:new" + ) + # Probe sandbox support now so builds don't fail later inside a subprocess. + spack.sandbox.get_sandbox() + if use_old_installer: from spack.installer import PackageInstaller # type: ignore else: diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index bb1a1a6877214c..b93e2bf3f08ae0 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -73,6 +73,7 @@ import spack.mirrors.mirror import spack.paths import spack.report +import spack.sandbox import spack.spec import spack.stage import spack.store @@ -223,7 +224,7 @@ def send_installed_from_binary_cache(state_pipe: io.TextIOWrapper) -> None: state_pipe.write("\n") -def tee(control_r: int, log_r: int, log_path: str, parent_w: int) -> None: +def tee(control_r: int, log_r: int, log_file: io.BufferedWriter, parent_w: int) -> None: """Forward log_r to file_w and parent_w (if echoing is enabled). Echoing is enabled and disabled by reading from control_r.""" echo_on = False @@ -232,7 +233,7 @@ def tee(control_r: int, log_r: int, log_path: str, parent_w: int) -> None: selector.register(control_r, selectors.EVENT_READ) try: - with open(log_path, "ab") as log_file, open(parent_w, "wb", closefd=False) as parent: + with log_file, open(parent_w, "wb", closefd=False) as parent: while True: for key, _ in selector.select(): if key.fd == log_r: @@ -270,10 +271,11 @@ def __init__(self, control: Connection, parent: Connection, log_path: str) -> No self.saved_fds = {fd: os.dup(fd) for fd in fds} #: The path of the log file self.log_path = log_path + log_file = open(self.log_path, "ab") r, w = os.pipe() self.tee_thread = threading.Thread( target=tee, - args=(self.control.fileno(), r, self.log_path, self.parent.fileno()), + args=(self.control.fileno(), r, log_file, self.parent.fileno()), daemon=True, ) self.tee_thread.start() @@ -668,6 +670,44 @@ def _archive_build_metadata(pkg: "spack.package_base.PackageBase") -> None: spack.llnl.util.tty.debug(e) +def _enable_sandbox(config: dict, spec: spack.spec.Spec, stage_path: str) -> None: + if not config.get("enable", False): + return + + try: + sandbox = spack.sandbox.get_sandbox() + except spack.sandbox.SandboxError as e: + raise spack.error.InstallError(f"Cannot enable build sandbox: {e}") from e + + for dep in spec.traverse(root=False): + if not dep.external: + sandbox.allow_read(dep.prefix) + + sandbox.allow_write(stage_path) + sandbox.allow_write(spec.prefix) + + # POSIX prescribes /tmp and /dev/null are present. In the future we can consider setting + # TMPPATH to a sibling of the stage path to isolate concurrent builds better. + sandbox.allow_write(tempfile.gettempdir()) + sandbox.allow_write(os.devnull) + + # Allow read access to sbang, which might be needed to run build scripts. + sandbox.allow_read(os.path.join(spack.store.STORE.unpadded_root, "bin", "sbang")) + for upstream_db in spack.store.STORE.upstreams or []: + sandbox.allow_read(os.path.join(upstream_db.root, "bin", "sbang")) + + # User-configured paths + for p in config.get("allow_read", []): + sandbox.allow_read(p) + for p in config.get("allow_write", []): + sandbox.allow_write(p) + + try: + sandbox.apply(block_network=not config.get("allow_network", True)) + except spack.sandbox.SandboxError as e: + raise spack.error.InstallError(f"Cannot enable build sandbox: {e}") from e + + def _install( spec: spack.spec.Spec, explicit: bool, @@ -766,6 +806,8 @@ def _install( if stop_at is not None and stop_at not in builder.phases: raise spack.error.InstallError(f"'{stop_at}' is not a valid phase for {pkg.name}") + _enable_sandbox(spack.config.get("config:sandbox", {}), spec, stage.path) + for phase in builder: if stop_before is not None and phase.name == stop_before: send_state(f"stopped before {stop_before}", state_stream) diff --git a/lib/spack/spack/sandbox.py b/lib/spack/spack/sandbox.py new file mode 100644 index 00000000000000..2fda7533ae6891 --- /dev/null +++ b/lib/spack/spack/sandbox.py @@ -0,0 +1,269 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) + +""" +This module implements an unprivileged sandbox for build environments. + +It enforces path-based filesystem whitelisting and optional network isolation, +dynamically adapting to the host kernel's supported Landlock ABI version. + +By design, to support standard build system behaviors like `try_compile` tests, +read access implicitly includes execution rights. IOCTLs and IPC mechanisms are +left unrestricted to ensure compatibility with compilers, terminal output, and +build jobservers. +""" + +import ctypes +import enum +import os +import platform +import stat +import warnings +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Dict, Union + +import spack.error + +# Linux landlock syscalls +SYSCALL_LANDLOCK_CREATE_RULESET = 444 +SYSCALL_LANDLOCK_ADD_RULE = 445 +SYSCALL_LANDLOCK_RESTRICT_SELF = 446 + +PR_SET_NO_NEW_PRIVS = 38 +LANDLOCK_CREATE_RULESET_VERSION = 1 << 0 +LANDLOCK_RULE_PATH_BENEATH = 1 +LANDLOCK_ACCESS_NET_BIND_TCP = 1 << 0 +LANDLOCK_ACCESS_NET_CONNECT_TCP = 1 << 1 +LANDLOCK_RESTRICT_SELF_TSYNC = 1 << 3 + + +class FSAccess(enum.IntFlag): + EXECUTE = 1 << 0 + WRITE_FILE = 1 << 1 + READ_FILE = 1 << 2 + READ_DIR = 1 << 3 + REMOVE_DIR = 1 << 4 + REMOVE_FILE = 1 << 5 + MAKE_CHAR = 1 << 6 + MAKE_DIR = 1 << 7 + MAKE_REG = 1 << 8 + MAKE_SOCK = 1 << 9 + MAKE_FIFO = 1 << 10 + MAKE_BLOCK = 1 << 11 + MAKE_SYM = 1 << 12 + REFER = 1 << 13 # ABI v2 + TRUNCATE = 1 << 14 # ABI v3 + + +def _check_syscall(result: int, name: str) -> int: + """Raise OSError if a libc syscall returned a negative value. + + Mirrors what Python's stdlib does for syscall-backed os.* functions. + """ + if result < 0: + err = ctypes.get_errno() + raise OSError(err, f"{name}: {os.strerror(err)}") + return result + + +class RulesetAttr(ctypes.Structure): + _fields_ = [ + ("handled_access_fs", ctypes.c_uint64), + ("handled_access_net", ctypes.c_uint64), + ("scoped", ctypes.c_uint64), + ] + + +class PathBeneathAttr(ctypes.Structure): + _fields_ = [("allowed_access", ctypes.c_uint64), ("parent_fd", ctypes.c_int32)] + + +class Sandbox(ABC): + """Abstract base class for sandbox implementations.""" + + def allow_read(self, path: Union[str, Path]): + p = Path(path).absolute() + resolved = p.resolve() + if resolved.exists(): + self._allow_read(p, resolved) + + def allow_write(self, path: Union[str, Path]): + p = Path(path).absolute() + resolved = p.resolve() + if resolved.exists(): + self._allow_write(p, resolved) + + @abstractmethod + def _allow_read(self, original: Path, resolved: Path): ... + + @abstractmethod + def _allow_write(self, original: Path, resolved: Path): ... + + @abstractmethod + def apply(self, block_network: bool = False): ... + + +def _get_write_flags(abi_version: int) -> int: + flags = ( + FSAccess.MAKE_BLOCK + | FSAccess.MAKE_CHAR + | FSAccess.MAKE_DIR + | FSAccess.MAKE_FIFO + | FSAccess.MAKE_REG + | FSAccess.MAKE_SOCK + | FSAccess.MAKE_SYM + | FSAccess.REMOVE_DIR + | FSAccess.REMOVE_FILE + | FSAccess.WRITE_FILE + ) + if abi_version >= 2: + flags |= FSAccess.REFER + if abi_version >= 3: + flags |= FSAccess.TRUNCATE + return flags + + +class LandlockSandbox(Sandbox): + def __init__(self, libc=None): + self.libc = libc if libc is not None else ctypes.CDLL(None, use_errno=True) + self.abi_version = self._get_abi_version() + self.path_rules: Dict[Path, int] = {} + self.write_flags = _get_write_flags(self.abi_version) + self.read_flags = FSAccess.EXECUTE | FSAccess.READ_FILE | FSAccess.READ_DIR + self.dir_flags = ( + FSAccess.MAKE_BLOCK + | FSAccess.MAKE_CHAR + | FSAccess.MAKE_DIR + | FSAccess.MAKE_FIFO + | FSAccess.MAKE_REG + | FSAccess.MAKE_SOCK + | FSAccess.MAKE_SYM + | FSAccess.READ_DIR + | FSAccess.REFER + | FSAccess.REMOVE_DIR + | FSAccess.REMOVE_FILE + ) + + def _get_abi_version(self) -> int: + res = self.libc.syscall( + ctypes.c_long(SYSCALL_LANDLOCK_CREATE_RULESET), + None, + ctypes.c_size_t(0), + ctypes.c_uint32(LANDLOCK_CREATE_RULESET_VERSION), + ) + return _check_syscall(res, "landlock_create_ruleset(version)") + + def _allow_read(self, original: Path, resolved: Path): + current_flags = self.path_rules.get(resolved, 0) + self.path_rules[resolved] = current_flags | self.read_flags + + def _allow_write(self, original: Path, resolved: Path): + current_flags = self.path_rules.get(resolved, 0) + self.path_rules[resolved] = current_flags | self.write_flags | self.read_flags + + def _syscall_create_ruleset(self, handled_access_fs: int, handled_access_net: int) -> int: + attr = RulesetAttr( + handled_access_fs=handled_access_fs, handled_access_net=handled_access_net + ) + return _check_syscall( + self.libc.syscall( + ctypes.c_long(SYSCALL_LANDLOCK_CREATE_RULESET), + ctypes.byref(attr), + ctypes.c_size_t(ctypes.sizeof(attr)), + ctypes.c_uint32(0), + ), + "landlock_create_ruleset", + ) + + def _syscall_add_rule(self, ruleset_fd: int, allowed_access: int, path_fd: int) -> None: + rule = PathBeneathAttr(allowed_access=allowed_access, parent_fd=path_fd) + _check_syscall( + self.libc.syscall( + ctypes.c_long(SYSCALL_LANDLOCK_ADD_RULE), + ctypes.c_int(ruleset_fd), + ctypes.c_int(LANDLOCK_RULE_PATH_BENEATH), + ctypes.byref(rule), + ctypes.c_uint32(0), + ), + "landlock_add_rule", + ) + + def _syscall_restrict_self(self, ruleset_fd: int, tsync_flag: int) -> None: + _check_syscall( + self.libc.syscall( + ctypes.c_long(SYSCALL_LANDLOCK_RESTRICT_SELF), + ctypes.c_int(ruleset_fd), + ctypes.c_uint32(tsync_flag), + ), + "landlock_restrict_self", + ) + + def _prctl_no_new_privs(self) -> None: + _check_syscall( + self.libc.prctl( + ctypes.c_int(PR_SET_NO_NEW_PRIVS), + ctypes.c_ulong(1), + ctypes.c_ulong(0), + ctypes.c_ulong(0), + ctypes.c_ulong(0), + ), + "prctl(PR_SET_NO_NEW_PRIVS)", + ) + + def apply(self, block_network: bool = False): + # Network access requires ABI v4 + if block_network and self.abi_version < 4: + raise SandboxError( + f"Blocking network access requires Landlock ABI v4+ (kernel 6.7+), " + f"but this kernel only supports ABI v{self.abi_version}." + ) + net_flags = ( + LANDLOCK_ACCESS_NET_CONNECT_TCP | LANDLOCK_ACCESS_NET_BIND_TCP if block_network else 0 + ) + try: + self._apply(net_flags) + except OSError as e: + raise SandboxError(f"Failed to apply build sandbox: {e}") from e + + def _apply(self, net_flags: int) -> None: + ruleset_fd = self._syscall_create_ruleset(self.write_flags | self.read_flags, net_flags) + + try: + for path, flags in self.path_rules.items(): + try: + # use O_PATH to get an fd w/o needing permissions, and O_NOFOLLOW to avoid + # TOCTOU issues after we've called resolve() on the path. + fd = os.open(str(path), os.O_PATH | os.O_CLOEXEC | os.O_NOFOLLOW) + except OSError as e: + warnings.warn(f"Cannot allow sandbox access to {path} due to: {e}") + continue + try: + st = os.fstat(fd) + if not stat.S_ISDIR(st.st_mode): + # Strip directory-specific flags + flags &= ~self.dir_flags + self._syscall_add_rule(ruleset_fd, flags, fd) + finally: + os.close(fd) + + # Lock down the current process with this ruleset + self._prctl_no_new_privs() + tsync_flag = LANDLOCK_RESTRICT_SELF_TSYNC if self.abi_version >= 8 else 0 + self._syscall_restrict_self(ruleset_fd, tsync_flag) + finally: + os.close(ruleset_fd) + + +def get_sandbox() -> Sandbox: + if platform.system() != "Linux": + raise SandboxError("Build sandboxing is only supported on Linux") + try: + return LandlockSandbox() + except OSError as e: + raise SandboxError(f"Landlock sandboxing is unavailable: {e}") from e + + +class SandboxError(spack.error.SpackError): + """Raised when the build sandbox cannot be set up or applied.""" diff --git a/lib/spack/spack/schema/config.py b/lib/spack/spack/schema/config.py index 745cc1da7e5f95..188cad095ad17b 100644 --- a/lib/spack/spack/schema/config.py +++ b/lib/spack/spack/schema/config.py @@ -233,6 +233,31 @@ "enum": ["old", "new"], "description": "Which installer to use. The new installer is experimental.", }, + "sandbox": { + "type": "object", + "description": "Restrict filesystem and network access during builds.", + "additionalProperties": False, + "properties": { + "enable": { + "type": "boolean", + "description": "Enable or disable the build sandbox.", + }, + "allow_network": { + "type": "boolean", + "description": "Allow TCP network access during the build phase.", + }, + "allow_read": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional paths with read and execute permissions.", + }, + "allow_write": { + "type": "array", + "items": {"type": "string"}, + "description": "Additional paths with write and execute permissions.", + }, + }, + }, }, } } diff --git a/lib/spack/spack/test/sandbox.py b/lib/spack/spack/test/sandbox.py new file mode 100644 index 00000000000000..9c9e901848e83b --- /dev/null +++ b/lib/spack/spack/test/sandbox.py @@ -0,0 +1,205 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +"""Unit tests for Linux Landlock sandboxing in the new installer.""" + +import sys + +import pytest + +if sys.platform != "linux": + pytest.skip("Landlock sandboxing is Linux only", allow_module_level=True) + +import os +import pathlib +import tempfile +from typing import List, Tuple + +import spack.concretize +import spack.sandbox +import spack.store +from spack.new_installer import _enable_sandbox + + +class SpyLandlockSandbox(spack.sandbox.LandlockSandbox): + """LandlockSandbox that records _syscall_* and _prctl_* calls.""" + + def __init__(self, abi_version: int = 3) -> None: + self._abi_version_override = abi_version + super().__init__() + self._fds: List[int] = [] + self.ruleset_fd = -1 + # (fs_flags, net_flags) + self.create_ruleset_calls: List[Tuple[int, int]] = [] + # (ruleset_fd, allowed_access, path_fd) + self.add_rule_calls: List[Tuple[int, int, int]] = [] + # (ruleset_fd, tsync_flag) + self.restrict_self_calls: List[Tuple[int, int]] = [] + self.prctl_called: bool = False + + def __del__(self): + for fd in self._fds: + os.close(fd) + + def _new_fd(self) -> int: + fd = os.open(os.devnull, os.O_RDONLY) + self._fds.append(fd) + return fd + + def _get_abi_version(self) -> int: + return self._abi_version_override + + def _syscall_create_ruleset(self, handled_access_fs: int, handled_access_net: int) -> int: + self.create_ruleset_calls.append((handled_access_fs, handled_access_net)) + self.ruleset_fd = self._new_fd() + return self.ruleset_fd + + def _syscall_add_rule(self, ruleset_fd: int, allowed_access: int, path_fd: int) -> None: + self.add_rule_calls.append((ruleset_fd, allowed_access, path_fd)) + + def _syscall_restrict_self(self, ruleset_fd: int, tsync_flag: int) -> None: + self.restrict_self_calls.append((ruleset_fd, tsync_flag)) + + def _prctl_no_new_privs(self) -> None: + self.prctl_called = True + + +def test_landlock_sandbox_syscall_args(tmp_path: pathlib.Path): + """Test that LandlockSandbox passes correct arguments to each syscall.""" + sandbox = SpyLandlockSandbox(abi_version=3) + + test_dir = tmp_path / "dir" + test_dir.mkdir() + test_file = test_dir / "file" + test_file.touch() + + sandbox.allow_read(test_dir) + sandbox.allow_write(test_file) + sandbox.apply(block_network=False) + + # Ruleset covers both read and write access; no network flags + [(fs_flags, net_flags)] = sandbox.create_ruleset_calls + assert fs_flags & spack.sandbox.FSAccess.READ_FILE + assert fs_flags & spack.sandbox.FSAccess.WRITE_FILE + assert net_flags == 0 + + # One rule per path, both using the same ruleset fd + assert len(sandbox.add_rule_calls) == 2 + for ruleset_fd, _access, path_fd in sandbox.add_rule_calls: + assert ruleset_fd == sandbox.ruleset_fd + assert path_fd > 0 + + # Read-only directory: has READ_DIR, no WRITE_FILE + dir_access = next( + a for _, a, _ in sandbox.add_rule_calls if a & spack.sandbox.FSAccess.READ_DIR + ) + assert not (dir_access & spack.sandbox.FSAccess.WRITE_FILE) + + # Write file: has WRITE_FILE, no READ_DIR (dir flags stripped for non-dirs) + file_access = next( + a for _, a, _ in sandbox.add_rule_calls if a & spack.sandbox.FSAccess.WRITE_FILE + ) + assert not (file_access & spack.sandbox.FSAccess.READ_DIR) + + # RESTRICT_SELF gets the correct ruleset fd + [(restrict_fd, tsync)] = sandbox.restrict_self_calls + assert restrict_fd == sandbox.ruleset_fd + assert tsync == 0 # ABI v3: no tsync flag + + assert sandbox.prctl_called + + +def test_landlock_sandbox_network_args(): + """Test that block_network=True sets the correct net flags in the ruleset.""" + sandbox = SpyLandlockSandbox(abi_version=4) + sandbox.apply(block_network=True) + + [(_, net_flags)] = sandbox.create_ruleset_calls + assert net_flags & spack.sandbox.LANDLOCK_ACCESS_NET_CONNECT_TCP + assert net_flags & spack.sandbox.LANDLOCK_ACCESS_NET_BIND_TCP + assert sandbox.prctl_called + + +class MockSandbox(spack.sandbox.Sandbox): + def __init__(self): + self.read_calls: List[Tuple[pathlib.Path, pathlib.Path]] = [] + self.write_calls: List[Tuple[pathlib.Path, pathlib.Path]] = [] + self.apply_calls: List[bool] = [] + + def _allow_read(self, original: pathlib.Path, resolved: pathlib.Path): + self.read_calls.append((original, resolved)) + + def _allow_write(self, original: pathlib.Path, resolved: pathlib.Path): + self.write_calls.append((original, resolved)) + + def apply(self, block_network=False): + self.apply_calls.append(block_network) + + +def test_enable_sandbox_paths( + monkeypatch, mock_packages, temporary_store: spack.store.Store, tmp_path: pathlib.Path +): + """Test that _enable_sandbox in new_installer calls allow_read/allow_write correctly.""" + mock_sandbox = MockSandbox() + monkeypatch.setattr(spack.sandbox, "get_sandbox", lambda: mock_sandbox) + + spec = spack.concretize.concretize_one("dependent-install") + + # Create prefix directories so resolved.exists() passes + pathlib.Path(spec.prefix).mkdir(parents=True, exist_ok=True) + for dep in spec.traverse(root=False): + pathlib.Path(dep.prefix).mkdir(parents=True, exist_ok=True) + + stage_path = tmp_path / "stage" + stage_path.mkdir() + + custom_write = tmp_path / "custom_write" + custom_write.mkdir() + + # Create a symlink to verify original vs resolved path logic + custom_read_target = tmp_path / "custom_read_target" + custom_read_target.mkdir() + custom_read_link = tmp_path / "custom_read_link" + custom_read_link.symlink_to(custom_read_target) + + # Ensure the sbang exists + temporary_store.install_sbang() + sbang_file = pathlib.Path(temporary_store.unpadded_root) / "bin" / "sbang" + + config = { + "enable": True, + "allow_read": [str(custom_read_link)], + "allow_write": [str(custom_write)], + "allow_network": True, + } + + _enable_sandbox(config, spec, str(stage_path)) + + allow_read_resolved = [c[1] for c in mock_sandbox.read_calls] + for dep in spec.traverse(root=False): + assert pathlib.Path(dep.prefix).resolve() in allow_read_resolved + + # Verify symlink resolution in read_calls + assert custom_read_target.resolve() in allow_read_resolved + assert (custom_read_link.absolute(), custom_read_target.resolve()) in mock_sandbox.read_calls + + # Verify sbang read + assert sbang_file.resolve() in allow_read_resolved + + allow_write_resolved = [c[1] for c in mock_sandbox.write_calls] + assert stage_path.resolve() in allow_write_resolved + assert pathlib.Path(spec.prefix).resolve() in allow_write_resolved + assert custom_write.resolve() in allow_write_resolved + assert pathlib.Path(tempfile.gettempdir()).resolve() in allow_write_resolved + + assert mock_sandbox.apply_calls == [False] + + +def test_sandbox_network_blocking_requires_abi_v4(): + """Test that blocking network access on an older kernel raises a RuntimeError.""" + sandbox = SpyLandlockSandbox(abi_version=3) + + with pytest.raises( + spack.sandbox.SandboxError, match="Blocking network access requires Landlock ABI v4\\+" + ): + sandbox.apply(block_network=True) From 1e9438d61dd621734a1e9246e29aca27b3ed445c Mon Sep 17 00:00:00 2001 From: Massimiliano Culpo Date: Wed, 13 May 2026 10:48:16 +0200 Subject: [PATCH 329/337] solver: fix a bug with toolchain expansion in `prefer` rules (#52388) Toolchains are currently not expanded in the condition of a conditional preference. Here we fix the issue and add a regression test. Signed-off-by: Massimiliano Culpo --- lib/spack/spack/solver/requirements.py | 11 +++++----- .../spack/test/concretization/requirements.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/solver/requirements.py b/lib/spack/spack/solver/requirements.py index 5014d57c671e82..976ac263523205 100644 --- a/lib/spack/spack/solver/requirements.py +++ b/lib/spack/spack/solver/requirements.py @@ -117,7 +117,7 @@ class RequirementRule(NamedTuple): def preference( pkg_name: str, constraint: spack.spec.Spec, - condition: spack.spec.Spec = spack.spec.Spec(), + condition: spack.spec.Spec = spack.spec.EMPTY_SPEC, origin: RequirementOrigin = RequirementOrigin.PREFER_YAML, kind: RequirementKind = RequirementKind.PACKAGE, message: Optional[str] = None, @@ -141,7 +141,7 @@ def preference( def conflict( pkg_name: str, constraint: spack.spec.Spec, - condition: spack.spec.Spec = spack.spec.Spec(), + condition: spack.spec.Spec = spack.spec.EMPTY_SPEC, origin: RequirementOrigin = RequirementOrigin.CONFLICT_YAML, kind: RequirementKind = RequirementKind.PACKAGE, message: Optional[str] = None, @@ -277,11 +277,12 @@ def _parse_prefer_conflict_item(self, item): # The item is either a string or an object with at least a "spec" attribute if isinstance(item, str): spec = self._parse_and_expand(item) - condition = spack.spec.Spec() + condition = spack.spec.EMPTY_SPEC message = None else: spec = self._parse_and_expand(item["spec"]) - condition = spack.spec.Spec(item.get("when")) + when_str = item.get("when") + condition = self._parse_and_expand(when_str) if when_str else spack.spec.EMPTY_SPEC message = item.get("message") raw_key = item if isinstance(item, str) else item.get("spec", item) _check_unknown_targets([raw_key], [spec], always_warn=True) @@ -338,7 +339,7 @@ def _rules_from_requirements( _check_unknown_targets(raw_strs, constraints) _check_unknown_virtuals_on_edges(raw_strs, constraints) when_str = requirement.get("when") - when = self._parse_and_expand(when_str) if when_str else spack.spec.Spec() + when = self._parse_and_expand(when_str) if when_str else spack.spec.EMPTY_SPEC constraints = [ x diff --git a/lib/spack/spack/test/concretization/requirements.py b/lib/spack/spack/test/concretization/requirements.py index e29b716d9816c8..140e77b69b7595 100644 --- a/lib/spack/spack/test/concretization/requirements.py +++ b/lib/spack/spack/test/concretization/requirements.py @@ -1643,3 +1643,23 @@ def test_penalties_for_language_preferences(concretize_scope, mock_packages): assert s.satisfies("%c=gcc@10") assert all(s[name].satisfies("%c=clang") for name in dependency_names) assert s["mpi"].satisfies("%c,cxx=clang %fortran=gcc@10") + + +def test_prefer_when_condition_expands_toolchain(concretize_scope, mutable_config, mock_packages): + """Tests that toolchains in the 'when' condition of a 'prefer' rule must are expanded.""" + # If the expansion to %gcc doesn't happen, the preference for @2.1 is silently ignored + mutable_config.set("toolchains", {"gcc_toolchain": "%c=gcc"}, scope="concretize") + update_packages_config(""" +packages: + multivalue-variant: + prefer: + - spec: "@2.1" + when: "%gcc_toolchain" +""") + + s_gcc = spack.concretize.concretize_one("multivalue-variant %c=gcc") + assert s_gcc.satisfies("@2.1 %c=gcc"), f"expected @2.1 with gcc, got {s_gcc.version}" + + # With clang as compiler, condition does not fire -> default highest version @2.3 + s_clang = spack.concretize.concretize_one("multivalue-variant %clang") + assert s_clang.satisfies("@2.3 %c=clang"), f"expected @2.3 with clang, got {s_clang.version}" From 84cb7ff86b42e7fa45476750b6729144c58f70dc Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Wed, 13 May 2026 22:16:38 +0200 Subject: [PATCH 330/337] new_installer.py: close proc after join (#52401) This closes the sentinel file descriptors, which would otherwise only happen on exit, resulting in an fd leak. Signed-off-by: Harmen Stoppels --- lib/spack/spack/new_installer.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/spack/spack/new_installer.py b/lib/spack/spack/new_installer.py index b93e2bf3f08ae0..5b7c92251a26f6 100644 --- a/lib/spack/spack/new_installer.py +++ b/lib/spack/spack/new_installer.py @@ -186,8 +186,9 @@ def __init__( def save_to_db(self, db: spack.database.Database) -> None: return db._add(self.spec, explicit=self.explicit) - def cleanup(self, selector: selectors.BaseSelector) -> None: - """Unregister and close file descriptors, and join the child process.""" + def close(self, selector: selectors.BaseSelector) -> int: + """Unregister and close file descriptors, and join the child process. + Returns the exit code of the child process.""" try: selector.unregister(self.output_r_conn.fileno()) except KeyError: @@ -204,6 +205,11 @@ def cleanup(self, selector: selectors.BaseSelector) -> None: self.state_r_conn.close() self.control_w_conn.close() self.proc.join() + exit_code = self.proc.exitcode + assert exit_code is not None, "Finished build should have exit code set" + if hasattr(self.proc, "close"): # No known equivalent in Python 3.6 + self.proc.close() + return exit_code def send_state(state: str, state_pipe: io.TextIOWrapper) -> None: @@ -2751,9 +2757,7 @@ def _handle_finished_build( self.build_status.set_jobs(jobserver.num_jobs, jobserver.target_jobs) self._drain_child_output(build, selector) self._drain_child_state(build, selector) - build.cleanup(selector) - exitcode = build.proc.exitcode - assert exitcode is not None, "Finished build should have exit code set" + exitcode = build.close(selector) self.report_data.finish_record(build.spec, exitcode, build.log_path) if exitcode == ExitCode.SUCCESS: From 8001bb26991637a9d45d43d6f9148ab5df48a2e7 Mon Sep 17 00:00:00 2001 From: Caetano Melone Date: Wed, 13 May 2026 17:24:40 -0500 Subject: [PATCH 331/337] add audit check for placeholder maintainers (#52399) * add audit check for placeholder maintainers https://github.com/spack/spack-packages/pull/4821 is the only package that has this issue; this will ensure it can't get past CI. Signed-off-by: Caetano Melone * update package hash to be valid sha256 Signed-off-by: Caetano Melone * update mock package to avoid boilerplate url error Signed-off-by: Caetano Melone * fix unit tests Signed-off-by: Caetano Melone * set intersections for lookup rather than loops Signed-off-by: Caetano Melone Co-authored-by: Alec Scott --------- Signed-off-by: Caetano Melone Co-authored-by: Alec Scott --- lib/spack/spack/audit.py | 16 ++++++++++++++++ lib/spack/spack/test/audit.py | 2 ++ lib/spack/spack/test/cmd/maintainers.py | 8 ++++++++ .../packages/invalid_maintainer/package.py | 14 ++++++++++++++ 4 files changed, 40 insertions(+) create mode 100644 var/spack/test_repos/spack_repo/builtin_mock/packages/invalid_maintainer/package.py diff --git a/lib/spack/spack/audit.py b/lib/spack/spack/audit.py index 9065510fc92f6d..21919b94daf97d 100644 --- a/lib/spack/spack/audit.py +++ b/lib/spack/spack/audit.py @@ -1188,6 +1188,22 @@ def _version_constraints_are_satisfiable_by_some_version_in_repo(pkgs, error_cls return errors +@package_directives +def _ensure_maintainers_are_not_placeholders(pkgs, error_cls): + """Ensure placeholder maintainers are not defined in the package.""" + errors = [] + placeholder_maintainers = ("github_user1", "github_user2") + for pkg_name in pkgs: + pkg_cls = spack.repo.PATH.get_pkg_class(pkg_name) + found_placeholders = set(pkg_cls.maintainers).intersection(placeholder_maintainers) + + if found_placeholders: + summary = f"Package '{pkg_name}' has placeholder maintainer(s)" + details = [f"Remove placeholder maintainer(s): {found_placeholders}"] + errors.append(error_cls(summary, details)) + return errors + + def _issues_in_directive_constraint(pkg, constraint, *, directive, error_cls, filename, requestor): errors = [] errors.extend( diff --git a/lib/spack/spack/test/audit.py b/lib/spack/spack/test/audit.py index db12424e0848cd..12c14784903e42 100644 --- a/lib/spack/spack/test/audit.py +++ b/lib/spack/spack/test/audit.py @@ -32,6 +32,8 @@ (["fail-test-audit-docstring"], ["PKG-PROPERTIES"]), # This package has a stand-alone test method without an implementation (["fail-test-audit-impl"], ["PKG-PROPERTIES"]), + # This package has maintainers with placeholders + (["invalid-maintainer"], ["PKG-DIRECTIVES"]), # This package has no issues (["mpileaks"], None), # This package has a conflict with a trigger which cannot constrain the constraint diff --git a/lib/spack/spack/test/cmd/maintainers.py b/lib/spack/spack/test/cmd/maintainers.py index 6c040016280ce5..28a93cbb412cb8 100644 --- a/lib/spack/spack/test/cmd/maintainers.py +++ b/lib/spack/spack/test/cmd/maintainers.py @@ -15,6 +15,7 @@ MAINTAINED_PACKAGES = [ "gcc-runtime", + "invalid-maintainer", "maintainers-1", "maintainers-2", "maintainers-3", @@ -43,6 +44,9 @@ def test_all(): assert out == [ "gcc-runtime:", "haampie", + "invalid-maintainer:", + "github_user1,", + "github_user2", "maintainers-1:", "user1,", "user2", @@ -66,6 +70,10 @@ def test_all(): def test_all_by_user(): out = split(maintainers("--all", "--by-user")) assert out == [ + "github_user1:", + "invalid-maintainer", + "github_user2:", + "invalid-maintainer", "haampie:", "gcc-runtime", "user0:", diff --git a/var/spack/test_repos/spack_repo/builtin_mock/packages/invalid_maintainer/package.py b/var/spack/test_repos/spack_repo/builtin_mock/packages/invalid_maintainer/package.py new file mode 100644 index 00000000000000..ed653dc1d60da6 --- /dev/null +++ b/var/spack/test_repos/spack_repo/builtin_mock/packages/invalid_maintainer/package.py @@ -0,0 +1,14 @@ +# Copyright Spack Project Developers. See COPYRIGHT file for details. +# +# SPDX-License-Identifier: (Apache-2.0 OR MIT) +from spack.package import * + + +class InvalidMaintainer(Package): + """Package with invalid maintainers (placeholders).""" + + url = "https://www.invalid-maintainer.org/archive/v1.0.tar.gz" + + maintainers("github_user1", "github_user2") + + version("1.0", sha256="0f22de2391d80d8b393c4f9d11488600126c60ae36ceef780c6a4b3d9dab2e96") From 5e55a514eb3350e4fc825556a9958f1d6892042a Mon Sep 17 00:00:00 2001 From: "Seth R. Johnson" Date: Fri, 15 May 2026 14:28:39 -0400 Subject: [PATCH 332/337] Fix setup failure when nonexist directory has '.' in basename (#52355) * Fix setup failure when nonexist directory has '.' in basename I encountered a nasty failure that happens before the `-v`/`-d` arguments are parsed in spack: ``` ==> Error: File-based scope does not exist yet: should have a .yaml/.yml extension for file scopes, or no extension for directory scopes (currently .04) ``` This happened because (the FNAL fork of) spack was looking for a missing directory `.../etc/spack/linux/ubuntu24.04`. The logic in place assumed that having an extension meant it was a file. Signed-off-by: Seth R Johnson * Print a warning Signed-off-by: Seth R Johnson * Improve nomenclature Signed-off-by: Seth R Johnson * Redo logic Signed-off-by: Seth R Johnson * Fix typos Signed-off-by: Seth R Johnson * Style Signed-off-by: Seth R Johnson --------- Signed-off-by: Seth R Johnson --- lib/spack/spack/config.py | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/lib/spack/spack/config.py b/lib/spack/spack/config.py index 3498a0eb79670a..85fcecfc81812b 100644 --- a/lib/spack/spack/config.py +++ b/lib/spack/spack/config.py @@ -1156,16 +1156,28 @@ def _scope( config_name = f"{config_name}:{included_name}" - _, ext = os.path.splitext(config_path) - ext_is_yaml = ext == ".yaml" or ext == ".yml" - is_dir = os.path.isdir(config_path) - exists = os.path.exists(config_path) + # Type | Extension | RESULT + # -------- | --------- | --------- + # missing | none | Directory + # missing | yaml | File + # missing | other | No scope + # directory | none/any | Directory + # file | yaml | File + # file | other | Error + exists = os.path.exists(config_path) if not exists and not self.optional: dest = f" at ({config_path})" if config_path != os.path.normpath(path) else "" raise ValueError(f"Required path ({path}) does not exist{dest}") - if (exists and not is_dir) or ext_is_yaml: + _, ext = os.path.splitext(config_path) + if os.path.isdir(config_path) or not ext: + # directories are treated as regular ConfigScopes + tty.debug(f"Creating DirectoryConfigScope {config_name} for '{config_path}'") + return DirectoryConfigScope( + config_name, config_path, prefer_modify=self.prefer_modify, included=True + ) + elif ext == ".yaml" or ext == ".yml": tty.debug(f"Creating SingleFileScope {config_name} for '{config_path}'") return SingleFileScope( config_name, @@ -1174,19 +1186,16 @@ def _scope( prefer_modify=self.prefer_modify, included=True, ) - - if ext and not is_dir: + elif exists: raise ValueError( - f"File-based scope does not exist yet: should have a .yaml/.yml extension \ -for file scopes, or no extension for directory scopes (currently {ext})" + f"Unsupported file-based scope: path ({path}) should have " + "a .yaml/.yml extension for file scopes, " + "or no extension for directory scopes" ) - # directories are treated as regular ConfigScopes - # assign by "default" - tty.debug(f"Creating DirectoryConfigScope {config_name} for '{config_path}'") - return DirectoryConfigScope( - config_name, config_path, prefer_modify=self.prefer_modify, included=True - ) + # Nonexistent files without yaml extension are ignored + tty.debug(f"Ignoring missing config path ({path})") + return None def _validate_parent_scope(self, parent_scope: ConfigScope): """Validates that a parent scope is a valid configuration object""" From afd4002255cdaceaac1b7368119eea8613b0ba45 Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Fri, 15 May 2026 20:50:28 -0400 Subject: [PATCH 333/337] Windows: Remove pywin32 dependency (#52331) Spack-on-Windows has long had a dependency on a pip-installed pywin32 module as that module typically vendors a lot of the win32 api interactions missing from the Python standard library. This PR removes this dependency and reimplements the functions we used it for. Signed-off-by: John Parent --- .github/workflows/unit_tests.yaml | 2 +- bin/haspywin.py | 18 --- lib/spack/spack/cmd/license.py | 1 - lib/spack/spack/llnl/util/filesystem.py | 202 +++++++++++++++++++----- share/spack/setup-env.bat | 4 - share/spack/setup-env.ps1 | 5 - 6 files changed, 165 insertions(+), 67 deletions(-) delete mode 100644 bin/haspywin.py diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index 159e517de861d6..0a29ce83b10881 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -247,7 +247,7 @@ jobs: python-version: '3.14' - name: Install Python packages run: | - python -m pip install --upgrade pip pywin32 -r .github/workflows/requirements/unit_tests/requirements.txt + python -m pip install --upgrade pip -r .github/workflows/requirements/unit_tests/requirements.txt python -m pip install --upgrade pip -r .github/workflows/requirements/style/requirements.txt - name: Create local develop run: | diff --git a/bin/haspywin.py b/bin/haspywin.py deleted file mode 100644 index c04285c86ea759..00000000000000 --- a/bin/haspywin.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright Spack Project Developers. See COPYRIGHT file for details. -# -# SPDX-License-Identifier: (Apache-2.0 OR MIT) -import subprocess -import sys - - -def getpywin(): - try: - import win32con # noqa: F401 - except ImportError: - print("pyWin32 not installed but is required...\nInstalling via pip:") - subprocess.check_call([sys.executable, "-m", "pip", "-q", "install", "--upgrade", "pip"]) - subprocess.check_call([sys.executable, "-m", "pip", "-q", "install", "pywin32"]) - - -if __name__ == "__main__": - getpywin() diff --git a/lib/spack/spack/cmd/license.py b/lib/spack/spack/cmd/license.py index 09fa5ba7a31722..dd8a970383fb60 100644 --- a/lib/spack/spack/cmd/license.py +++ b/lib/spack/spack/cmd/license.py @@ -33,7 +33,6 @@ r"^bin/spack_pwsh\.ps1$", r"^bin/sbang$", r"^bin/spack-python$", - r"^bin/haspywin\.py$", # all of spack core except unparse r"^lib/spack/spack/(?!vendor/|util/unparse|util/ctest_log_parser|test/util/unparse).*\.py$", r"^lib/spack/spack/.*\.sh$", diff --git a/lib/spack/spack/llnl/util/filesystem.py b/lib/spack/spack/llnl/util/filesystem.py index f9cab433d800ad..92af0ef2f2303f 100644 --- a/lib/spack/spack/llnl/util/filesystem.py +++ b/lib/spack/spack/llnl/util/filesystem.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: (Apache-2.0 OR MIT) import collections import collections.abc +import ctypes import errno import fnmatch import glob @@ -41,12 +42,11 @@ from spack.llnl.util import lang, tty from spack.llnl.util.lang import dedupe, fnmatch_translate_multiple, memoized -if sys.platform != "win32": +if sys.platform == "win32": + from ctypes import wintypes +else: import grp import pwd -else: - import win32security - from win32file import CreateHardLink __all__ = [ @@ -201,18 +201,17 @@ def polite_filename(filename: str) -> str: if sys.platform == "win32": - def _getuid_win32() -> Union[str, int]: + def _getuid_win32() -> str: """Returns os getuid on non Windows On Windows returns 0 for admin users, login string otherwise This is in line with behavior from get_owner_uid which always returns the login string on Windows """ - import ctypes # If not admin, use the string name of the login as a unique ID if ctypes.windll.shell32.IsUserAnAdmin() == 0: return os.getlogin() - return 0 + return "ADMINISTRATORS" getuid = _getuid_win32 else: @@ -534,6 +533,129 @@ def exploding_archive_handler(tarball_container, stage): shutil.move(tarball_container, stage.source_path) +@system_path_filter +def get_windows_file_security(path: str) -> str: + if sys.platform == "win32": + # Validate path exists before calling API to get a clear Python error + if not os.path.exists(path): + raise FileNotFoundError(f"The system cannot find the path specified: '{path}'") + + advapi = ctypes.WinDLL("advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + # SE_FILE_OBJECT applies to both files and directories + SE_FILE_OBJECT = 1 + OWNER_SECURITY_INFO = 1 + ERROR_SUCCESS = 0 + + # Describe LocalFree API + LocalFree = kernel32.LocalFree + LocalFree.argtypes = [wintypes.HLOCAL] + LocalFree.restype = wintypes.HLOCAL + + # Describe GetNamedSecurityInfoW API + GetNamedSecurityInfo = advapi.GetNamedSecurityInfoW + GetNamedSecurityInfo.argtypes = [ + wintypes.LPCWSTR, # pObjectName (The path) + ctypes.c_int, # ObjectType + wintypes.DWORD, # SecurityInfo + ctypes.POINTER(wintypes.LPVOID), # ppsidOwner + ctypes.POINTER(wintypes.LPVOID), # ppsidGroup + ctypes.POINTER(wintypes.LPVOID), # ppDacl + ctypes.POINTER(wintypes.LPVOID), # ppSacl + ctypes.POINTER(wintypes.LPVOID), # ppSecurityDescriptor + ] + GetNamedSecurityInfo.restype = wintypes.DWORD + + # Describe LookupAccountSID API + LookupAccountSid = advapi.LookupAccountSidW + LookupAccountSid.argtypes = [ + wintypes.LPCWSTR, # lpSystemName + wintypes.LPVOID, # Sid + wintypes.LPWSTR, # Name + wintypes.LPDWORD, # cchName + wintypes.LPWSTR, # ReferencedDomainName + wintypes.LPDWORD, # cchReferencedDomainName + ctypes.POINTER(ctypes.c_int), # peUse + ] + LookupAccountSid.restype = ctypes.c_bool + + p_sid_owner = wintypes.LPVOID() + psd = wintypes.LPVOID() + + # Call GetNamedSecurityInfo directly with the path string + res = GetNamedSecurityInfo( + path, + SE_FILE_OBJECT, + OWNER_SECURITY_INFO, + ctypes.byref(p_sid_owner), + None, + None, + None, + ctypes.byref(psd), + ) + + if res != ERROR_SUCCESS: + raise ctypes.WinError(res, f"Failed to get security info for {path}") + + try: + # establish vars for Lookup account sid return params + dwacct_name = wintypes.DWORD(0) + dw_domain_name = wintypes.DWORD(0) + e_use = ctypes.c_int() + + # first call to lookup account SID to determine buffer sizes + success = LookupAccountSid( + None, + p_sid_owner, + None, + ctypes.byref(dwacct_name), + None, + ctypes.byref(dw_domain_name), + ctypes.byref(e_use), + ) + + # 122 is ERROR_INSUFFICIENT_BUFFER, which we expect + if not success: + err = ctypes.get_last_error() + # 122 is ERROR_INSUFFICIENT_BUFFER, which we want/expect! + if err != 122: + raise ctypes.WinError( + err, f"Unexpected error when obtaining buffer for : {path}" + ) + + # create buffers + acct_name_buf = dwacct_name.value * wintypes.WCHAR + acct_name = acct_name_buf() + domain_name_buf = dw_domain_name.value * wintypes.WCHAR + domain_name = domain_name_buf() + + # second call to fetch the actual names + success = LookupAccountSid( + None, + p_sid_owner, + acct_name, + ctypes.byref(dwacct_name), + domain_name, + ctypes.byref(dw_domain_name), + ctypes.byref(e_use), + ) + + if not success: + raise ctypes.WinError( + ctypes.get_last_error(), f"Could not determine owner for : {path}" + ) + + finally: + # Free the security descriptor + if psd: + LocalFree(psd) + + return acct_name.value + else: + raise RuntimeError("cannot determine Windows file security on non-Windows") + + @system_path_filter(arg_slice=slice(1)) def get_owner_uid(path, err_msg=None) -> Union[str, int]: """Returns owner UID of path destination @@ -560,10 +682,7 @@ def get_owner_uid(path, err_msg=None) -> Union[str, int]: if sys.platform != "win32": owner_uid = p_stat.st_uid else: - sid = win32security.GetFileSecurity( - path, win32security.OWNER_SECURITY_INFORMATION - ).GetSecurityDescriptorOwner() - owner_uid = win32security.LookupAccountSid(None, sid)[0] + owner_uid = get_windows_file_security(path) return owner_uid @@ -1213,19 +1332,17 @@ def windows_sfn(path: os.PathLike): path: Path to be transformed into SFN (8.3 filename) format """ # This should not be run-able on linux/macos - if sys.platform != "win32": - return path - path = str(path) - import ctypes - - k32 = ctypes.WinDLL("kernel32", use_last_error=True) - # Method with null values returns size of short path name - sz = k32.GetShortPathNameW(path, None, 0) - # stub Windows types TCHAR[LENGTH] - TCHAR_arr = ctypes.c_wchar * sz - ret_str = TCHAR_arr() - k32.GetShortPathNameW(path, ctypes.byref(ret_str), sz) - return ret_str.value + if sys.platform == "win32": + path = str(path) + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + # Method with null values returns size of short path name + sz = k32.GetShortPathNameW(path, None, 0) + # stub Windows types TCHAR[LENGTH] + TCHAR_arr = ctypes.c_wchar * sz + ret_str = TCHAR_arr() + k32.GetShortPathNameW(path, ctypes.byref(ret_str), sz) + return ret_str.value + return path @contextmanager @@ -2991,23 +3108,23 @@ def _windows_is_junction(path: str) -> bool: Returns: bool - whether the path is a junction or not. """ - if sys.platform != "win32" or os.path.islink(path) or os.path.isfile(path): - return False - - import ctypes.wintypes + if sys.platform == "win32": + if os.path.islink(path) or os.path.isfile(path): + return False - get_file_attributes = ctypes.windll.kernel32.GetFileAttributesW # type: ignore[attr-defined] - get_file_attributes.argtypes = (ctypes.wintypes.LPWSTR,) - get_file_attributes.restype = ctypes.wintypes.DWORD + get_file_attributes = ctypes.windll.kernel32.GetFileAttributesW # type: ignore[attr-defined] + get_file_attributes.argtypes = (wintypes.LPWSTR,) + get_file_attributes.restype = wintypes.DWORD - invalid_file_attributes = 0xFFFFFFFF - reparse_point = 0x400 - file_attr = get_file_attributes(str(path)) + invalid_file_attributes = 0xFFFFFFFF + reparse_point = 0x400 + file_attr = get_file_attributes(str(path)) - if file_attr == invalid_file_attributes: - return False + if file_attr == invalid_file_attributes: + return False - return file_attr & reparse_point > 0 + return file_attr & reparse_point > 0 + return False @lang.memoized @@ -3106,7 +3223,16 @@ def _windows_create_hard_link(path: str, link: str): raise SymlinkError(f"File path ({link}) is not a file. Cannot create hard link.") else: tty.debug(f"Creating hard link {link} pointing to {path}") - CreateHardLink(link, path) + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + CreateHardLink = k32.CreateHardLinkW + CreateHardLink.argtypes = [ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p] + CreateHardLink.restype = ctypes.c_bool + success = CreateHardLink(link, path, None) + if not success: + error_code = ctypes.GetLastError() + raise ctypes.WinError( + error_code, f"Failed to create hardlink for path {path} and link {link}" + ) def _windows_readlink(path: str, *, dir_fd=None): diff --git a/share/spack/setup-env.bat b/share/spack/setup-env.bat index c3b91ece1fccdf..0c282427b6f192 100644 --- a/share/spack/setup-env.bat +++ b/share/spack/setup-env.bat @@ -56,10 +56,6 @@ if defined py_path ( set "PATH=%py_path%;%PATH%" ) -if defined py_exe ( - "%py_exe%" "%SPACK_ROOT%\bin\haspywin.py" -) - if not defined EDITOR ( set EDITOR=notepad ) diff --git a/share/spack/setup-env.ps1 b/share/spack/setup-env.ps1 index 16399d403f0264..de609e1e4d9d34 100644 --- a/share/spack/setup-env.ps1 +++ b/share/spack/setup-env.ps1 @@ -34,11 +34,6 @@ if (!$null -eq $py_path) $Env:Path = "$py_path;$Env:Path" } -if (!$null -eq $py_exe) -{ - & "$py_exe" "$Env:SPACK_ROOT\bin\haspywin.py" -} - $Env:Path = "$Env:SPACK_ROOT\bin;$Env:Path" if ($null -eq $Env:EDITOR) { From 3791c7b19412b5e13313bc1846a1cc3acb104d14 Mon Sep 17 00:00:00 2001 From: Harmen Stoppels Date: Mon, 18 May 2026 11:27:56 +0200 Subject: [PATCH 334/337] parallel.py: enable under forkserver/spawn (#52410) With Python 3.14 using forkserver, and macOS using spawn, spack buildcache push is sequential. This PR makes it parallel. That's beneficial if we can avoid expensive pickling of environments, so make that opt-in. Further, improve fork-safety by clearing urlopen related state, similar to what the new installer does. Signed-off-by: Harmen Stoppels --- lib/spack/spack/concretize.py | 7 ++++++- lib/spack/spack/detection/path.py | 2 +- lib/spack/spack/stage.py | 2 +- lib/spack/spack/subprocess_context.py | 25 +++++++++++++++++----- lib/spack/spack/util/parallel.py | 30 ++++++++++++++------------- lib/spack/spack/util/web.py | 2 +- 6 files changed, 45 insertions(+), 23 deletions(-) diff --git a/lib/spack/spack/concretize.py b/lib/spack/spack/concretize.py index b884b82a266e81..8e10479d6ef78a 100644 --- a/lib/spack/spack/concretize.py +++ b/lib/spack/spack/concretize.py @@ -191,7 +191,12 @@ def concretize_separately( for j, (i, concrete, duration) in enumerate( spack.util.parallel.imap_unordered( - _concretize_task, args, processes=num_procs, debug=tty.is_debug(), maxtaskperchild=1 + _concretize_task, + args, + processes=num_procs, + debug=tty.is_debug(), + maxtaskperchild=1, + serialize_env=True, ) ): ret.append((i, concrete)) diff --git a/lib/spack/spack/detection/path.py b/lib/spack/spack/detection/path.py index b058d3fde5cd39..292d36468b5253 100644 --- a/lib/spack/spack/detection/path.py +++ b/lib/spack/spack/detection/path.py @@ -441,7 +441,7 @@ def by_path( if max_workers == 1: executor = spack.util.parallel.SequentialExecutor() else: - executor = spack.util.parallel.make_concurrent_executor(max_workers, require_fork=False) + executor = spack.util.parallel.make_concurrent_executor(max_workers) with executor: for pkg in packages_to_search: executable_future = executor.submit( diff --git a/lib/spack/spack/stage.py b/lib/spack/spack/stage.py index 4addfca7114cf8..c98ee2f495a015 100644 --- a/lib/spack/spack/stage.py +++ b/lib/spack/spack/stage.py @@ -1299,7 +1299,7 @@ def get_checksums_for_versions( else: version_hashes[version] = result - with spack.util.parallel.make_concurrent_executor(concurrency, require_fork=False) as executor: + with spack.util.parallel.make_concurrent_executor(concurrency) as executor: results = [ (version, executor.submit(_fetch_and_checksum, url, fetch_options, keep_stage)) for url, version in search_arguments diff --git a/lib/spack/spack/subprocess_context.py b/lib/spack/spack/subprocess_context.py index 0fbd05114a9d6d..0d34234ee929b3 100644 --- a/lib/spack/spack/subprocess_context.py +++ b/lib/spack/spack/subprocess_context.py @@ -72,7 +72,7 @@ def __init__( ctx: Optional[multiprocessing.context.BaseContext] = None, ): ctx = ctx or multiprocessing.get_context() - self.global_state = GlobalStateMarshaler(ctx=ctx) + self.global_state = GlobalStateMarshaler(ctx=ctx, serialize_env=True) self.pkg = pkg if ctx.get_start_method() == "fork" else serialize(pkg) self.spack_working_dir = spack.paths.spack_working_dir @@ -90,23 +90,38 @@ class GlobalStateMarshaler: """ def __init__( - self, *, ctx: Optional[Optional[multiprocessing.context.BaseContext]] = None + self, + *, + ctx: Optional[Optional[multiprocessing.context.BaseContext]] = None, + serialize_env: bool = False, ) -> None: ctx = ctx or multiprocessing.get_context() self.is_forked = ctx.get_start_method() == "fork" if self.is_forked: return - from spack.environment import active_environment - self.config = spack.config.CONFIG.ensure_unwrapped() self.platform = spack.platforms.host self.store = spack.store.STORE self.test_patches = TestPatches.create() - self.env = active_environment() + if serialize_env: + from spack.environment import active_environment + + self.env = active_environment() + else: + self.env = None def restore(self): if self.is_forked: + # Erase singletons that hold open SSL contexts / boto3 clients, since OpenSSL + # and botocore connection pools are not fork-safe. + from spack.oci import opener + from spack.util import web + from spack.util.s3 import s3_client_cache + + web.urlopen._instance = None + opener.urlopen._instance = None + s3_client_cache.clear() return spack.config.CONFIG = self.config spack.repo.enable_repo(spack.repo.RepoPath.from_config(self.config)) diff --git a/lib/spack/spack/util/parallel.py b/lib/spack/spack/util/parallel.py index ae05905f062f48..ef60753b44141d 100644 --- a/lib/spack/spack/util/parallel.py +++ b/lib/spack/spack/util/parallel.py @@ -60,7 +60,13 @@ def __call__(self, *args, **kwargs): def imap_unordered( - f, list_of_args, *, processes: int, maxtaskperchild: Optional[int] = None, debug=False + f, + list_of_args, + *, + processes: int, + maxtaskperchild: Optional[int] = None, + debug=False, + serialize_env: bool = False, ): """Wrapper around multiprocessing.Pool.imap_unordered. @@ -83,7 +89,7 @@ def imap_unordered( from spack.subprocess_context import GlobalStateMarshaler - marshaler = GlobalStateMarshaler() + marshaler = GlobalStateMarshaler(serialize_env=serialize_env) with multiprocessing.Pool( processes, initializer=marshaler.restore, maxtasksperchild=maxtaskperchild ) as p: @@ -107,22 +113,18 @@ def submit(self, fn, *args, **kwargs): def make_concurrent_executor( - jobs: Optional[int] = None, *, require_fork: bool = True + jobs: Optional[int] = None, *, serialize_env: bool = False ) -> concurrent.futures.Executor: - """Create a concurrent executor. If require_fork is True, then the executor is sequential - if the platform does not enable forking as the default start method. Effectively - require_fork=True makes the executor sequential in the current process on Windows, macOS, and - Linux from Python 3.14+ (which changes defaults)""" - - if ( - not ENABLE_PARALLELISM - or (require_fork and multiprocessing.get_start_method() != "fork") - or sys.version_info[:2] == (3, 6) - ): + """Create a concurrent executor. + + If serialize_env is False (default), the active Spack environment is not transmitted to the + worker processes, which avoids the cost of pickling potentially large environment state.""" + + if not ENABLE_PARALLELISM or sys.version_info[:2] == (3, 6): return SequentialExecutor() from spack.subprocess_context import GlobalStateMarshaler jobs = jobs or spack.config.determine_number_of_jobs(parallel=True) - marshaler = GlobalStateMarshaler() + marshaler = GlobalStateMarshaler(serialize_env=serialize_env) return concurrent.futures.ProcessPoolExecutor(jobs, initializer=marshaler.restore) # novermin diff --git a/lib/spack/spack/util/web.py b/lib/spack/spack/util/web.py index e133a404f0b423..d2bede6717df75 100644 --- a/lib/spack/spack/util/web.py +++ b/lib/spack/spack/util/web.py @@ -786,7 +786,7 @@ def spider( root = urllib.parse.urlparse(root_str) spider_args.append((root, go_deeper, _visited)) - with spack.util.parallel.make_concurrent_executor(concurrency, require_fork=False) as tp: + with spack.util.parallel.make_concurrent_executor(concurrency) as tp: while current_depth <= depth: tty.debug( f"SPIDER: [depth={current_depth}, max_depth={depth}, urls={len(spider_args)}]" From e88eef45e3633db6a5705d403acaef552d520fe2 Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Mon, 18 May 2026 11:03:09 -0400 Subject: [PATCH 335/337] Windows Shells: Allow bin/spack to work as expected (#52298) Previously scripts assumed you were working in an established spack shell. This allows them to be invoked directly like bin/spack (or bin\spack for cmd) Signed-off-by: John Parent --- bin/spack.bat | 7 +++++++ bin/spack.ps1 | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/bin/spack.bat b/bin/spack.bat index b771fd9789090f..7a7334cf08f0fe 100644 --- a/bin/spack.bat +++ b/bin/spack.bat @@ -12,6 +12,13 @@ :: . /path/to/spack/install/spack_cmd.bat :: @echo off +:: We're directly invoking this script +:: compute spack_root from this +if "%SPACK_ROOT%" =="" ( + pushd %~dp0.. + set SPACK_ROOT=%CD% + popd +) set spack="%SPACK_ROOT%\bin\spack" diff --git a/bin/spack.ps1 b/bin/spack.ps1 index 7cbc8a484d9275..3645081583d0fa 100644 --- a/bin/spack.ps1 +++ b/bin/spack.ps1 @@ -125,9 +125,19 @@ function Invoke-SpackLoad { } } +function Set-SpackRoot { + if ([string]::IsNullOrEmpty($Env:SPACK_ROOT)) { + Push-Location $PSScriptRoot/.. + $Env:SPACK_ROOT = $PWD.Path + Pop-Location + } +} + +Set-SpackRoot $SpackCMD_params, $SpackSubCommand, $SpackSubCommandArgs = Read-SpackArgs $args + if (Compare-CommonArgs $SpackCMD_params) { python "$Env:SPACK_ROOT/bin/spack" $SpackCMD_params $SpackSubCommand $SpackSubCommandArgs exit $LASTEXITCODE From 687ba457781ba541aca48a4d21b349850a2669df Mon Sep 17 00:00:00 2001 From: Alec Scott Date: Wed, 20 May 2026 09:52:43 -0700 Subject: [PATCH 336/337] Use temporary_store to prevent permission errors with xdist parallel (#52417) Signed-off-by: Alec Scott --- lib/spack/spack/test/builder.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/test/builder.py b/lib/spack/spack/test/builder.py index ebce27a9ba610a..ed548278a18881 100644 --- a/lib/spack/spack/test/builder.py +++ b/lib/spack/spack/test/builder.py @@ -76,7 +76,9 @@ def builder_test_repository(config): ) @pytest.mark.usefixtures("builder_test_repository", "config") @pytest.mark.disable_clean_stage_check -def test_callbacks_and_installation_procedure(spec_str, expected_values, working_env): +def test_callbacks_and_installation_procedure( + spec_str, expected_values, working_env, temporary_store +): """Test the correct execution of callbacks and installation procedures for packages.""" s = spack.concretize.concretize_one(spec_str) builder = spack.builder.create(s.package) @@ -111,7 +113,7 @@ def test_old_style_compatibility_with_super(spec_str, method_name, expected): @pytest.mark.regression("33928") @pytest.mark.usefixtures("builder_test_repository", "config", "working_env") @pytest.mark.disable_clean_stage_check -def test_build_time_tests_are_executed_from_default_builder(): +def test_build_time_tests_are_executed_from_default_builder(temporary_store): s = spack.concretize.concretize_one("old-style-autotools") builder = spack.builder.create(s.package) builder.pkg.run_tests = True @@ -152,7 +154,9 @@ def test_monkey_patching_test_log_file(): # Windows context manager's __exit__ fails with ValueError ("I/O operation # on closed file"). @pytest.mark.not_on_windows("Does not run on windows") -def test_install_time_test_callback(tmp_path: pathlib.Path, config, mock_packages, mock_stage): +def test_install_time_test_callback( + tmp_path: pathlib.Path, config, mock_packages, mock_stage, temporary_store +): """Confirm able to run stand-alone test as a post-install callback.""" s = spack.concretize.concretize_one("py-test-callback") builder = spack.builder.create(s.package) From 59f09492d8cdbdc9a59b18143703f1fbeb168456 Mon Sep 17 00:00:00 2001 From: "John W. Parent" <45471568+johnwparent@users.noreply.github.com> Date: Wed, 20 May 2026 13:56:42 -0400 Subject: [PATCH 337/337] Bootstrap Environment: ensure paths are posix (#52409) Signed-off-by: John Parent --- lib/spack/spack/bootstrap/environment.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/spack/spack/bootstrap/environment.py b/lib/spack/spack/bootstrap/environment.py index 50746f96415f5c..7fffbfc9239950 100644 --- a/lib/spack/spack/bootstrap/environment.py +++ b/lib/spack/spack/bootstrap/environment.py @@ -130,11 +130,11 @@ def _write_spack_yaml_file(self) -> None: template = env.get_template("bootstrap/spack.yaml") context = { "python_spec": f"{spec_for_current_python()}+ctypes", - "python_prefix": sys.exec_prefix, + "python_prefix": pathlib.Path(sys.exec_prefix).as_posix(), "architecture": spack.vendor.archspec.cpu.host().family, - "environment_path": self.environment_root(), + "environment_path": self.environment_root().as_posix(), "environment_specs": self.spack_dev_requirements(), - "store_path": store_path(), + "store_path": pathlib.Path(store_path()).as_posix(), "bootstrap_mirrors": dev_bootstrap_mirror_names(), } self.environment_root().mkdir(parents=True, exist_ok=True)