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