Repository: brainelectronics/micropython-modbus (language: python)

Issue:
Add contribution guideline
Mention steps to be execucted locally before pushing changes in a PR. Maybe add also [precommit hooks](https://pre-commit.com/) at least for [flake8](https://flake8.pycqa.org/en/latest/user/using-hooks.html)

              @beyonlo I've published https://test.pypi.org/project/micropython-modbus/2.4.0rc62.dev56/ for testing. 

@GimmickNG please check flake8 before pushing changes, builds are not even reaching the tests anymore
```bash
pip install -r requirements-test.txt
flake8 .
yamllint .

# tests can be locally checked by
docker build --tag micropython-test --file Dockerfile.tests .
docker compose up --build --exit-code-from micropython-host
docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host
docker compose -f docker-compose-rtu.yaml up --build --exit-code-from micropython-host
docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host
```

_Originally posted by @brainelectronics in https://github.com/brainelectronics/micropython-modbus/issues/56#issuecomment-1488091972_
            
Add contribution guideline
Mention steps to be execucted locally before pushing changes in a PR. Maybe add also [precommit hooks](https://pre-commit.com/) at least for [flake8](https://flake8.pycqa.org/en/latest/user/using-hooks.html)

              @beyonlo I've published https://test.pypi.org/project/micropython-modbus/2.4.0rc62.dev56/ for testing. 

@GimmickNG please check flake8 before pushing changes, builds are not even reaching the tests anymore
```bash
pip install -r requirements-test.txt
flake8 .
yamllint .

# tests can be locally checked by
docker build --tag micropython-test --file Dockerfile.tests .
docker compose up --build --exit-code-from micropython-host
docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host
docker compose -f docker-compose-rtu.yaml up --build --exit-code-from micropython-host
docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host
```

_Originally posted by @brainelectronics in https://github.com/brainelectronics/micropython-modbus/issues/56#issuecomment-1488091972_
            


Discussion / hints:

Fix patch:
diff --git a/.gitmodules b/.gitmodules
index 839d04e..2ec7249 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -3,4 +3,4 @@
 	# changed to https due to RTD build issue
 	# see https://github.com/readthedocs/readthedocs.org/issues/4043
 	# url = git@github.com:brainelectronics/python-modules.git
-	url = https://github.com/brainelectronics/python-modules.git
\ No newline at end of file
+	url = https://github.com/brainelectronics/python-modules.git
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..f79eb72
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,39 @@
+---
+
+# To install pre-commit hooks, install `pre-commit` and activate it here:
+#     pip3 install pre-commit
+#     pre-commit install
+#
+default_stages:
+  - commit
+  - push
+  - manual
+
+repos:
+  - repo: https://github.com/pre-commit/pre-commit-hooks
+    rev: v2.3.0
+    hooks:
+      - id: check-yaml
+      - id: trailing-whitespace
+        args: [--markdown-linebreak-ext=md]
+  - repo: https://github.com/PyCQA/flake8
+    rev: 5.0.4
+    hooks:
+      - id: flake8
+  - repo: https://github.com/brainelectronics/micropython-package-validation
+    rev: 0.5.0
+    hooks:
+      - id: upy-package
+        args:
+          - "--setup_file=setup.py"
+          - "--package_changelog_file=changelog.md"
+          - "--package_file=package.json"
+          - "--validate"
+  - repo: https://github.com/brainelectronics/changelog2version
+    rev: 0.10.0
+    hooks:
+      - id: changelog2version
+        args:
+          - "--changelog_file=changelog.md"
+          - "--version_file=umodbus/version.py"
+          - "--validate"
diff --git a/Dockerfile.client_rtu b/Dockerfile.client_rtu
index 0b37d95..3dfd300 100644
--- a/Dockerfile.client_rtu
+++ b/Dockerfile.client_rtu
@@ -10,6 +10,4 @@ FROM micropython/unix:v1.18
 # COPY ./ /home
 # COPY umodbus /root/.micropython/lib/umodbus
 
-RUN micropython-dev -m upip install micropython-ulogging
-
 CMD [ "micropython-dev", "-m", "examples/rtu_client_example.py" ]
diff --git a/Dockerfile.client_tcp b/Dockerfile.client_tcp
index e1b5f20..8b52fe5 100644
--- a/Dockerfile.client_tcp
+++ b/Dockerfile.client_tcp
@@ -10,6 +10,4 @@ FROM micropython/unix:v1.18
 # COPY ./ /home
 # COPY umodbus /root/.micropython/lib/umodbus
 
-RUN micropython-dev -m upip install micropython-ulogging
-
 CMD [ "micropython-dev", "-m", "examples/tcp_client_example.py" ]
diff --git a/Dockerfile.host_rtu b/Dockerfile.host_rtu
index e1f4dd3..869d063 100644
--- a/Dockerfile.host_rtu
+++ b/Dockerfile.host_rtu
@@ -10,6 +10,4 @@ FROM micropython/unix:v1.18
 # COPY ./ /home
 # COPY umodbus /root/.micropython/lib/umodbus
 
-RUN micropython-dev -m upip install micropython-ulogging
-
 CMD [ "micropython-dev", "-m", "examples/rtu_host_example.py" ]
diff --git a/Dockerfile.host_tcp b/Dockerfile.host_tcp
index 689a2bc..7e1091f 100644
--- a/Dockerfile.host_tcp
+++ b/Dockerfile.host_tcp
@@ -10,6 +10,4 @@ FROM micropython/unix:v1.18
 # COPY ./ /home
 # COPY umodbus /root/.micropython/lib/umodbus
 
-RUN micropython-dev -m upip install micropython-ulogging
-
 CMD [ "micropython-dev", "-m", "examples/tcp_host_example.py" ]
diff --git a/changelog.md b/changelog.md
index a8c8b62..e64a3ce 100644
--- a/changelog.md
+++ b/changelog.md
@@ -15,6 +15,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 <!-- ## [Unreleased] -->
 
 ## Released
+## [2.3.6] - 2023-07-19
+### Added
+- Add contribution guideline, see #67
+- Content of `package.json` is validated on each test workflow run
+- Precommit hooks for `package.json` and package version file validation, yaml style, flake8 and trailing whitespace checks, contributes to #67
+
+### Changed
+- `umodbus/version.py` file is validated against the latest changelog entry before running all tests and testing the package creation
+- `ulogging` placed into `tests` folder instead of installing it with deprecated `upip` in all Docker containers
+
+### Fixed
+- Added missing empty line in several files
+
 ## [2.3.5] - 2023-07-01
 ### Fixed
 - Time between RS485 control pin raise and UART transmission reduced by 80% from 1000us to 200us
@@ -290,8 +303,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - PEP8 style issues on all files of [`lib/uModbus`](lib/uModbus)
 
 <!-- Links -->
-[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/2.3.5...develop
+[Unreleased]: https://github.com/brainelectronics/micropython-modbus/compare/2.3.6...develop
 
+[2.3.6]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.6
 [2.3.5]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.5
 [2.3.4]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.4
 [2.3.3]: https://github.com/brainelectronics/micropython-modbus/tree/2.3.3
diff --git a/docker-compose-rtu.yaml b/docker-compose-rtu.yaml
index c28332b..f4cd743 100644
--- a/docker-compose-rtu.yaml
+++ b/docker-compose-rtu.yaml
@@ -19,6 +19,7 @@ services:
       - ./:/home
       - ./umodbus:/root/.micropython/lib/umodbus
       - ./fakes:/usr/lib/micropython
+      - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py
     expose:
       - "65433"
     ports:
@@ -39,6 +40,7 @@ services:
       - ./:/home
       - ./umodbus:/root/.micropython/lib/umodbus
       - ./fakes:/usr/lib/micropython
+      - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py
     depends_on:
       - micropython-client
     networks:
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
index bd05ca4..2307ccd 100755
--- a/docs/CONTRIBUTING.md
+++ b/docs/CONTRIBUTING.md
@@ -4,4 +4,208 @@ Guideline to contribute to this package
 
 ---------------
 
-## TBD
+## General
+
+You're always welcome to contribute to this package with or without raising an
+issue before creating a PR.
+
+Please follow this guideline covering all necessary steps and hints to ensure
+a smooth review and contribution process.
+
+## Code
+
+To test and verify your changes it is recommended to run all checks locally in
+a virtual environment. Use the following commands to setup and install all
+tools.
+
+```bash
+python3 -m venv .venv
+source .venv/bin/activate
+
+pip install -r requirements-test.txt
+```
+
+For very old systems it might be necessary to use an older version of
+`pre-commit`, an "always" working version is `1.18.3` with the drawback of not
+having `flake8` and maybe other checks in place.
+
+### Format
+
+The Python code format is checked by `flake8` with the default line length
+limit of 79. Further configuration can be found in the `.flake8` file in the
+repo root.
+
+The YAML code format is checked by `yamllint` with some small adjustments as
+defined in the `.yamllint` file in the repo root.
+
+Use the following commands (inside the virtual environment) to run the Python
+and YAML checks
+
+```bash
+# check Python
+flake8 .
+
+# check YAML
+yamllint .
+```
+
+### Tests
+
+Every code should be covered by a unittest. This can be achieved for
+MicroPython up to some degree, as hardware specific stuff can't be always
+tested by a unittest.
+
+For now `mpy_unittest` is used as tool of choice and runs directly on the
+divice. For ease of use a Docker container is used as not always a device is
+at hand or connected to the CI.
+
+The hardware UART connection is faked by a TCP connection providing the same
+interface and basic functions as a real hardware interface.
+
+The tests are defined, as usual, in the `tests` folder. The `mpy_unittest`
+takes and runs all tests defined and imported there by the `__init__.py` file.
+
+Further tests, which could be called Integration tests, are defined in this
+folder as well. To be usable they may require a counterpart e.g. a client
+communicating with a host, which is simply achieved by two Docker containers,
+defined in the `docker-compose-tcp-test.yaml` or `docker-compose-rtu-test.yaml`
+file, located in the repo root. The examples for TCP or RTU client usage are
+used to provide a static setup.
+
+Incontrast to Python, no individual test results will be reported as parsable
+XML or similar, the container will exit with either `1` in case of an error or
+with `0` on success.
+
+```bash
+# build and run the "native" unittests
+docker build --tag micropython-test --file Dockerfile.tests .
+
+# Execute client/host TCP examples
+docker compose up --build --exit-code-from micropython-host
+
+# Run client/host TCP tests
+docker compose -f docker-compose-tcp-test.yaml up --build --exit-code-from micropython-host
+
+# Run client/host RTU examples with faked RTU via TCP
+docker compose -f docker-compose-rtu.yaml up --build --exit-code-from micropython-host
+
+# Run client/host RTU tests
+docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host
+```
+
+### Precommit hooks
+
+This repo is equipped with a `.pre-commit-config.yaml` file to combine most of
+the previously mentioned checks plus the changelog validation, see next
+section, into one handy command. It additionally allows to automatically run
+the checks on every commit.
+
+In order to run this repo's pre commit hooks, perform the following steps
+
+```bash
+# install pre-commit to run before each commit, optionally
+pre-commit install
+
+pre-commit run --all-files
+```
+
+## Changelog
+
+The changelog format is based on [Keep a Changelog][ref-keep-a-changelog], and
+this project adheres to [Semantic Versioning][ref-semantic-versioning].
+
+Please add a changelog entry for every PR you contribute. The versions are
+seperated into `MAJOR.MINOR.PATCH`:
+
+- Increment the major version by 1 in case you created a breaking, non
+backwards compatible change which requires the user to perform additional
+tasks, adopt his currently running code or in general can't be used as is anymore.
+- Increment the minor version by 1 on new "features" which can be used or are
+optional, but in either case do not require any changes by the user to keep
+the system running after upgrading.
+- Increment the patch version by 1 on bugfixes which fix an issue but can be
+used out of the box, like features, without any changes by the user. In some
+cases bugfixes can be breaking changes of course.
+
+Please add the date the change has been made as well to the changelog
+following the format `## [MAJOR.MINOR.PATCH] - YYYY-MM-DD`. It is not
+necessary to keep this date up to date, it is just used as meta data.
+
+The changelog entry shall be short but meaningful and can of course contain
+links and references to other issues or PRs. New lines are only allowed for a
+new bulletpoint entry. Usage examples or other code snippets should be placed
+in the code documentation, README or the docs folder.
+
+### General
+
+The package version file, located at `umodbus/version.py` contains the latest
+changelog version.
+
+To avoid a manual sync of the changelog version and the package version file
+content, the `changelog2version` package is used. It parses the changelog,
+extracts the latest version and updates the version file.
+
+The package version file can be generated with the following command consuming
+the latest changelog entry
+
+```bash
+changelog2version \
+	--changelog_file changelog.md \
+	--version_file umodbus/version.py \
+	--version_file_type py \
+	--debug
+```
+
+To validate the existing package version file against the latest changelog
+entry use this command
+
+```bash
+changelog2version \
+	--changelog_file=changelog.md \
+	--version_file=umodbus/version.py \
+	--validate
+```
+
+### MicroPython
+
+On MicroPython the `mip` package is used to install packages instead of `pip`
+at MicroPython version 1.20.0 and newer. This utilizes a `package.json` file
+in the repo root to define all files and dependencies of a package to be
+downloaded by [`mip`][ref-mip-docs].
+
+To avoid a manual sync of the changelog version and the MicroPython package
+file content, the `setup2upypackage` package is used. It parses the changelog,
+extracts the latest version and updates the package file version entry. It
+additionally parses the `setup.py` file and adds entries for all files
+contained in the package to the `urls` section and all other external
+dependencies to the `deps` section.
+
+The MicroPython package file can be generated with the following command based
+on the latest changelog entry and `setup` file.
+
+```bash
+upy-package \
+	--setup_file setup.py \
+	--package_changelog_file changelog.md \
+	--package_file package.json
+```
+
+To validate the existing package file against the latest changelog entry and
+setup file content use this command
+
+```bash
+upy-package \
+	--setup_file setup.py \
+	--package_changelog_file changelog.md \
+	--package_file package.json \
+	--validate
+```
+
+## Documentation
+
+Please check the `docs/DOCUMENTATION.md` file for further details.
+
+<!-- Links -->
+[ref-keep-a-changelog]: https://keepachangelog.com/en/1.0.0/
+[ref-semantic-versioning]: https://semver.org/spec/v2.0.0.html
+[ref-mip-docs]: https://docs.micropython.org/en/v1.20.0/reference/packages.html
diff --git a/docs/changelog_link.rst b/docs/changelog_link.rst
index e446ab8..cdb4bb4 100755
--- a/docs/changelog_link.rst
+++ b/docs/changelog_link.rst
@@ -1,3 +1,3 @@
 
 .. include:: ../changelog.md
-   :parser: myst_parser.sphinx_
\ No newline at end of file
+   :parser: myst_parser.sphinx_
diff --git a/docs/readme_link.rst b/docs/readme_link.rst
index dd42123..9bd843d 100755
--- a/docs/readme_link.rst
+++ b/docs/readme_link.rst
@@ -1,3 +1,3 @@
 
 .. include:: ../README.md
-   :parser: myst_parser.sphinx_
\ No newline at end of file
+   :parser: myst_parser.sphinx_
diff --git a/package.json b/package.json
index a9ab915..ffac8a7 100644
--- a/package.json
+++ b/package.json
@@ -37,8 +37,6 @@
             "github:brainelectronics/micropython-modbus/umodbus/version.py"
         ]
     ],
-    "deps": [
-    ],
-    "version": "2.3.4"
-}
-
+    "deps": [],
+    "version": "2.3.6"
+}
\ No newline at end of file
diff --git a/requirements-deploy.txt b/requirements-deploy.txt
index b9821c8..96b0350 100644
--- a/requirements-deploy.txt
+++ b/requirements-deploy.txt
@@ -2,4 +2,4 @@
 # Avoid fixed versions
 # # to upload package to PyPi or other package hosts
 twine>=4.0.1,<5
-changelog2version>=0.5.0,<1
\ No newline at end of file
+changelog2version>=0.5.0,<1
diff --git a/requirements.txt b/requirements.txt
index f9dfa09..0884c36 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
 # adafruit-ampy>=1.1.0,<2.0.0
 esptool
 rshell>=0.0.30,<1.0.0
-mpremote>=0.4.0,<1
\ No newline at end of file
+mpremote>=0.4.0,<1
diff --git a/umodbus/version.py b/umodbus/version.py
index 3856329..1b69b57 100644
--- a/umodbus/version.py
+++ b/umodbus/version.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
 # -*- coding: UTF-8 -*-
 
-__version_info__ = ("0", "0", "0")
+__version_info__ = ("2", "3", "6")
 __version__ = '.'.join(__version_info__)


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 4a60686..eeee8f7 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -40,10 +40,24 @@ jobs:
       - name: Lint with yamllint
         run: |
           yamllint .
-      - name: Install deploy dependencies
+      - name: Validate package version file
+        # the package version file has to be always up to date as mip is using
+        # the file directly from the repo. On a PyPi package the version file
+        # is updated and then packed
         run: |
-          python -m pip install --upgrade pip
-          if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi
+          changelog2version \
+            --changelog_file changelog.md \
+            --version_file umodbus/version.py \
+            --version_file_type py \
+            --validate \
+            --debug
+      - name: Validate mip package file
+        run: |
+          upy-package \
+            --setup_file setup.py \
+            --package_changelog_file changelog.md \
+            --package_file package.json \
+            --validate
       - name: Execute tests
         run: |
           docker build --tag micropython-test --file Dockerfile.tests .
@@ -59,13 +73,12 @@ jobs:
       - name: Run Client/Host RTU test
         run: |
           docker compose -f docker-compose-rtu-test.yaml up --build --exit-code-from micropython-host
+      - name: Install deploy dependencies
+        run: |
+          python -m pip install --upgrade pip
+          if [ -f requirements-deploy.txt ]; then pip install -r requirements-deploy.txt; fi
       - name: Build package
         run: |
-          changelog2version \
-            --changelog_file changelog.md \
-            --version_file umodbus/version.py \
-            --version_file_type py \
-            --debug
           python setup.py sdist
           rm dist/*.orig
       - name: Test built package
diff --git a/Dockerfile.test_examples b/Dockerfile.test_examples
index ae80e57..3978860 100644
--- a/Dockerfile.test_examples
+++ b/Dockerfile.test_examples
@@ -11,5 +11,3 @@ FROM micropython/unix:v1.18
 # COPY ./ /home
 # COPY umodbus /root/.micropython/lib/umodbus
 # COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py
-
-RUN micropython-dev -m upip install micropython-ulogging
diff --git a/Dockerfile.tests b/Dockerfile.tests
index d7ad927..a01b4b1 100644
--- a/Dockerfile.tests
+++ b/Dockerfile.tests
@@ -12,8 +12,8 @@ COPY ./ /home
 COPY registers/example.json /home/tests/test-registers.json
 COPY umodbus /root/.micropython/lib/umodbus
 COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py
+COPY tests/ulogging.py /root/.micropython/lib/ulogging.py
 
-RUN micropython-dev -m upip install micropython-ulogging
 RUN micropython-dev -c "import mpy_unittest as unittest; unittest.main('tests')"
 
 ENTRYPOINT ["/bin/bash"]
diff --git a/Dockerfile.tests_manually b/Dockerfile.tests_manually
index 0753480..f34b381 100644
--- a/Dockerfile.tests_manually
+++ b/Dockerfile.tests_manually
@@ -12,7 +12,6 @@ COPY ./ /home
 COPY registers/example.json /home/tests/test-registers.json
 COPY umodbus /root/.micropython/lib/umodbus
 COPY mpy_unittest.py /root/.micropython/lib/mpy_unittest.py
-
-RUN micropython-dev -m upip install micropython-ulogging
+COPY tests/ulogging.py /root/.micropython/lib/ulogging.py
 
 ENTRYPOINT ["/bin/bash"]
diff --git a/docker-compose-rtu-test.yaml b/docker-compose-rtu-test.yaml
index 702fd9b..0cb4944 100644
--- a/docker-compose-rtu-test.yaml
+++ b/docker-compose-rtu-test.yaml
@@ -20,6 +20,7 @@ services:
       - ./registers:/home/registers
       - ./umodbus:/root/.micropython/lib/umodbus
       - ./fakes:/usr/lib/micropython
+      - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py
     expose:
       - "65433"
     ports:
@@ -41,6 +42,7 @@ services:
       - ./umodbus:/root/.micropython/lib/umodbus
       - ./fakes:/usr/lib/micropython
       - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py
+      - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py
     depends_on:
       - micropython-client
     command:
diff --git a/docker-compose-tcp-test.yaml b/docker-compose-tcp-test.yaml
index 09ad0c7..c215d80 100644
--- a/docker-compose-tcp-test.yaml
+++ b/docker-compose-tcp-test.yaml
@@ -19,6 +19,7 @@ services:
       - ./:/home
       - ./registers:/home/registers
       - ./umodbus:/root/.micropython/lib/umodbus
+      - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py
     expose:
       - "502"
     ports:
@@ -39,6 +40,7 @@ services:
       - ./:/home
       - ./umodbus:/root/.micropython/lib/umodbus
       - ./mpy_unittest.py:/root/.micropython/lib/mpy_unittest.py
+      - ./tests/ulogging.py:/root/.micropython/lib/ulogging.py
     depends_on:
       - micropython-client
     command:
diff --git a/requirements-test.txt b/requirements-test.txt
index 94945cf..2ac46f6 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -1,6 +1,9 @@
 # List external packages here
 # Avoid fixed versions
-flake8>=5.0.0,<6
+changelog2version>=0.10.0,<1
 coverage>=6.4.2,<7
+flake8>=5.0.0,<6
 nose2>=0.12.0,<1
+setup2upypackage>=0.4.0,<1
+pre-commit>=3.3.3,<4
 yamllint>=1.29,<2
diff --git a/tests/ulogging.py b/tests/ulogging.py
new file mode 100644
index 0000000..0d1c8d5
--- /dev/null
+++ b/tests/ulogging.py
@@ -0,0 +1,260 @@
+#!/usr/bin/env python3
+# -*- coding: UTF-8 -*-
+
+"""
+This file has been copied from micropython-lib
+
+https://github.com/micropython/micropython-lib/blob/7128d423c2e7c0309ac17a1e6ba873b909b24fcc/python-stdlib/logging/logging.py
+"""
+
+try:
+    from micropython import const  # noqa: F401
+except ImportError:
+
+    def const(x):
+        return x
+
+
+import sys
+import time
+
+CRITICAL = const(50)
+ERROR = const(40)
+WARNING = const(30)
+INFO = const(20)
+DEBUG = const(10)
+NOTSET = const(0)
+
+_DEFAULT_LEVEL = const(WARNING)
+
+_level_dict = {
+    CRITICAL: "CRITICAL",
+    ERROR: "ERROR",
+    WARNING: "WARNING",
+    INFO: "INFO",
+    DEBUG: "DEBUG",
+    NOTSET: "NOTSET",
+}
+
+_loggers = {}
+_stream = sys.stderr
+_default_fmt = "%(levelname)s:%(name)s:%(message)s"
+_default_datefmt = "%Y-%m-%d %H:%M:%S"
+
+
+class LogRecord:
+    def set(self, name, level, message):
+        self.name = name
+        self.levelno = level
+        self.levelname = _level_dict[level]
+        self.message = message
+        self.ct = time.time()
+        self.msecs = int((self.ct - int(self.ct)) * 1000)
+        self.asctime = None
+
+
+class Handler:
+    def __init__(self, level=NOTSET):
+        self.level = level
+        self.formatter = None
+
+    def close(self):
+        pass
+
+    def setLevel(self, level):
+        self.level = level
+
+    def setFormatter(self, formatter):
+        self.formatter = formatter
+
+    def format(self, record):
+        return self.formatter.format(record)
+
+
+class StreamHandler(Handler):
+    def __init__(self, stream=None):
+        self.stream = _stream if stream is None else stream
+        self.terminator = "\n"
+
+    def close(self):
+        if hasattr(self.stream, "flush"):
+            self.stream.flush()
+
+    def emit(self, record):
+        if record.levelno >= self.level:
+            self.stream.write(self.format(record) + self.terminator)
+
+
+class FileHandler(StreamHandler):
+    def __init__(self, filename, mode="a", encoding="UTF-8"):
+        super().__init__(stream=open(filename, mode=mode, encoding=encoding))
+
+    def close(self):
+        super().close()
+        self.stream.close()
+
+
+class Formatter:
+    def __init__(self, fmt=None, datefmt=None):
+        self.fmt = _default_fmt if fmt is None else fmt
+        self.datefmt = _default_datefmt if datefmt is None else datefmt
+
+    def usesTime(self):
+        return "asctime" in self.fmt
+
+    def formatTime(self, datefmt, record):
+        if hasattr(time, "strftime"):
+            return time.strftime(datefmt, time.localtime(record.ct))
+        return None
+
+    def format(self, record):
+        if self.usesTime():
+            record.asctime = self.formatTime(self.datefmt, record)
+        return self.fmt % {
+            "name": record.name,
+            "message": record.message,
+            "msecs": record.msecs,
+            "asctime": record.asctime,
+            "levelname": record.levelname,
+        }
+
+
+class Logger:
+    def __init__(self, name, level=NOTSET):
+        self.name = name
+        self.level = level
+        self.handlers = []
+        self.record = LogRecord()
+
+    def setLevel(self, level):
+        self.level = level
+
+    def isEnabledFor(self, level):
+        return level >= self.getEffectiveLevel()
+
+    def getEffectiveLevel(self):
+        return self.level or getLogger().level or _DEFAULT_LEVEL
+
+    def log(self, level, msg, *args):
+        if self.isEnabledFor(level):
+            if args:
+                if isinstance(args[0], dict):
+                    args = args[0]
+                msg = msg % args
+            self.record.set(self.name, level, msg)
+            handlers = self.handlers
+            if not handlers:
+                handlers = getLogger().handlers
+            for h in handlers:
+                h.emit(self.record)
+
+    def debug(self, msg, *args):
+        self.log(DEBUG, msg, *args)
+
+    def info(self, msg, *args):
+        self.log(INFO, msg, *args)
+
+    def warning(self, msg, *args):
+        self.log(WARNING, msg, *args)
+
+    def error(self, msg, *args):
+        self.log(ERROR, msg, *args)
+
+    def critical(self, msg, *args):
+        self.log(CRITICAL, msg, *args)
+
+    def exception(self, msg, *args):
+        self.log(ERROR, msg, *args)
+        if hasattr(sys, "exc_info"):
+            sys.print_exception(sys.exc_info()[1], _stream)
+
+    def addHandler(self, handler):
+        self.handlers.append(handler)
+
+    def hasHandlers(self):
+        return len(self.handlers) > 0
+
+
+def getLogger(name=None):
+    if name is None:
+        name = "root"
+    if name not in _loggers:
+        _loggers[name] = Logger(name)
+        if name == "root":
+            basicConfig()
+    return _loggers[name]
+
+
+def log(level, msg, *args):
+    getLogger().log(level, msg, *args)
+
+
+def debug(msg, *args):
+    getLogger().debug(msg, *args)
+
+
+def info(msg, *args):
+    getLogger().info(msg, *args)
+
+
+def warning(msg, *args):
+    getLogger().warning(msg, *args)
+
+
+def error(msg, *args):
+    getLogger().error(msg, *args)
+
+
+def critical(msg, *args):
+    getLogger().critical(msg, *args)
+
+
+def exception(msg, *args):
+    getLogger().exception(msg, *args)
+
+
+def shutdown():
+    for k, logger in _loggers.items():
+        for h in logger.handlers:
+            h.close()
+        _loggers.pop(logger, None)
+
+
+def addLevelName(level, name):
+    _level_dict[level] = name
+
+
+def basicConfig(
+    filename=None,
+    filemode="a",
+    format=None,
+    datefmt=None,
+    level=WARNING,
+    stream=None,
+    encoding="UTF-8",
+    force=False,
+):
+    if "root" not in _loggers:
+        _loggers["root"] = Logger("root")
+
+    logger = _loggers["root"]
+
+    if force or not logger.handlers:
+        for h in logger.handlers:
+            h.close()
+        logger.handlers = []
+
+        if filename is None:
+            handler = StreamHandler(stream)
+        else:
+            handler = FileHandler(filename, filemode, encoding)
+
+        handler.setLevel(level)
+        handler.setFormatter(Formatter(format, datefmt))
+
+        logger.setLevel(level)
+        logger.addHandler(handler)
+
+
+if hasattr(sys, "atexit"):
+    sys.atexit(shutdown)