From 36153e395a7ddf026813a96d94bfec427ac96582 Mon Sep 17 00:00:00 2001 From: jsconan Date: Mon, 4 Sep 2023 22:34:22 +0200 Subject: [PATCH 01/10] chore: add vscode settings --- .vscode/settings.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..015774b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,18 @@ +{ + "editor.formatOnPaste": true, + "editor.formatOnSave": true, + "editor.formatOnType": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true, + "source.fixAll": true + }, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "python.defaultInterpreterPath": "${workspaceRoot}/.venv/bin/python", + "python.terminal.focusAfterLaunch": true, + "python.testing.unittestEnabled": true, + "python.analysis.autoFormatStrings": true, + "python.analysis.autoImportCompletions": true, + "autoDocstring.startOnNewLine": true +} From ff249d689f46c80b3c742b2a1190a831e355b0da Mon Sep 17 00:00:00 2001 From: jsconan Date: Mon, 4 Sep 2023 22:35:27 +0200 Subject: [PATCH 02/10] chore: add requirements --- requirements.txt | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4943664 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +astroid==2.15.6 +dill==0.3.7 +isort==5.12.0 +lazy-object-proxy==1.9.0 +mccabe==0.7.0 +platformdirs==3.10.0 +pylint==2.17.5 +tomlkit==0.12.1 +wrapt==1.15.0 From 1e305f26620c97ecb26cbd0701d4970d60bf2917 Mon Sep 17 00:00:00 2001 From: jsconan Date: Mon, 4 Sep 2023 22:36:42 +0200 Subject: [PATCH 03/10] chore: set project entry point --- toolbox/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 toolbox/__init__.py diff --git a/toolbox/__init__.py b/toolbox/__init__.py new file mode 100644 index 0000000..e69de29 From 587e33cbd2c96eb4e97e84dbf3a528c8512d162a Mon Sep 17 00:00:00 2001 From: jsconan Date: Mon, 4 Sep 2023 22:54:48 +0200 Subject: [PATCH 04/10] doc: extend the readme file --- README.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/README.md b/README.md index 59b92e1..4055e3d 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,89 @@ # py-toolbox + A set of utilities for Python projects + + + +- [Requirements](#Requirements) +- [Installation](#Installation) +- [Development](#Development) +- [Testing](#Testing) +- [License](#License) + + + + +## Requirements + +The toolbox has been written in **`Python 3`**, and it needs version `3.11`. + +The dependencies are managed by `pip` using the file `requirements.txt`. + +## Installation + +Check out the repository: + +```sh +git clone git@github.com:jsconan/py-toolbox.git +``` + +Then, create the virtual env and install the dependencies: + +```sh +cd py-toolbox +python3 -m venv ".venv" +source "./venv/bin/activate" +pip install -r requirements.txt +``` + +**Note:** For deactivating the virtual env, call the command `deactivate`. + +**Automating the environment activation/deactivation** + +For activating the virtual env automatically when entering the project folder, and deactivating it when leaving the folder, you can add this snippet to you shell profile: + +```sh +cd() { + builtin cd "$@" + + local venv=".venv" + + # If a Python virtualenv is active, deactivate it if the new folder is outside + if [[ -v VIRTUAL_ENV ]] ; then + local parent=$(dirname "${VIRTUAL_ENV}") + if [[ "${PWD}"/ != "${parent}"/* ]] ; then + deactivate + fi + fi + + # If a Python env folder is found then activate the virtualenv + if [[ -d "./${venv}" ]] ; then + # Is it a Python venv? + if [[ -f "./${venv}/bin/activate" ]] ; then + source "./${venv}/bin/activate" + fi + fi +} +``` + +## Development + +Code is linted using PyLint and formatted using Black. + +## Testing + +Each module comes with unit tests, by convention, a `test` folder must be added to each package + +Unit tests are made using `unittest`. To run them: + +```sh +python3 -m unittest +``` + +## License + +Copyright (c) 2023 Jean-Sébastien CONAN +Distributed under the MIT License (See LICENSE file or copy at [http://opensource.org/licenses/MIT](http://opensource.org/licenses/MIT)). From 0d5f802be534f680e96c8605d82f2a40edf6bbd0 Mon Sep 17 00:00:00 2001 From: jsconan Date: Tue, 5 Sep 2023 20:38:59 +0200 Subject: [PATCH 05/10] feat: add file path helpers --- toolbox/files/__init__.py | 12 +++ toolbox/files/path.py | 124 +++++++++++++++++++++++ toolbox/files/test/__init__.py | 0 toolbox/files/test/test_path.py | 168 ++++++++++++++++++++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 toolbox/files/__init__.py create mode 100644 toolbox/files/path.py create mode 100644 toolbox/files/test/__init__.py create mode 100644 toolbox/files/test/test_path.py diff --git a/toolbox/files/__init__.py b/toolbox/files/__init__.py new file mode 100644 index 0000000..440cd81 --- /dev/null +++ b/toolbox/files/__init__.py @@ -0,0 +1,12 @@ +""" +Entry point for the `files` package. +""" +from toolbox.files.path import ( + create_file_path, + delete_path, + get_application_name, + get_application_path, + get_file_path, + get_module_folder_path, + get_module_path, +) diff --git a/toolbox/files/path.py b/toolbox/files/path.py new file mode 100644 index 0000000..4346655 --- /dev/null +++ b/toolbox/files/path.py @@ -0,0 +1,124 @@ +""" +A collection of utilities around file paths. +""" +import os +import sys +from pathlib import PurePath + + +def get_module_path(name: str) -> PurePath(): + """ + Gets the path to the given module. + + Args: + - name (str): The module for which get the path. + + Returns: + PurePath: The path to the module. + """ + if name in sys.modules: + return PurePath(sys.modules[name].__file__) + + return PurePath() + + +def get_module_folder_path(name: str) -> PurePath(): + """ + Gets the path to the folder containing the given module. + + Args: + - name (str): The module for which get the path. + + Returns: + PurePath|None: The path to the folder containing the given module. + """ + return get_module_path(name).parent + + +def get_application_path() -> PurePath: + """ + Gets the path to the application's root. + + Returns: + PurePath: The path to the application's root. + """ + return get_module_folder_path("__main__") + + +def get_application_name() -> str: + """ + Gets the name of the application, based on the root folder. + + Returns: + - str: The name of the application. + """ + return get_application_path().name + + +def get_file_path(relative) -> PurePath: + """ + Gets a full path for a file inside the application. + + Args: + - relative (str): The internal path the file from the application's root. + + Returns: + PurePath: The full path. + """ + return get_application_path().joinpath(relative) + + +def create_file_path(path: str) -> bool: + """ + Creates the parent path for a file. + + Note: exceptions are caught internally, the function will always + return either with `True` in case of success, or `False` otherwise. + + Args: + - path (str): The path to the file. + + Returns: + bool: `True` if the path has been created, `False` otherwise. + """ + folder = str(PurePath(path).parent) + + try: + if not os.path.isdir(folder): + os.makedirs(folder) + return True + + except OSError: + return False + + return False + + +def delete_path(path: str) -> bool: + """ + Deletes the file or the folder at the given path. + + If this is a folder, it must be empty. + + Note: exceptions are caught internally, the function will always + return either with `True` in case of success, or `False` otherwise. + + Args: + - path (str): The path to the file or folder to delete. + + Returns: + bool: `True` if the path has been deleted, `False` otherwise. + """ + try: + if os.path.isdir(path): + os.rmdir(path) + else: + os.remove(path) + + except FileNotFoundError: + return False + + except OSError: + return False + + return True diff --git a/toolbox/files/test/__init__.py b/toolbox/files/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/toolbox/files/test/test_path.py b/toolbox/files/test/test_path.py new file mode 100644 index 0000000..dc82ad8 --- /dev/null +++ b/toolbox/files/test/test_path.py @@ -0,0 +1,168 @@ +""" +Test the collection of utilities around file paths. +""" +import sys +import unittest +from pathlib import PurePath +from unittest.mock import Mock, patch + +from toolbox.files import path + + +class TestFilePaths(unittest.TestCase): + """ + Test suite for the file path helpers. + """ + + def test_get_module_path(self): + """ + Tests the helper get_module_path(). + """ + mock_namespace = "app.package.module" + mock_path = "/root/app/package/module.py" + + module_mock = Mock() + module_mock.__file__ = mock_path + + with patch.dict(sys.modules, {mock_namespace: module_mock}): + result = path.get_module_path(mock_namespace) + self.assertIsInstance(result, PurePath) + self.assertEqual(str(result), mock_path) + + def test_get_module_folder(self): + """ + Tests the helper get_module_folder(). + """ + mock_namespace = "app.package.module" + mock_path = "/root/app/package/module.py" + mock_folder = "/root/app/package" + + module_mock = Mock() + module_mock.__file__ = mock_path + + with patch.dict(sys.modules, {mock_namespace: module_mock}): + result = path.get_module_folder_path(mock_namespace) + self.assertIsInstance(result, PurePath) + self.assertEqual(str(result), mock_folder) + + def test_get_application_path(self): + """ + Tests the helper get_application_path(). + """ + mock_namespace = "__main__" + mock_path = "/root/app/main.py" + mock_root = "/root/app" + + module_mock = Mock() + module_mock.__file__ = mock_path + + with patch.dict(sys.modules, {mock_namespace: module_mock}): + result = path.get_application_path() + self.assertIsInstance(result, PurePath) + self.assertEqual(str(result), mock_root) + + def test_get_application_name(self): + """ + Tests the helper get_application_name(). + """ + mock_namespace = "__main__" + mock_path = "/root/app/main.py" + mock_name = "app" + + module_mock = Mock() + module_mock.__file__ = mock_path + + with patch.dict(sys.modules, {mock_namespace: module_mock}): + result = path.get_application_name() + self.assertIsInstance(result, str) + self.assertEqual(result, mock_name) + + def test_get_file_path(self): + """ + Tests the helper get_file_path(). + """ + mock_namespace = "__main__" + mock_path = "/root/app/main.py" + mock_root = "/root/app" + test_param = "subfolder/file" + + module_mock = Mock() + module_mock.__file__ = mock_path + + with patch.dict(sys.modules, {mock_namespace: module_mock}): + result = path.get_file_path(test_param) + self.assertIsInstance(result, PurePath) + self.assertEqual(str(result), f"{mock_root}/{test_param}") + + def test_create_file_path(self): + """ + Test the helper create_file_path(). + """ + folder_path = "/root/folder" + file_path = "/root/folder/file" + + with patch("os.path.isdir", return_value=True) as mock: + result = path.create_file_path(file_path) + self.assertFalse(result) + mock.assert_called_once_with(folder_path) + + with patch("os.path.isdir", return_value=False) as mock_isdir: + with patch("os.makedirs") as mock_makedirs: + result = path.create_file_path(file_path) + self.assertTrue(result) + mock_isdir.assert_called_once_with(folder_path) + mock_makedirs.assert_called_once_with(folder_path) + + with patch("os.path.isdir", return_value=False) as mock_isdir: + with patch("os.makedirs", side_effect=OSError("error")) as mock_makedirs: + result = path.create_file_path(file_path) + self.assertFalse(result) + mock_isdir.assert_called_once_with(folder_path) + mock_makedirs.assert_called_once_with(folder_path) + mock_makedirs.assert_called_once_with(folder_path) + mock_makedirs.assert_called_once_with(folder_path) + mock_makedirs.assert_called_once_with(folder_path) + + def test_delete_path_file(self): + """ + Tests the helper delete_path() for deleting a file. + """ + file_path = "/root/folder/file" + + with patch("os.path.isdir", return_value=False): + with patch("os.remove") as mock: + result = path.delete_path(file_path) + self.assertTrue(result) + mock.assert_called_once_with(file_path) + + with patch("os.remove", side_effect=OSError("error")) as mock: + result = path.delete_path(file_path) + self.assertFalse(result) + mock.assert_called_once_with(file_path) + + with patch("os.remove", side_effect=FileNotFoundError("error")) as mock: + result = path.delete_path(file_path) + self.assertFalse(result) + mock.assert_called_once_with(file_path) + + def test_delete_path_folder(self): + """ + Tests the helper delete_path() for deleting a folder. + """ + file_path = "/root/folder/subfolder" + + with patch("os.path.isdir", return_value=True): + with patch("os.rmdir") as mock: + result = path.delete_path(file_path) + self.assertTrue(result) + mock.assert_called_once_with(file_path) + + with patch("os.rmdir", side_effect=OSError("error")) as mock: + result = path.delete_path(file_path) + self.assertFalse(result) + mock.assert_called_once_with(file_path) + + with patch("os.rmdir", side_effect=FileNotFoundError("error")) as mock: + result = path.delete_path(file_path) + self.assertFalse(result) + mock.assert_called_once_with(file_path) From 3522d2f9b10260f88e2149d4d9b2a0f7a1efe0fa Mon Sep 17 00:00:00 2001 From: jsconan Date: Tue, 5 Sep 2023 20:57:16 +0200 Subject: [PATCH 06/10] doc: document the file path helpers --- README.md | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4055e3d..7f0f75b 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # py-toolbox -A set of utilities for Python projects +`py-toolbox` is a set of utilities for Python projects - [Requirements](#Requirements) - [Installation](#Installation) - [Development](#Development) -- [Testing](#Testing) + - [Code style](#Codestyle) + - [Testing](#Testing) - [License](#License)