diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf0e38a..552795f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-20.04, macos-latest, windows-latest] - python-version: [3.7, 3.8, 3.9, '3.10'] + os: [ubuntu-18.04, macos-12, windows-latest] + python-version: [3.6, 3.7, 3.9, '3.10'] steps: - name: Set git crlf/eol run: | diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3b57bfe..f2a7277 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -134,22 +134,28 @@ jobs: PIP_DOWNLOAD_CACHE: ${{ github.workspace }}/../.pip_download_cache steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: python-version: ${{ env.PYTHON }} + cache: 'pip' # caching pip dependencies - name: Add python requirements run: | python -m pip install --upgrade pip pip install tox - - name: Generate coverage and fix pkg name + - name: Setup old python for test + uses: actions/setup-python@v4 + with: + python-version: 3.6 + + - name: Generate coverage run: | - tox -e py + tox -e coverage,py36,py39 - name: Code Coverage Summary Report (data) uses: irongut/CodeCoverageSummary@v1.1.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4092ae1..5bc09b6 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -18,8 +18,8 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-20.04, macos-latest, windows-latest] - python-version: [3.7, 3.8, 3.9, '3.10'] + os: [ubuntu-20.04, macos-11, windows-2019] + python-version: [3.6, 3.7, 3.9, '3.10'] steps: - name: Set git crlf/eol @@ -46,7 +46,7 @@ jobs: tox -e build,check - name: Upload artifacts - if: matrix.python-version == 3.8 && runner.os == 'Linux' + if: matrix.python-version == 3.9 && runner.os == 'Linux' uses: actions/upload-artifact@v2 with: name: wheels diff --git a/README.rst b/README.rst index 18196ff..2b9abe4 100644 --- a/README.rst +++ b/README.rst @@ -163,13 +163,14 @@ To run pylint:: To install the latest release, eg with your own ``tox.ini`` file in another project, use something like this:: - $ pip install -U -f https://github.com/sarnold/pyserv/releases/ pyserv + $ pip install https://github.com/sarnold/pyserv/releases/download/1.2.4/pyserv-1.2.4-py3-none-any.whl If you have a ``requirements.txt`` file, you can add something like this:: - -f https://github.com/sarnold/pyserv/releases/ - pyserv>=1.2.3 + pyserv @ https://github.com/sarnold/pyserv/releases/download/1.2.4/pyserv-1.2.4.tar.gz +Note the newest pip versions may no longer work using ``-f`` with just +the GH "releases" path to get the latest release from Github. .. _Tox: https://github.com/tox-dev/tox diff --git a/mypy.ini b/mypy.ini index 2f41309..54ad14e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4,3 +4,9 @@ warn_unused_configs = True [mypy-appdirs] ignore_missing_imports = True + +[mypy-daemon] +ignore_missing_imports = True + +[mypy-daemon.parent_logger] +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml index acbaa10..6854053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,6 @@ requires = [ "setuptools>=42", "versioningit >= 1.1.1", - "wheel", ] build-backend = "setuptools.build_meta" @@ -17,7 +16,10 @@ markers = "subscript" [tool.coverage.run] branch = true -source = ["pyserv"] +source = [ + "pyserv/", + ".tox/py*/lib/python*/site-packages/", +] omit = [ "tests", ".tox", @@ -27,7 +29,7 @@ omit = [ source = ["pyserv"] [tool.coverage.report] -fail_under = 85 +fail_under = 80 show_missing = true exclude_lines = [ "pragma: no cover", diff --git a/pyserv/__init__.py b/pyserv/__init__.py index 966e0d9..f4fdfd0 100644 --- a/pyserv/__init__.py +++ b/pyserv/__init__.py @@ -1,9 +1,13 @@ -"""Simple HTTP server classes with GET path rewriting and request/header logging.""" +""" +Simple HTTP server classes with GET path rewriting and request/header logging. +""" import logging +import sys import threading from functools import partial -from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from http.server import HTTPServer, SimpleHTTPRequestHandler +from socketserver import ThreadingMixIn from urllib.parse import urlparse from ._version import __version__ @@ -15,10 +19,10 @@ def munge_url(ota_url): """ - Parse the url sent by OTA command for file path and netloc. + Parse the url sent by OTA command for file path and host string. :param ota_url: (possibly) broken GET path - :return tuple: netloc and path from `urlparse` + :return tuple: netloc and path from ``urlparse`` """ url_data = urlparse(str(ota_url)) file_path = url_data.path @@ -28,6 +32,13 @@ def munge_url(ota_url): return host_str, file_path +class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): + """ + Backwards-compatible server class for Python <3.7 on older distros, + eg, Ubuntu bionic LTS. + """ + + class GetHandler(SimpleHTTPRequestHandler): """ Munge the incoming request path from Dialog OTA. Runs `urlparse` on @@ -78,7 +89,10 @@ def __init__(self, iface, port, directory): self.iface = iface self.port = int(port) self.directory = directory - self.handler = partial(GetHandler, directory=self.directory) + if sys.version_info < (3, 7): + self.handler = GetHandler + else: + self.handler = partial(GetHandler, directory=self.directory) self.server = ThreadingHTTPServer((self.iface, self.port), self.handler) def run(self): diff --git a/pyserv/server.py b/pyserv/server.py index 29dd363..765da32 100644 --- a/pyserv/server.py +++ b/pyserv/server.py @@ -4,10 +4,12 @@ """ import logging +import os import sys +from pathlib import Path from . import GetServer -from .settings import DEBUG +from .settings import DEBUG, DOCROOT LVL_NAME = 'DEBUG' if DEBUG else 'INFO' @@ -25,7 +27,7 @@ def serv_init(iface, port, directory): return httpd_handler -def serv_run(iface='', port=8080, directory='.'): # pragma: no cover +def serv_run(iface='', port=8080, directory=DOCROOT): # pragma: no cover """ Run in foreground command wrapper for console entry point; init logging and server, run the server, stop the server. @@ -33,6 +35,10 @@ def serv_run(iface='', port=8080, directory='.'): # pragma: no cover :param iface: server listen interface :param port: server listen port """ + start_dir = Path.cwd() + path_diff = start_dir.name != Path(directory).name + if sys.version_info < (3, 7) and path_diff: + os.chdir(directory) httpd = serv_init(iface, port, directory) logging.info('Starting HTTP SERVER at %s:%s', iface, port) try: @@ -40,6 +46,7 @@ def serv_run(iface='', port=8080, directory='.'): # pragma: no cover httpd.join() except KeyboardInterrupt: httpd.stop() + os.chdir(start_dir) print("\nExiting ...") diff --git a/pyserv/settings.py b/pyserv/settings.py index b8e32a3..e28a3e7 100644 --- a/pyserv/settings.py +++ b/pyserv/settings.py @@ -1,5 +1,5 @@ """ -Pyserv default settings for daemon mode. +Pyserv default settings for server and daemon modes. """ import importlib import os @@ -60,12 +60,12 @@ def show_uservars(): Display defaults and (possibly) overridden host paths and environment variables. """ - print("Python version:", sys.version) + print(f"Python version: {sys.version}") print("-" * 79) print(f"pyserv {version}") iface = 'all' if not IFACE else IFACE - dirnames = ['log_dir', 'pid_dir', 'doc_dir'] + dirnames = ['log_dir', 'pid_dir', 'work_dir'] modname = 'pyserv.settings' try: mod = importlib.import_module(modname) @@ -79,18 +79,20 @@ def show_uservars(): print(f" DEBUG: {DEBUG}") print(f" PORT: {PORT}") print(f" IFACE: {iface}") + print(f" LPNAME: {LPNAME}") print(f" LOG: {LOG}") print(f" PID: {PID}") print(f" DOCROOT: {DOCROOT}") print("-" * 79) except (ImportError, NameError) as exc: - print("FAILED:", repr(exc)) + print(f"FAILED: {repr(exc)}") DEBUG = os.getenv('DEBUG', default=None) PORT = os.getenv('PORT', default='8080') IFACE = os.getenv('IFACE', default='127.0.0.1') -LOG = os.getenv('LOG', default=str(get_userdirs()[0].joinpath('httpd.log'))) -PID = os.getenv('PID', default=str(get_userdirs()[1].joinpath('httpd.pid'))) +LPNAME = os.getenv('LPNAME', default='httpd') +LOG = os.getenv('LOG', default=str(get_userdirs()[0].joinpath(f'{LPNAME}.log'))) +PID = os.getenv('PID', default=str(get_userdirs()[1].joinpath(f'{LPNAME}.pid'))) DOCROOT = os.getenv('DOCROOT', default=str(get_userdirs()[2])) diff --git a/requirements.txt b/requirements.txt index f668b58..18ac047 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ # daemon requirements, useful for tox/pip --f https://github.com/sarnold/python-daemonizer/releases/ -daemonizer>=0.3.4; platform_system!="Windows" +daemonizer @ git+https://github.com/sarnold/python-daemonizer.git@0.3.5#5f6bc3c80a90344b2c8e4cc24ed0b8c098a7af50; platform_system!="Windows" appdirs diff --git a/scripts/httpdaemon b/scripts/httpdaemon index 386ae21..7c52164 100755 --- a/scripts/httpdaemon +++ b/scripts/httpdaemon @@ -1,11 +1,12 @@ #!/usr/bin/env python3 +""" +Http daemon script using pyserv (see settings.py for env vars). +""" import argparse -import datetime import logging import os import sys -from datetime import timezone from pathlib import Path from daemon import Daemon @@ -29,25 +30,30 @@ from pyserv.settings import ( logger = logging.getLogger(__name__) -class servDaemon(Daemon): +class ServDaemon(Daemon): + """ + Init daemon with custom run/cleanup methods, pass user vars to the + server. + """ + servd = None + def run(self): """ Daemon needs a run method. In this case we need to instantiate our GetServer obj here, ie, *after* the Daemon object. """ - servd = GetServer(IFACE, PORT, DOCROOT) - servd.start() + if sys.version_info < (3, 7): + os.chdir(DOCROOT) + self.servd = GetServer(IFACE, PORT, DOCROOT) + self.servd.start() def cleanup(self): """And we need a cleanup method.""" - servd.stop() + self.servd.stop() if __name__ == "__main__": - """ - Collect and process environment vars, init directories if needed, - setup logging. Check if platform is POSIX, then start the daemon. - """ + if not platform_check(): raise OSError(f'Incompatible platform type "{sys.platform}"') @@ -67,7 +73,7 @@ if __name__ == "__main__": setup_logging(DEBUG, Path(LOG), 'httpd') # printout() - d = servDaemon( + d = ServDaemon( Path(PID), home_dir=DOCROOT, verbose=0, diff --git a/setup.cfg b/setup.cfg index 4bd03b8..0c1efaa 100644 --- a/setup.cfg +++ b/setup.cfg @@ -14,6 +14,7 @@ classifiers = Intended Audience :: Developers Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 @@ -28,7 +29,7 @@ keywords = httpd [options] -python_requires = >= 3.7 +python_requires = >= 3.6 install_requires = appdirs daemonizer @ git+https://github.com/sarnold/python-daemonizer.git@0.3.5#5f6bc3c80a90344b2c8e4cc24ed0b8c098a7af50 diff --git a/tests/test_handler.py b/tests/test_handler.py index d43bd4c..e44098e 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -1,24 +1,43 @@ -# -*- coding: utf-8 -*- import pathlib +import sys import unittest import urllib.request import httptest +import pytest from pyserv import GetHandler FILE_PATH = pathlib.Path(__file__) +REAL_PATH = pathlib.Path('requirements.txt') -class TestHTTPTestMethods(unittest.TestCase): +class TestHTTPHandler(unittest.TestCase): + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="requires python3.7 or higher") @httptest.Server( lambda *args: GetHandler( *args, directory=FILE_PATH.parent ) ) - def test_call_response(self, ts=httptest.NoServer()): + def test_call_response_dir(self, ts=httptest.NoServer()): with urllib.request.urlopen(ts.url() + FILE_PATH.name) as f: self.assertEqual(f.read().decode('utf-8'), FILE_PATH.read_text()) + @httptest.Server(GetHandler) + def test_url(self, ts=httptest.NoServer()): + ''' + Make sure the Server.url() method works. + ''' + url = ts.url() + self.assertIn(':', url) + self.assertEqual(':'.join(url.split(':')[:-1]), 'http://localhost') + + @httptest.Server(GetHandler) + def test_call_response_no_dir(self, ts=httptest.NoServer()): + with urllib.request.urlopen(ts.url() + REAL_PATH.name) as f: + self.assertEqual(f.read().decode('utf-8'), REAL_PATH.read_text()) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_serv.py b/tests/test_serv.py index a76f43b..5f63c87 100644 --- a/tests/test_serv.py +++ b/tests/test_serv.py @@ -4,7 +4,8 @@ import requests -from pyserv import GetServer, munge_url +import pyserv +from pyserv import GetHandler, GetServer, munge_url from pyserv.server import * directory = '.' @@ -19,6 +20,14 @@ def get_request(port, iface): return r +def test_get_handler_attrs(): + """Test GetHandler attrs """ + + handler = GetHandler + assert hasattr(handler, 'do_GET') + assert hasattr(handler, 'log_message') + + def test_get_server_attrs(): """Test GetServer attrs """ diff --git a/tox.ini b/tox.ini index 6452e2b..6d3159f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,23 @@ [tox] -envlist = py3{7,8,9,10}-{linux,macos,windows} +envlist = py3{6,7,9,10}-{linux,macos,windows},coverage skip_missing_interpreters = true isolated_build = true skipsdist = true [gh-actions] python = + 3.6: py36 3.7: py37 - 3.8: py38 3.9: py39 3.10: py310 [gh-actions:env] PLATFORM = + ubuntu-18.04: linux ubuntu-20.04: linux - macos-latest: macos + macos-11: macos + macos-12: macos + windows-2019: windows windows-latest: windows [base] @@ -44,6 +47,8 @@ passenv = PYTHONIOENCODING PIP_DOWNLOAD_CACHE +setenv = COVERAGE_FILE=.coverage.{envname} + whitelist_externals = bash @@ -54,16 +59,44 @@ deps = commands = pytest -v --capture=fd . --cov=pyserv --cov-branch --cov-report term-missing pyserv/ + +[testenv:coverage] +basepython = + python3 + +skip_install = + true + +whitelist_externals = + bash + +#setenv = +# COVERAGE_FILE = .coverage + +deps = + coverage + +commands = + bash -c 'coverage combine .coverage.py*' coverage xml - bash -c './.github/fix_pkg_name.sh' + +depends = + py36 + py37 + py39 + py310 + +[coverage:run] +parallel=True [testenv:dev] skip_install = true setenv = + LPNAME = {env:LPNAME:daemon} DEBUG = {env:DEBUG:1} - LOG = {env:LOG:{envlogdir}/httpd.log} - PID = {env:PID:{envtmpdir}/httpd.pid} + LOG = {env:LOG:{envlogdir}/{env:LPNAME}.log} + PID = {env:PID:{envtmpdir}/{env:LPNAME}.pid} TAIL = {env:TAIL:1} passenv = @@ -150,7 +183,7 @@ deps = requests commands = - pip install pyserv --force-reinstall --pre --prefer-binary -f dist/ + pip install pyserv --pre -f dist/ python -c "from pyserv import server; print(server.__doc__)" python -c 'from pyserv.settings import show_uservars; show_uservars()' @@ -171,7 +204,7 @@ commands_pre = python setup.py egg_info commands = - pylint --fail-under=9.90 -d C0114 pyserv/ + pylint --ignore=_version.py --fail-under=9.90 pyserv/ scripts/ [testenv:style] passenv = @@ -198,7 +231,7 @@ deps = mypy commands = - python -m mypy --follow-imports=normal --install-types --non-interactive pyserv/ + python -m mypy --follow-imports=normal --install-types --non-interactive pyserv/ scripts/ [testenv:isort] skip_install = true