From 31ac8cf4c73eb5128f48b96edb175d58ca8c7680 Mon Sep 17 00:00:00 2001 From: Julien M Date: Sat, 26 Nov 2022 14:49:36 +0100 Subject: [PATCH 1/4] Add utils module to check path --- qgis_deployment_toolbelt/utils/__init__.py | 2 + qgis_deployment_toolbelt/utils/check_path.py | 245 ++++++++++++++++++ tests/test_utils_check_path.py | 252 +++++++++++++++++++ 3 files changed, 499 insertions(+) create mode 100755 qgis_deployment_toolbelt/utils/check_path.py create mode 100644 tests/test_utils_check_path.py diff --git a/qgis_deployment_toolbelt/utils/__init__.py b/qgis_deployment_toolbelt/utils/__init__.py index 2463fec3..7c180a2e 100644 --- a/qgis_deployment_toolbelt/utils/__init__.py +++ b/qgis_deployment_toolbelt/utils/__init__.py @@ -1,5 +1,7 @@ #! python3 # noqa: E265 +"""Common tools and helpers.""" + # submodules from .proxies import get_proxy_settings # noqa: E402 F401 from .str2bool import str2bool # noqa: E402 F401 diff --git a/qgis_deployment_toolbelt/utils/check_path.py b/qgis_deployment_toolbelt/utils/check_path.py new file mode 100755 index 00000000..e8af2c2b --- /dev/null +++ b/qgis_deployment_toolbelt/utils/check_path.py @@ -0,0 +1,245 @@ +#! python3 # noqa: E265 + +""" + Helpers to check file: readable, exists, etc.. + + Author: Julien Moura (https://github.com/guts) +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import logging +from os import R_OK, W_OK, access +from pathlib import Path +from typing import Union + +# ############################################################################# +# ########## Globals ############### +# ################################## + +# logs +logger = logging.getLogger(__name__) + +# ############################################################################# +# ########## Functions ############# +# ################################## + + +def check_var_can_be_path(input_var: str, raise_error: bool = True) -> bool: + """Check is the path can be converted as pathlib.Path. + + Args: + input_var (str): var to check + raise_error (bool, optional): if True, it raises an exception. Defaults to True. + + Raises: + TypeError: if input path can't be converted and raise_error is False + + Returns: + bool: True if the input can be converted to pathlib.Path + """ + try: + input_var = Path(input_var) + return True + except Exception as exc: + error_message = f"Converting {input_var} into Path failed. Trace: {exc}" + if raise_error: + raise TypeError(error_message) + else: + logger.error(error_message) + return False + + +def check_path_exists(input_path: Union[str, Path], raise_error: bool = True) -> bool: + """Check if the input path (file or folder) exists. + + Args: + input_path (Union[str, Path]): path to check + raise_error (bool, optional): if True, it raises an exception. Defaults to True. + + Raises: + FileExistsError: if the path doesn't exist and raise_error is False + + Returns: + bool: True if the path exists. + """ + if not isinstance(input_path, Path): + if ( + not check_var_can_be_path(input_path, raise_error=raise_error) + and not raise_error + ): + return False + # if previous check passed, let's convert it safely + input_path = Path(input_path) + if not input_path.exists(): + error_message = f"{input_path.resolve()} doesn't exist." + if raise_error: + raise FileExistsError(error_message) + else: + logger.error(error_message) + return False + else: + return True + + +def check_path_is_readable(input_path: Path, raise_error: bool = True) -> bool: + """Check if the input path (file or folder) is readable. + + Args: + input_path (Path): path to check + raise_error (bool, optional): if True, it raises an exception. Defaults to True. + + Raises: + FileExistsError: if the path is not readable and raise_error is False + + Returns: + bool: True if the path is readable. + """ + # firstly check the path is valid and exists + if not isinstance(input_path, Path): + if ( + not check_path_exists(input_path, raise_error=raise_error) + and not raise_error + ): + return False + # if previous check passed, let's convert it safely + input_path = Path(input_path) + + if not access(input_path, R_OK): + error_message = f"{input_path.resolve()} isn't readable." + if raise_error: + raise IOError(error_message) + else: + logger.error(f"{input_path.resolve()} isn't readable.") + return False + else: + return True + + +def check_path_is_writable(input_path: Path, raise_error: bool = True) -> bool: + """Check if the input path (file or folder) is writable. + + Args: + input_path (Path): path to check + raise_error (bool, optional): if True, it raises an exception. Defaults to True. + + Raises: + FileExistsError: if the path is not writable and raise_error is False + + Returns: + bool: True if the path is writable. + """ + # firstly check the path is valid and exists + if not isinstance(input_path, Path): + if ( + not check_path_exists(input_path, raise_error=raise_error) + and not raise_error + ): + return False + # if previous check passed, let's convert it safely + input_path = Path(input_path) + + if not access(input_path, W_OK): + error_message = f"{input_path.resolve()} isn't writable." + if raise_error: + raise IOError(error_message) + else: + logger.error(error_message) + return False + else: + return True + + +def check_path( + input_path: Union[str, Path], + must_exists: bool = True, + must_be_readable: bool = True, + must_be_writable: bool = False, + must_be_a_folder: bool = False, + must_be_a_file: bool = False, + raise_error: bool = True, +) -> bool: + """Meta function of the module. Check if a given path complies with some constraints. + + Args: + input_path (Union[str, Path]): path to check + must_exists (bool, optional): path must exist. Defaults to True. + must_be_readable (bool, optional): path must be readable. Defaults to True. + must_be_writable (bool, optional): path must be writable. Defaults to False. + must_be_a_folder (bool, optional): path must be a folder. Mutually exclusive \ + with must_be_a_file. Defaults to False. + must_be_a_file (bool, optional): path must be a file. Mutually exclusive with \ + must_be_a_folder. Defaults to False. + raise_error (bool, optional): if True, it raises an exception. Defaults to True. + + Raises: + ValueError: if must_be_a_file and must_be_a_folder are both set to True + FileNotFoundError: if the path is not a file and must_be_a_file is set to True + NotADirectoryError: if the path is not a folder and must_be_a_folder is set to True + + Returns: + bool: True if the path complies with constraints. + """ + # check arguments + if all([must_be_a_file, must_be_a_folder]): + raise ValueError( + "These options are mutually exclusive: must_be_a_file, must_be_a_folder" + ) + + # check input path if usable with pathlib.Path + if not isinstance(input_path, Path): + check_var = check_var_can_be_path(input_var=input_path, raise_error=raise_error) + if not check_var and not raise_error: + return False + input_path = Path(input_path) + + # check + if must_exists: + check_exist = check_path_exists(input_path=input_path, raise_error=raise_error) + if not check_exist and not raise_error: + return False + + # check file or folder + if must_be_a_file and not input_path.is_file(): + error_message = f"{input_path.resolve()} is not a file." + if raise_error: + raise FileNotFoundError(error_message) + else: + logger.error(error_message) + return False + if must_be_a_folder and not input_path.is_dir(): + error_message = f"{input_path.resolve()} is not a folder." + if raise_error: + raise NotADirectoryError(error_message) + else: + logger.error(error_message) + return False + + # check chmod + if must_be_readable: + check_readable = check_path_is_readable( + input_path=input_path, raise_error=raise_error + ) + if not check_readable and not raise_error: + return False + + if must_be_writable: + check_writable = check_path_is_writable( + input_path=input_path, raise_error=raise_error + ) + if not check_writable and not raise_error: + return False + + return True + + +# ############################################################################ +# ##### Stand alone program ######## +# ################################## + +if __name__ == "__main__": + """Standalone execution.""" + pass diff --git a/tests/test_utils_check_path.py b/tests/test_utils_check_path.py new file mode 100644 index 00000000..f58cb64a --- /dev/null +++ b/tests/test_utils_check_path.py @@ -0,0 +1,252 @@ +#! python3 # noqa E265 + +""" + Usage from the repo root folder: + + .. code-block:: bash + # for whole tests + python -m unittest tests.test_utils_check_path + # for specific test + python -m unittest tests.test_utils_check_path.TestUtilsCheckPath.test_check_path_as_str_ok +""" + + +# standard library +import stat +import unittest +from os import chmod, getenv +from pathlib import Path + +# project +from src.utils.check_path import ( + check_path, + check_path_exists, + check_path_is_readable, + check_path_is_writable, + check_var_can_be_path, +) + +# ############################################################################ +# ########## Classes ############# +# ################################ + + +class TestUtilsCheckPath(unittest.TestCase): + """Test package metadata.""" + + def test_check_path_as_str_ok(self): + """Test filepath as str is converted into Path.""" + self.assertTrue( + check_var_can_be_path(input_var="/this/is/an/imaginary/file.imaginary") + ) + + def test_check_path_as_str_ko(self): + """Test filepath from int can't be converted into Path.""" + with self.assertRaises(TypeError): + check_var_can_be_path(input_var=1000) + # no exception but False + self.assertFalse(check_var_can_be_path(input_var=1000, raise_error=False)) + + def test_check_path_exists_ok(self): + """Test path exists.""" + # a valid Path instance pointing to an existing file + self.assertTrue(check_path_exists(input_path=Path("setup.py"))) + # str is valid and point to an existing file + self.assertTrue(check_path_exists(input_path="setup.py")) + + def test_check_path_exists_ko(self): + """Test path exists fail cases.""" + # str is not valid + with self.assertRaises(TypeError): + check_path_exists(input_path=1000) + self.assertFalse(check_path_exists(input_path=1000, raise_error=False)) + # str is valid but not an existing file + with self.assertRaises(FileExistsError): + check_path_exists(input_path="/this/is/an/imaginary/file.imaginary") + # no exception but False + self.assertFalse( + check_path_exists( + input_path="/this/is/an/imaginary/file.imaginary", raise_error=False + ) + ) + + def test_check_path_readable_ok(self): + """Test path is readable.""" + # a valid Path instance pointing to an existing file which is readable + self.assertTrue(check_path_is_readable(input_path=Path("setup.py"))) + # str is valid and point to an existing file which is readable + self.assertTrue(check_path_is_readable(input_path="setup.py")) + + def test_check_path_readable_ko(self): + """Test path is readable fail cases.""" + # str is not valid + with self.assertRaises(TypeError): + check_path_is_readable(input_path=1000) + self.assertFalse(check_path_is_readable(input_path=1000, raise_error=False)) + + @unittest.skipIf( + getenv("CI"), "Creating file on CI with specific rights is not working." + ) + def test_check_path_readable_ko_specific(self): + """Test path is readable fail cases.""" + # temporary fixture + new_file = Path("tests/tmp_file_no_readable.txt") + new_file.touch(mode=0o333, exist_ok=True) + + # str not valid, an existing file but not readable + with self.assertRaises(IOError): + check_path_is_readable(input_path=new_file) + + # no exception but False + self.assertFalse(check_path_is_readable(input_path=new_file, raise_error=False)) + + # temporary fixture + new_file.unlink(missing_ok=True) + + def test_check_path_writable_ok(self): + """Test path is writable.""" + # a valid Path instance pointing to an existing file which is writable + self.assertTrue(check_path_is_writable(input_path=Path("setup.py"))) + # str is valid and point to an existing file which is writable + self.assertTrue(check_path_is_writable(input_path="setup.py")) + + def test_check_path_writable_ko(self): + """Test path is writable fail cases.""" + # str is not valid + with self.assertRaises(TypeError): + check_path_exists(input_path=1000) + self.assertFalse(check_path_is_writable(input_path=1000, raise_error=False)) + + @unittest.skipIf( + getenv("CI"), "Creating file on CI with specific rights is not working." + ) + def test_check_path_writable_ko_specific(self): + """Test path is writable fail cases (specific).""" + # temporary fixture + not_writable_file = Path("tests/tmp_file_no_writable.txt") + not_writable_file.touch(mode=0o400, exist_ok=True) + + # str not valid, an existing file but not writable + with self.assertRaises(IOError): + check_path_is_writable(input_path=not_writable_file) + + # no exception but False + self.assertFalse( + check_path_is_writable(input_path=not_writable_file, raise_error=False) + ) + + not_writable_file.unlink() + + def test_check_path_meta_ok(self): + """Test meta check path.""" + # an existing file + check_path( + input_path="requirements.txt", + must_be_a_file=True, + must_be_a_folder=False, + ) + check_path( + input_path=Path("requirements.txt"), + must_be_a_file=True, + must_be_a_folder=False, + ) + + # an existing folder + check_path( + input_path=Path(__file__).parent, + must_be_a_file=False, + must_be_a_folder=True, + ) + + def test_check_path_meta_ko(self): + """Test meta check path fail cases.""" + # invalid path + with self.assertRaises(TypeError): + check_path( + input_path=1000, + ) + self.assertFalse(check_path(input_path=1000, raise_error=False)) + + # mutual exclusive options + with self.assertRaises(ValueError): + check_path( + input_path="requirements.txt", + must_be_a_file=True, + must_be_a_folder=True, + ) + + # must exist + self.assertFalse( + check_path(input_path="imaginary/path", raise_error=False, must_exists=True) + ) + with self.assertRaises(FileExistsError): + check_path(input_path="imaginary/path", must_exists=True) + + # must be a file + self.assertFalse( + check_path( + input_path=Path(__file__).parent, + raise_error=False, + must_exists=True, + must_be_a_file=True, + ) + ) + with self.assertRaises(FileNotFoundError): + check_path( + input_path=Path(__file__).parent, + raise_error=True, + must_exists=True, + must_be_a_file=True, + ) + + # must be a folder + self.assertFalse( + check_path( + input_path=Path(__file__), + raise_error=False, + must_exists=True, + must_be_a_folder=True, + ) + ) + with self.assertRaises(NotADirectoryError): + check_path( + input_path=Path(__file__), + raise_error=True, + must_exists=True, + must_be_a_folder=True, + ) + + @unittest.skipIf( + getenv("CI"), "Creating file on CI with specific rights is not working." + ) + def test_check_path_meta_ko_specific(self): + """Test meta check path is readbale / writable fail cases (specific).""" + # temporary fixture + not_writable_file = Path("tests/tmp_file_no_writable.txt") + not_writable_file.touch() + chmod(not_writable_file, stat.S_IREAD | stat.S_IROTH) + + # str not valid, an existing file but not writable + with self.assertRaises(IOError): + check_path( + input_path=not_writable_file, must_be_a_file=True, must_be_writable=True + ) + + # no exception but False + self.assertFalse( + check_path( + input_path=not_writable_file, + must_be_a_file=True, + must_be_writable=True, + raise_error=False, + ) + ) + + not_writable_file.unlink() + + +# ############################################################################ +# ####### Stand-alone run ######## +# ################################ +if __name__ == "__main__": + unittest.main() From 5f273240ff6b100ce4a6e9e955c3b9b2e4fba5b1 Mon Sep 17 00:00:00 2001 From: Julien M Date: Sat, 26 Nov 2022 15:26:46 +0100 Subject: [PATCH 2/4] Fix import --- tests/test_utils_check_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_utils_check_path.py b/tests/test_utils_check_path.py index f58cb64a..25dd35cb 100644 --- a/tests/test_utils_check_path.py +++ b/tests/test_utils_check_path.py @@ -18,7 +18,7 @@ from pathlib import Path # project -from src.utils.check_path import ( +from qgis_deployment_toolbelt.utils.check_path import ( check_path, check_path_exists, check_path_is_readable, From b99f78fa23d22fc8bab1235916fdf6dde91f3253 Mon Sep 17 00:00:00 2001 From: Julien M Date: Sat, 26 Nov 2022 16:34:13 +0100 Subject: [PATCH 3/4] Use check path in other modules --- .../profiles/qdt_profile.py | 17 ++++++++--- .../scenarios/scenario_reader.py | 29 ++++++------------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/qgis_deployment_toolbelt/profiles/qdt_profile.py b/qgis_deployment_toolbelt/profiles/qdt_profile.py index 962b9d00..453780de 100644 --- a/qgis_deployment_toolbelt/profiles/qdt_profile.py +++ b/qgis_deployment_toolbelt/profiles/qdt_profile.py @@ -27,6 +27,7 @@ # Package from qgis_deployment_toolbelt.constants import OS_CONFIG +from qgis_deployment_toolbelt.utils.check_path import check_path # ############################################################################# # ########## Globals ############### @@ -145,10 +146,18 @@ def from_json(cls, profile_json_path: Path, profile_folder: Path = None) -> Self """ # checks - if not profile_json_path.is_file(): - raise FileNotFoundError(f"{profile_json_path.resolve()} does not exist.") - if profile_folder and not profile_folder.is_dir(): - raise TypeError(f"{profile_folder.resolve()} is not a folder.") + check_path( + input_path=profile_json_path, + must_be_a_file=True, + must_exists=True, + must_be_readable=True, + ) + check_path( + input_path=profile_folder, + must_be_a_folder=True, + must_exists=True, + must_be_readable=True, + ) # load JSON with profile_json_path.open(mode="r", encoding="utf8") as in_profile_json: diff --git a/qgis_deployment_toolbelt/scenarios/scenario_reader.py b/qgis_deployment_toolbelt/scenarios/scenario_reader.py index 8691de3f..62d3bfd0 100644 --- a/qgis_deployment_toolbelt/scenarios/scenario_reader.py +++ b/qgis_deployment_toolbelt/scenarios/scenario_reader.py @@ -15,13 +15,15 @@ import logging from functools import lru_cache from io import BufferedIOBase -from os import R_OK, access from pathlib import Path from typing import List, Tuple, Union # 3rd party import yaml +# package +from qgis_deployment_toolbelt.utils.check_path import check_path + # ############################################################################# # ########## Globals ############### # ################################## @@ -65,25 +67,12 @@ def check_yaml_file(self, yaml_path: Union[str, Path]) -> Path: :rtype: Path """ # if path as string load it in Path object - if isinstance(yaml_path, str): - try: - yaml_path = Path(yaml_path) - except Exception as exc: - raise TypeError("Converting yaml path failed: {}".format(exc)) - - # check if file exists - if not yaml_path.exists(): - raise FileExistsError( - "YAML file to check doesn't exist: {}".format(yaml_path.resolve()) - ) - - # check if it's a file - if not yaml_path.is_file(): - raise IOError("YAML file is not a file: {}".format(yaml_path.resolve())) - - # check if file is readable - if not access(yaml_path, R_OK): - raise IOError("yaml file isn't readable: {}".format(yaml_path)) + check_path( + input_path=yaml_path, + must_be_a_file=True, + must_exists=True, + must_be_readable=True, + ) # check integrity and structure with yaml_path.open(mode="r") as in_yaml_file: From 5767e9eea07fc3bdfe2f91bb0303a393a804b70f Mon Sep 17 00:00:00 2001 From: Julien M Date: Sat, 26 Nov 2022 17:26:29 +0100 Subject: [PATCH 4/4] Fix tests --- qgis_deployment_toolbelt/profiles/qdt_profile.py | 13 +++++++------ .../scenarios/scenario_reader.py | 1 + tests/test_qdt_profile_object.py | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/qgis_deployment_toolbelt/profiles/qdt_profile.py b/qgis_deployment_toolbelt/profiles/qdt_profile.py index 453780de..51054b5f 100644 --- a/qgis_deployment_toolbelt/profiles/qdt_profile.py +++ b/qgis_deployment_toolbelt/profiles/qdt_profile.py @@ -152,12 +152,13 @@ def from_json(cls, profile_json_path: Path, profile_folder: Path = None) -> Self must_exists=True, must_be_readable=True, ) - check_path( - input_path=profile_folder, - must_be_a_folder=True, - must_exists=True, - must_be_readable=True, - ) + if profile_folder: + check_path( + input_path=profile_folder, + must_be_a_folder=True, + must_exists=True, + must_be_readable=True, + ) # load JSON with profile_json_path.open(mode="r", encoding="utf8") as in_profile_json: diff --git a/qgis_deployment_toolbelt/scenarios/scenario_reader.py b/qgis_deployment_toolbelt/scenarios/scenario_reader.py index 62d3bfd0..1838f507 100644 --- a/qgis_deployment_toolbelt/scenarios/scenario_reader.py +++ b/qgis_deployment_toolbelt/scenarios/scenario_reader.py @@ -73,6 +73,7 @@ def check_yaml_file(self, yaml_path: Union[str, Path]) -> Path: must_exists=True, must_be_readable=True, ) + yaml_path = Path(yaml_path) # check integrity and structure with yaml_path.open(mode="r") as in_yaml_file: diff --git a/tests/test_qdt_profile_object.py b/tests/test_qdt_profile_object.py index c6ff2c24..ef55ddb6 100644 --- a/tests/test_qdt_profile_object.py +++ b/tests/test_qdt_profile_object.py @@ -7,7 +7,7 @@ # for whole tests python -m unittest tests.test_qdt_profile_object # for specific test - python -m unittest tests.test_qdt_profile_object.TestQdtProfile.test_profile_load_from_json + python -m unittest tests.test_qdt_profile_object.TestQdtProfile.test_profile_load_from_json_basic """ # standard @@ -36,7 +36,7 @@ def setUpClass(cls): def test_profile_load_from_json_basic(self): """Test profile loading from JSON.""" for i in self.good_profiles_files: - qdt_profile = QdtProfile.from_json(i) + qdt_profile = QdtProfile.from_json(profile_json_path=i) self.assertIsInstance(qdt_profile, QdtProfile) # attributes types