From baf2bfd1d5c95fabd58ea0aee120a8153ea0a3e6 Mon Sep 17 00:00:00 2001 From: Alex Traveylan <108667536+AlexTraveylan@users.noreply.github.com> Date: Tue, 1 Oct 2024 21:16:51 +0200 Subject: [PATCH] Initial commit --- .github/workflows/main.yml | 34 +++++++ .gitignore | 24 +++++ .pytest.ini | 5 + .vscode/extensions.json | 7 ++ .vscode/launch.json | 13 +++ .vscode/settings.json | 27 ++++++ Dockerfile | 17 ++++ LICENSE | 21 +++++ README.md | 90 ++++++++++++++++++ app/__init__.py | 9 ++ app/adapter/__init__.py | 0 app/adapter/exception/__init__.py | 0 app/adapter/exception/app_exception.py | 32 +++++++ app/adapter/logger/__init__.py | 0 app/adapter/logger/activation_condition.py | 31 +++++++ app/adapter/logger/config_log.json | 48 ++++++++++ app/adapter/logger/logs/__init__.py | 0 app/adapter/logger/mylogger.py | 92 +++++++++++++++++++ app/adapter/logger/setup_logging.py | 39 ++++++++ app/core/__init__.py | 0 app/core/constants.py | 24 +++++ app/main.py | 27 ++++++ dev_requirements.txt | 7 ++ requirements.txt | 0 ruff.toml | 84 +++++++++++++++++ setup.py | 12 +++ tests/endtoend/__init__.py | 0 tests/integration/__init__.py | 0 .../adapter/exception/test_app_exception.py | 17 ++++ .../logger/test_activation_condition.py | 45 +++++++++ 30 files changed, 705 insertions(+) create mode 100644 .github/workflows/main.yml create mode 100644 .gitignore create mode 100644 .pytest.ini create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/adapter/__init__.py create mode 100644 app/adapter/exception/__init__.py create mode 100644 app/adapter/exception/app_exception.py create mode 100644 app/adapter/logger/__init__.py create mode 100644 app/adapter/logger/activation_condition.py create mode 100644 app/adapter/logger/config_log.json create mode 100644 app/adapter/logger/logs/__init__.py create mode 100644 app/adapter/logger/mylogger.py create mode 100644 app/adapter/logger/setup_logging.py create mode 100644 app/core/__init__.py create mode 100644 app/core/constants.py create mode 100644 app/main.py create mode 100644 dev_requirements.txt create mode 100644 requirements.txt create mode 100644 ruff.toml create mode 100644 setup.py create mode 100644 tests/endtoend/__init__.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/unit/adapter/exception/test_app_exception.py create mode 100644 tests/unit/adapter/logger/test_activation_condition.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..ee0ffbb --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,34 @@ +name: +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Set PYTHONPATH + run: | + echo "PYTHONPATH=${{ github.workspace }}" >> $GITHUB_ENV + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest bandit + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Run tests + run: | + pytest + - name: Run Bandit security checks + run: | + bandit -r ./ -ll -ii -x B101 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36ecb0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python cache files +__pycache__/ +*_cache/ + +# Logs +*.log +log*.jsonl + +# Editor files +# .vscode/ + + +# virtual environment +env*/ +.env* +venv*/ +.venv*/ + +# coverage +.coverage +htmlcov/ +*.egg-info/ +dist/ +build/ \ No newline at end of file diff --git a/.pytest.ini b/.pytest.ini new file mode 100644 index 0000000..057b61b --- /dev/null +++ b/.pytest.ini @@ -0,0 +1,5 @@ +[pytest] +minversion = 6.0 +addopts = -ra -q +testpaths = + tests diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..8ef0c5d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "charliermarsh.ruff" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..4ed2a87 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..97fbbd7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "always", + "source.organizeImports.ruff": "always", + "source.unusedImports.ruff": "never", + } + }, + "terminal.integrated.env.windows": { + "PYTHONPATH": "${workspaceFolder}" + }, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "editor.rulers": [ + 88, + 90 + ], + "files.exclude": { + "**/__pycache__": true, + "**/*_cache": true + }, +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..33922d6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Use an official Python runtime as a parent image +FROM python:3.10 + +# Set the working directory in the container to /app +WORKDIR /app + +# Add the current directory contents into the container at /app +ADD . /app + +# Install any needed packages specified in requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# Make port 80 available to the world outside this container +EXPOSE 80 + +# Run app.py when the container launches +CMD ["python", "app/main.py"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eb407ff --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 AlexTraveylan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..09fee36 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Python Template + +## Description + +This is a Python project that uses pytest for testing and is configured for development in Visual Studio Code. It also includes a Dockerfile for containerization. + +## Project Structure + +The main application code is located in the `app` directory, with the entry point in `app/main.py`. The `app` directory also contains a `core` subdirectory for core functionality and an `adapter` subdirectory for adapters. + +The `tests` directory contains unit, integration, and end-to-end tests. + +## Setup + +### Requirements + +- Python 3.10 +- pip + +### Installation + +1. Clone the repository. +2. Install the dependencies: + +```sh +pip install -r dev_requirements.txt +``` + +### Running the Application + +To run the application: + +```sh +python app/main.py +``` + +### Running the Tests + +To run the tests: + +```sh +pytest +``` + +## Development + +This project is configured for development in Visual Studio Code with settings for the Python extension, including formatting and linting settings. The `.vscode` directory contains the configuration files. + +## Docker + +A Dockerfile is included for building a Docker image of the application. To build the image: + +```sh +docker build -t . +``` + +To run the application in a Docker container: + +```sh +docker run -p 80:80 +``` + +## Continuous Integration + +The project includes a GitHub Actions workflow for continuous integration, which runs tests and security checks on push and pull request events to the main branch. + +## Logging + +The application uses Python's built-in logging module, with configuration in `app/adapter/logger/config_log.json`. + +## package + +To create the package, run the following command: +```bash +python setup.py sdist bdist_wheel +``` + +The package is available on whl file in the dist folder. To install it, run the following command: +```bash +pip install dist/-0.1-py3-none-any.whl +``` +Note : 0.1 is the version of the package, change it if needed. + +## Contributing + +Contributions are welcome. Please submit a pull request or create an issue to discuss the changes. + +## License + +[MIT](LICENSE) \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..f3f7e4e --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,9 @@ +"""Entry point for the application.""" + +import sys + +from app.adapter.logger.activation_condition import is_on_site_packages +from app.adapter.logger.setup_logging import init_logger + +if not is_on_site_packages(__file__) and "pytest" not in sys.modules: + init_logger() diff --git a/app/adapter/__init__.py b/app/adapter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/adapter/exception/__init__.py b/app/adapter/exception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/adapter/exception/app_exception.py b/app/adapter/exception/app_exception.py new file mode 100644 index 0000000..3f168da --- /dev/null +++ b/app/adapter/exception/app_exception.py @@ -0,0 +1,32 @@ +""" +Module for the AppException class + +:author: Alex Traveylan +:date: 2024 +""" + + +class AppException(Exception): + """ + Exception class for the application + + Attributes + ---------- + message : str + The message of the exception + """ + + def __init__(self, message: str): + """ + Constructor for AppException + + Parameters + ---------- + message : str + The message of the exception + """ + super().__init__(message) + self.message = message + + def __str__(self): + return self.message diff --git a/app/adapter/logger/__init__.py b/app/adapter/logger/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/adapter/logger/activation_condition.py b/app/adapter/logger/activation_condition.py new file mode 100644 index 0000000..b7e61e1 --- /dev/null +++ b/app/adapter/logger/activation_condition.py @@ -0,0 +1,31 @@ +""" +Module to determine if a file is in the site-packages directory. +Usage: not initiate the logger if the file is in the site-packages directory. + +:author: Alex Traveylan +:date: 2024 +""" + +from pathlib import Path + + +def is_on_site_packages(actual_file_path: str | Path) -> bool: + """ + This function checks if the file is in the site-packages directory. + + Parameters + ---------- + actual_file_path : str | Path + Path to the file to check. + + Returns + ------- + bool + True if the file is in the site-packages directory, False otherwise. + """ + absolute_file_path = Path(actual_file_path).resolve() + + is_lib_in_path = "Lib" in str(absolute_file_path) + is_site_packages_in_path = "site-packages" in str(absolute_file_path) + + return is_lib_in_path and is_site_packages_in_path diff --git a/app/adapter/logger/config_log.json b/app/adapter/logger/config_log.json new file mode 100644 index 0000000..f4a1382 --- /dev/null +++ b/app/adapter/logger/config_log.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "disable_existing_loggers": false, + "formatters": { + "simple": { + "format": "[%(levelname)s|%(module)s|L%(lineno)d] %(asctime)s: %(message)s", + "datefmt": "%Y-%m-%dT%H:%M:%S%z" + }, + "json": { + "()": "app.adapter.logger.mylogger.MyJSONFormatter", + "fmt_keys": { + "level": "levelname", + "message": "message", + "timestamp": "timestamp", + "logger": "name", + "module": "module", + "function": "funcName", + "line": "lineno", + "thread_name": "threadName" + } + } + }, + "handlers": { + "stderr": { + "class": "logging.StreamHandler", + "level": "INFO", + "formatter": "simple", + "stream": "ext://sys.stderr" + }, + "file_json": { + "class": "logging.handlers.RotatingFileHandler", + "level": "DEBUG", + "formatter": "json", + "filename": "app/adapter/logger/logs/log.jsonl", + "maxBytes": 100000, + "backupCount": 3 + } + }, + "loggers": { + "root": { + "level": "DEBUG", + "handlers": [ + "stderr", + "file_json" + ] + } + } + } \ No newline at end of file diff --git a/app/adapter/logger/logs/__init__.py b/app/adapter/logger/logs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/adapter/logger/mylogger.py b/app/adapter/logger/mylogger.py new file mode 100644 index 0000000..c5e4c6b --- /dev/null +++ b/app/adapter/logger/mylogger.py @@ -0,0 +1,92 @@ +""" +Module to define custom logger and formatter + +:author: Alex Traveylan +:date: 2024 +""" + +import datetime as dt +import json +import logging + +LOG_RECORD_BUILTIN_ATTRS = { + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", + "taskName", +} + + +class MyJSONFormatter(logging.Formatter): + """ + MyJSONFormatter is a custom formatter for logging messages in JSON format. + """ + + def __init__( + self, + *, + fmt_keys: dict[str, str] | None = None, + ): + super().__init__() + self.fmt_keys = fmt_keys if fmt_keys is not None else {} + + def format(self, record: logging.LogRecord) -> str: + message = self._prepare_log_dict(record) + return json.dumps(message, default=str) + + def _prepare_log_dict(self, record: logging.LogRecord): + always_fields = { + "message": record.getMessage(), + "timestamp": dt.datetime.fromtimestamp( + record.created, tz=dt.timezone.utc + ).isoformat(), + } + if record.exc_info is not None: + always_fields["exc_info"] = self.formatException(record.exc_info) + + if record.stack_info is not None: + always_fields["stack_info"] = self.formatStack(record.stack_info) + + message = { + key: ( + msg_val + if (msg_val := always_fields.pop(val, None)) is not None + else getattr(record, val) + ) + for key, val in self.fmt_keys.items() + } + message.update(always_fields) + + for key, val in record.__dict__.items(): + if key not in LOG_RECORD_BUILTIN_ATTRS: + message[key] = val + + return message + + +class NonErrorFilter(logging.Filter): + """ + NonErrorFilter is a custom filter to filter out log records with levelno + """ + + def filter(self, record: logging.LogRecord) -> bool | logging.LogRecord: + return record.levelno <= logging.INFO diff --git a/app/adapter/logger/setup_logging.py b/app/adapter/logger/setup_logging.py new file mode 100644 index 0000000..b5a2a48 --- /dev/null +++ b/app/adapter/logger/setup_logging.py @@ -0,0 +1,39 @@ +""" +Module to setup logging for the application. + +:author: Alex Traveylan +:date: 2024 +""" + +import json +import logging.config +import logging.handlers +from pathlib import Path + +from app.core.constants import LOGGER_NAME, LOGGING_CONFIG_PATH + +logger = logging.getLogger(LOGGER_NAME) + + +def setup_logging(config_file_path: Path | str): + """Setup logging configuration from a JSON file. + + Parameters + ---------- + config_file_path : Path | str + Path to the JSON file containing the logging configuration. + """ + with open(config_file_path, encoding="utf-8") as f_in: + config = json.load(f_in) + + logging.config.dictConfig(config) + + +def init_logger(): + """Initialize the logger with the default configuration.""" + + setup_logging(LOGGING_CONFIG_PATH) + logging.basicConfig(level="DEBUG") + + # Example usage + logger.info("Logger initialized.") diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/constants.py b/app/core/constants.py new file mode 100644 index 0000000..5ac7335 --- /dev/null +++ b/app/core/constants.py @@ -0,0 +1,24 @@ +""" +This module contains the configuration for the app + +:author: Alex Traveylan +:date: 2024 +""" + +from pathlib import Path + +# Paths of the application + +WORKSPACE_DIR = Path(__file__).parents[2].absolute() + +APP_DIR = WORKSPACE_DIR / "app" + +ADAPTERS_DIR = APP_DIR / "adapter" + +CORE_DIR = APP_DIR / "core" + +# logging configuration + +LOGGING_CONFIG_PATH = ADAPTERS_DIR / "logger" / "config_log.json" + +LOGGER_NAME = "app_logger" # modify this to change the logger name diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..fe71238 --- /dev/null +++ b/app/main.py @@ -0,0 +1,27 @@ +""" +Main module for the application +""" + +import logging +from atexit import register + +from app.core.constants import LOGGER_NAME + +logger = logging.getLogger(LOGGER_NAME) + + +def main(): + """Entry point for the application.""" + + logger.info("Application started.") + + +@register +def exit_function(): + """Auto execute when application end""" + + logger.info("application ended") + + +if __name__ == "__main__": + main() diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..778693e --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,7 @@ +pytest +pytest-cov +coverage +ruff +wheel +setuptools +-r requirements.txt \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..0d7050a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,84 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Same as Black. +line-length = 88 +indent-width = 4 + +# Assume Python 3.10 +target-version = "py310" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +select = ["E4", "E7", "E9", "F", "B", "Q"] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +# Allow unused variables when underscore-prefixed. +dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" + +[format] +# Like Black, use double quotes for strings. +quote-style = "double" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false + +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = true + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" + +[lint.pydocstyle] +# Use numpy-style docstrings. +convention = "numpy" + +[lint.pylint] +max-nested-blocks = 3 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ab60648 --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +""" +This file is the setup script for the package. +""" + +from setuptools import find_packages, setup + +setup( + name="", + version="0.1", + packages=find_packages(), + install_requires=["numpy", "pandas", "PyArrow"], +) diff --git a/tests/endtoend/__init__.py b/tests/endtoend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/adapter/exception/test_app_exception.py b/tests/unit/adapter/exception/test_app_exception.py new file mode 100644 index 0000000..640c116 --- /dev/null +++ b/tests/unit/adapter/exception/test_app_exception.py @@ -0,0 +1,17 @@ +""" +Test the AppException class + +:author: Alex Traveylan +:date: 2024 +""" + +from app.adapter.exception.app_exception import AppException + + +def test_app_exception(): + """Test the AppException class.""" + + message = "Test exception message" + exception = AppException(message) + + assert str(exception) == message diff --git a/tests/unit/adapter/logger/test_activation_condition.py b/tests/unit/adapter/logger/test_activation_condition.py new file mode 100644 index 0000000..8e2b7ec --- /dev/null +++ b/tests/unit/adapter/logger/test_activation_condition.py @@ -0,0 +1,45 @@ +""" +Tests for the file adapter/logger/activation_condition.py + +:author: Alex Traveylan +:date: 2024 +""" + +from app.adapter.logger.activation_condition import is_on_site_packages + + +def test_is_on_site_packages_when_file_in_site_packages(): + """ + Test that is_on_site_packages returns True when the file is in the site-packages directory. + """ + + # When + file_path = "/path/to/Lib/site-packages/file.py" + + # Then + result = is_on_site_packages(file_path) + + # Expected + excepted_result = True + + # Assert + assert result is excepted_result + + +def test_is_on_site_packages_when_file_not_in_site_packages(): + """ + Test that is_on_site_packages returns False when the file + is not in the site-packages directory. + """ + + # When + file_path = "/path/to/other-directory/file.py" + + # Then + result = is_on_site_packages(file_path) + + # Expected + excepted_result = False + + # Assert + assert result is excepted_result