Warning
This document is for an in-development version of Galaxy. You can alternatively view this page in the latest release if it exists or view the top of the latest release's documentation.
Source code for galaxy.tool_shed.util.hg_util
import logging
import os
import subprocess
from typing import (
Any,
Literal,
TYPE_CHECKING,
)
from galaxy.tool_shed.util import basic_util
from galaxy.util import (
smart_str,
unicodify,
)
if TYPE_CHECKING:
from mercurial.interfaces.repository import IRepo
log = logging.getLogger(__name__)
INITIAL_CHANGELOG_HASH = "000000000000"
[docs]
def clone_repository(
repository_clone_url: str, repository_file_dir: str, ctx_rev: str | None = None
) -> tuple[bool, str | None]:
"""
Clone the repository up to the specified changeset_revision. No subsequent revisions will be
present in the cloned repository.
"""
cmd = ["hg", "clone", "--stream"]
if ctx_rev:
cmd.extend(["-r", str(ctx_rev)])
cmd.extend([repository_clone_url, repository_file_dir])
# Make sure the destination path actually exists before attempting to clone
if not os.path.exists(repository_file_dir):
os.makedirs(repository_file_dir)
try:
subprocess.check_output(cmd, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
return True, None
except Exception as e:
error_message = f"Error cloning repository: {unicodify(e)}"
if isinstance(e, subprocess.CalledProcessError):
error_message += f"\nOutput was:\n{unicodify(e.output)}"
log.error(error_message)
return False, error_message
[docs]
def copy_file_from_manifest(repo, changeset_revision, filename, dir):
"""
Copy the latest version of the file named filename from the repository manifest to the directory
to which dir refers.
"""
for changeset in reversed_upper_bounded_changelog(repo, changeset_revision):
changeset_ctx = repo[changeset]
fctx = get_file_context_from_ctx(changeset_ctx, filename)
if fctx is not None and fctx != "DELETED":
file_path = os.path.join(dir, filename)
fh = open(file_path, "wb")
fh.write(fctx.data())
fh.close()
return file_path
return None
[docs]
def get_changectx_for_changeset(repo: "IRepo", changeset_revision: str) -> Any:
"""Retrieve a specified changectx from a repository."""
from mercurial import (
error,
scmutil,
)
from mercurial.node import nullrev
try:
node = scmutil.resolvehexnodeidprefix(repo, smart_str(changeset_revision))
except (error.LookupError, error.WdirUnsupported):
return None
if node is None:
return None
ctx = repo[node]
# Only the exact short hash of a committed changeset names it, not a prefix or the null revision.
if ctx.rev() == nullrev or str(ctx) != changeset_revision:
return None
return ctx
[docs]
def get_config_from_disk(config_file: str, relative_install_dir: str) -> str | None:
for root, _dirs, files in os.walk(relative_install_dir):
if root.find(".hg") < 0:
for name in files:
if name == config_file:
return os.path.abspath(os.path.join(root, name))
return None
[docs]
def get_ctx_file_path_from_manifest(filename: str, repo: "IRepo", changeset_revision: str) -> tuple[Any, Any]:
"""
Get the ctx file path for the latest revision of filename from the repository manifest up
to the value of changeset_revision.
"""
stripped_filename = basic_util.strip_path(filename)
for changeset in reversed_upper_bounded_changelog(repo, changeset_revision):
manifest_ctx = repo[changeset]
for ctx_file in manifest_ctx.files():
ctx_file_name = basic_util.strip_path(unicodify(ctx_file))
if ctx_file_name == stripped_filename:
return manifest_ctx, ctx_file
return None, None
[docs]
def get_file_data_from_manifest(
filename: str, repo: "IRepo", changeset_revision: str, max_size: int | None = None
) -> bytes | None:
"""
Return the contents of filename in the repository manifest as of changeset_revision, or None
if that revision is unknown, has no such file, or the file is larger than max_size bytes.
filename is matched as a path within the repository first and then by file name alone.
"""
ctx = get_changectx_for_changeset(repo, changeset_revision)
if ctx is None:
return None
path: bytes | None = smart_str(filename)
if path not in ctx:
stripped_filename = basic_util.strip_path(filename)
matches = (f for f in ctx.manifest() if basic_util.strip_path(unicodify(f)) == stripped_filename)
path = next(matches, None)
if path is None:
return None
fctx = ctx[path]
# filectx.size() reads a newly added file whole to look for copy metadata, while the revlog
# index records the stored size (an upper bound of the file size) without reading the file.
if max_size is not None and fctx.filelog()._revlog.rawsize(fctx.filerev()) > max_size:
return None
data: bytes = fctx.data()
return data
[docs]
def get_file_context_from_ctx(ctx: Any, filename: str) -> Any | Literal["DELETED"] | None:
"""Return the mercurial file context for a specified file."""
# We have to be careful in determining if we found the correct file because multiple files with
# the same name may be in different directories within ctx if the files were moved within the change
# set. For example, in the following ctx.files() list, the former may have been moved to the latter:
# ['tmap_wrapper_0.0.19/tool_data_table_conf.xml.sample', 'tmap_wrapper_0.3.3/tool_data_table_conf.xml.sample'].
# Another scenario is that the file has been deleted.
deleted = False
filename = basic_util.strip_path(filename)
for ctx_file in ctx.files():
ctx_file_name = basic_util.strip_path(unicodify(ctx_file))
if filename == ctx_file_name:
try:
# If the file was moved, its destination will be returned here.
fctx = ctx[ctx_file]
return fctx
except LookupError:
# Set deleted for now, and continue looking in case the file was moved instead of deleted.
deleted = True
if deleted:
return "DELETED"
return None
[docs]
def pull_repository(repo_path: str, repository_clone_url: str, ctx_rev: str) -> None:
"""Pull changes from a remote repository to a local one."""
try:
subprocess.check_output(
["hg", "pull", "-r", ctx_rev, repository_clone_url], stderr=subprocess.STDOUT, cwd=repo_path
)
except Exception as e:
error_message = f"Error pulling revision '{ctx_rev}': {unicodify(e)}"
if isinstance(e, subprocess.CalledProcessError):
error_message += f"\nOutput was:\n{unicodify(e.output)}"
raise Exception(error_message)
[docs]
def reversed_lower_upper_bounded_changelog(
repo: "IRepo",
excluded_lower_bounds_changeset_revision: str | None,
included_upper_bounds_changeset_revision: str | None,
) -> list[Any]:
"""
Return a reversed list of changesets in the repository changelog after the excluded_lower_bounds_changeset_revision,
but up to and including the included_upper_bounds_changeset_revision. The value of excluded_lower_bounds_changeset_revision
will be the value of INITIAL_CHANGELOG_HASH if no valid changesets exist before included_upper_bounds_changeset_revision.
"""
# To set excluded_lower_bounds_changeset_revision, calling methods should do the following, where the value
# of changeset_revision is a downloadable changeset_revision.
# excluded_lower_bounds_changeset_revision = \
# metadata_util.get_previous_metadata_changeset_revision(app, repository, changeset_revision)
if excluded_lower_bounds_changeset_revision == INITIAL_CHANGELOG_HASH:
appending_started = True
else:
appending_started = False
reversed_changelog: list[Any] = []
for changeset in repo.changelog:
changeset_hash = str(repo[changeset])
if appending_started:
reversed_changelog.insert(0, changeset)
if changeset_hash == excluded_lower_bounds_changeset_revision and not appending_started:
appending_started = True
if changeset_hash == included_upper_bounds_changeset_revision:
break
return reversed_changelog
[docs]
def reversed_upper_bounded_changelog(repo: "IRepo", included_upper_bounds_changeset_revision: str | None) -> list[Any]:
"""
Return a reversed list of changesets in the repository changelog up to and including the
included_upper_bounds_changeset_revision.
"""
return reversed_lower_upper_bounded_changelog(
repo, INITIAL_CHANGELOG_HASH, included_upper_bounds_changeset_revision
)
[docs]
def update_repository(repo_path: str, ctx_rev: str | None = None) -> None:
"""
Update the cloned repository to changeset_revision. It is critical that the installed repository is updated to the desired
changeset_revision before metadata is set because the process for setting metadata uses the repository files on disk.
"""
# TODO: We may have files on disk in the repo directory that aren't being tracked, so they must be removed.
# The codes used to show the status of files are as follows.
# M = modified
# A = added
# R = removed
# C = clean
# ! = deleted, but still tracked
# ? = not tracked
# I = ignored
# It would be nice if we could use mercurial's purge extension to remove untracked files. The problem is that
# purging is not supported by the mercurial API.
cmd = ["hg", "update"]
if ctx_rev:
cmd.extend(["-r", ctx_rev])
try:
subprocess.check_output(cmd, stderr=subprocess.STDOUT, cwd=repo_path)
except Exception as e:
error_message = f"Error updating repository: {unicodify(e)}"
if isinstance(e, subprocess.CalledProcessError):
error_message += f"\nOutput was:\n{unicodify(e.output)}"
raise Exception(error_message)
__all__ = (
"clone_repository",
"copy_file_from_manifest",
"get_changectx_for_changeset",
"get_config_from_disk",
"get_ctx_file_path_from_manifest",
"get_file_data_from_manifest",
"get_file_context_from_ctx",
"pull_repository",
"reversed_lower_upper_bounded_changelog",
"reversed_upper_bounded_changelog",
"update_repository",
)