Add the gifn-apply test tool
Add a tool that checks out the repositories mentioned in
a git-if-needed patch series, runs gif-it-needed, and performs
some checks on its operation. This tool will eventually be used in
a Zuul test job for this repository.
Change-Id: Id02fb7c21f5ab34d9639bf845fcc3961d929b13b
diff --git a/tools/git-if-needed/python/gifn_apply/repo_url.py b/tools/git-if-needed/python/gifn_apply/repo_url.py
new file mode 100644
index 0000000..3204811
--- /dev/null
+++ b/tools/git-if-needed/python/gifn_apply/repo_url.py
@@ -0,0 +1,122 @@
+# SPDX-FileCopyrightText: StorPool <support@storpool.com>
+# SPDX-License-Identifier: BSD-2-Clause
+"""Parse base/URL repository location pairs."""
+
+from __future__ import annotations
+
+import dataclasses
+import os
+import re
+import urllib.parse as uparse
+
+from . import defs
+from . import util
+
+
+_RE_ENV_BASE = re.compile(r"^ [A-Z][A-Z0-9_]* $", re.X)
+
+
+@dataclasses.dataclass(frozen=True)
+class RepoURLResult:
+ """Base class for the OK/error parsed URL dichotomy."""
+
+
+@dataclasses.dataclass(frozen=True)
+class RepoURLOK(RepoURLResult):
+ """Successfully parsed a base URL for repositories."""
+
+ url: defs.RepoURL
+
+
+@dataclasses.dataclass(frozen=True)
+class RepoURLError(RepoURLResult):
+ """Could not parse a base URL for repositories."""
+
+ name: str
+ value: str
+ err: ValueError
+
+
+@dataclasses.dataclass(frozen=True)
+class RepoURLPair:
+ """A base/URL pair for an URL obtained from the command line."""
+
+ base: str
+ url: defs.RepoURL
+
+
+def _slash_extend(url: uparse.ParseResult) -> uparse.ParseResult:
+ """Add a / at the end of the path if there is none."""
+ if url.path.endswith("/"):
+ return url
+
+ return url._replace(path=url.path + "/")
+
+
+def _validate_file(name: str, value: str, url: uparse.ParseResult) -> RepoURLResult:
+ """Make sure a file:// URL has no host and an absolute path."""
+ if url.netloc:
+ return RepoURLError(name, value, ValueError("No hostname expected for a 'file' URL"))
+ if not url.path.startswith("/"):
+ return RepoURLError(name, value, ValueError("Expected an absolute path for a 'file' URL"))
+
+ url = _slash_extend(url)
+ return RepoURLOK(defs.RepoURL(url))
+
+
+def _validate_http(name: str, value: str, url: uparse.ParseResult) -> RepoURLResult:
+ """Make sure a http(s):// URL has a host, slash-terminate the path."""
+ if not url.netloc:
+ return RepoURLError(
+ name, value, ValueError("Expected a hostname for 'http' or 'https' URLs")
+ )
+
+ url = _slash_extend(url)
+ return RepoURLOK(defs.RepoURL(url))
+
+
+_SCHEME_VALIDATORS = {
+ "file": _validate_file,
+ "http": _validate_http,
+ "https": _validate_http,
+}
+
+
+def parse_url(name: str, base: str, value: str) -> RepoURLResult:
+ """Parse and validate a single base/URL pair."""
+ if not _RE_ENV_BASE.match(base):
+ return RepoURLError(name, value, ValueError(f"Invalid URL base {base!r}"))
+
+ try:
+ url = uparse.urlparse(value)
+ except ValueError as err:
+ return RepoURLError(name, value, err)
+
+ validator = _SCHEME_VALIDATORS.get(url.scheme)
+ if validator is None:
+ return RepoURLError(
+ name, value, ValueError("Expected 'http', 'https', or 'file' as the URL scheme")
+ )
+ return validator(name, value, url)
+
+
+def get_env_repo_urls(environ: dict[str, str] | None = None) -> dict[str, RepoURLResult]:
+ """Parse the REPO_URL_<base> environment variables."""
+ if environ is None:
+ environ = dict(os.environ)
+
+ res: dict[str, RepoURLResult] = {
+ "OPENSTACK": RepoURLOK(defs.RepoURL(uparse.urlparse("https://github.com/openstack")))
+ }
+ for name, value in environ.items():
+ base = util.str_removeprefix(name, "REPO_URL_")
+ if base == name:
+ continue
+ res[base] = parse_url(name, base, value)
+
+ return res
+
+
+def parse_base_url_pair(arg: str) -> RepoURLPair:
+ """Parse a `--repo-url base=url` command-line argument."""
+ raise NotImplementedError(repr(arg))