diff --git a/.gitignore b/.gitignore index 2333e55..49b3b87 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ examples/client/cache/ examples/client/programs/ examples/repo/keystore/ examples/repo/repository/ +.ruff_cache/ # files .tufup-repo-config diff --git a/src/tufup/client.py b/src/tufup/client.py index 5ccebd2..3de539a 100644 --- a/src/tufup/client.py +++ b/src/tufup/client.py @@ -1,8 +1,10 @@ import bsdiff4 +import json import logging import pathlib import shutil import sys +import tarfile import tempfile from typing import Callable, Dict, Iterator, List, Optional, Tuple, Union from urllib import parse @@ -13,11 +15,14 @@ import tuf.ngclient from tufup.common import TargetMeta +from tufup.utils import remove_path from tufup.utils.platform_specific import install_update logger = logging.getLogger(__name__) -DEFAULT_EXTRACT_DIR = pathlib.Path(tempfile.gettempdir()) / 'tufup' +# dir name must be unequivocally linked to tufup, as dir contents are removed +EXTRACT_DIR_NAME = 'tufup_extract_dir' +DEFAULT_EXTRACT_DIR = pathlib.Path(tempfile.gettempdir()) / EXTRACT_DIR_NAME class Client(tuf.ngclient.Updater): @@ -33,6 +38,7 @@ def __init__( extract_dir: Optional[pathlib.Path] = None, refresh_required: bool = False, session_auth: Optional[Dict[str, Union[Tuple[str, str], AuthBase]]] = None, + purge_extract_dir: bool = False, **kwargs, ): # tuf.ngclient.Updater.__init__ loads local root metadata automatically @@ -46,6 +52,7 @@ def __init__( ) self.app_install_dir = app_install_dir self.extract_dir = extract_dir + self.purge_extract_dir = purge_extract_dir self.refresh_required = refresh_required self.current_archive = TargetMeta(name=app_name, version=current_version) self.current_archive_local_path = target_dir / self.current_archive.path @@ -257,17 +264,9 @@ def _apply_updates( self.new_archive_info.verify_length_and_hashes(data=archive_bytes) # write the patched new archive self.new_archive_local_path.write_bytes(archive_bytes) - # extract archive to temporary directory - if self.extract_dir is None: - self.extract_dir = DEFAULT_EXTRACT_DIR - self.extract_dir.mkdir(exist_ok=True) - logger.debug(f'default extract dir created: {self.extract_dir}') - # extract - shutil.unpack_archive( - filename=self.new_archive_local_path, extract_dir=self.extract_dir - ) - logger.debug(f'files extracted to {self.extract_dir}') - # install + # extract archive to "temporary" directory + self._extract_archive() + # install, i.e. move extracted files from "temporary" dir to final "install" dir confirmation_message = f'Install update in {self.app_install_dir}? [y]/n' if skip_confirmation or input(confirmation_message) in ['y', '']: # start a script that moves the extracted files to the app install @@ -279,7 +278,24 @@ def _apply_updates( ) else: logger.warning('Installation aborted.') - # todo: clean up deprecated local archive + + def _extract_archive(self): + # ensure extract_dir exists + if self.extract_dir is None: + self.extract_dir = DEFAULT_EXTRACT_DIR + logger.debug(f'using default extract_dir: {self.extract_dir}') + self.extract_dir.mkdir(exist_ok=True) + # purge extract_dir + purge_manifest = PurgeManifest(dir_to_purge=self.extract_dir) + if self.purge_extract_dir: + purge_manifest.purge() + # extract the new archive into the extract_dir + shutil.unpack_archive( + filename=self.new_archive_local_path, extract_dir=self.extract_dir + ) + logger.debug(f'files extracted to {self.extract_dir}') + # create new purge manifest in extract_dir + purge_manifest.create_from_archive(archive=self.new_archive_local_path) class AuthRequestsFetcher(tuf.ngclient.RequestsFetcher): @@ -356,3 +372,70 @@ def _chunks(self, response: 'requests.Response') -> Iterator[bytes]: for data in super()._chunks(response=response): self._progress(bytes_new=len(data)) yield data + + +class PurgeManifest(object): + _filename = 'purge.manifest' + + def __init__(self, dir_to_purge: Union[pathlib.Path, str]): + """ + a purge manifest is used to remove files and subdirectories from the + specified directory (`dir_to_purge`) in a safe manner, i.e. minimizing the + risk of accidental removal of pre-existing files/dirs that are unrelated to + the present application + + this is achieved by removing only those items listed in a purge.manifest file + in the specified directory + + if there is no purge.manifest file, nothing is removed + + the purge.manifest file should be created (or updated) whenever files are + added to the `dir_to_purge` + + the purge manifest can be used to clear the extract_dir or the + app_install_dir, for example + """ + self.dir_to_purge = pathlib.Path(dir_to_purge) + + @property + def file_path(self) -> pathlib.Path: + return self.dir_to_purge / self._filename + + def read_from_file(self) -> Optional[List[str]]: + if not self.file_path.exists(): + logger.warning(f'cannot read manifest: {self.file_path} not found') + return + return json.loads(self.file_path.read_text()) + + def write_to_file(self, manifest: List[str]): + self.file_path.write_text(json.dumps(manifest)) + logger.debug(f'purge manifest created: {self.file_path}') + + def purge(self) -> None: + """ + remove all files/folders listed in the purge manifest from the specified + directory (`dir_to_purge`) + """ + manifest = self.read_from_file() + if manifest: + for path in self.dir_to_purge.iterdir(): + relative_path = path.relative_to(self.dir_to_purge) + if str(relative_path) in manifest: + remove_path(path=path, remove_readonly=True) + else: + logger.warning(f'{path} remains: not in {self._filename}') + logger.info(f'directory purged: {self.dir_to_purge}') + + def create_from_archive(self, archive: Union[pathlib.Path, str]) -> None: + """create/overwrite purge manifest in extract_dir""" + # note that `shutil.make_archive` puts everything in a dir called "." (for + # cwd) and prefixes items with "./" (if this ever poses a problem, we could + # switch to using `tarfile.TarFile.add()` with the `arcname` argument) see + # e.g https://stackoverflow.com/a/70081512 + with tarfile.open(archive) as tar: + manifest = [self._filename] + [ + tarinfo.name[2:] if tarinfo.name.startswith('./') else tarinfo.name + for tarinfo in tar + if tarinfo.name != '.' + ] + self.write_to_file(manifest=manifest) diff --git a/src/tufup/repo/__init__.py b/src/tufup/repo/__init__.py index 30b8890..cf49e9a 100644 --- a/src/tufup/repo/__init__.py +++ b/src/tufup/repo/__init__.py @@ -97,7 +97,7 @@ def make_gztar_archive( if input(f'Found existing archive: {archive_path}.\nOverwrite? [n]/y') != 'y': print('Using existing archive.') return TargetMeta(archive_path) - # make archive + # make archive (see https://stackoverflow.com/a/70081512 about "." directory) base_name = str(dst_dir / archive_filename.replace(SUFFIX_ARCHIVE, '')) archive_path_str = shutil.make_archive( base_name=base_name, # archive file path, no suffix diff --git a/src/tufup/utils/__init__.py b/src/tufup/utils/__init__.py index b63ccd9..10c0437 100644 --- a/src/tufup/utils/__init__.py +++ b/src/tufup/utils/__init__.py @@ -1,4 +1,5 @@ import logging +import os import pathlib import shutil import sys @@ -9,7 +10,7 @@ _INPUT_SEPARATOR = ' ' -def remove_path(path: Union[pathlib.Path, str]) -> bool: +def remove_path(path: Union[pathlib.Path, str], remove_readonly=False) -> bool: """ Recursively remove directory or file at specified path. @@ -18,11 +19,27 @@ def remove_path(path: Union[pathlib.Path, str]) -> bool: for path in my_dir_path.iterdir(): remove_path(path) """ + def _remove_readonly(failed_function, failed_path, _): + """ + clear the readonly bit and reattempt the removal + + copied from https://docs.python.org/3/library/shutil.html#rmtree-example + """ + os.chmod(failed_path, 0o777) + failed_function(failed_path) + # enforce pathlib.Path path = pathlib.Path(path) try: + if remove_readonly: + # override any read-only permissions (note this will fail when dealing + # with a file in a readonly directory on linux, but the alternative would + # be to (temporarily) change permissions on the parent directory) + path.chmod(0o777) if path.is_dir(): - shutil.rmtree(path=path) + shutil.rmtree( + path=path, onerror=_remove_readonly if remove_readonly else None + ) utils_logger.debug(f'Removed directory {path}') elif path.is_file(): path.unlink() diff --git a/src/tufup/utils/platform_specific.py b/src/tufup/utils/platform_specific.py index 096519c..323989a 100644 --- a/src/tufup/utils/platform_specific.py +++ b/src/tufup/utils/platform_specific.py @@ -128,7 +128,7 @@ def _install_update_win( batch_template_extra_kwargs: Optional[dict] = None, log_file_name: Optional[str] = None, robocopy_options_override: Optional[List[str]] = None, - process_creation_flags = None, + process_creation_flags=None, ): """ Create a batch script that moves files from src to dst, then run the diff --git a/tests/data/README.md b/tests/data/README.md index 1fb1581..f9fc49a 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -1,3 +1,5 @@ Large parts of the test data were copied verbatim from the `python-tuf` [repository_data][1] folder. +The repository data were created using `examples/repo_workflow_example.py`. + [1]: https://github.com/theupdateframework/python-tuf/tree/develop/tests/repository_data diff --git a/tests/test_client.py b/tests/test_client.py index 482652c..b5a7c38 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,7 +1,10 @@ +import json import logging import os import pathlib import shutil +import subprocess +import tarfile from typing import Optional import unittest from unittest.mock import Mock, patch @@ -11,8 +14,10 @@ from tuf.ngclient import TargetFile from tests import TempDirTestCase, TEST_REPO_DIR -from tufup.client import AuthRequestsFetcher, Client +import tufup.client # for patch +from tufup.client import AuthRequestsFetcher, Client, EXTRACT_DIR_NAME, PurgeManifest from tufup.common import TargetMeta +from tufup.utils.platform_specific import ON_WINDOWS ROOT_FILENAME = 'root.json' TARGETS_FILENAME = 'targets.json' @@ -77,6 +82,25 @@ def get_refreshed_client(self): ) return client + def populate_refreshed_client(self, client): + # directly use target files from test repo as downloaded files + client.downloaded_target_files = { + target_meta: TEST_REPO_DIR / 'targets' / str(target_meta) + for target_meta in client.trusted_target_metas + if target_meta.is_patch and str(target_meta.version) in ['2.0', '3.0rc0'] + } + # specify new archive (normally done in _check_updates) + archives = [ + tp + for tp in client.trusted_target_metas + if tp.is_archive and str(tp.version) == '3.0rc0' + ] + client.new_archive_info = client.get_targetinfo(archives[-1]) + client.new_archive_local_path = pathlib.Path( + client.target_dir, client.new_archive_info.path + ) + return client + def test_init_no_metadata(self): # cannot initialize without root metadata file (self.metadata_dir / ROOT_FILENAME).unlink() @@ -185,23 +209,7 @@ def test__download_updates(self): self.assertEqual(downloaded_path, str(local_path)) def test__apply_updates(self): - client = self.get_refreshed_client() - # directly use target files from test repo as downloaded files - client.downloaded_target_files = { - target_meta: TEST_REPO_DIR / 'targets' / str(target_meta) - for target_meta in client.trusted_target_metas - if target_meta.is_patch and str(target_meta.version) in ['2.0', '3.0rc0'] - } - # specify new archive (normally done in _check_updates) - archives = [ - tp - for tp in client.trusted_target_metas - if tp.is_archive and str(tp.version) == '3.0rc0' - ] - client.new_archive_info = client.get_targetinfo(archives[-1]) - client.new_archive_local_path = pathlib.Path( - client.target_dir, client.new_archive_info.path - ) + client = self.populate_refreshed_client(client=self.get_refreshed_client()) # test confirmation mock_install = Mock() with patch('builtins.input', Mock(return_value='y')): @@ -213,6 +221,44 @@ def test__apply_updates(self): client._apply_updates(install=mock_install, skip_confirmation=True) mock_install.assert_called() + def test__extract_archive_with_purge(self): + temp_extract_dir = self.temp_dir_path / EXTRACT_DIR_NAME + temp_extract_dir.mkdir() + self.client_kwargs['extract_dir'] = temp_extract_dir + self.client_kwargs['purge_extract_dir'] = True + client = self.populate_refreshed_client(client=self.get_refreshed_client()) + # ensure new archive exists in targets dir(dummy) + shutil.copy( + src=TEST_REPO_DIR / 'targets' / client.new_archive_local_path.name, + dst=client.new_archive_local_path, + ) + # test extract + client._extract_archive() + self.assertTrue(any(client.extract_dir.iterdir())) + # a purge manifest file should now exist + purge_manifest = PurgeManifest(dir_to_purge=client.extract_dir) + self.assertTrue(purge_manifest.file_path.exists()) + + def test__extract_archive_without_purge(self): + temp_extract_dir = self.temp_dir_path / EXTRACT_DIR_NAME + temp_extract_dir.mkdir() + self.client_kwargs['extract_dir'] = temp_extract_dir + self.client_kwargs['purge_extract_dir'] = False + client = self.populate_refreshed_client(client=self.get_refreshed_client()) + # ensure new archive exists in targets dir(dummy) + shutil.copy( + src=TEST_REPO_DIR / 'targets' / client.new_archive_local_path.name, + dst=client.new_archive_local_path, + ) + # test extract + with patch.object(tufup.client.PurgeManifest, 'purge') as mock_purge: + client._extract_archive() + # purge must not be called + self.assertFalse(mock_purge.called) + # a purge manifest file should now exist + purge_manifest = PurgeManifest(dir_to_purge=client.extract_dir) + self.assertTrue(purge_manifest.file_path.exists()) + class AuthRequestsFetcherTests(unittest.TestCase): def setUp(self) -> None: @@ -308,3 +354,109 @@ def mock_iter_content(*args): for __ in fetcher._chunks(response=mock_response): pass self.assertEqual(chunk_count, mock_hook.call_count) + + +class PurgeManifestTests(TempDirTestCase): + def test_init(self): + self.assertTrue(PurgeManifest(dir_to_purge='some/path')) + + def test_file_path_property(self): + self.assertTrue(PurgeManifest(dir_to_purge='some/path').file_path) + + def test_read_from_file(self): + purge_manifest = PurgeManifest(dir_to_purge=self.temp_dir_path) + self.assertIsNone(purge_manifest.read_from_file()) + # write dummy manifest file + purge_manifest.file_path.write_text('[]') + # test + self.assertEqual([], purge_manifest.read_from_file()) + + def test_write_to_file(self): + purge_manifest = PurgeManifest(dir_to_purge=self.temp_dir_path) + manifest = ['some.file'] + purge_manifest.write_to_file(manifest=manifest) + self.assertEqual(manifest, json.loads(purge_manifest.file_path.read_text())) + + def test_purge_no_manifest_file(self): + # prepare + dir_to_purge = self.temp_dir_path + dummy_file_path = dir_to_purge.joinpath('dummy.file') + dummy_file_path.touch() + purge_manifest = PurgeManifest(dir_to_purge=dir_to_purge) + # test + purge_manifest.purge() + self.assertTrue(dummy_file_path.exists()) + + def test_purge(self): + # create dummy files + dir_to_purge = self.temp_dir_path + subdir = dir_to_purge / 'subdir' + subdir.mkdir() + readonly_file = dir_to_purge / 'readonly.dummy' + items_to_purge = [ + readonly_file, + dir_to_purge / 'some.dummy', + subdir / 'other.dummy', + subdir, + ] + items_to_keep = [dir_to_purge / 'file.to.keep'] + for item in items_to_purge + items_to_keep: + if not item.exists(): + item.touch() + # make sure readonly file is readonly + readonly_file.chmod(0o444) # could also use touch(mode=0o444) + self.assertFalse(os.access(readonly_file, os.W_OK)) + # write manifest file manually (when writing the manifest from a .tar.gz + # archive, each dir and each item in that dir is listed, recursively, + # so we also need to include both subdir and the items inside subdir here) + purge_manifest = PurgeManifest(dir_to_purge=dir_to_purge) + manifest = [ + str(item.relative_to(dir_to_purge)) + for item in items_to_purge + [purge_manifest.file_path] + ] + purge_manifest.file_path.write_text(json.dumps(manifest)) + # test + purge_manifest.purge() + for path in items_to_purge: + self.assertFalse(path.exists()) + for path in items_to_keep: + self.assertTrue(path.exists()) + + def test_create_from_archive(self): + # create dummy files, including some hidden + dir_to_purge = self.temp_dir_path + hidden_subdir = dir_to_purge / '.hidden' + hidden_subdir.mkdir() + if ON_WINDOWS: + # https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/attrib + subprocess.run(['attrib', '+h', str(hidden_subdir)], check=True) + dummy_file_in_hidden_subdir = hidden_subdir / 'other.dummy' + dummy_file_in_hidden_subdir.touch() + dummy_file = dir_to_purge / 'some.dummy' + dummy_file.touch() + # create dummy archive + archive_path = self.temp_dir_path / 'dummy_archive.tar.gz' + # todo: should create tar using shutil.make_archive, like in make_gztar_archive, to test handling of "." and "./" + with tarfile.open(archive_path, mode='w:gz') as tar: + for path in [dummy_file, hidden_subdir]: + tar.add( + name=path, arcname=path.relative_to(dir_to_purge), recursive=True + ) + # test + purge_manifest = PurgeManifest(dir_to_purge=dir_to_purge) + purge_manifest.create_from_archive(archive=archive_path) + self.assertTrue(purge_manifest.file_path.exists()) + # load manifest data manually (normally we would use read_from_file) + manifest = json.loads(purge_manifest.file_path.read_text()) + self.assertEqual( + { + item.relative_to(dir_to_purge).as_posix() + for item in [ + purge_manifest.file_path, + hidden_subdir, + dummy_file, + dummy_file_in_hidden_subdir, + ] + }, + set(manifest), + ) diff --git a/tests/test_repo.py b/tests/test_repo.py index 4ed6170..fd644ad 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -3,6 +3,8 @@ import json import logging import pathlib +import shutil +import tarfile from tempfile import TemporaryDirectory from time import sleep from unittest.mock import Mock, patch @@ -892,3 +894,76 @@ def test_publish_changes(self): # verify change in config config_from_disk = repo.load_config() self.assertIn(config_change, config_from_disk['encrypted_keys']) + + +class ArchivingTests(TempDirTestCase): + def test_equivalence_shutil_vs_tarfile(self): + """ + compares archives created using the high-level shutil.make_archive with those + created using the low-level tarfile approach, to test their equivalence + + the reason is that shutil puts all content inside a "." directory in the + archive, see e.g. [1] + + [1]: https://stackoverflow.com/a/70081512 + """ + + def make_archive_using_shutil(src_dir, dst_dir, filename): + """this is a simplified version of repo.make_gztar_archive""" + archive_path = dst_dir / filename + archive_path_str = shutil.make_archive( + base_name=str(archive_path).replace('.tar.gz', ''), # no suffix + root_dir=str(src_dir), # paths in archive will be relative to root_dir + format='gztar', + ) + self.assertEqual( + archive_path.resolve(), pathlib.Path(archive_path_str).resolve() + ) + return archive_path + + def make_archive_using_tarfile(src_dir, dst_dir, filename): + archive_path = dst_dir / filename + with tarfile.open(archive_path, mode='w:gz') as tar: + for item in src_dir.iterdir(): + tar.add(item, arcname=item.relative_to(src_dir)) + return archive_path + + def list_archive_content(archive_path): + # alternatively we could run `tar -f --list` using subprocess + # but on windows we would need to do that via powershell + with tarfile.open(archive_path) as tar: + content = [item.name for item in tar] + return content + + # create dummy content + dst_dir = self.temp_dir_path + src_dir = self.temp_dir_path / 'source' + src_dir.mkdir() + dummy_file_name = 'dummy.file' + dummy_file = src_dir / dummy_file_name + dummy_file.touch() + # create archive using shutil + shutil_archive = make_archive_using_shutil( + src_dir=src_dir, dst_dir=dst_dir, filename='shutil_archive.tar.gz' + ) + tarfile_archive = make_archive_using_tarfile( + src_dir=src_dir, dst_dir=dst_dir, filename='tarfile_archive.tar.gz' + ) + # make sure the archives were created in the correct location + for archive in [shutil_archive, tarfile_archive]: + self.assertIn(archive, list(self.temp_dir_path.iterdir())) + # list archive content using tarfile (this shows the difference: shutil + # places everything in a subdirectory called ".") + for archive, expected in [ + (shutil_archive, ['.', './' + dummy_file_name]), + (tarfile_archive, [dummy_file_name]), + ]: + self.assertEqual(expected, list_archive_content(archive)) + # verify that the "." dir from shutil does not pose a problem upon extraction + output_dir = self.temp_dir_path / 'output' + output_dir.mkdir() + with tarfile.open(shutil_archive) as tar: + tar.extractall(path=output_dir) + output_dir_items = list(output_dir.iterdir()) + self.assertEqual(1, len(output_dir_items)) + self.assertEqual(dummy_file_name, output_dir_items[0].name) diff --git a/tests/test_utils.py b/tests/test_utils.py index 64e73c0..6fef185 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ from unittest.mock import Mock, patch import tufup.utils +from tufup.utils.platform_specific import ON_WINDOWS from tests import TempDirTestCase @@ -23,6 +24,77 @@ def test_remove_path(self): self.assertTrue(tufup.utils.remove_path(path=arg_type(dir_path))) self.assertFalse(dir_path.exists()) + def test_remove_dir_containing_readonly_file(self): + """ + on linux: a readonly file does not prevent deletion of the parent directory + + on windows: a readonly file prevents deletion of the parent directory + """ + # prepare readonly file + dir_path = self.temp_dir_path / 'dir' + file_path = dir_path / 'readonly.file' + dir_path.mkdir() + file_path.touch(mode=0o444) + # test + self.assertEqual(not ON_WINDOWS, tufup.utils.remove_path(dir_path)) + if ON_WINDOWS: + self.assertTrue(tufup.utils.remove_path(dir_path, remove_readonly=True)) + + def test_remove_readonly_file(self): + """ + on linux: a readonly file does not prevent deletion of the file + + on windows: a readonly file prevents file deletion + """ + # prepare readonly file + dir_path = self.temp_dir_path / 'dir' + file_path = dir_path / 'readonly.file' + dir_path.mkdir() + file_path.touch(mode=0o444) + # test + self.assertEqual(not ON_WINDOWS, tufup.utils.remove_path(file_path)) + if ON_WINDOWS: + self.assertTrue(tufup.utils.remove_path(file_path, remove_readonly=True)) + + def test_remove_readonly_dir(self): + """ + on linux: a readonly directory prevents deletion of the directory itself + + on windows: a "readonly" directory prevents deletion of the directory, + even though some sources suggest "the Read-only attribute for a folder is + typically ignored by Windows" [1] + + [1]: https://support.microsoft.com/en-gb/topic/you-cannot-view-or-change-the-read-only-or-the-system-attributes-of-folders-in-windows-server-2003-in-windows-xp-in-windows-vista-or-in-windows-7-55bd5ec5-d19e-6173-0df1-8f5b49247165 + """ + # prepare readonly directory + dir_path = self.temp_dir_path / 'readonly_dir' + file_path = dir_path / 'dummy.file' + dir_path.mkdir() + file_path.touch() + dir_path.chmod(0o555) # dir must have execution permission + # test + self.assertFalse(tufup.utils.remove_path(dir_path)) + self.assertTrue(tufup.utils.remove_path(dir_path, remove_readonly=True)) + + def test_remove_file_from_readonly_dir(self): + """ + on linux: a readonly directory prevents deletion of the files in the directory + + on windows: a readonly directory does not prevent file deletion + """ + # prepare readonly directory + dir_path = self.temp_dir_path / 'readonly_dir' + file_path = dir_path / 'dummy.file' + dir_path.mkdir() + file_path.touch() + dir_path.chmod(0o555) # dir must have execution permission + # test + self.assertEqual(ON_WINDOWS, tufup.utils.remove_path(file_path)) + if not ON_WINDOWS: + # for this to work, remove_path would have to change permissions on the + # file's parent directory, which could lead to unwelcome surprises... + self.assertFalse(tufup.utils.remove_path(file_path, remove_readonly=True)) + class InputTests(unittest.TestCase): def test_input_bool(self):