Skip to content

Commit

Permalink
The start
Browse files Browse the repository at this point in the history
  • Loading branch information
benfitzpatrick committed Jan 17, 2024
1 parent 0d47cfe commit 689784e
Show file tree
Hide file tree
Showing 11 changed files with 437 additions and 11 deletions.
7 changes: 4 additions & 3 deletions metomi/rose/config_processors/fileinstall.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def _process(self, conf_tree, nodes, loc_dao, **kwargs):
source.scheme = scheme
break
self.loc_handlers_manager.parse(source, conf_tree)
except ValueError:
except ValueError as exc:
if source.is_optional:
sources.pop(source.name)
for name in source.used_by_names:
Expand All @@ -216,6 +216,7 @@ def _process(self, conf_tree, nodes, loc_dao, **kwargs):
raise ConfigProcessError(
["file:" + source.used_by_names[0], "source"],
source.name,
exc
)
prev_source = loc_dao.select(source.name)
source.is_out_of_date = (
Expand Down Expand Up @@ -852,7 +853,7 @@ def parse(self, loc, conf_tree):
# Scheme specified in the configuration.
handler = self.get_handler(loc.scheme)
if handler is None:
raise ValueError(loc.name)
raise ValueError(f"don't support scheme {loc.scheme}")
else:
# Scheme not specified in the configuration.
scheme = urlparse(loc.name).scheme
Expand All @@ -861,7 +862,7 @@ def parse(self, loc, conf_tree):
if handler is None:
handler = self.guess_handler(loc)
if handler is None:
raise ValueError(loc.name)
raise ValueError(f"don't know how to process {loc.name}")
else:
handler = self.get_handler(self.DEFAULT_SCHEME)
return handler.parse(loc, conf_tree)
Expand Down
2 changes: 1 addition & 1 deletion metomi/rose/loc_handlers/fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def parse(cls, loc, _):
loc.scheme = "fs"
name = os.path.expanduser(loc.name)
if not os.path.exists(name):
raise ValueError(loc.name)
raise ValueError(f"path does not exist or not accessible: {name}")
paths_and_checksums = get_checksum(name)
for path, checksum, access_mode in paths_and_checksums:
loc.add_path(path, checksum, access_mode)
Expand Down
192 changes: 192 additions & 0 deletions metomi/rose/loc_handlers/git.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Copyright (C) British Crown (Met Office) & Contributors.
# This file is part of Rose, a framework for meteorological suites.
#
# Rose is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Rose is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Rose. If not, see <http://www.gnu.org/licenses/>.
# -----------------------------------------------------------------------------
"""A handler of Git locations."""

import os
import re
import tempfile
from urllib.parse import urlparse


REC_COMMIT_HASH = re.compile(r"^[0-9a-f]+$")


class GitLocHandler:
"""Handler of Git locations."""

GIT = "git"
SCHEMES = [GIT]
WEB_SCHEMES = ["https"]
URI_SEPARATOR = "::"

def __init__(self, manager):
self.manager = manager
ret_code, versiontext, stderr = self.manager.popen.run(
"git", "version")
if ret_code:
self.git_version = None
else:
version_nums = []
for num_string in versiontext.split()[-1].split("."):
try:
version_nums.append(int(num_string))
except ValueError:
break
self.git_version = tuple(version_nums)

def can_pull(self, loc):
if self.git_version is None:
return False
scheme = urlparse(loc.name).scheme
if scheme in self.SCHEMES:
return True
if self.URI_SEPARATOR not in loc.name:
return False
remote = self._parse_name(loc)[0]
return (
scheme in self.WEB_SCHEMES
and not os.path.exists(loc.name) # same as svn...
and not self.manager.popen.run(
"git", "ls-remote", "--exit-code", remote)[0]
# https://superuser.com/questions/227509/git-ping-check-if-remote-repository-exists
)

def parse(self, loc, conf_tree):
"""Set loc.real_name, loc.scheme, loc.loc_type.
Within Git we have a lot of trouble figuring out remote
loc_type - a clone is required, unfortunately.
Short commit hashes will not work since there is no remote
rev-parse functionality. Long commit hashes will work if the
uploadpack.allowAnySHA1InWant configuration is set on the
remote repo or server.
Filtering requires uploadpack.allowFilter to be set true on
the remote repo or server.
"""
loc.scheme = self.SCHEMES[0]
remote, path, ref = self._parse_name(loc)
with tempfile.TemporaryDirectory() as tmpdirname:
git_dir_opt = f"--git-dir={tmpdirname}/.git"
self.manager.popen.run_ok(
"git", git_dir_opt, "init"
)
self.manager.popen.run_ok(
"git", git_dir_opt, "remote", "add", "origin", remote
)

# Make sure we configure for minimum fetching.
if self.git_version >= (2, 25, 0):
self.manager.popen.run_ok(
"git", git_dir_opt, "sparse-checkout", "set", path,
"--no-cone"
)
else:
self.manager.popen.run_ok(
"git", git_dir_opt, "config", "extensions.partialClone",
"true"
)

# Extract the commit hash if we don't already have it.
commithash = self._get_commithash(remote, ref)

# Fetch the ref/commit as efficiently as we can.
ret_code, _, stderr = self.manager.popen.run(
"git", git_dir_opt, "fetch", "--depth=1",
"--filter=blob:none", "origin", commithash
)
if ret_code:
raise ValueError(f"source={loc.name}: {stderr}")

# Determine the type of the path object.
ret_code, typetext, stderr = self.manager.popen.run(
"git", git_dir_opt, "cat-file", "-t", f"{commithash}:{path}"
) # N.B. git versions >1.8 can use '-C' to set git dir.
if ret_code:
raise ValueError(f"source={loc.name}: {stderr}")

if typetext.strip() == "tree":
loc.loc_type = loc.TYPE_TREE
else:
loc.loc_type = loc.TYPE_BLOB
loc.real_name = (
f"remote:{remote} ref:{ref} commit:{commithash} path:{path}"
)
loc.key = commithash

async def pull(self, loc, conf_tree):
"""Get loc to its cache.
We would strongly prefer to use git sparse-checkout (with
filtered clones) but it is not available on many systems (Git
>= 2.25) and it is still marked as experimental.
"""
if not loc.real_name:
self.parse(loc, conf_tree)
remote, path, ref = self._parse_name(loc)
with tempfile.TemporaryDirectory() as tmpdirname:
git_dir_opt = f"--git-dir={tmpdirname}/.git"
await self.manager.popen.run_ok_async(
"git", git_dir_opt, "init"
)
await self.manager.popen.run_ok_async(
"git", git_dir_opt, "remote", "add", "origin", remote
)
if self.git_version >= (2, 25, 0):
await self.manager.popen.run_ok_async(
"git", git_dir_opt, "sparse-checkout", "set", path,
"--no-cone"
)
else:
await self.manager.popen.run_ok_async(
"git", git_dir_opt, "config", "extensions.partialClone",
"true"
)
await self.manager.popen.run_ok_async(
"git", git_dir_opt, "fetch", "--depth=1", "--filter=blob:none",
"origin", loc.key
)
await self.manager.popen.run_ok_async(
"git", git_dir_opt, f"--work-tree={tmpdirname}", "checkout",
loc.key
)
name = tmpdirname + "/" + path
dest = loc.cache
if loc.loc_type == "tree":
dest += "/"
cmd = self.manager.popen.get_cmd("rsync", name, dest)
await self.manager.popen.run_ok_async(*cmd)

def _parse_name(self, loc):
scheme, nonscheme = loc.name.split(":", 1)
return re.split(self.URI_SEPARATOR, nonscheme, maxsplit=3)

def _get_commithash(self, remote, ref):
ret_code, info, _ = self.manager.popen.run(
"git", "ls-remote", "--exit-code", remote, ref)
if ret_code:
err = f"ls-remote: could not find ref '{ref}' in '{remote}'"
if REC_COMMIT_HASH.match(ref):
if len(ref) == 40:
# Likely a full commit hash.
return ref
err += ": you may be using an unsupported short commit hash"
raise ValueError(err)
return info.split()[0]
2 changes: 1 addition & 1 deletion metomi/rose/loc_handlers/namelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def parse(self, loc, conf_tree):
if section_value is None:
sections.remove(section)
if not sections:
raise ValueError(loc.name)
raise ValueError(f"could not locate {loc.name}")
return sections

async def pull(self, loc, conf_tree):
Expand Down
2 changes: 1 addition & 1 deletion metomi/rose/loc_handlers/rsync.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def parse(self, loc, _):
out = self.manager.popen(*cmd, stdin=stdin)[0]
lines = out.splitlines()
if not lines or lines[0] not in [loc.TYPE_BLOB, loc.TYPE_TREE]:
raise ValueError(loc.name)
raise ValueError(f"could not locate {path} on host {host}")
loc.loc_type = lines.pop(0)
if loc.loc_type == loc.TYPE_BLOB:
line = lines.pop(0)
Expand Down
11 changes: 11 additions & 0 deletions sphinx/api/configuration/file-creation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ root directory to install file targets with a relative path:
:opt svn: The Subversion scheme. The location is a Subversion URL or
an FCM location keyword. A URI with these schemes ``svn``,
``svn+ssh`` and ``fcm`` are automatically recognised.
:opt git: The Git scheme. The location is complex due to Git semantics.
It must have the scheme ``git`` and be of the form
``git:REPOSITORY_URL::PATHSPEC::TREEISH``. ``REPOSITORY_URL`` should
be a Git repository URI which may itself have a scheme ``ssh``,
``git``, ``https``, or be of the form ``HOST:PATH``, or ``PATH`` for
local repositories. ``PATHSPEC`` should be a path to a file or
directory that you want to extract. ``TREEISH`` should be a tag,
branch, or long commit hash to specify the commit at which you want
to extract. These should follow the same semantics as if you git
cloned ``REPOSITORY_URL``, git checkout'ed ``TREEISH``, and extracted
the path ``PATHSPEC`` within the clone.
:opt rsync: This scheme is useful for pulling a file or directory from
a remote host using ``rsync`` via ``ssh``. A URI should have the
form ``HOST:PATH``.
Expand Down
3 changes: 3 additions & 0 deletions sphinx/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ non-Python dependencies are satisfied.
+ ``rose task-run``
+ ``cylc install``

Git is likewise required for installing files from Git repositories or
hosting services such as GitHub. Note Git is not automatically installed
by the metomi-rose conda.

Configuring Rose
----------------
Expand Down
15 changes: 13 additions & 2 deletions t/rose-app-run/05-file.t
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Hello and good bye.
__CONTENT__
OUT=$(cd config/file && cat hello1 hello2 hello3/text)
#-------------------------------------------------------------------------------
tests 62
tests 65
#-------------------------------------------------------------------------------
# Normal mode with free format files.
TEST_KEY=$TEST_KEY_BASE
Expand All @@ -70,7 +70,18 @@ run_fail "$TEST_KEY" rose app-run --config=../config -q \
--define='[file:hello4]source=stuff:ing'
file_cmp "$TEST_KEY.out" "$TEST_KEY.out" </dev/null
file_cmp "$TEST_KEY.err" "$TEST_KEY.err" <<'__CONTENT__'
[FAIL] file:hello4=source=stuff:ing: bad or missing value
[FAIL] file:hello4=source=stuff:ing: don't know how to process stuff:ing
__CONTENT__
test_teardown
#-------------------------------------------------------------------------------
# Normal mode with free format files and a file with an invalid scheme.
TEST_KEY=$TEST_KEY_BASE-invalid-content
test_setup
run_fail "$TEST_KEY" rose app-run --config=../config -q \
--define='schemes=stuff*=where_is_the_stuff' --define='[file:hello4]source=stuff:ing'
file_cmp "$TEST_KEY.out" "$TEST_KEY.out" </dev/null
file_cmp "$TEST_KEY.err" "$TEST_KEY.err" <<'__CONTENT__'
[FAIL] file:hello4=source=stuff:ing: don't support scheme where_is_the_stuff
__CONTENT__
test_teardown
#-------------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions t/rose-app-run/06-namelist.t
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ run_fail "$TEST_KEY" rose app-run --config=../config -q \
'--define=[file:shopping-list-3.nl]source=namelist:shopping_list'
file_cmp "$TEST_KEY.out" "$TEST_KEY.out" </dev/null
file_cmp "$TEST_KEY.err" "$TEST_KEY.err" <<'__CONTENT__'
[FAIL] file:shopping-list-3.nl=source=namelist:shopping_list: bad or missing value
[FAIL] file:shopping-list-3.nl=source=namelist:shopping_list: could not locate namelist:shopping_list
__CONTENT__
test_teardown
#-------------------------------------------------------------------------------
Expand All @@ -221,7 +221,7 @@ run_fail "$TEST_KEY" rose app-run --config=../config -q \
'--define=[!namelist:shopping_list]'
file_cmp "$TEST_KEY.out" "$TEST_KEY.out" </dev/null
file_cmp "$TEST_KEY.err" "$TEST_KEY.err" <<'__CONTENT__'
[FAIL] file:shopping-list-3.nl=source=namelist:shopping_list: bad or missing value
[FAIL] file:shopping-list-3.nl=source=namelist:shopping_list: could not locate namelist:shopping_list
__CONTENT__
test_teardown
#-------------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion t/rose-app-run/15-file-permission.t
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ run_fail "$TEST_KEY" rose app-run --config=../config -q
file_cmp "$TEST_KEY.out" "$TEST_KEY.out" <<'__OUT__'
__OUT__
file_cmp "$TEST_KEY.err" "$TEST_KEY.err" <<__ERR__
[FAIL] file:qux=source=$TEST_DIR/no_read_target_dir/qux: bad or missing value
[FAIL] file:qux=source=$TEST_DIR/no_read_target_dir/qux: path does not exist or not accessible: $TEST_DIR/no_read_target_dir/qux
__ERR__
chmod u+x $TEST_DIR/no_read_target_dir
rm $TEST_DIR/no_read_target_dir/qux
Expand Down
Loading

0 comments on commit 689784e

Please sign in to comment.