././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690875873.4228065 test_server-0.0.43/0000755000175000017500000000000014462133741012726 5ustar00useruser././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1670889485.0 test_server-0.0.43/LICENSE0000644000175000017500000000210114345740015013723 0ustar00useruserThe MIT License (MIT) Copyright (c) 2015-2023, Gregory Petukhov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690875873.4228065 test_server-0.0.43/PKG-INFO0000644000175000017500000001002514462133741014021 0ustar00useruserMetadata-Version: 2.1 Name: test_server Version: 0.0.43 Summary: Server for testing HTTP clients Author-email: Gregory Petukhov License: The MIT License (MIT) Copyright (c) 2015-2023, Gregory Petukhov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Project-URL: homepage, http://github.com/lorien/test_server Keywords: test,testing,server,http server Classifier: Typing :: Typed Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: License :: OSI Approved :: MIT License Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: Operating System :: OS Independent Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Internet :: WWW/HTTP Requires-Python: >=3.8 Description-Content-Type: text/markdown License-File: LICENSE # Documentation for test_server package [![Test Status](https://github.com/lorien/test_server/actions/workflows/test.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/test.yml) [![Code Quality](https://github.com/lorien/test_server/actions/workflows/check.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/test.yml) [![Type Check](https://github.com/lorien/test_server/actions/workflows/mypy.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/mypy.yml) [![Test Coverage Status](https://coveralls.io/repos/github/lorien/test_server/badge.svg)](https://coveralls.io/github/lorien/test_server) [![Documentation Status](https://readthedocs.org/projects/test_server/badge/?version=latest)](http://user-agent.readthedocs.org) Simple HTTP Server for testing HTTP clients. ## Installation Run `pip install -U test_server` ## Usage Example ```python from unittest import TestCase import unittest from urllib.request import urlopen from test_server import TestServer, Response, HttpHeaderStorage class UrllibTestCase(TestCase): @classmethod def setUpClass(cls): cls.server = TestServer() cls.server.start() @classmethod def tearDownClass(cls): cls.server.stop() def setUp(self): self.server.reset() def test_get(self): self.server.add_response( Response( data=b"hello", headers={"foo": "bar"}, ) ) self.server.add_response(Response(data=b"zzz")) url = self.server.get_url() info = urlopen(url) self.assertEqual(b"hello", info.read()) self.assertEqual("bar", info.headers["foo"]) info = urlopen(url) self.assertEqual(b"zzz", info.read()) self.assertTrue("bar" not in info.headers) unittest.main() ``` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1671209758.0 test_server-0.0.43/README.md0000644000175000017500000000346214347121436014212 0ustar00useruser# Documentation for test_server package [![Test Status](https://github.com/lorien/test_server/actions/workflows/test.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/test.yml) [![Code Quality](https://github.com/lorien/test_server/actions/workflows/check.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/test.yml) [![Type Check](https://github.com/lorien/test_server/actions/workflows/mypy.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/mypy.yml) [![Test Coverage Status](https://coveralls.io/repos/github/lorien/test_server/badge.svg)](https://coveralls.io/github/lorien/test_server) [![Documentation Status](https://readthedocs.org/projects/test_server/badge/?version=latest)](http://user-agent.readthedocs.org) Simple HTTP Server for testing HTTP clients. ## Installation Run `pip install -U test_server` ## Usage Example ```python from unittest import TestCase import unittest from urllib.request import urlopen from test_server import TestServer, Response, HttpHeaderStorage class UrllibTestCase(TestCase): @classmethod def setUpClass(cls): cls.server = TestServer() cls.server.start() @classmethod def tearDownClass(cls): cls.server.stop() def setUp(self): self.server.reset() def test_get(self): self.server.add_response( Response( data=b"hello", headers={"foo": "bar"}, ) ) self.server.add_response(Response(data=b"zzz")) url = self.server.get_url() info = urlopen(url) self.assertEqual(b"hello", info.read()) self.assertEqual("bar", info.headers["foo"]) info = urlopen(url) self.assertEqual(b"zzz", info.read()) self.assertTrue("bar" not in info.headers) unittest.main() ``` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875862.0 test_server-0.0.43/pyproject.toml0000644000175000017500000001101514462133726015643 0ustar00useruser[project] name = "test_server" version = "0.0.43" description = "Server for testing HTTP clients" readme = "README.md" requires-python = ">=3.8" license = {"file" = "LICENSE"} keywords = ["test", "testing", "server", "http server"] authors = [ {name = "Gregory Petukhov", email = "lorien@lorien.name"} ] # https://pypi.org/pypi?%3Aaction=list_classifiers classifiers = [ "Typing :: Typed", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "License :: OSI Approved :: MIT License", "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Internet :: WWW/HTTP", ] dependencies = ["multipart"] [project.optional-dependencies] [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" [project.urls] homepage = "http://github.com/lorien/test_server" [tool.setuptools] packages=["test_server"] [tool.setuptools.package-data] "*" = ["py.typed"] [[tool.mypy.overrides]] module = "multipart" ignore_missing_imports = true [tool.isort] profile = "black" line_length = 88 # skip_gitignore = true # throws errors in stderr when ".git" dir does not exist [tool.bandit] # B101 assert_used # B410 Using HtmlElement to parse untrusted XML data skips = ["B101", "B410"] [tool.pylint.main] jobs=4 extension-pkg-whitelist="lxml" disable="missing-docstring,broad-except,too-few-public-methods,consider-using-f-string,fixme" variable-rgx="[a-z_][a-z0-9_]{1,30}$" attr-rgx="[a-z_][a-z0-9_]{1,30}$" argument-rgx="[a-z_][a-z0-9_]{1,30}$" max-line-length=88 max-args=9 load-plugins=[ "pylint.extensions.check_elif", "pylint.extensions.comparetozero", "pylint.extensions.comparison_placement", "pylint.extensions.consider_ternary_expression", "pylint.extensions.docstyle", "pylint.extensions.emptystring", "pylint.extensions.for_any_all", "pylint.extensions.overlapping_exceptions", "pylint.extensions.redefined_loop_name", "pylint.extensions.redefined_variable_type", "pylint.extensions.set_membership", "pylint.extensions.typing", ] [tool.pytest.ini_options] testpaths = ["tests"] [tool.ruff] select = ["ALL"] ignore = [ "A003", # Class attribute `type` is shadowing a python builtin "ANN101", # Missing type annotation for `self` in method "ANN401", # Dynamically typed expressions (typing.Any) are disallowed "BLE001", # Do not catch blind exception: `Exception` "COM812", # Trailing comma missing "D100", # Missing docstring in public module "D101", # Missing docstring in public class "D102", # Missing docstring in public method1 "D103", # Missing docstring in public function "D104", # Missing docstring in public package "D105", # Missing docstring in magic method, "D107", # Missing docstring in `__init__` "D203", # 1 blank line required before class docstring "D213", # Multi-line docstring summary should start at the second line "EM101", # Check for raw usage of a string literal in Exception raising "EM102", # Check for raw usage of an f-string literal in Exception raising "EM103", # Check for raw usage of .format on a string literal in Exception raising "F401", # Imported but unused "FBT", # Boolean arg/value in function definition "PTH", # A plugin finding use of functions that can be replaced by pathlib module "S101", # Use of `assert` detected "T201", # print found "T203", # pprint found "TCH", # Move import into a type-checking block "TRY003", # Avoid specifying long messages outside the exception class "UP032", # Use f-string instead of format call "ERA001", # Found commented-out code "RUF001", # String contains ambiguous unicode character "ANN102", # Missing type annotation for `cls` in classmethod "TD002", # Missing author in TODO "TD003", # Missing issue link on the line following this TODO "FIX002", # Line contains TODO "RUF003", # Comment contains ambiguous "RUF012", # Mutable class attributes should be annotated "TRY400", # Use `logging.exception` instead of `logging.error` "PERF401", # Use a list comprehension to create a transformed list "RUF100", # [*] Unused `noqa` directive ] pylint.max-args=9 target-version = "py38" extend-exclude = ["var"] ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690875873.4228065 test_server-0.0.43/setup.cfg0000644000175000017500000000004614462133741014547 0ustar00useruser[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690875873.4228065 test_server-0.0.43/test_server/0000755000175000017500000000000014462133741015273 5ustar00useruser././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875181.0 test_server-0.0.43/test_server/__init__.py0000644000175000017500000000052014462132455017402 0ustar00useruserfrom test_server.error import * # pylint: disable=wildcard-import # noqa: F403 from test_server.server import * # pylint: disable=wildcard-import # noqa: F403 from test_server.structure import * # pylint: disable=wildcard-import # noqa: F403 from .const import TEST_SERVER_PACKAGE_VERSION __version__ = TEST_SERVER_PACKAGE_VERSION ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875862.0 test_server-0.0.43/test_server/const.py0000644000175000017500000000004714462133726016777 0ustar00useruserTEST_SERVER_PACKAGE_VERSION = "0.0.43" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690867469.0 test_server-0.0.43/test_server/error.py0000644000175000017500000000142014462113415016767 0ustar00useruser__all__ = [ "TestServerError", "WaitTimeoutError", "InternalError", "RequestNotProcessedError", "NoResponseError", ] class TestServerError(Exception): """Base class for all errrors raised by test_server package.""" __test__ = False # for pytest ignore this class class WaitTimeoutError(TestServerError): """Raised by wait_request method if timed out waiting a request done.""" class InternalError(TestServerError): """Raised when exception happens during the processing a request sent by client.""" class RequestNotProcessedError(TestServerError): """Raised by get_request method when no request has been processed.""" class NoResponseError(TestServerError): """Raised when no response data is configured to hande a request.""" ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1645994717.0 test_server-0.0.43/test_server/py.typed0000644000175000017500000000000014206761335016762 0ustar00useruser././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875151.0 test_server-0.0.43/test_server/server.py0000644000175000017500000003227214462132417017160 0ustar00useruserfrom __future__ import annotations import logging import time from collections import defaultdict from collections.abc import Callable, Mapping, MutableMapping from email.message import Message from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler from io import BytesIO from pprint import pprint # pylint: disable=unused-import from socketserver import BaseRequestHandler, TCPServer, ThreadingMixIn from threading import Event, Thread from typing import Any, cast from urllib.parse import parse_qsl, urljoin from multipart import parse_form_data from .const import TEST_SERVER_PACKAGE_VERSION from .error import ( InternalError, NoResponseError, RequestNotProcessedError, TestServerError, WaitTimeoutError, ) from .structure import HttpHeaderStorage, HttpHeaderStream __all__: list[str] = ["TestServer", "WaitTimeoutError", "Response", "Request"] INTERNAL_ERROR_RESPONSE_STATUS: int = 555 class HandlerResult: __slots__ = ["status", "headers", "data"] def __init__( self, status: None | int = None, headers: None | HttpHeaderStorage = None, data: None | bytes = None, ) -> None: self.status = status if status is not None else 200 self.headers = headers if headers else HttpHeaderStorage() self.data = data if data else b"" class Response: def __init__( self, callback: None | Callable[..., Mapping[str, Any]] = None, raw_callback: None | Callable[..., bytes] = None, data: None | bytes = None, headers: None | HttpHeaderStream = None, sleep: None | float = None, status: None | int = None, ) -> None: self.callback = callback self.raw_callback = raw_callback self.data = b"" if data is None else data self.headers = HttpHeaderStorage(headers) self.sleep = sleep self.status = 200 if status is None else status class Request: # pylint: disable=too-many-instance-attributes def __init__( self, args: Mapping[str, Any], client_ip: str, cookies: SimpleCookie[Any], data: bytes, files: Mapping[str, Any], headers: HttpHeaderStream, method: str, path: str, ) -> None: self.args = args self.client_ip = client_ip self.cookies = cookies self.data = data self.files = files self.headers = HttpHeaderStorage(headers) self.method = method self.path = path VALID_METHODS: list[str] = ["get", "post", "put", "delete", "options", "patch"] class ThreadingTCPServer(ThreadingMixIn, TCPServer): allow_reuse_address: bool = True started: bool = False def __init__( self, server_address: tuple[str, int], request_handler_class: Callable[ [Any, Any, ThreadingTCPServer], BaseRequestHandler ], test_server: TestServer, **kwargs: Any, ) -> None: super().__init__(server_address, request_handler_class, **kwargs) self.test_server = test_server self.test_server.server_started.set() class TestServerHandler(BaseHTTPRequestHandler): server: ThreadingTCPServer def process_multipart_files( self, request_data: bytes, headers: Message ) -> Mapping[str, list[Mapping[str, Any]]]: if not headers.get("Content-Type", "").startswith("multipart/form-data;"): return {} env = { "REQUEST_METHOD": "POST", "CONTENT_TYPE": headers["Content-Type"], "wsgi.input": BytesIO(request_data), } if "content-length" in headers: env["content-length"] = headers["content-length"] _, files = parse_form_data(env) ret: MutableMapping[str, list[Mapping[str, Any]]] = {} for field_key, item in files.iterallitems(): ret.setdefault(field_key, []).append( { "name": field_key, "content_type": item.content_type, "filename": item.filename, "content": item.raw, } ) return ret def _read_request_data(self) -> bytes: content_len: int = int(self.headers["Content-Length"] or "0") return self.rfile.read(content_len) def _parse_qs_args(self) -> Mapping[str, Any]: try: qs = self.path.split("?")[1] except IndexError: qs = "" return dict(parse_qsl(qs)) def _collect_request_data(self, method: str) -> Request: req_data = self._read_request_data() return Request( args=self._parse_qs_args(), client_ip=self.client_address[0], path=self.path.split("?")[0], data=req_data, method=method.upper(), cookies=SimpleCookie(self.headers["Cookie"]), files=self.process_multipart_files(req_data, self.headers), headers=dict(self.headers), ) def process_callback_result( self, cb_res: Mapping[str, Any], result: HandlerResult ) -> None: if not isinstance(cb_res, dict): raise InternalError("Callback response is not a dict") if cb_res.get("type") != "response": raise InternalError( "Callback response has invalid type key: %s" % cb_res.get("type", "NA") ) for key in cb_res: if key not in ("type", "status", "headers", "data"): raise InternalError("Callback response contains invalid key: %s" % key) if "status" in cb_res: result.status = cb_res["status"] if "headers" in cb_res: result.headers.extend(cb_res["headers"]) if "data" in cb_res: if isinstance(cb_res["data"], bytes): result.data = cb_res["data"] else: raise InternalError('Callback repsponse field "data" must be bytes') def _process_required_response_headers(self, headers: HttpHeaderStorage) -> None: port = self.server.test_server.port headers.set("Listen-Port", str(port)) if "content-type" not in headers: headers.set("Content-Type", "text/html; charset=utf-8") if "server" not in headers: headers.set("Server", "TestServer/%s" % TEST_SERVER_PACKAGE_VERSION) def _request_handler(self) -> None: try: test_srv = self.server.test_server method = self.command.lower() resp = test_srv.get_response(method) if resp.sleep: time.sleep(resp.sleep) test_srv.add_request(self._collect_request_data(method)) result = HandlerResult() if resp.raw_callback: data = resp.raw_callback() if isinstance(data, bytes): self.write_raw_response_data(data) return raise InternalError( # noqa: TRY301 "Raw callback must return bytes data" ) if resp.callback: self.process_callback_result(resp.callback(), result) else: result.status = resp.status result.headers.extend(resp.headers.items()) data = resp.data if isinstance(data, bytes): result.data = data else: raise InternalError( # noqa: TRY301 'Response parameter "data" must be bytes' ) self._process_required_response_headers(result.headers) self.write_response_data(result.status, result.headers, result.data) except Exception as ex: logging.exception("Unexpected error happend in test server request handler") self.write_response_data( INTERNAL_ERROR_RESPONSE_STATUS, HttpHeaderStorage(), str(ex).encode("utf-8"), ) finally: test_srv.num_req_processed += 1 def write_response_data( self, status: int, headers: HttpHeaderStorage, data: bytes ) -> None: self.send_response(status) for key, val in headers.items(): self.send_header(key, val) self.end_headers() self.wfile.write(data) def write_raw_response_data(self, data: bytes) -> None: self.wfile.write(data) # pylint: disable=attribute-defined-outside-init self._headers_buffer: list[str] = [] # https://github.com/python/cpython/blob/main/Lib/http/server.py def send_response(self, code: int, message: None | str = None) -> None: """Do not send Server and Date headers. This method overrides standard method from super class. """ self.log_request(code) self.send_response_only(code, message) do_GET = _request_handler # noqa: N815 do_POST = _request_handler # noqa: N815 do_PUT = _request_handler # noqa: N815 do_DELETE = _request_handler # noqa: N815 do_OPTIONS = _request_handler # noqa: N815 do_PATCH = _request_handler # noqa: N815 class TestServer: # pylint: disable=too-many-instance-attributes __test__ = False # for pytest ignore this class def __init__(self, address: str = "127.0.0.1", port: int = 0) -> None: self.server_started: Event = Event() self._requests: list[Request] = [] self._responses: MutableMapping[ None | str, list[MutableMapping[str, Any]] ] = defaultdict(list) self.port: None | int = None self._config_port: int = port self.address: str = address self._thread: None | Thread = None self._server: None | ThreadingTCPServer = None self._started: Event = Event() self.num_req_processed: int = 0 self.reset() def _thread_server(self) -> None: """Ask HTTP server start processing requests. This function is supposed to be run in separate thread. """ self._server = ThreadingTCPServer( (self.address, self._config_port), TestServerHandler, test_server=self ) self._server.serve_forever(poll_interval=0.1) # **************** # Public Interface # **************** def add_request(self, req: Request) -> None: self._requests.append(req) def reset(self) -> None: self.num_req_processed = 0 self._requests.clear() self._responses.clear() def start(self, daemon: bool = True) -> None: """Start the HTTP server.""" self._thread = Thread( target=self._thread_server, ) self._thread.daemon = daemon self._thread.start() self.wait_server_started() self.port = cast(ThreadingTCPServer, self._server).socket.getsockname()[1] def wait_server_started(self) -> None: # I could not foind another way # to handle multiple socket issues # other than taking some sleep time.sleep(0.01) self.server_started.wait() def stop(self) -> None: if self._server: self._server.shutdown() self._server.server_close() def get_url(self, path: str = "", port: None | int = None) -> str: """Build URL that is served by HTTP server.""" if port is None: port = cast(int, self.port) return urljoin("http://%s:%d" % (self.address, port), path) def wait_request(self, timeout: float) -> None: """Stupid implementation that eats CPU.""" start: float = time.time() while True: if self.num_req_processed: break time.sleep(0.01) if time.time() - start > timeout: raise WaitTimeoutError("No request processed in %d seconds" % timeout) def request_is_done(self) -> bool: return self.num_req_processed > 0 def get_request(self) -> Request: try: return self._requests[-1] except IndexError as ex: raise RequestNotProcessedError("Request has not been processed") from ex @property def request(self) -> Request: return self.get_request() def add_response( self, resp: Response, count: int = 1, method: None | str = None ) -> None: assert method is None or isinstance(method, str) assert count < 0 or count > 0 if method and method not in VALID_METHODS: raise TestServerError("Invalid method: %s" % method) self._responses[method].append( { "count": count, "response": resp, }, ) def get_response(self, method: str) -> Response: while True: item = None scope = None try: scope = self._responses[method] item = scope[0] except IndexError: try: scope = self._responses[None] item = scope[0] except IndexError as ex: raise NoResponseError("No response available") from ex if item["count"] == -1: return cast(Response, item["response"]) item["count"] -= 1 if item["count"] < 1: scope.pop(0) return cast(Response, item["response"]) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1670893030.0 test_server-0.0.43/test_server/structure.py0000644000175000017500000000465714345746746017740 0ustar00useruserfrom __future__ import annotations import typing from collections import OrderedDict from collections.abc import Iterator, MutableMapping from pprint import pprint # pylint: disable=unused-import from typing import Any, Tuple, Union, cast __all__ = ["HttpHeaderStorage"] # pylint: disable=deprecated-typing-alias,consider-alternative-union-syntax HttpHeaderStream = Union[ typing.Mapping[str, str], typing.Iterable[Tuple[str, str]], ] # pylint: enable=deprecated-typing-alias,consider-alternative-union-syntax class HttpHeaderStorage: """Storage for HTTP Headers. The storage maps string keys to one or multiple string values. Keys are case insensitive though the original case is stored. """ def __init__( self, data: None | HttpHeaderStream = None, charset: str = "utf-8" ) -> None: self._store: MutableMapping[str, list[str]] = OrderedDict() self._charset = charset if data is not None: self.extend(data) # Public Interface def set(self, key: str, value: str) -> None: # Store original case of key self._store[key.lower()] = [key, value] def get(self, key: str) -> str: return self._store[key.lower()][1] def getlist(self, key: str) -> list[str]: return self._store[key.lower()][1:] def remove(self, key: str) -> None: del self._store[key.lower()] def add(self, key: str, value: str) -> None: box = self._store.setdefault(key.lower(), [key]) box.append(value) def extend(self, data: HttpHeaderStream) -> None: seq = ( data.items() if isinstance(data, MutableMapping) # pylint: disable=deprecated-typing-alias else cast(typing.Iterable[Tuple[str, str]], data) # pylint: enable=deprecated-typing-alias ) for key, val in seq: self.add(key, val) def __contains__(self, key: str) -> bool: return key.lower() in self._store def count_keys(self) -> int: return len(self._store.keys()) def count_items(self) -> int: return sum(1 for _ in self.items()) def items(self) -> Iterator[tuple[str, Any]]: for items in self._store.values(): original_key = items[0] for idx, item in enumerate(items): if idx > 0: yield original_key, item def __repr__(self) -> str: return str(list(self.items())) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690875873.4228065 test_server-0.0.43/test_server.egg-info/0000755000175000017500000000000014462133741016765 5ustar00useruser././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875873.0 test_server-0.0.43/test_server.egg-info/PKG-INFO0000644000175000017500000001002514462133741020060 0ustar00useruserMetadata-Version: 2.1 Name: test-server Version: 0.0.43 Summary: Server for testing HTTP clients Author-email: Gregory Petukhov License: The MIT License (MIT) Copyright (c) 2015-2023, Gregory Petukhov Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. Project-URL: homepage, http://github.com/lorien/test_server Keywords: test,testing,server,http server Classifier: Typing :: Typed Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: License :: OSI Approved :: MIT License Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: Operating System :: OS Independent Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Internet :: WWW/HTTP Requires-Python: >=3.8 Description-Content-Type: text/markdown License-File: LICENSE # Documentation for test_server package [![Test Status](https://github.com/lorien/test_server/actions/workflows/test.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/test.yml) [![Code Quality](https://github.com/lorien/test_server/actions/workflows/check.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/test.yml) [![Type Check](https://github.com/lorien/test_server/actions/workflows/mypy.yml/badge.svg)](https://github.com/lorien/test_server/actions/workflows/mypy.yml) [![Test Coverage Status](https://coveralls.io/repos/github/lorien/test_server/badge.svg)](https://coveralls.io/github/lorien/test_server) [![Documentation Status](https://readthedocs.org/projects/test_server/badge/?version=latest)](http://user-agent.readthedocs.org) Simple HTTP Server for testing HTTP clients. ## Installation Run `pip install -U test_server` ## Usage Example ```python from unittest import TestCase import unittest from urllib.request import urlopen from test_server import TestServer, Response, HttpHeaderStorage class UrllibTestCase(TestCase): @classmethod def setUpClass(cls): cls.server = TestServer() cls.server.start() @classmethod def tearDownClass(cls): cls.server.stop() def setUp(self): self.server.reset() def test_get(self): self.server.add_response( Response( data=b"hello", headers={"foo": "bar"}, ) ) self.server.add_response(Response(data=b"zzz")) url = self.server.get_url() info = urlopen(url) self.assertEqual(b"hello", info.read()) self.assertEqual("bar", info.headers["foo"]) info = urlopen(url) self.assertEqual(b"zzz", info.read()) self.assertTrue("bar" not in info.headers) unittest.main() ``` ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875873.0 test_server-0.0.43/test_server.egg-info/SOURCES.txt0000644000175000017500000000061114462133741020647 0ustar00useruserLICENSE README.md pyproject.toml test_server/__init__.py test_server/const.py test_server/error.py test_server/py.typed test_server/server.py test_server/structure.py test_server.egg-info/PKG-INFO test_server.egg-info/SOURCES.txt test_server.egg-info/dependency_links.txt test_server.egg-info/requires.txt test_server.egg-info/top_level.txt tests/test_httpheaderstorage.py tests/test_server.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875873.0 test_server-0.0.43/test_server.egg-info/dependency_links.txt0000644000175000017500000000000114462133741023033 0ustar00useruser ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875873.0 test_server-0.0.43/test_server.egg-info/requires.txt0000644000175000017500000000001214462133741021356 0ustar00userusermultipart ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690875873.0 test_server-0.0.43/test_server.egg-info/top_level.txt0000644000175000017500000000001414462133741021512 0ustar00userusertest_server ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1690875873.4228065 test_server-0.0.43/tests/0000755000175000017500000000000014462133741014070 5ustar00useruser././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690867736.0 test_server-0.0.43/tests/test_httpheaderstorage.py0000644000175000017500000000275714462114030021217 0ustar00useruserimport pytest from test_server.structure import HttpHeaderStorage def test_constructor_no_data() -> None: HttpHeaderStorage() def test_constructor_dict() -> None: HttpHeaderStorage({"foo": "bar"}) def test_constructor_list() -> None: HttpHeaderStorage([("foo", "bar")]) def test_set_get_simple_value() -> None: obj = HttpHeaderStorage() obj.set("foo", "bar") assert obj.get("foo") == "bar" def test_set_get_multi_value() -> None: obj = HttpHeaderStorage() obj.add("foo", "bar") obj.add("foo", "baz") assert obj.getlist("foo") == ["bar", "baz"] def test_delitem() -> None: obj = HttpHeaderStorage() with pytest.raises(KeyError): obj.remove("foo") obj.set("foo", "bar") obj.remove("foo") with pytest.raises(KeyError): obj.remove("foo") def test_repr() -> None: obj = HttpHeaderStorage() obj.add("foo", "bar") obj.add("foo", "baz") assert repr(obj) == "[('foo', 'bar'), ('foo', 'baz')]" def test_constructor_key_multivalue() -> None: obj = HttpHeaderStorage([("set-cookie", "foo=bar"), ("set-cookie", "baz=gaz")]) assert obj.getlist("set-cookie") == ["foo=bar", "baz=gaz"] def test_count_keys() -> None: obj = HttpHeaderStorage() obj.add("foo", "bar") obj.add("foo", "baz") assert obj.count_keys() == 1 # noqa: PLR2004 def test_count_items() -> None: obj = HttpHeaderStorage() obj.add("foo", "bar") obj.add("foo", "baz") assert obj.count_items() == 2 # noqa: PLR2004 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1690869312.0 test_server-0.0.43/tests/test_server.py0000644000175000017500000003176714462117100017014 0ustar00useruserfrom __future__ import annotations import time from pprint import pprint # pylint: disable=unused-import from threading import Thread from typing import Any from urllib.parse import quote, unquote import pytest from urllib3 import PoolManager from urllib3.response import BaseHTTPResponse from urllib3.util.retry import Retry import test_server from test_server import ( Request, RequestNotProcessedError, Response, TestServer, TestServerError, WaitTimeoutError, ) from test_server.server import INTERNAL_ERROR_RESPONSE_STATUS from .util import fixture_global_server, fixture_server # pylint: disable=unused-import NETWORK_TIMEOUT = 1 SPECIFIC_TEST_PORT = 10100 HTTP_STATUS_OK = 200 pool = PoolManager() def request( url: str, data: None | bytes = None, method: None | str = None, headers: None | dict[str, Any] = None, fields: None | dict[str, Any] = None, retries_redirect: int = 10, ) -> BaseHTTPResponse: params: dict[str, Any] = { "headers": headers, "timeout": NETWORK_TIMEOUT, "retries": Retry( total=None, connect=0, read=0, redirect=retries_redirect, other=0, ), "fields": fields, } if data: assert isinstance(data, bytes) params["body"] = data if not method: method = "POST" if (data or fields) else "GET" return pool.request(method, url, **params) # WTF: urllib3 makes TWO requests :-/ # def test_non_ascii_header(server: TestServer) -> None: # server.add_response(Response(headers=[("z", server.get_url() + "фыва")])) # res = request(server.get_url(), retries_redirect=False) # print(res.headers) def test_non_ascii_header(server: TestServer) -> None: server.add_response( Response(status=301, headers=[("Location", server.get_url(quote("фыва")))]) ) server.add_response(Response()) request(server.get_url()) assert quote("фыва") in server.get_request().path def test_get(server: TestServer) -> None: valid_data = b"zorro" server.add_response(Response(data=valid_data)) res = request(server.get_url()) assert res.data == valid_data def test_non_utf_request_data(server: TestServer) -> None: server.add_response(Response(data=b"abc")) res = request(url=server.get_url(), data="конь".encode("cp1251")) assert res.data == b"abc" assert server.get_request().data == "конь".encode("cp1251") def test_request_client_ip(server: TestServer) -> None: server.add_response(Response()) request(server.get_url()) assert server.address == server.get_request().client_ip def test_path(server: TestServer) -> None: server.add_response(Response()) request(server.get_url("/foo?bar=1")) assert server.get_request().path == "/foo" assert server.get_request().args["bar"] == "1" def test_post(server: TestServer) -> None: server.add_response(Response(data=b"abc"), method="post") res = request(server.get_url(), b"req-data") assert res.data == b"abc" assert server.get_request().data == b"req-data" def test_response_once_specific_method(server: TestServer) -> None: server.add_response(Response(data=b"bar"), method="get") server.add_response(Response(data=b"foo")) assert request(server.get_url()).data == b"bar" def test_request_headers(server: TestServer) -> None: server.add_response(Response()) request(server.get_url(), headers={"Foo": "Bar"}) assert server.get_request().headers.get("foo") == "Bar" def test_response_once_reset_headers(server: TestServer) -> None: server.add_response(Response(headers=[("foo", "bar")])) server.reset() res = request(server.get_url()) assert res.status == INTERNAL_ERROR_RESPONSE_STATUS assert b"No response" in res.data def test_method_sleep(server: TestServer) -> None: server.add_response(Response()) delay = 0.3 start = time.time() request(server.get_url()) elapsed = time.time() - start assert elapsed <= delay server.add_response(Response(sleep=delay)) start = time.time() request(server.get_url()) elapsed = time.time() - start assert elapsed > delay def test_request_done_after_start(server: TestServer) -> None: server = TestServer() try: server.start() assert not server.request_is_done() finally: server.stop() def test_request_done(server: TestServer) -> None: assert not server.request_is_done() server.add_response(Response()) request(server.get_url()) assert server.request_is_done() def test_wait_request(server: TestServer) -> None: server.add_response(Response(data=b"foo")) def worker() -> None: time.sleep(1) request(server.get_url("?method=test-wait-request")) th = Thread(target=worker) th.start() with pytest.raises(WaitTimeoutError): server.wait_request(0.5) server.wait_request(2) th.join() def test_request_cookies(server: TestServer) -> None: server.add_response(Response()) request(url=server.get_url(), headers={"Cookie": "foo=bar"}) assert server.get_request().cookies["foo"].value == "bar" def test_default_header_content_type(server: TestServer) -> None: server.add_response(Response()) info = request(server.get_url()) assert info.headers["content-type"] == "text/html; charset=utf-8" def test_custom_header_content_type(server: TestServer) -> None: server.add_response( Response(headers=[("Content-Type", "text/html; charset=koi8-r")]) ) info = request(server.get_url()) assert info.headers["content-type"] == "text/html; charset=koi8-r" def test_default_header_server(server: TestServer) -> None: server.add_response(Response()) info = request(server.get_url()) assert info.headers["server"] == ("TestServer/%s" % test_server.__version__) def test_custom_header_server(server: TestServer) -> None: server.add_response(Response(headers=[("Server", "Google")])) info = request(server.get_url()) assert info.headers["server"] == "Google" def test_options_method(server: TestServer) -> None: server.add_response(Response(data=b"abc")) res = request(url=server.get_url(), method="OPTIONS") assert server.get_request().method == "OPTIONS" assert res.data == b"abc" def test_multiple_start_stop_cycles() -> None: for _ in range(30): server = TestServer() server.start() try: server.add_response(Response(data=b"zorro"), count=10) for _ in range(10): res = request(server.get_url()) assert res.data == b"zorro" finally: server.stop() def test_specific_port() -> None: server = TestServer(address="localhost", port=SPECIFIC_TEST_PORT) try: server.start() server.add_response(Response(data=b"abc")) data = request(server.get_url()).data assert data == b"abc" finally: server.stop() def test_null_bytes(server: TestServer) -> None: server.add_response( Response( status=302, headers=[ ("Location", server.get_url().rstrip("/") + "/\x00/"), ], ) ) server.add_response(Response(data=b"zzz")) res = request(server.get_url()) assert res.data == b"zzz" assert unquote(server.get_request().path) == "/\x00/" def test_callback(server: TestServer) -> None: def get_callback() -> dict[str, Any]: return { "type": "response", "data": b"Hello", "headers": [ ("method", "get"), ], } def post_callback() -> dict[str, Any]: return { "type": "response", "status": 201, "data": b"hey", "headers": [ ("method", "post"), ("set-cookie", "foo=bar"), ], } server.add_response(Response(callback=get_callback)) server.add_response(Response(callback=post_callback), method="post") info = request(server.get_url()) assert info.headers.get("method") == "get" assert info.data == b"Hello" info = request(server.get_url(), b"key=val") assert info.headers["method"] == "post" assert info.headers["set-cookie"] == "foo=bar" assert info.data == b"hey" assert info.status == 201 # noqa: PLR2004 def test_response_data_invalid_type(server: TestServer) -> None: server.add_response(Response(data=1)) # type: ignore[arg-type] res = request(server.get_url()) assert res.status == INTERNAL_ERROR_RESPONSE_STATUS assert b"must be bytes" in res.data def test_stop_not_started_server() -> None: server = TestServer() server.stop() def test_start_request_stop_same_port() -> None: server = TestServer() for _ in range(10): try: server.start() server.add_response(Response()) request(server.get_url()) finally: server.stop() def test_file_uploading(server: TestServer) -> None: server.add_response(Response()) request( server.get_url(), fields={ "image": ("emoji.png", b"zzz"), }, ) img_file = server.get_request().files["image"][0] assert img_file["name"] == "image" def test_callback_response_not_dict(server: TestServer) -> None: def callback() -> list[str]: return ["foo", "bar"] server.add_response(Response(callback=callback)) # type: ignore[arg-type] res = request(server.get_url()) assert res.status == INTERNAL_ERROR_RESPONSE_STATUS assert b"is not a dict" in res.data def test_callback_response_invalid_type(server: TestServer) -> None: def callback() -> dict[str, Any]: return { "foo": "bar", } server.add_response(Response(callback=callback)) res = request(server.get_url()) assert res.status == INTERNAL_ERROR_RESPONSE_STATUS assert b"invalid type key" in res.data def test_callback_response_invalid_key(server: TestServer) -> None: def callback() -> dict[str, Any]: return { "type": "response", "foo": "bar", } server.add_response(Response(callback=callback)) res = request(server.get_url()) assert res.status == INTERNAL_ERROR_RESPONSE_STATUS assert b"contains invalid key" in res.data def test_callback_data_non_bytes(server: TestServer) -> None: def callback() -> dict[str, Any]: return { "type": "response", "data": "bar", } server.add_response(Response(callback=callback)) res = request(server.get_url()) assert res.status == INTERNAL_ERROR_RESPONSE_STATUS assert b"must be bytes" in res.data def test_invalid_response_key() -> None: with pytest.raises(TypeError) as ex: # pylint: disable=unexpected-keyword-arg Response(foo="bar") # type: ignore[call-arg] assert "unexpected keyword argument" in str(ex.value) def test_get_request_no_request(server: TestServer) -> None: with pytest.raises(RequestNotProcessedError): server.get_request() def test_add_response_invalid_method(server: TestServer) -> None: with pytest.raises(TestServerError) as ex: server.add_response(Response(), method="foo") assert "Invalid method" in str(ex.value) def test_add_response_count_minus_one(server: TestServer) -> None: server.add_response(Response(), count=-1) for _ in range(3): assert request(server.get_url()).status == HTTP_STATUS_OK def test_add_response_count_one_default(server: TestServer) -> None: server.add_response(Response()) assert request(server.get_url()).status == HTTP_STATUS_OK assert b"No response" in request(server.get_url()).data server.add_response(Response(), count=1) assert request(server.get_url()).status == HTTP_STATUS_OK assert b"No response" in request(server.get_url()).data def test_add_response_count_two(server: TestServer) -> None: server.add_response(Response(), count=2) assert request(server.get_url()).status == HTTP_STATUS_OK assert request(server.get_url()).status == HTTP_STATUS_OK assert b"No response" in request(server.get_url()).data def test_raw_callback(server: TestServer) -> None: def callback() -> bytes: return b"HTTP/1.0 200 OK\nFoo: Bar\nGaz: Baz\nContent-Length: 5\n\nhello" server.add_response(Response(raw_callback=callback)) res = request(server.get_url()) assert "foo" in res.headers assert res.data == b"hello" def test_raw_callback_invalid_type(server: TestServer) -> None: def callback() -> str: return "hey" server.add_response(Response(raw_callback=callback)) # type: ignore[arg-type] res = request(server.get_url()) assert b"must return bytes" in res.data def test_request_property(server: TestServer) -> None: server.add_response(Response()) request(server.get_url()) assert isinstance(server.request, Request) def test_put_request(server: TestServer) -> None: server.add_response(Response()) request(server.get_url(), data=b"foo", method="put") assert server.request.method == "PUT"