diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml new file mode 100644 index 0000000..ad04a3f --- /dev/null +++ b/.github/workflows/python-app.yml @@ -0,0 +1,55 @@ +# This workflow will install Python dependencies, run tests and lint with a single version of Python +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions + +name: Python application + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.9.18", "3.10.13", "3.12.1" ] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest pytest-cov pytest-mock requests coverage pylint pycodestyle check-manifest readme_renderer + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: check-manifest + run: | + check-manifest + - name: confirm required package meta-data in setup.py + run: | + python setup.py check -m -s + - name: Analysing the code with pylint + run: | + pylint --max-line-length=127 $(git ls-files '*.py') + - name: Analysing the code with pycodestyle (formerly called pep8) + run: | + pycodestyle --max-line-length=127 --exclude=.svn,CVS,.bzr,.hg,.git,__pycache__,.tox,.eggs + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + run: | + pytest diff --git a/.gitignore b/.gitignore index e54b6a3..8e4a4d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ +/.coverage /.eggs/ +/.mypy_cache/ +/.project +/.pydevproject +/.settings /.tox/ -/dist/ \ No newline at end of file +/build/ +/dist/ +/*.egg-info/ +/__pycache__/ +*.pyc +*.pyd +*.pyo diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/httpd-echo.iml b/.idea/httpd-echo.iml new file mode 100644 index 0000000..3373c82 --- /dev/null +++ b/.idea/httpd-echo.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..0a6212d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,65 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..db8786c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..62eba3f --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..d6435fc --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include tox.ini +include pytest.ini +include Makefile +include .idea/httpd-echo.iml +include .idea/inspectionProfiles/Project_Default.xml +include .idea/inspectionProfiles/profiles_settings.xml +include .idea/misc.xml +include .idea/modules.xml +include .idea/vcs.xml diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ad304e8 --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +export PYTHONPATH=$(shell dirname "$(abspath $(lastword $(MAKEFILE_LIST)))") +name:=httpdecho + + +.PHONY: test +test: + python3 -m pytest -v + + +.PHONY: lint +lint: + python3 -m pylint *.py + + +.PHONY: build +build: + python3 -m build + + +.PHONY: clean +clean: + -python3 -m coverage erase + rm -rf site.py build/ dist/ .tox/ .pytest_cache/ .mypy_cache/ + find . -depth \( -name '*.pyc' -o -name '__pycache__' -o -name '__pypackages__' \ + -o -name '*.pyd' -o -name '*.pyo' -o -name '*.egg-info' \ + -o -name '*.py,cover' \) -exec rm -rf \{\} \; diff --git a/README.rst b/README.rst index 96c1eac..c547f42 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,6 @@ -========== httpd-echo ========== + A Simple Python HTTP server that echos the request in the response ------------------------------------------------------------------ @@ -21,16 +21,17 @@ starting at 8000 to try and be as predictable as possible:: >>> import sys >>> import time >>> import subprocess - >>> from six.moves import SimpleHTTPServer >>> startup_delay = 0.5 >>> simple_popen = subprocess.Popen( - ... [sys.executable, '-m', SimpleHTTPServer.__name__] + ... [sys.executable, '-m', 'http.server'] ... ); time.sleep(1) >>> echo_popen = subprocess.Popen( ... [sys.executable, '-m', 'httpdecho'] ... ); time.sleep(1) >>> echo_popen.poll() >>> simple_popen.kill() + >>> simple_popen.communicate() + (None, None) Once running, HTTP requests are echoed in the responses. The default response body format is basically HTTP header format, from @@ -68,15 +69,31 @@ request, the body or the responses body will contain the POST body:: Shutdown the server:: >>> echo_popen.kill() + >>> echo_popen.communicate() + (None, None) ----------------------------- +---- TODO ----------------------------- +---- + + Features for future releases ____________________________ -``Content-Type`` and ``Accept`` support for content negotiation: +- ``Content-Type`` and ``Accept`` support for content negotiation: Return the response body in the format specified in the ``Accept`` header if - given, otherwise in the same ``Content-Type`` as the request. \ No newline at end of file + given, otherwise in the same ``Content-Type`` as the request. + +- ``HTTP 2/0`` and further support. + +Tests +_____ + +- Use pytest and coverage to test every single line. + +Pypi +---- + +- Upload, what else? ;-) diff --git a/httpdecho.py b/httpdecho.py old mode 100644 new mode 100755 index e01a548..94416e6 --- a/httpdecho.py +++ b/httpdecho.py @@ -1,113 +1,113 @@ -""" -A Simple Python HTTP server that echos the request in the response. -""" +#!/usr/bin/python3 +# -*- coding: utf-8 -*- + +"""A Simple Python HTTP server that echos the request in the response.""" -import socket import argparse -from six.moves.urllib import parse import email.message -try: - from email.generator import BytesGenerator -except ImportError: - # BBB Python 2 compatibility - from email.generator import Generator as BytesGenerator - -from six.moves import BaseHTTPServer - -parser = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.ArgumentDefaultsHelpFormatter) -parser.add_argument( - '--address', '-a', default='localhost', - help='Hostname or IP address to accept requests on.') -parser.add_argument( - '--port', '-p', help='Port to accept requests on. ' - 'If not specified, use the first available port after 8000.') - - -class EchoHTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): - """ - A Simple Python HTTP server that echos the request in the response. - """ - - def do_GET(self): - """ - Echo a request without a body. - """ +import socket +from email.generator import BytesGenerator +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib import parse + +__version__ = '0.3.1' + +__all__ = ["EchoHTTPRequestHandler"] + + +class EchoHTTPRequestHandler(BaseHTTPRequestHandler): + """A Simple Python HTTP server that echos the request in the response.""" + + def do_GET(self): # noqa:N802, pylint: disable=invalid-name + """Echo a request without a body.""" message = self.get_message() self.send_head() BytesGenerator(self.wfile).flatten(message, unixfrom=False) - do_HEAD = do_GET - do_OPTIONS = do_GET - do_DELETE = do_GET + do_HEAD = do_GET # noqa:N815 - def do_POST(self): - """ - Echo a request with a body. - """ + do_OPTIONS = do_GET # noqa:N815 + + do_DELETE = do_GET # noqa:N815 + + def do_POST(self): # noqa:N802, pylint: disable=invalid-name + """Echo a request with a body.""" message = self.get_message() - message.set_payload(self.rfile.read( - int(self.headers['Content-Length']))) - self.send_head() - BytesGenerator(self.wfile).flatten(message, unixfrom=False) + try: + length = int(self.headers["Content-Length"]) + except (TypeError, ValueError) as exc: + message.set_payload(f"Invalid Content-Length: {exc}") + else: + message.set_payload(self.rfile.read(length)) + finally: + self.send_head() + BytesGenerator(self.wfile).flatten(message, unixfrom=False) - do_PUT = do_POST - do_PATCH = do_POST + do_PUT = do_POST # noqa:N815 + + do_PATCH = do_POST # noqa:N815 def send_head(self): - """ - Send all the basic, required headers. - """ + """Send all the basic, required headers.""" self.send_response(200) - self.send_header("Content-Type", 'text/rfc822-headers; charset=UTF-8') + # self.send_header("Content-Type", "text/rfc822-headers; charset=UTF-8") + self.send_header("Content-Type", "text/plain; charset=UTF-8") self.send_header("Last-Modified", self.date_time_string()) self.end_headers() def get_message(self): - """ - Assemble the basic message including query parameters. - """ + """Assemble the basic message including query parameters.""" message = email.message.Message() - message['Method'] = self.command - message['Path'] = self.path + message["Method"] = self.command + message["Path"] = self.path - server_url = parse.SplitResult('http', '{0}:{1}'.format( - self.server.server_name, self.server.server_port), '', '', '') + server_url = parse.SplitResult(scheme="http", netloc=f"{self.server.server_name}:{self.server.server_port}", + path="", query="", fragment="") request_url = parse.urlsplit(server_url.geturl() + self.path) - for header, value in parse.parse_qs(request_url.query).items(): - message.add_header(header, value[0]) + for name, value in parse.parse_qs(request_url.query).items(): + message.add_header(name, value[0]) + + cookies = SimpleCookie(self.headers.get('Cookie')) + for name, value in cookies.items(): + message.add_header(f'Cookie-{name}', repr(value)) return message def main(args=None, default_port=8000): - """ - Run the echo HTTP server. - """ + """Run the echo HTTP server.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.ArgumentDefaultsHelpFormatter) + parser.add_argument( + "--address", "-a", default="127.0.0.1", + help="Hostname or IP address to accept requests on.") + parser.add_argument( + "--port", "-p", type=int, + help="Port number to accept requests on. If not specified, use the first available port after 8000.") + args = parser.parse_args(args) - port = args.port - if port is None: - port = default_port + if args.port is None: + args.port = default_port bound = False while not bound: try: - httpd = BaseHTTPServer.HTTPServer( - (args.address, port), EchoHTTPRequestHandler) - except socket.error: - port += 1 - if port > 65535: - raise ValueError('No available port found') + httpd = HTTPServer((args.address, args.port), EchoHTTPRequestHandler) + except socket.error as exc: + args.port += 1 + if args.port > 65535: + raise ValueError("No available port found") from exc else: bound = True else: - httpd = BaseHTTPServer.HTTPServer( - (args.address, int(port)), EchoHTTPRequestHandler) + httpd = HTTPServer((args.address, args.port), EchoHTTPRequestHandler) - print('Echoing HTTP at http://{0}:{1} ...'.format(args.address, port)) - httpd.serve_forever() + print(f"Echoing HTTP at http://{args.address}:{args.port}, press Ctrl+C to stop.") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print(" - Stopped") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eb84fa2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] + +requires = [ + "setuptools>=41", + "wheel" +] + +build-backend = "setuptools.build_meta" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b0a2e4d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,22 @@ +[pytest] +minversion = 3.4.2 +log_format = %(asctime)s %(levelname)s %(message)s +log_date_format = %Y-%m-%d %H:%M:%S +addopts = + -ra -q + --doctest-modules --doctest-glob='*.rst' + --cov=. --cov-branch + --pdbcls=IPython.terminal.debugger:TerminalPdb +# --pdb + +timeout = 300 +testpaths = + . + +doctest_rst = enabled +doctest_optionflags = + NORMALIZE_WHITESPACE + ELLIPSIS + +filterwarnings = + ignore::UserWarning diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1aa92d1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,109 @@ +[metadata] +name = httpdecho +version = attr: httpdecho.__version__ +description = A Simple Python HTTP server that echos the request in the response +long_description = file: README.rst +long_description_content_type = text/x-rst +keywords = httpd, http, echo, server +author = Ross Patterson +author_email = me@rpatterson.net +maintainer = Doncho N. Gunchev +maintainer_email = dgunchev@gmail.com +url = https://github.com/gunchev/httpd-echo +license = GPL +platforms = any +classifiers = + Development Status :: 4 - Beta + Environment :: Console + Intended Audience :: Developers + License :: OSI Approved :: GNU General Public License (GPL) + Natural Language :: English + Operating System :: OS Independent + Programming Language :: Python + Programming Language :: Python :: 3.6 + Topic :: Internet :: WWW/HTTP :: HTTP Servers + Topic :: Utilities + + +[options] +zip_safe = False +python_requires = >= 3.6 +py_modules = httpdecho +include_package_data = True + +setup_requires = + setuptools>=41.0 + +tests_require = + requests + coverage + pytest + pytest-cov + pytest-mock + + + +[options.entry_points] +console_scripts = + httpd-echo = httpdecho:main + + +[bdist_wheel] +universal = true + + +[options.package_data] +* = *.rst + + +# python setup.py sdist +[sdist] +# formats = zip,gztar,bztar,xztar +formats = zip,xztar + + +[coverage:run] +branch = true +concurrency = thread +omit = setup.py + + +[coverage:report] +# Regexes for lines to exclude from consideration +show_missing = true +skip_covered = false +skip_empty = true +exclude_lines = +# Have to re-enable the standard pragma + pragma: no cover + +# Don't complain about missing debug-only code: + def __repr__ + if self\.debug + +# Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + return NotImplemented + +# Don't complain if non-runnable code isn't run: + if 0: + if False: + if __name__ == .__main__.: + if typing.TYPE_CHECKING: + +# Don't complain about abstract methods, they aren't run: + @(abc\.)?abstractmethod + @(abc\.)?abstractproperty + +# Multiple library version support + except ImportError + + +[coverage:html] +directory = htmlcov + + +[coverage:paths] +source = + ./ diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index d6241ad..fd3bd4b --- a/setup.py +++ b/setup.py @@ -1,42 +1,9 @@ -from setuptools import setup, find_packages -import os +#!/usr/bin/python3 +# -*- coding: utf-8 -*- -version = '0.1' +"""setup.py for the Simple Python HTTP server that echos the request in the response.""" -test_requires = ['requests'] +from setuptools import setup -setup(name='httpd-echo', - version=version, - description=( - "A Simple Python HTTP server that echos the request in the response" - ), - long_description=open( - os.path.join(os.path.dirname(__file__), 'README.rst')).read(), - classifiers=[ - 'Development Status :: 3 - Alpha', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: GNU General Public License (GPL)', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python', - 'Topic :: Internet :: WWW/HTTP :: HTTP Servers', - 'Topic :: Utilities', - ], - keywords='httpd http echo server', - author='Ross Patterson', - author_email='me@rpatterson.net', - url='https://github.com/rpatterson/httpd-echo', - license='GPL', - packages=find_packages(exclude=['ez_setup', 'examples', 'tests']), - include_package_data=True, - zip_safe=False, - install_requires=[ - # -*- Extra requirements: -*- - 'six', - ], - tests_require=test_requires, - extras_require=dict(test=test_requires), - test_suite='tests', - entry_points=dict(console_scripts=['httpd-echo=httpdecho:main']), - ) +if __name__ == '__main__': + setup() # https://stackoverflow.com/a/58534041/1136400 diff --git a/tests.py b/tests.py deleted file mode 100644 index e61b45c..0000000 --- a/tests.py +++ /dev/null @@ -1,12 +0,0 @@ -""" -Test the echo HTTP server. -""" - -import doctest - -from six.moves import SimpleHTTPServer # noqa - - -def load_tests(loader=None, tests=None, ignore=None): - return doctest.DocFileSuite( - 'README.rst', optionflags=doctest.REPORT_NDIFF) diff --git a/tox.ini b/tox.ini index 76e3b16..9ccaf18 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,39 @@ [tox] -envlist = py27,py35 +envlist = py3{6,7,8,9,10,11},pypy3.9 +# At least this version is needed for PEP 517/518 support. +minversion = 3.3.0 +# Activate isolated build environment. tox will use a virtual environment +# to build a source distribution from the source tree. For build tools and +# arguments use the pyproject.toml file as specified in PEP-517 and PEP-518. +isolated_build = true + + [testenv] -commands = python setup.py test +commands = + check-manifest # confirm items checked into vcs are in your sdist + python setup.py check -m -s # confirm required package meta-data in setup.py +# stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics +# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + python -m pytest . + +setenv = + PYTHONPATH = {toxinidir} + +deps = + requests + coverage + pytest-cov + pytest + check-manifest >= 0.42 + readme_renderer # confirms your long_description will render correctly on PyPI. + flake8 + + + +[flake8] +select = E,W,F +max-line-length = 127 +exclude = .tox, *.egg, build, data, .git, .eggs, __pycache__, test/, docs/, build/, dist/, env.py