Allow / in GitVersion (#39398)

This commit allows version specifiers to refer to git branches that contain
forward slashes. For example, the following is valid syntax now:

    pkg@git.releases/1.0
   
It also adds a new method `Spec.format_path(fmt)` which is like `Spec.format`,
but also maps unsafe characters to `_` after interpolation. The difference is
as follows:

    >>> Spec("pkg@git.releases/1.0").format("{name}/{version}")
    'pkg/git.releases/1.0'

    >>> Spec("pkg@git.releases/1.0").format_path("{name}/{version}")
    'pkg/git.releases_1.0'

The `format_path` method is used in all projections. Notice that this method
also maps `=` to `_`

    >>> Spec("pkg@git.main=1.0").format_path("{name}/{version}")
    'pkg/git.main_1.0'
   
which should avoid syntax issues when `Spec.prefix` is literally copied into a
Makefile as sometimes happens in AutotoolsPackage or MakefilePackage
This commit is contained in:
Peter Scheibel 2023-10-17 11:33:59 -07:00 committed by GitHub
parent 49ea0a8e2e
commit 9cde25b39e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 214 additions and 27 deletions

View File

@ -156,6 +156,37 @@ def lookup(name):
shutil.copystat = copystat shutil.copystat = copystat
def polite_path(components: Iterable[str]):
"""
Given a list of strings which are intended to be path components,
generate a path, and format each component to avoid generating extra
path entries.
For example all "/", "\", and ":" characters will be replaced with
"_". Other characters like "=" will also be replaced.
"""
return os.path.join(*[polite_filename(x) for x in components])
@memoized
def _polite_antipattern():
# A regex of all the characters we don't want in a filename
return re.compile(r"[^A-Za-z0-9_.-]")
def polite_filename(filename: str) -> str:
"""
Replace generally problematic filename characters with underscores.
This differs from sanitize_filename in that it is more aggressive in
changing characters in the name. For example it removes "=" which can
confuse path parsing in external tools.
"""
# This character set applies for both Windows and Linux. It does not
# account for reserved filenames in Windows.
return _polite_antipattern().sub("_", filename)
def getuid(): def getuid():
if sys.platform == "win32": if sys.platform == "win32":
import ctypes import ctypes

View File

@ -797,11 +797,7 @@ def tarball_directory_name(spec):
Return name of the tarball directory according to the convention Return name of the tarball directory according to the convention
<os>-<architecture>/<compiler>/<package>-<version>/ <os>-<architecture>/<compiler>/<package>-<version>/
""" """
return os.path.join( return spec.format_path("{architecture}/{compiler.name}-{compiler.version}/{name}-{version}")
str(spec.architecture),
f"{spec.compiler.name}-{spec.compiler.version}",
f"{spec.name}-{spec.version}",
)
def tarball_name(spec, ext): def tarball_name(spec, ext):
@ -809,10 +805,10 @@ def tarball_name(spec, ext):
Return the name of the tarfile according to the convention Return the name of the tarfile according to the convention
<os>-<architecture>-<package>-<dag_hash><ext> <os>-<architecture>-<package>-<dag_hash><ext>
""" """
return ( spec_formatted = spec.format_path(
f"{spec.architecture}-{spec.compiler.name}-{spec.compiler.version}-" "{architecture}-{compiler.name}-{compiler.version}-{name}-{version}-{hash}"
f"{spec.name}-{spec.version}-{spec.dag_hash()}{ext}"
) )
return f"{spec_formatted}{ext}"
def tarball_path_name(spec, ext): def tarball_path_name(spec, ext):

View File

@ -579,7 +579,9 @@ def ci_rebuild(args):
"SPACK_COLOR=always", "SPACK_COLOR=always",
"SPACK_INSTALL_FLAGS={}".format(args_to_string(deps_install_args)), "SPACK_INSTALL_FLAGS={}".format(args_to_string(deps_install_args)),
"-j$(nproc)", "-j$(nproc)",
"install-deps/{}".format(job_spec.format("{name}-{version}-{hash}")), "install-deps/{}".format(
ev.depfile.MakefileSpec(job_spec).safe_format("{name}-{version}-{hash}")
),
], ],
spack_cmd + ["install"] + root_install_args, spack_cmd + ["install"] + root_install_args,
] ]

View File

@ -240,8 +240,7 @@ def default_log_file(spec):
"""Computes the default filename for the log file and creates """Computes the default filename for the log file and creates
the corresponding directory if not present the corresponding directory if not present
""" """
fmt = "test-{x.name}-{x.version}-{hash}.xml" basename = spec.format_path("test-{name}-{version}-{hash}.xml")
basename = fmt.format(x=spec, hash=spec.dag_hash())
dirname = fs.os.path.join(spack.paths.reports_path, "junit") dirname = fs.os.path.join(spack.paths.reports_path, "junit")
fs.mkdirp(dirname) fs.mkdirp(dirname)
return fs.os.path.join(dirname, basename) return fs.os.path.join(dirname, basename)

View File

@ -104,7 +104,7 @@ def relative_path_for_spec(self, spec):
_check_concrete(spec) _check_concrete(spec)
projection = spack.projections.get_projection(self.projections, spec) projection = spack.projections.get_projection(self.projections, spec)
path = spec.format(projection) path = spec.format_path(projection)
return str(Path(path)) return str(Path(path))
def write_spec(self, spec, path): def write_spec(self, spec, path):

View File

@ -500,7 +500,7 @@ def get_projection_for_spec(self, spec):
proj = spack.projections.get_projection(self.projections, locator_spec) proj = spack.projections.get_projection(self.projections, locator_spec)
if proj: if proj:
return os.path.join(self._root, locator_spec.format(proj)) return os.path.join(self._root, locator_spec.format_path(proj))
return self._root return self._root
def get_all_specs(self): def get_all_specs(self):
@ -776,7 +776,7 @@ def get_relative_projection_for_spec(self, spec):
spec = spec.package.extendee_spec spec = spec.package.extendee_spec
p = spack.projections.get_projection(self.projections, spec) p = spack.projections.get_projection(self.projections, spec)
return spec.format(p) if p else "" return spec.format_path(p) if p else ""
def get_projection_for_spec(self, spec): def get_projection_for_spec(self, spec):
""" """
@ -791,7 +791,7 @@ def get_projection_for_spec(self, spec):
proj = spack.projections.get_projection(self.projections, spec) proj = spack.projections.get_projection(self.projections, spec)
if proj: if proj:
return os.path.join(self._root, spec.format(proj)) return os.path.join(self._root, spec.format_path(proj))
return self._root return self._root

View File

@ -1039,7 +1039,7 @@ def test_pkg_id(cls, spec):
Returns: Returns:
str: the install test package identifier str: the install test package identifier
""" """
return spec.format("{name}-{version}-{hash:7}") return spec.format_path("{name}-{version}-{hash:7}")
@classmethod @classmethod
def test_log_name(cls, spec): def test_log_name(cls, spec):

View File

@ -586,7 +586,7 @@ def use_name(self):
if not projection: if not projection:
projection = self.conf.default_projections["all"] projection = self.conf.default_projections["all"]
name = self.spec.format(projection) name = self.spec.format_path(projection)
# Not everybody is working on linux... # Not everybody is working on linux...
parts = name.split("/") parts = name.split("/")
name = os.path.join(*parts) name = os.path.join(*parts)

View File

@ -9,6 +9,7 @@
import posixpath import posixpath
from typing import Any, Dict, List from typing import Any, Dict, List
import llnl.util.filesystem as fs
import llnl.util.lang as lang import llnl.util.lang as lang
import spack.compilers import spack.compilers
@ -283,8 +284,10 @@ def token_to_path(self, name, value):
Returns: Returns:
str: part of the path associated with the service str: part of the path associated with the service
""" """
# General format for the path part # General format for the path part
path_part_fmt = os.path.join("{token.name}", "{token.version}") def path_part_fmt(token):
return fs.polite_path([f"{token.name}", f"{token.version}"])
# If we are dealing with a core compiler, return 'Core' # If we are dealing with a core compiler, return 'Core'
core_compilers = self.conf.core_compilers core_compilers = self.conf.core_compilers
@ -296,13 +299,13 @@ def token_to_path(self, name, value):
# CompilerSpec does not have a hash, as we are not allowed to # CompilerSpec does not have a hash, as we are not allowed to
# use different flavors of the same compiler # use different flavors of the same compiler
if name == "compiler": if name == "compiler":
return path_part_fmt.format(token=value) return path_part_fmt(token=value)
# In case the hierarchy token refers to a virtual provider # In case the hierarchy token refers to a virtual provider
# we need to append a hash to the version to distinguish # we need to append a hash to the version to distinguish
# among flavors of the same library (e.g. openblas~openmp vs. # among flavors of the same library (e.g. openblas~openmp vs.
# openblas+openmp) # openblas+openmp)
path = path_part_fmt.format(token=value) path = path_part_fmt(token=value)
path = "-".join([path, value.dag_hash(length=7)]) path = "-".join([path, value.dag_hash(length=7)])
return path return path

View File

@ -991,13 +991,14 @@ def find_valid_url_for_version(self, version):
return None return None
def _make_resource_stage(self, root_stage, resource): def _make_resource_stage(self, root_stage, resource):
pretty_resource_name = fsys.polite_filename(f"{resource.name}-{self.version}")
return ResourceStage( return ResourceStage(
resource.fetcher, resource.fetcher,
root=root_stage, root=root_stage,
resource=resource, resource=resource,
name=self._resource_stage(resource), name=self._resource_stage(resource),
mirror_paths=spack.mirror.mirror_archive_paths( mirror_paths=spack.mirror.mirror_archive_paths(
resource.fetcher, os.path.join(self.name, f"{resource.name}-{self.version}") resource.fetcher, os.path.join(self.name, pretty_resource_name)
), ),
path=self.path, path=self.path,
) )
@ -1008,8 +1009,10 @@ def _download_search(self):
def _make_root_stage(self, fetcher): def _make_root_stage(self, fetcher):
# Construct a mirror path (TODO: get this out of package.py) # Construct a mirror path (TODO: get this out of package.py)
format_string = "{name}-{version}"
pretty_name = self.spec.format_path(format_string)
mirror_paths = spack.mirror.mirror_archive_paths( mirror_paths = spack.mirror.mirror_archive_paths(
fetcher, os.path.join(self.name, f"{self.name}-{self.version}"), self.spec fetcher, os.path.join(self.name, pretty_name), self.spec
) )
# Construct a path where the stage should build.. # Construct a path where the stage should build..
s = self.spec s = self.spec

View File

@ -76,7 +76,9 @@
IDENTIFIER = r"(?:[a-zA-Z_0-9][a-zA-Z_0-9\-]*)" IDENTIFIER = r"(?:[a-zA-Z_0-9][a-zA-Z_0-9\-]*)"
DOTTED_IDENTIFIER = rf"(?:{IDENTIFIER}(?:\.{IDENTIFIER})+)" DOTTED_IDENTIFIER = rf"(?:{IDENTIFIER}(?:\.{IDENTIFIER})+)"
GIT_HASH = r"(?:[A-Fa-f0-9]{40})" GIT_HASH = r"(?:[A-Fa-f0-9]{40})"
GIT_VERSION = rf"(?:(?:git\.(?:{DOTTED_IDENTIFIER}|{IDENTIFIER}))|(?:{GIT_HASH}))" #: Git refs include branch names, and can contain "." and "/"
GIT_REF = r"(?:[a-zA-Z_0-9][a-zA-Z_0-9./\-]*)"
GIT_VERSION_PATTERN = rf"(?:(?:git\.(?:{GIT_REF}))|(?:{GIT_HASH}))"
NAME = r"[a-zA-Z_0-9][a-zA-Z_0-9\-.]*" NAME = r"[a-zA-Z_0-9][a-zA-Z_0-9\-.]*"
@ -127,7 +129,8 @@ class TokenType(TokenBase):
# Dependency # Dependency
DEPENDENCY = r"(?:\^)" DEPENDENCY = r"(?:\^)"
# Version # Version
VERSION_HASH_PAIR = rf"(?:@(?:{GIT_VERSION})=(?:{VERSION}))" VERSION_HASH_PAIR = rf"(?:@(?:{GIT_VERSION_PATTERN})=(?:{VERSION}))"
GIT_VERSION = rf"@(?:{GIT_VERSION_PATTERN})"
VERSION = rf"(?:@\s*(?:{VERSION_LIST}))" VERSION = rf"(?:@\s*(?:{VERSION_LIST}))"
# Variants # Variants
PROPAGATED_BOOL_VARIANT = rf"(?:(?:\+\+|~~|--)\s*{NAME})" PROPAGATED_BOOL_VARIANT = rf"(?:(?:\+\+|~~|--)\s*{NAME})"
@ -358,8 +361,10 @@ def parse(self, initial_spec: Optional[spack.spec.Spec] = None) -> Optional[spac
compiler_name.strip(), compiler_version compiler_name.strip(), compiler_version
) )
self.has_compiler = True self.has_compiler = True
elif self.ctx.accept(TokenType.VERSION) or self.ctx.accept( elif (
TokenType.VERSION_HASH_PAIR self.ctx.accept(TokenType.VERSION_HASH_PAIR)
or self.ctx.accept(TokenType.GIT_VERSION)
or self.ctx.accept(TokenType.VERSION)
): ):
if self.has_version: if self.has_version:
raise spack.spec.MultipleVersionError( raise spack.spec.MultipleVersionError(

View File

@ -54,6 +54,7 @@
import io import io
import itertools import itertools
import os import os
import pathlib
import platform import platform
import re import re
import socket import socket
@ -4453,6 +4454,42 @@ def cformat(self, *args, **kwargs):
kwargs.setdefault("color", None) kwargs.setdefault("color", None)
return self.format(*args, **kwargs) return self.format(*args, **kwargs)
def format_path(
# self, format_string: str, _path_ctor: Optional[pathlib.PurePath] = None
self,
format_string: str,
_path_ctor: Optional[Callable[[Any], pathlib.PurePath]] = None,
) -> str:
"""Given a `format_string` that is intended as a path, generate a string
like from `Spec.format`, but eliminate extra path separators introduced by
formatting of Spec properties.
Path separators explicitly added to the string are preserved, so for example
"{name}/{version}" would generate a directory based on the Spec's name, and
a subdirectory based on its version; this function guarantees though that
the resulting string would only have two directories (i.e. that if under
normal circumstances that `str(Spec.version)` would contain a path
separator, it would not in this case).
"""
format_component_with_sep = r"\{[^}]*[/\\][^}]*}"
if re.search(format_component_with_sep, format_string):
raise SpecFormatPathError(
f"Invalid path format string: cannot contain {{/...}}\n\t{format_string}"
)
path_ctor = _path_ctor or pathlib.PurePath
format_string_as_path = path_ctor(format_string)
if format_string_as_path.is_absolute():
output_path_components = [format_string_as_path.parts[0]]
input_path_components = list(format_string_as_path.parts[1:])
else:
output_path_components = []
input_path_components = list(format_string_as_path.parts)
output_path_components += [
fs.polite_filename(self.format(x)) for x in input_path_components
]
return str(path_ctor(*output_path_components))
def __str__(self): def __str__(self):
sorted_nodes = [self] + sorted( sorted_nodes = [self] + sorted(
self.traverse(root=False), key=lambda x: x.name or x.abstract_hash self.traverse(root=False), key=lambda x: x.name or x.abstract_hash
@ -5379,6 +5416,10 @@ class SpecFormatStringError(spack.error.SpecError):
"""Called for errors in Spec format strings.""" """Called for errors in Spec format strings."""
class SpecFormatPathError(spack.error.SpecError):
"""Called for errors in Spec path-format strings."""
class SpecFormatSigilError(SpecFormatStringError): class SpecFormatSigilError(SpecFormatStringError):
"""Called for mismatched sigils and attributes in format strings""" """Called for mismatched sigils and attributes in format strings"""

View File

@ -58,7 +58,7 @@ def compute_stage_name(spec):
"""Determine stage name given a spec""" """Determine stage name given a spec"""
default_stage_structure = stage_prefix + "{name}-{version}-{hash}" default_stage_structure = stage_prefix + "{name}-{version}-{hash}"
stage_name_structure = spack.config.get("config:stage_name", default=default_stage_structure) stage_name_structure = spack.config.get("config:stage_name", default=default_stage_structure)
return spec.format(format_string=stage_name_structure) return spec.format_path(format_string=stage_name_structure)
def create_stage_root(path: str) -> None: def create_stage_root(path: str) -> None:

View File

@ -3,6 +3,8 @@
# #
# SPDX-License-Identifier: (Apache-2.0 OR MIT) # SPDX-License-Identifier: (Apache-2.0 OR MIT)
import pathlib
import pytest import pytest
import spack.directives import spack.directives
@ -1005,6 +1007,84 @@ def test_spec_override(self):
assert new_spec.compiler_flags["cxxflags"] == ["-O1"] assert new_spec.compiler_flags["cxxflags"] == ["-O1"]
@pytest.mark.parametrize(
"spec_str,format_str,expected",
[
("zlib@git.foo/bar", "{name}-{version}", str(pathlib.Path("zlib-git.foo_bar"))),
("zlib@git.foo/bar", "{name}-{version}-{/hash}", None),
("zlib@git.foo/bar", "{name}/{version}", str(pathlib.Path("zlib", "git.foo_bar"))),
(
"zlib@{0}=1.0%gcc".format("a" * 40),
"{name}/{version}/{compiler}",
str(pathlib.Path("zlib", "{0}_1.0".format("a" * 40), "gcc")),
),
(
"zlib@git.foo/bar=1.0%gcc",
"{name}/{version}/{compiler}",
str(pathlib.Path("zlib", "git.foo_bar_1.0", "gcc")),
),
],
)
def test_spec_format_path(spec_str, format_str, expected):
_check_spec_format_path(spec_str, format_str, expected)
def _check_spec_format_path(spec_str, format_str, expected, path_ctor=None):
spec = Spec(spec_str)
if not expected:
with pytest.raises((spack.spec.SpecFormatPathError, spack.spec.SpecFormatStringError)):
spec.format_path(format_str, _path_ctor=path_ctor)
else:
formatted = spec.format_path(format_str, _path_ctor=path_ctor)
assert formatted == expected
@pytest.mark.parametrize(
"spec_str,format_str,expected",
[
(
"zlib@git.foo/bar",
r"C:\\installroot\{name}\{version}",
r"C:\installroot\zlib\git.foo_bar",
),
(
"zlib@git.foo/bar",
r"\\hostname\sharename\{name}\{version}",
r"\\hostname\sharename\zlib\git.foo_bar",
),
# Windows doesn't attribute any significance to a leading
# "/" so it is discarded
("zlib@git.foo/bar", r"/installroot/{name}/{version}", r"installroot\zlib\git.foo_bar"),
],
)
def test_spec_format_path_windows(spec_str, format_str, expected):
_check_spec_format_path(spec_str, format_str, expected, path_ctor=pathlib.PureWindowsPath)
@pytest.mark.parametrize(
"spec_str,format_str,expected",
[
("zlib@git.foo/bar", r"/installroot/{name}/{version}", "/installroot/zlib/git.foo_bar"),
("zlib@git.foo/bar", r"//installroot/{name}/{version}", "//installroot/zlib/git.foo_bar"),
# This is likely unintentional on Linux: Firstly, "\" is not a
# path separator for POSIX, so this is treated as a single path
# component (containing literal "\" characters); secondly,
# Spec.format treats "\" as an escape character, so is
# discarded (unless directly following another "\")
(
"zlib@git.foo/bar",
r"C:\\installroot\package-{name}-{version}",
r"C__installrootpackage-zlib-git.foo_bar",
),
# "\" is not a POSIX separator, and Spec.format treats "\{" as a literal
# "{", which means that the resulting format string is invalid
("zlib@git.foo/bar", r"package\{name}\{version}", None),
],
)
def test_spec_format_path_posix(spec_str, format_str, expected):
_check_spec_format_path(spec_str, format_str, expected, path_ctor=pathlib.PurePosixPath)
@pytest.mark.regression("3887") @pytest.mark.regression("3887")
@pytest.mark.parametrize("spec_str", ["py-extension2", "extension1", "perl-extension"]) @pytest.mark.parametrize("spec_str", ["py-extension2", "extension1", "perl-extension"])
def test_is_extension_after_round_trip_to_dict(config, mock_packages, spec_str): def test_is_extension_after_round_trip_to_dict(config, mock_packages, spec_str):

View File

@ -517,6 +517,14 @@ def _specfile_for(spec_str, filename):
[Token(TokenType.VERSION, value="@:0.4"), Token(TokenType.COMPILER, value="% nvhpc")], [Token(TokenType.VERSION, value="@:0.4"), Token(TokenType.COMPILER, value="% nvhpc")],
"@:0.4%nvhpc", "@:0.4%nvhpc",
), ),
(
"zlib@git.foo/bar",
[
Token(TokenType.UNQUALIFIED_PACKAGE_NAME, "zlib"),
Token(TokenType.GIT_VERSION, "@git.foo/bar"),
],
"zlib@git.foo/bar",
),
], ],
) )
def test_parse_single_spec(spec_str, tokens, expected_roundtrip): def test_parse_single_spec(spec_str, tokens, expected_roundtrip):

View File

@ -675,6 +675,25 @@ def test_git_ref_comparisons(mock_git_version_info, install_mockery, mock_packag
assert str(spec_branch.version) == "git.1.x=1.2" assert str(spec_branch.version) == "git.1.x=1.2"
def test_git_branch_with_slash():
class MockLookup(object):
def get(self, ref):
assert ref == "feature/bar"
return "1.2", 0
v = spack.version.from_string("git.feature/bar")
assert isinstance(v, GitVersion)
v.attach_lookup(MockLookup())
# Create a version range
test_number_version = spack.version.from_string("1.2")
v.satisfies(test_number_version)
serialized = VersionList([v]).to_dict()
v_deserialized = VersionList.from_dict(serialized)
assert v_deserialized[0].ref == "feature/bar"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"string,git", "string,git",
[ [