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
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():
if sys.platform == "win32":
import ctypes

View File

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

View File

@ -579,7 +579,9 @@ def ci_rebuild(args):
"SPACK_COLOR=always",
"SPACK_INSTALL_FLAGS={}".format(args_to_string(deps_install_args)),
"-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,
]

View File

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

View File

@ -104,7 +104,7 @@ def relative_path_for_spec(self, spec):
_check_concrete(spec)
projection = spack.projections.get_projection(self.projections, spec)
path = spec.format(projection)
path = spec.format_path(projection)
return str(Path(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)
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
def get_all_specs(self):
@ -776,7 +776,7 @@ def get_relative_projection_for_spec(self, spec):
spec = spec.package.extendee_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):
"""
@ -791,7 +791,7 @@ def get_projection_for_spec(self, spec):
proj = spack.projections.get_projection(self.projections, spec)
if proj:
return os.path.join(self._root, spec.format(proj))
return os.path.join(self._root, spec.format_path(proj))
return self._root

View File

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

View File

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

View File

@ -9,6 +9,7 @@
import posixpath
from typing import Any, Dict, List
import llnl.util.filesystem as fs
import llnl.util.lang as lang
import spack.compilers
@ -283,8 +284,10 @@ def token_to_path(self, name, value):
Returns:
str: part of the path associated with the service
"""
# 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'
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
# use different flavors of the same 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
# we need to append a hash to the version to distinguish
# among flavors of the same library (e.g. openblas~openmp vs.
# openblas+openmp)
path = path_part_fmt.format(token=value)
path = path_part_fmt(token=value)
path = "-".join([path, value.dag_hash(length=7)])
return path

View File

@ -991,13 +991,14 @@ def find_valid_url_for_version(self, version):
return None
def _make_resource_stage(self, root_stage, resource):
pretty_resource_name = fsys.polite_filename(f"{resource.name}-{self.version}")
return ResourceStage(
resource.fetcher,
root=root_stage,
resource=resource,
name=self._resource_stage(resource),
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,
)
@ -1008,8 +1009,10 @@ def _download_search(self):
def _make_root_stage(self, fetcher):
# 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(
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..
s = self.spec

View File

@ -76,7 +76,9 @@
IDENTIFIER = r"(?:[a-zA-Z_0-9][a-zA-Z_0-9\-]*)"
DOTTED_IDENTIFIER = rf"(?:{IDENTIFIER}(?:\.{IDENTIFIER})+)"
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\-.]*"
@ -127,7 +129,8 @@ class TokenType(TokenBase):
# Dependency
DEPENDENCY = r"(?:\^)"
# 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}))"
# Variants
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
)
self.has_compiler = True
elif self.ctx.accept(TokenType.VERSION) or self.ctx.accept(
TokenType.VERSION_HASH_PAIR
elif (
self.ctx.accept(TokenType.VERSION_HASH_PAIR)
or self.ctx.accept(TokenType.GIT_VERSION)
or self.ctx.accept(TokenType.VERSION)
):
if self.has_version:
raise spack.spec.MultipleVersionError(

View File

@ -54,6 +54,7 @@
import io
import itertools
import os
import pathlib
import platform
import re
import socket
@ -4453,6 +4454,42 @@ def cformat(self, *args, **kwargs):
kwargs.setdefault("color", None)
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):
sorted_nodes = [self] + sorted(
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."""
class SpecFormatPathError(spack.error.SpecError):
"""Called for errors in Spec path-format strings."""
class SpecFormatSigilError(SpecFormatStringError):
"""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"""
default_stage_structure = stage_prefix + "{name}-{version}-{hash}"
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:

View File

@ -3,6 +3,8 @@
#
# SPDX-License-Identifier: (Apache-2.0 OR MIT)
import pathlib
import pytest
import spack.directives
@ -1005,6 +1007,84 @@ def test_spec_override(self):
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.parametrize("spec_str", ["py-extension2", "extension1", "perl-extension"])
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")],
"@: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):

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"
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(
"string,git",
[