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 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..71eb99a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2023-09-05 + +### Added + +- Helpers around file paths. diff --git a/README.md b/README.md index 59b92e1..7f0f75b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,204 @@ # 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) + - [Code style](#Codestyle) + - [Testing](#Testing) +- [License](#License) + + + + +## Requirements + +The toolbox has been written in **`Python 3`**, and it needs version `3.7`. + +The dependencies are managed by `pip` using the file `requirements.txt`. + +## Installation + +To add `py-toolbox` to your project, run the following command: + +```sh +pip install git+https://github.com/jsconan/py-toolbox.git +``` + +## Usage + +`py-toolbox` offers several utilities per domain. + +### Files + +The package `toolbox.files` offers file related utilities. + +- `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. + + *** + +- `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. + + *** + +- `get_application_name() -> str`: + + Gets the name of the application, based on the root folder. + + Returns: + + - `str`: The name of the application. + + *** + +- `get_application_path() -> PurePath`: + + Gets the path to the application's root. + + Returns: + + - `PurePath`: The path to the application's root. + + *** + +- `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. + + *** + +- `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. + + *** + +- `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. + + *** + +## Development + +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 +} +``` + +### Code style + +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)). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ae320a8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "py-toolbox" +version = "0.1.0" +authors = [{ name = "Jean-Sébastien CONAN", email = "jsconan@gmail.com" }] +description = "A set of utilities for Python projects" +readme = "README.md" +requires-python = ">=3.7" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/jsconan/py-toolbox" +"Bug Tracker" = "https://github.com/jsconan/py-toolbox/issues" 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 diff --git a/toolbox/__init__.py b/toolbox/__init__.py new file mode 100644 index 0000000..e69de29 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)