blob: b2e11a5ab0ab24ad4dc7763a523948f1f7f84318 [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 recipe_api
import os
import fnmatch
# 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 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.
"""
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.
"""
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, link):
"""Creates a symlink from link to source on the local filesystem.
Behaves identically to os.symlink.
Args:
* name (str) - The name of the step.
* source (Path|Placeholder) - The path to link to.
* link (Path|Placeholder) - The link to create.
Raises file.Error
"""
self._assert_absolute_path_or_placeholder(source)
self._assert_absolute_path_or_placeholder(link)
self._run(name, ['symlink', source, link])
self.m.path.mock_copy_paths(source, link)