Repository: victoraugustofd/trafalgar-log (language: python)

Issue:
Create python-publish-test-pypi.yml



Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2c94da4
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+# ignore everything
+*
+
+# accept directories
+!*/
+
+# accept files in folders
+!.github/workflows/*.yml
+!trafalgar_log/**/*.py
+!tests/**/*.py
+
+# accept files in root
+!/.gitignore
+!README.md
+!LICENSE
+!setup.py
+!pyproject.toml
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..e7f3e5e
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,6 @@
+[build-system]
+requires = [
+    "setuptools",
+    "semver",
+    "uplink",
+]
\ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..7d9b17d
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,112 @@
+import os
+import pathlib
+
+from semver import VersionInfo
+from setuptools import setup, find_packages
+from uplink import Consumer, get, response_handler
+
+# The directory containing this file
+HERE = pathlib.Path(__file__).parent
+
+# The text of the README file
+README = (HERE / "README.md").read_text()
+TEST_PYPI_URL = "https://test.pypi.org"
+PYPI_URL = "https://pypi.org"
+PACKAGE_ENDPOINT = "/pypi/trafalgar-log/json"
+
+
+def raise_for_status(response) -> VersionInfo:
+    status_code: int = response.status_code
+
+    if _is_success(status_code):
+        print(f"Successfully get PyPI data {response.json()}")
+
+        return VersionInfo.parse(
+            response.json().get("info").get("version").replace("rc", "-rc.")
+        )
+
+
+def _is_success(status_code: int):
+    return 200 <= status_code < 300
+
+
+class PyPIClient(Consumer):
+    @response_handler(raise_for_status)
+    @get(PACKAGE_ENDPOINT)
+    def get_version(
+        self,
+    ) -> VersionInfo:
+        """Generate new token."""
+
+
+TEST_PYPI_ADAPTER = PyPIClient(base_url=TEST_PYPI_URL)
+PYPI_ADAPTER = PyPIClient(base_url=PYPI_URL)
+
+
+def _is_dev_env(env: str) -> bool:
+    return env.lower() == "dev"
+
+
+def _is_main_env(env: str) -> bool:
+    return env.lower() == "main"
+
+
+def _is_versions_equals(version_a, version_b):
+    return version_a.compare(version_b) == 0
+
+
+def _define_version(
+    test_pypi_version: VersionInfo, pypi_version: VersionInfo
+) -> VersionInfo:
+    env: str = os.getenv("ENV")
+    test_pypi_version_without_prerelease = VersionInfo(
+        major=test_pypi_version.major,
+        minor=test_pypi_version.minor,
+        patch=test_pypi_version.patch,
+    )
+
+    if not test_pypi_version and not pypi_version:
+        return VersionInfo.parse("1.0.0-rc.1")
+
+    if _is_dev_env(env):
+        if not pypi_version or not _is_versions_equals(
+            test_pypi_version_without_prerelease, pypi_version
+        ):
+            return test_pypi_version.next_version(part="prerelease")
+        else:
+            return test_pypi_version.bump_minor().bump_prerelease()
+    if _is_main_env(env):
+        return test_pypi_version.finalize_version()
+
+
+def _get_version() -> str:
+    version: VersionInfo
+    test_pypi_version: VersionInfo = TEST_PYPI_ADAPTER.get_version()
+    pypi_version: VersionInfo = PYPI_ADAPTER.get_version()
+
+    return str(_define_version(test_pypi_version, pypi_version))
+
+
+# Package version
+VERSION = _get_version()
+
+setup(
+    name="trafalgar-log",
+    version=VERSION,
+    description="A log framework that prints logs as JSON.",
+    long_description=README,
+    long_description_content_type="text/markdown",
+    url="https://github.com/victoraugustofd/trafalgar-log",
+    author="victoraugustofd",
+    classifiers=[
+        "License :: OSI Approved :: MIT License",
+        "Intended Audience :: Developers",
+        "Programming Language :: Python :: 3.7",
+        "Topic :: System :: Logging",
+    ],
+    keywords="logs",
+    packages=find_packages(exclude="tests"),
+    python_requires=">=3.7, <4",
+    install_requires=["semver", "uplink", "dynaconf", "python-json-logger"],
+    license="MIT",
+)
diff --git a/trafalgar_log/__init__.py b/trafalgar_log/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/trafalgar_log/app/__init__.py b/trafalgar_log/app/__init__.py
new file mode 100644
index 0000000..3c25217
--- /dev/null
+++ b/trafalgar_log/app/__init__.py
@@ -0,0 +1,22 @@
+from dynaconf import Dynaconf, Validator, ValidationError
+
+from trafalgar_log.core.exceptions import ConfigurationError
+
+SETTINGS = Dynaconf(
+    envvar_prefix="TRA_LOG",
+    load_dotenv=True,
+    validators=[
+        Validator(
+            "APP_NAME",
+            "DOMAIN",
+            must_exist=True,
+        ),
+        Validator("LOG_LEVEL", default="INFO"),
+        Validator("FIELDS_TO_MASK", default=[]),
+    ],
+)
+
+try:
+    SETTINGS.validators.validate_all()
+except ValidationError as e:
+    raise ConfigurationError()
diff --git a/trafalgar_log/core/__init__.py b/trafalgar_log/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/trafalgar_log/core/enums.py b/trafalgar_log/core/enums.py
new file mode 100644
index 0000000..3c36c6e
--- /dev/null
+++ b/trafalgar_log/core/enums.py
@@ -0,0 +1,16 @@
+from enum import Enum
+
+
+class LogFields(Enum):
+    APP = "app"
+    CHANNEL = "channel"
+    CODE_LINE = "code_line"
+    CORRELATION_ID = "correlation_id"
+    DATE_TIME = "date_time"
+    DOMAIN = "domain"
+    INSTANCE_ID = "instance_id"
+    LOG_CODE = "log_code"
+    LOG_MESSAGE = "log_message"
+    PAYLOAD = "payload"
+    SEVERITY = "severity"
+    TIMESTAMP = "timestamp"
diff --git a/trafalgar_log/core/exceptions.py b/trafalgar_log/core/exceptions.py
new file mode 100644
index 0000000..fa84461
--- /dev/null
+++ b/trafalgar_log/core/exceptions.py
@@ -0,0 +1,2 @@
+class ConfigurationError(Exception):
+    pass
diff --git a/trafalgar_log/core/logger.py b/trafalgar_log/core/logger.py
new file mode 100644
index 0000000..5f54f1e
--- /dev/null
+++ b/trafalgar_log/core/logger.py
@@ -0,0 +1,60 @@
+import logging
+from logging import INFO, DEBUG, WARN, ERROR, CRITICAL
+
+from trafalgar_log.core.utils import (
+    initialize_logger,
+    LOG_CODE,
+    PAYLOAD,
+    SEVERITY,
+    get_payload,
+)
+
+_logger = initialize_logger()
+
+
+class _TrafalgarLogger(object):
+    def info(self, log_code: str, log_message: str, payload: object):
+        if _logger.isEnabledFor(INFO):
+            self._do_log(INFO, log_code, log_message, payload)
+
+    def debug(self, log_code: str, log_message: str, payload: object):
+        if _logger.isEnabledFor(DEBUG):
+            self._do_log(DEBUG, log_code, log_message, payload)
+
+    def warn(self, log_code: str, log_message: str, payload: object):
+        if _logger.isEnabledFor(WARN):
+            self._do_log(WARN, log_code, log_message, payload)
+
+    def error(self, log_code: str, log_message: str, payload: object):
+        if _logger.isEnabledFor(ERROR):
+            self._do_log(ERROR, log_code, log_message, payload)
+
+    def critical(self, log_code: str, log_message: str, payload: object):
+        if _logger.isEnabledFor(CRITICAL):
+            self._do_log(CRITICAL, log_code, log_message, payload)
+
+    @staticmethod
+    def _do_log(
+        level: int,
+        log_code: str,
+        log_message: str,
+        payload: object,
+    ):
+        if isinstance(payload, BaseException):
+            payload = str(payload)
+
+        extra_fields = {
+            "extra": {
+                LOG_CODE: log_code,
+                PAYLOAD: get_payload(payload),
+                SEVERITY: logging.getLevelName(level),
+            }
+        }
+
+        if level in [ERROR, CRITICAL]:
+            _logger.exception(log_message, **extra_fields)
+        else:
+            _logger.log(level, log_message, **extra_fields)
+
+
+Logger = _TrafalgarLogger()
diff --git a/trafalgar_log/core/utils.py b/trafalgar_log/core/utils.py
new file mode 100644
index 0000000..43ea3f5
--- /dev/null
+++ b/trafalgar_log/core/utils.py
@@ -0,0 +1,207 @@
+import inspect
+import json
+import logging
+import os
+import sys
+from datetime import datetime
+from inspect import FrameInfo
+from logging import Logger, LogRecord, StreamHandler
+from typing import Dict, Any, List
+from uuid import uuid4
+
+from pythonjsonlogger.jsonlogger import JsonFormatter
+
+from trafalgar_log.app import SETTINGS
+from trafalgar_log.core.enums import LogFields
+
+APP = LogFields.APP.value
+CHANNEL = LogFields.CHANNEL.value
+CODE_LINE = LogFields.CODE_LINE.value
+CORRELATION_ID = LogFields.CORRELATION_ID.value
+DATE_TIME = LogFields.DATE_TIME.value
+DOMAIN = LogFields.DOMAIN.value
+INSTANCE_ID = LogFields.INSTANCE_ID.value
+LOG_CODE = LogFields.LOG_CODE.value
+LOG_MESSAGE = LogFields.LOG_MESSAGE.value
+PAYLOAD = LogFields.PAYLOAD.value
+SEVERITY = LogFields.SEVERITY.value
+TIMESTAMP = LogFields.TIMESTAMP.value
+FIELDS_TO_MASK = [
+    field.strip().lower() for field in SETTINGS.get("FIELDS_TO_MASK").split(",")
+]
+MASK_CHARACTER = "*"
+
+
+class CustomJsonFormatter(JsonFormatter):
+    def add_fields(
+        self,
+        log_record: Dict[str, Any],
+        record: LogRecord,
+        message_dict: Dict[str, Any],
+    ):
+        super(CustomJsonFormatter, self).add_fields(
+            log_record, record, message_dict
+        )
+
+        log_record[APP] = SETTINGS.get("APP_NAME")
+        log_record[CHANNEL] = os.getenv(CHANNEL, "NOT_SET")
+        log_record[CODE_LINE] = _get_code_line()
+        log_record[DATE_TIME] = _get_date_time(record)
+        log_record[DOMAIN] = SETTINGS.get(DOMAIN)
+        log_record[INSTANCE_ID] = os.getenv(INSTANCE_ID, "NOT_SET")
+        log_record[LOG_MESSAGE] = record.message
+        log_record[TIMESTAMP] = _get_timestamp(record)
+
+        _set_correlation_id(log_record)
+        _set_stacktrace(log_record)
+
+
+def _get_os_paths():
+    return [
+        "".join([path, os.sep])
+        for path in sorted(
+            map(os.path.abspath, sys.path), key=len, reverse=True
+        )
+        if not path.endswith(os.sep)
+    ]
+
+
+def _find_relative_path(a, pathname):
+    return next(
+        os.path.relpath(pathname, path)
+        for path in a
+        if pathname.startswith(path)
+    )
+
+
+def _get_code_line():
+    stack = _find_log_caller_stack()
+
+    # ensure that the path separator is always the os separator
+    pathname = stack.filename.replace("/", os.sep)
+    relativepath = _find_relative_path(_get_os_paths(), pathname)
+
+    return (
+        f"{relativepath.replace(os.sep, '/')} - "
+        f"{stack.function}:{stack.lineno}"
+    )
+
+
+def _find_log_caller_stack():
+    stacks: List[FrameInfo] = inspect.stack()
+    log_stack_index = [
+        index
+        for index, stack in enumerate(stacks)
+        if stack.function == "_do_log"
+    ][0]
+
+    # since the caller method is always _do_log and it is called from
+    # one of the _logger methods, the real caller is always two (2) stacks
+    # before _do_log stack
+    return stacks[log_stack_index + 2]
+
+
+def _get_date_time(record: LogRecord):
+    return datetime.fromtimestamp(record.created, tz=None).isoformat(
+        sep=" ", timespec="milliseconds"
+    )
+
+
+def _get_timestamp(record: LogRecord):
+    return int(record.created * 1000)
+
+
+def _set_correlation_id(log_record: Dict[str, Any]):
+    correlation_id: str = os.getenv(CORRELATION_ID)
+
+    if not correlation_id:
+        correlation_id = str(uuid4())
+        os.environ[CORRELATION_ID] = correlation_id
+
+    log_record[CORRELATION_ID] = correlation_id
+
+
+def _set_stacktrace(log_record: Dict[str, Any]):
+    if log_record.get("exc_info"):
+        log_record["stacktrace"] = log_record.pop("exc_info").split("\n")
+
+
+def _get_format():
+    return " ".join([f"%({log_field.value})" for log_field in LogFields])
+
+
+def _get_formatter() -> CustomJsonFormatter:
+    return CustomJsonFormatter(_get_format())
+
+
+def _remove_handlers():
+    root = logging.getLogger()
+
+    if root.handlers:
+        for handler in root.handlers:
+            root.removeHandler(handler)
+
+
+def _get_handler():
+    log_handler = StreamHandler()
+    log_handler.setFormatter(_get_formatter())
+    return log_handler
+
+
+def initialize_logger() -> Logger:
+    # remove handlers to avoid conflict and log duplication
+    # refs.: https://stackoverflow.com/a/45624044/7973282
+    _remove_handlers()
+
+    logger = logging.getLogger(SETTINGS.get("APP_NAME"))
+    logger.addHandler(_get_handler())
+    logger.setLevel(logging.getLevelName(SETTINGS.get("LOG_LEVEL")))
+
+    return logger
+
+
+def _mask_list(value: list) -> list:
+    return [MASK_CHARACTER for _ in value]
+
+
+def is_primitive(obj):
+    return not hasattr(obj, "__dict__")
+
+
+def _should_mask_primitive_value(key, value):
+    return is_primitive(value) and key.lower() in FIELDS_TO_MASK
+
+
+def _mask_fields(payload) -> dict:
+    return _dict_replace_value(payload)
+
+
+# refs.: https://stackoverflow.com/a/60776516/7973282
+
+
+def _dict_replace_value(payload) -> dict:
+    new_payload = {}
+
+    for key, value in payload.items():
+        if isinstance(value, dict):
+            value = _dict_replace_value(value)
+        elif isinstance(value, list):
+            value = _mask_list(value)
+        elif _should_mask_primitive_value(key, value):
+            value = MASK_CHARACTER
+        new_payload[key] = value
+    return new_payload
+
+
+def get_payload(payload: object) -> dict:
+    return _mask_fields(
+        json.loads(
+            json.dumps(
+                payload,
+                skipkeys=True,
+                default=lambda o: o.__dict__
+                if hasattr(o, "__dict__")
+                else str(o),
+            )
+        ),
+    )


Test patch:
diff --git a/.github/workflows/python-publish-test-pypi.yml b/.github/workflows/python-publish-test-pypi.yml
index d568808..7286331 100644
--- a/.github/workflows/python-publish-test-pypi.yml
+++ b/.github/workflows/python-publish-test-pypi.yml
@@ -10,7 +10,9 @@ name: Upload Python Package
 
 on:
   push:
-    branches: [ dev ]
+    branches:
+      - dev
+      - main
   workflow_dispatch:
 
 permissions:
@@ -18,8 +20,7 @@ permissions:
 
 jobs:
   deploy:
-
-    runs-on: 3.9.14-alpine3.16
+    runs-on: ubuntu-latest
 
     steps:
     - uses: actions/checkout@v3
@@ -27,15 +28,33 @@ jobs:
       uses: actions/setup-python@v3
       with:
         python-version: '3.7'
+
+    - name: Sets env vars for dev
+      run: |
+        echo "ENV=dev" >> $GITHUB_ENV
+        echo "PYPI_TOKEN=${{ secrets.TEST_PYPI_API_TOKEN }}" >> $GITHUB_ENV
+        echo "PYPI_URL=https://test.pypi.org/legacy/" >> $GITHUB_ENV
+      if: startsWith(github.ref_name, 'dev')
+
+    - name: Sets env vars for main
+      run: |
+        echo "ENV=main" >> $GITHUB_ENV
+        echo "PYPI_TOKEN=${{ secrets.PYPI_API_TOKEN }}" >> $GITHUB_ENV
+        echo "PYPI_URL=https://pypi.org/simple/" >> $GITHUB_ENV
+      if: startsWith(github.ref_name, 'main')
+
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
         pip install build
+
     - name: Build package
-      run: python -m build
+      run: |
+        python -m build
+
     - name: Publish package
       uses: pypa/gh-action-pypi-publish@master
       with:
         user: __token__
-        password: ${{ secrets.PYPI_API_TOKEN }}
-        repository_url: https://test.pypi.org/legacy/
+        password: ${{ env.PYPI_TOKEN }}
+        repository_url: ${{ env.PYPI_URL }}
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29