blob: 11b52081af749c885ea59e261ec827855c6f432b [file] [log] [blame]
# Copyright 2017 The LUCI Authors. All rights reserved.
# Use of this source code is governed under the Apache License, Version 2.0
# that can be found in the LICENSE file.
"""File manipulation (read/write/delete/glob) methods."""
from recipe_engine import config_types
from recipe_engine import recipe_api
import fnmatch
import json
import os
class SymlinkTree(object):
"""A representation of a tree of symlinks."""
def __init__(self, root, api, symlink_resource):
"""See FileApi.symlink_tree for the public constructor."""
assert root and isinstance(root, config_types.Path)
self._root = root
self._api = api
self._resource = symlink_resource
# dict[Path]list(Path): Maps target to a list of linknames.
self._link_map = {}
# dict[Path]Path: Maps a linkname to its target.
self._reverse_map = {}
@property
def root(self):
"""The root (Path) of the symlink tree."""
return self._root
def register_link(self, target, linkname):
"""Registers a pair of paths to symlink.
Args:
* target (Path) - The file/directory to which the symlink will point.
* linkname (Path) - The location of the symlink. Must be a child of the
SymlinkTree's `root`. It is an error to register two links with the
same linkname.
"""
assert (isinstance(target, config_types.Path) and
isinstance(linkname, config_types.Path))
if linkname in self._link_map.get(target, ()):
return
else:
assert linkname not in self._reverse_map, (
'%s is alreadly linked to %s' % (linkname, self._reverse_map[linkname]))
assert self.root.is_parent_of(linkname), (
'%s is not within the root directory %s' % (linkname, self.root))
self._link_map.setdefault(target, []).append(linkname)
self._reverse_map[linkname] = target
def create_links(self, name):
"""Creates all registered symlinks on disk.
Args:
* name (str) - The name of the step.
"""
for target, linknames in self._link_map.iteritems():
for linkname in linknames:
self._api.path.mock_copy_paths(target, linkname)
self._api.python(
name,
self._resource,
args = [
'--link-json',
self._api.json.input({str(target) : linkname
for target, linkname in self._link_map.iteritems()
}),
],
infra_step=True)
# TODO(iannucci): Introduce the concept of a 'native step' and implement these
# directly in the current python interpreter without the need for a subprocess
# invocation.
class FileApi(recipe_api.RecipeApi):
class Error(recipe_api.StepFailure):
"""Error is an InfraFailure, except that it also contains an errno field
indicating the errno name (i.e. 'EEXIST') of the underlying error.
"""
def __init__(self, step_name, errno_name, message):
reason = 'Step(%r) failed %r with: %s' % (step_name, errno_name, message)
super(FileApi.Error, self).__init__(reason)
self.errno_name = errno_name
def _assert_absolute_path_or_placeholder(self, path_or_placeholder):
if isinstance(path_or_placeholder, recipe_api.Placeholder):
# We assume that all Placeholder classes will render to an absolute path,
# as this is part of their api contract.
return True
return self.m.path.assert_absolute(path_or_placeholder)
def _run(self, name, args, step_test_data=None, stdout=None):
if not step_test_data:
step_test_data = self.test_api.errno
args = [
'--json-output', self.m.json.output(add_json_log=False)
] + args
result = self.m.python(
name, self.resource('fileutil.py'), args=args,
step_test_data=step_test_data, stdout=stdout,
infra_step=True)
j = result.json.output
if not j['ok']:
result.presentation.status = self.m.step.FAILURE
result.presentation.step_text = j['message']
# pylint thinks this isn't a standard exception... silly pylint.
# pylint: disable=nonstandard-exception
raise self.Error(name, j['errno_name'], j['message'])
return result
def copy(self, name, source, dest):
"""Copies a file (including mode bits) from source to destination on the
local filesystem.
Behaves identically to shutil.copy.
Args:
* name (str) - The name of the step.
* source (Path|Placeholder) - The path to the file you want to copy.
* dest (Path|Placeholder) - The path to the destination file name. If this
path exists and is a directory, the basename of `source` will be
appended to derive a path to a destination file.
Raises file.Error
"""
self._assert_absolute_path_or_placeholder(source)
self._assert_absolute_path_or_placeholder(dest)
self._run(name, ['copy', source, dest])
self.m.path.mock_copy_paths(source, dest)
def copytree(self, name, source, dest, symlinks=False):
"""Recursively copies a directory tree.
Behaves identically to shutil.copytree.
`dest` must not exist.
Args:
* name (str) - The name of the step.
* source (Path) - The path of the directory to copy.
* dest (Path) - The place where you want the recursive copy to show up.
This must not already exist.
* symlinks (bool) - Preserve symlinks. No effect on Windows.
Raises file.Error
"""
self.m.path.assert_absolute(source)
self.m.path.assert_absolute(dest)
args = ['--symlinks'] if symlinks else []
self._run(name, ['copytree'] + args + [source, dest])
self.m.path.mock_copy_paths(source, dest)
def move(self, name, source, dest):
"""Moves a file or directory.
Behaves identically to shutil.move.
Args:
* name (str) - The name of the step.
* source (Path) - The path of the item to move.
* dest (Path) - The new name of the item.
Raises file.Error
"""
self.m.path.assert_absolute(source)
self.m.path.assert_absolute(dest)
self._run(name, ['move', source, dest])
self.m.path.mock_copy_paths(source, dest)
self.m.path.mock_remove_paths(source)
def read_raw(self, name, source, test_data=''):
"""Reads a file as raw data.
Args:
* name (str) - The name of the step.
* source (Path) - The path of the file to read.
* test_data (str) - Some default data for this step to return when running
under simulation.
Returns (str) - The unencoded (binary) contents of the file.
Raises file.Error
"""
self.m.path.assert_absolute(source)
step_test_data = lambda: self.test_api.read_raw(test_data)
result = self._run(name, ['copy', source, self.m.raw_io.output()],
step_test_data=step_test_data)
return result.raw_io.output
def write_raw(self, name, dest, data):
"""Write the given `data` to `dest`.
Args:
* name (str) - The name of the step.
* dest (Path) - The path of the file to write.
* data (str) - The data to write.
Raises file.Error.
"""
self.m.path.assert_absolute(dest)
self._run(name, ['copy', self.m.raw_io.input(data), dest])
self.m.path.mock_add_paths(dest)
def read_text(self, name, source, test_data=''):
"""Reads a file as UTF-8 encoded text.
Args:
* name (str) - The name of the step.
* source (Path) - The path of the file to read.
* test_data (str) - Some default data for this step to return when running
under simulation.
Returns (str) - The content of the file.
Raises file.Error
"""
self.m.path.assert_absolute(source)
step_test_data = lambda: self.test_api.read_text(test_data)
result = self._run(name, ['copy', source, self.m.raw_io.output_text()],
step_test_data=step_test_data)
return result.raw_io.output_text
def write_text(self, name, dest, text_data):
"""Write the given UTF-8 encoded `text_data` to `dest`.
Args:
* name (str) - The name of the step.
* dest (Path) - The path of the file to write.
* text_data (str) - The UTF-8 encoded data to write.
Raises file.Error.
"""
self.m.path.assert_absolute(dest)
self._run(name, ['copy', self.m.raw_io.input_text(text_data), dest])
self.m.path.mock_add_paths(dest)
def read_json(self, name, source, test_data=''):
"""Reads a file as UTF-8 encoded json.
Args:
* name (str) - The name of the step.
* source (Path) - The path of the file to read.
* test_data (object) - Some default json serializable data for this step
to return when running under simulation.
Returns (object) - The content of the file.
Raise file.Error
"""
test_data_text = json.dumps(test_data)
text = self.read_text(name, source, test_data=test_data_text)
return json.loads(text)
def write_json(self, name, dest, data):
"""Write the given json serializable `data` to `dest`.
Args:
* name (str) - The name of the step.
* dest (Path) - The path of the file to write.
* data (object) - Json serializable data to write.
Raises file.Error.
"""
text_data = json.dumps(data)
self.write_text(name, dest, text_data)
def glob_paths(self, name, source, pattern, test_data=()):
"""Performs glob expansion on `pattern`.
glob rules for `pattern` follow the same syntax as for the python `glob`
stdlib module.
Args:
* name (str) - The name of the step.
* source (Path) - The directory whose contents should be globbed.
* pattern (str) - The glob pattern to apply under `source`.
* test_data (iterable[str]) - Some default data for this step to return
when running under simulation. This should be the list of file items
found in this directory.
Returns list[Path] - All paths found.
Raises file.Error.
"""
assert isinstance(source, config_types.Path)
result = self._run(
name, ['glob', source, pattern],
lambda: self.test_api.glob_paths(test_data),
self.m.raw_io.output_text())
ret = [source.join(*x.split(self.m.path.sep))
for x in result.stdout.splitlines()]
result.presentation.logs["glob"] = map(str, ret)
return ret
def remove(self, name, source):
"""Remove a file.
Does not raise Error if the file doesn't exist.
Args:
* name (str) - The name of the step.
* source (Path) - The file to remove.
Raises file.Error.
"""
self.m.path.assert_absolute(source)
self._run(name, ['remove', source])
self.m.path.mock_remove_paths(source)
def listdir(self, name, source, test_data=()):
"""List all files inside a directory.
Args:
* name (str) - The name of the step.
* source (Path) - The directory to list.
* test_data (iterable[str]) - Some default data for this step to return
when running under simulation. This should be the list of file items
found in this directory.
Returns list[Path]
Raises file.Error.
"""
assert isinstance(source, config_types.Path)
self.m.path.assert_absolute(source)
result = self._run(
name, ['listdir', source],
lambda: self.test_api.listdir(test_data),
self.m.raw_io.output_text())
ret = [source.join(x) for x in result.stdout.splitlines()]
result.presentation.logs['listdir'] = map(str, ret)
return ret
def ensure_directory(self, name, dest, mode=0777):
"""Ensures that `dest` exists and is a directory.
Args:
* name (str) - The name of the step.
* dest (Path) - The directory to ensure.
* mode (int) - The mode to use if the directory doesn't exist. This method
does not ensure the mode if the directory already exists (if you need
that behaviour, file a bug).
Raises file.Error if the path exists but is not a directory.
"""
self.m.path.assert_absolute(dest)
self._run(
name, ['ensure-directory', '--mode', oct(mode), dest])
self.m.path.mock_add_paths(dest)
def filesizes(self, name, files, test_data=None):
"""Returns list of filesizes for the given files.
Args:
* name (str) - The name of the step.
* files (list[Path]) - Paths to files.
Returns list[int], size of each file in bytes.
"""
if test_data is None:
test_data = [111 * (i+1) + (i % 3 - 2) * i for i, _ in enumerate(files)]
for f in files:
self.m.path.assert_absolute(f)
result = self._run(
name, ['filesizes'] + list(files),
lambda: self.test_api.filesizes(test_data),
self.m.raw_io.output_text())
ret = map(int, result.stdout.strip().splitlines())
result.presentation.logs['filesizes'] = ['%s: \t%d' % fs
for fs in zip(files, ret)]
return ret
def rmtree(self, name, source):
"""Recursively removes a directory.
This uses a native python on Linux/Mac, and uses `rd` on Windows to avoid
issues w.r.t. path lengths and read-only attributes. If the directory is
gone already, this returns without error.
Args:
* name (str) - The name of the step.
* source (Path) - The directory to remove.
Raises file.Error.
"""
self.m.path.assert_absolute(source)
self._run(name, ['rmtree', source])
self.m.path.mock_remove_paths(str(source))
def rmcontents(self, name, source):
"""Similar to rmtree, but removes only contents not the directory.
This is useful e.g. when removing contents of current working directory.
Deleting current working directory makes all further getcwd calls fail
until chdir is called. chdir would be tricky in recipes, so we provide
a call that doesn't delete the directory itself.
Args:
* name (str) - The name of the step.
* source (Path) - The directory whose contents should be removed.
Raises file.Error.
"""
self.m.path.assert_absolute(source)
self._run(name, ['rmcontents', source])
self.m.path.mock_remove_paths(str(source)+self.m.path.sep)
def rmglob(self, name, source, pattern):
"""Removes all entries in `source` matching the glob `pattern`.
Args:
* name (str) - The name of the step.
* source (Path) - The directory whose contents should be filtered and
removed.
* pattern (str) - The glob pattern to apply under `source`. Anything
matching this pattern will be removed.
Raises file.Error.
"""
self.m.path.assert_absolute(source)
self._run(name, ['rmglob', source, pattern])
src = str(source)
def filt(p):
assert p.startswith(src), (src, p)
return fnmatch.fnmatch(p[len(src)+1:].split(os.path.sep)[0], pattern)
self.m.path.mock_remove_paths(str(source), filt)
def symlink(self, name, source, linkname):
"""Creates a symlink on the local filesystem.
Behaves identically to os.symlink.
Args:
* name (str) - The name of the step.
* source (Path|Placeholder) - The path to link from.
* linkname (Path|Placeholder) - The destination to link to.
Raises file.Error
"""
self._assert_absolute_path_or_placeholder(source)
self._assert_absolute_path_or_placeholder(linkname)
self._run(name, ['symlink', source, linkname])
self.m.path.mock_copy_paths(source, linkname)
def symlink_tree(self, root):
"""Creates a SymlinkTree, given a root directory.
Args:
* root (Path): root of a tree of symlinks.
"""
return SymlinkTree(root, self.m, self.resource('symlink.py'))
def truncate(self, name, path, size_mb=100):
"""Creates an empty file with path and size_mb on the local filesystem.
Args:
* name (str) - The name of the step.
* path (Path|str) - The absolute path to create.
* size_mb (int) - The size of the file in megabytes. Defaults to 100
Raises file.Error
"""
self._assert_absolute_path_or_placeholder(path)
self._run(name, ['truncate', path, size_mb])
def flatten_single_directories(self, name, path):
"""Flattens singular directories, starting at path.
Example:
$ mkdir -p dir/which_has/some/singlular/subdirs/
$ touch dir/which_has/some/singlular/subdirs/with
$ touch dir/which_has/some/singlular/subdirs/files
$ flatten_single_directories(dir)
$ ls dir
with
files
This can be useful when you just want the 'meat' of a very sparse directory
structure. For example, some tarballs like `foo-1.2.tar.gz` extract all
their contents into a subdirectory `foo-1.2/`.
Using this function would essentially move all the actual contents of the
extracted archive up to the top level directory, removing the need to e.g.
hard-code/find the subfolder name after extraction (not all archives are
even named after the subfolder they extract to).
Args:
* name (str) - The name of the step.
* path (Path|str) - The absolute path to begin flattening.
Raises file.Error
"""
self.m.path.assert_absolute(path)
self._run(name, ['flatten_single_directories', path])