diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4530d82..0000000 --- a/.travis.yml +++ /dev/null @@ -1,50 +0,0 @@ ---- -language: python - -python: - # - '3.7' - failed with uwsgi installation - - '3.8' - - '3.9' - # - '3.10' - not supported now :-( - -install: - - pip3 install -U pytest pytest-doctestplus pytest-pylint pytest-mypy - requests openapi-core uwsgi simplejson - - pip3 install -U types-simplejson types-requests types-PyYAML - -script: - - pytest -v poorwsgi --pylint --mypy --doctest-plus --doctest-rst - - pytest -v tests --mypy - - pytest -v examples --mypy - - pytest -v tests_integrity --mypy --with-uwsgi - - pytest -v tests_integrity --mypy - -before_deploy: - - python3 setup.py sdist - - python3 setup.py bdist_wheel - -deploy: - - provider: pypi - user: __token__ - password: - secure: JL4umK8xRzuxBJyxm6pE9bClGd7PB7Vuy4q7rfEQCt9bQg9SCd29eFlA2EowG/xkWjlVe940hygKY/ZzHOq0ieg3vU4u2D99opyHPncdLcqF6ECpyBkeeUbFMmxkfH8n8vhWUDzxaOcYErFB1B0rypyuJRVuUrm8fJJVBMjboiI3h/pn8xDDbV3i1iVPsk4qzU2eleSQgc5ONe+YVsZK4rnctycobLscz2vlo7rPHfZLtgMJq27squs1iMrFyTa4S35nuBCGl2Na9u4Wjl8Sniv/naa96ZORDxnWSJLBmANgjinaTd4UslU2K8SyGI0Y0YPxlbm8OefAsY6OXt2GgtQGKGrCM75dMHwUlK1ULDi7kiQZMIlDbtFLWwP4ykQRsuaQaWibSZW3Gckajo925OKxXVTGOU4jfcYhy4ZT0dTiIEA2zooYSc40DE2t9j5oaJ/edBTDZbYGbY/5CbKYEM8+SXt6CFTe8PuNv+S5TUxBoPYAs88NiEvRs/+xoAeRKAIX5N4qGXnc4kTre/Jix1RxZS9LV/oBF1tbefo+Nyv4iVi91B3NRQ9AyvcDvq4fMH9vp6UMPsyi703vwdAHhwx9LrkAAR0dtJA+h3TB9dfcxKrt5hfzuWUmOVOyG8LdeJeEIxRjEh64oBxqeO7NFj81UTvHzUr63ufWZNX/ETQ= - distribution: sdist bdist_wheel - skip_cleanup: true - skip_existing: true - overwrite: true - on: - repo: PoorHttp/PoorWSGI - branch: master - tags: true - - provider: releases - api_key: - secure: YCM2yf0GX6xx+m6uza40MEQXwVDMpwwq5aYaQkPge2inqwAHI05HLigYK0kop6KcxjiPg4ovzqkJ5b6gkpQrcHBvcv0CVGcDv2+G/Xy8PxLRbmulx/mrrwYRNE/IuWjmIXhOlPUQZpKsd+JyjA72F/6zM6TPLdUAfVzSX6lvahYHcVQlQxzsOtCfIrmSRP9L3/Xbtlcomb+WmH6dRRq38WYk909K741HKuq9zg6qSoUajfVi9IYdcj4YC8GZdNi66/abgkxQR9x9BI3vzGYg79ZaGutK8qPgwXQMgloUGpPP+75Q03Q4cdJAmPXMB2m8zlYZlV/SYpM88/0bulBkQmqdJHKu6n6SEN4uhxNM6x5GFkAX30LphIEXhXWlUj7q043FRXxNyD8Ay5bUthqjqzOcbLH6Z19mIi/CL82soxJ+6UMAU6UtfLBurJ+GU1hxb3uFXiFQOAh9yVSSf1zBdyCDGtni8LlCbIGRrVKen+26DZfohHnzVpIUtddfZvPTbLSq7/8WjBUmdpTF996VLUszKuc3F1mZiCDUtpwI5l30mAc9EiDMhGp34gLH0vm3osaqJn4Feyn4x+ZtJjWjtFLOV8xyrT3IeZ0qZcpZvE/TCre6pU4CankZuZrOMcneGR6jRolg/Ub5h49JHrSNIgMKJQF2FQJF0oDTvG7nHSE= - file: - - dist/PoorWSGI-$TRAVIS_TAG.tar.gz - - dist/PoorWSGI-$TRAVIS_TAG-py3-none-any.whl - skip_cleanup: true - overwrite: true - on: - repo: PoorHttp/PoorWSGI - branch: master - tags: true diff --git a/ARCHITECTURE.rst b/ARCHITECTURE.rst new file mode 100644 index 0000000..cc8ffbd --- /dev/null +++ b/ARCHITECTURE.rst @@ -0,0 +1,103 @@ +PoorWSGI Architecture +======================= + +This document describes the internal architecture of the PoorWSGI framework. It is intended for developers who want to understand how the framework works, extend its functionality, or contribute to its development. + +Project Philosophy +------------------ +PoorWSGI is designed as a minimalist, lightweight, and extensible WSGI framework. Its core philosophy is to provide a solid foundation for web applications and APIs without imposing a rigid structure or including unnecessary components. The design prioritizes simplicity, developer convenience, and clear, straightforward code. + +Key principles: + +* **Minimalism**: The core is small and has few dependencies. +* **Explicitness**: The request-response cycle is easy to follow. +* **Extensibility**: The framework provides clear extension points, such as middleware-like hooks and customizable objects. +* **Developer-Friendly Debugging**: In debug mode, the framework offers powerful introspection tools. + +Core Objects +------------ +The framework's functionality is built around a few central objects. + +`Application` (`poorwsgi/wsgi.py`) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is the heart of the framework. An instance of the `Application` class is the main entry point for the WSGI server. Its primary responsibilities are: + +* **Configuration**: Storing all application settings. +* **Routing**: Managing a routing table that maps URL paths and HTTP methods to handler functions. +* **Request Lifecycle**: Orchestrating the entire process of handling a request from start to finish. +* **Hooks**: Managing `before_response` and `after_response` hooks, which act as a simple middleware system. + +`Request` (`poorwsgi/request.py`) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This object encapsulates all data from an incoming HTTP request. It is created by the `Application` object for each request. Key features include: + +* **Automatic Parsing**: It automatically parses query strings (`req.args`), form data (`req.form`), and JSON bodies (`req.json`) based on the request's `Content-Type` header and application configuration. This simplifies data access within handlers. +* **Header Access**: Provides easy access to request headers via `req.headers`. +* **Extensibility**: The `Request` object can be easily extended with custom data, for example, by attaching a user object (`req.user`) or a database session (`req.db`) within a `before_response` hook. + +`Response` & `HTTPException` (`poorwsgi/response.py`) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module defines how outgoing responses are constructed. + +* `BaseResponse` is the base class for all response objects. Its children, like `Response`, `JSONResponse`, and `FileResponse`, handle different types of content. +* The `make_response` factory function is a crucial component. It intelligently converts a handler's return value (e.g., a `str`, `dict`, or `int` status code) into a complete `Response` object. This allows handlers to be very simple. +* `HTTPException` is the standard way to handle errors. Calling `abort(404)` or raising an `HTTPException` subclass will interrupt the normal flow and immediately send an error response. + +`results` (`poorwsgi/results.py`) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This module contains pre-defined handlers for all standard HTTP status codes (e.g., `not_found` for 404, `internal_server_error` for 500). + +* These are used as default error handlers if the user does not register their own. +* It also contains the powerful `debug_info` handler, which generates a comprehensive introspection page at the `/_debug-info` URL when the application is in debug mode. + +The Request-Response Lifecycle +------------------------------ +The core logic of the framework is the request lifecycle, which follows these steps: + +1. A WSGI server receives an HTTP request and calls the `Application` instance. +2. The `Application.__call__` method wraps the main logic in a `try...except` block to catch `HTTPException` and other errors. +3. The primary `Application.__request__` method is executed. +4. An instance of the `Request` class is created from the WSGI `environ` dictionary. +5. The routing system (`handler_from_table`) is used to find the appropriate handler function based on the request's path and method. If no handler is found, a 404 error is triggered. +6. All registered `before_response` hooks are executed in sequence. These hooks can modify the `Request` object (e.g., by adding a user session) or even short-circuit the request by returning a `Response`. +7. The selected handler function is called with the `Request` object as its argument (`handler(req)`). +8. The return value from the handler is passed to the `make_response` factory to create a valid `Response` object. +9. All registered `after_response` hooks are executed. They can modify the final `Response` object (e.g., by adding custom headers). +10. The `Response` object is used to call the `start_response` callable and return the response body as an iterable, fulfilling the WSGI specification. + +Routing +------- +URL routing is primarily managed via the `@app.route` decorator: + +.. code-block:: python + + from poorwsgi import Application, state + + app = Application() + + @app.route('/', state.GET|state.POST) + def index(req): + return "Hello, World!" + +The decorator adds the function to the application's internal routing table. The router matches the request path and HTTP method to find the correct handler to execute. It supports both static paths and paths with variable placeholders. + +Extending PoorWSGI +------------------ +The framework is designed to be easily extended. Here are some common ways to do so: + +* **Middleware via Hooks**: The `before_response` and `after_response` hooks are the primary way to implement middleware. Use `before_response` for tasks like authentication, database session management, or request logging. Use `after_response` for modifying headers or response content. +* **Custom Error Handlers**: You can replace the default error pages by registering your own handlers for specific HTTP status codes using `@app.error_handler(404)`. +* **Custom `Request` Attributes**: Attach any data you need to the `Request` object within a hook for easy access in your handlers (e.g., `req.session = get_session(...)`). +* **Custom `Response` Types**: For specialized content types, you can create a subclass of `poorwsgi.response.BaseResponse` and return an instance of it from your handlers. + +Project Structure +----------------- + +* `poorwsgi/`: The main package directory containing the framework's source code. +* `tests/`: Contains unit tests that test individual components of the framework in isolation. +* `tests_integrity/`: Contains integration tests that verify the behavior of the complete application, often by running servers from the `examples/` directory. +* `examples/`: A collection of sample applications demonstrating various features of the framework. diff --git a/CONTRIBUTION.rst b/CONTRIBUTION.rst index 227738b..ca8e850 100644 --- a/CONTRIBUTION.rst +++ b/CONTRIBUTION.rst @@ -1,36 +1,113 @@ -Contribution -============ +Contributing to the Project +=========================== +Thank you for your interest in contributing to the PoorWSGI project! Every contribution is welcome. This document will guide you through the process of reporting a bug, suggesting an enhancement, or directly contributing code. -Tests ------ -``test`` command in ``setup.py`` run unit tests automatically. There is used -``pytest`` toolkit, so pytest tool is needed. +Reporting Bugs +-------------- +If you encounter a bug, please ensure that you: -You can run tests with next commands: +1. Search the existing `issues `_ to make sure the bug has not already been reported. +2. If you cannot find the bug, create a new issue. +3. In the description, provide as much information as possible: -.. code:: sh + * The version of PoorWSGI you are using. + * The Python version and operating system. + * A short but descriptive summary of the bug. + * Steps to reproduce the bug. + * What you expected to happen and what actually happened. - # setup.py unit tests - ~$ python3 setup.py test +Suggesting Enhancements +----------------------- +Do you have an idea for a new feature or improvement? - # all test (with integrity tests) - ~$ pytest -v +1. Create a new issue and describe your suggestion. +2. Explain why this feature would be useful and how it should work. -**pytest** package have many additional plugins so you can use that. -Next command check all .rst files, source code with pep8 and doctest checkers. +Development and Code Contribution +--------------------------------- +If you want to contribute code, please follow these steps. The project is developed using a **Test-Driven Development (TDD)** approach. This means that for every new feature or bug fix, a failing test should be written first, followed by the code that makes the test pass. -.. code:: sh +1. Setting Up the Development Environment +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - # check pep8 and doctest (pytest-pep8 and pytest-doctestplus plugins) - ~$ pytest -v --pep8 --doctest-plus --doctest-rst +First, prepare your local development environment. +.. code-block:: bash -Directories ------------ -* ``poorwsgi`` - poorwsgi library -* ``tests`` - unit tests that only use code from poorwsgi -* ``tests_integrity`` - integrity tests, that needs running servers. If no - server url is set, that each test run it's server from ``examples`` directory. -* ``doc`` - documentation source for html documentation -* ``examples`` - example servers which is used by integrity tests + # 1. Fork the repository and clone it + git clone https://github.com/YOUR-USERNAME/PoorWSGI.git + cd PoorWSGI + + # 2. Create and activate a virtual environment + python3 -m venv .venv + source .venv/bin/activate + + # 3. Install the project in editable mode and the development dependencies + python -m pip install --upgrade pip + pip install -e . + pip install -U pre-commit flake8 setuptools pytest pytest-doctestplus pytest-pylint pytest-mypy ruff isort + pip install -U openapi-core uwsgi simplejson WSocket requests websocket-client + pip install -U types-simplejson types-requests types-PyYAML + + # 4. Activate pre-commit hooks for automatic code checking + pre-commit install + +2. Code Style and Automatic Checks (pre-commit) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To maintain a consistent style and code quality, the project **requires the use of `pre-commit`**. This tool automatically runs a set of checks (called "hooks") before each commit. These checks include formatting, linting, and other code analyses. The configuration is defined in the `.pre-commit-config.yaml` file. + +Thanks to `pre-commit`, you don't have to run all the tools manually. If a commit fails due to a check, `pre-commit` will often fix the code for you automatically. In that case, you just need to add the modified files again (`git add`) and repeat the commit. + +If you still wish to run the checks manually during development (outside of a commit), you can use the following commands: + +.. code-block:: bash + + # Manually run all pre-commit hooks on all files + pre-commit run --all-files + + # Alternatively, individual tools: + ruff format . # Formatting + ruff check . # Linting + pylint poorwsgi/ # In-depth analysis + isort . # Sorting imports + +3. Running Tests +~~~~~~~~~~~~~~~~ + +As mentioned, TDD is a key part of development. All tests must pass before you submit a Pull Request. + +* `tests/`: Contains unit tests. +* `tests_integrity/`: Contains integration tests, which may run real servers from the `examples/` directory. + +You can run the tests using `pytest`: + +.. code-block:: bash + + # Run all tests (unit and integration) + pytest -v + + # Run only unit tests + pytest -v tests/ + + # Run integration tests + pytest -v tests_integrity/ + +4. Submitting Changes (Pull Request) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Create a new branch for your changes: `git checkout -b feature/my-new-feature`. +2. Make your code changes and write tests for them. +3. Ensure that all local tests pass (`pytest -v`). +4. Create a commit. At this point, `pre-commit` will automatically check and possibly fix your code. If it fails, make the necessary adjustments and repeat the commit. + + .. code-block:: bash + + git add . + git commit -m "A brief description of the changes" + +5. Push the changes to your fork: `git push origin feature/my-new-feature`. +6. Open a `Pull Request `_ to the `main` branch of the main repository. + +Your Pull Request will be reviewed by automated tests (CI) and one of the project maintainers. Thank you for your help! diff --git a/poorwsgi/request.py b/poorwsgi/request.py index 43480ca..9e83b90 100644 --- a/poorwsgi/request.py +++ b/poorwsgi/request.py @@ -31,6 +31,7 @@ class SimpleRequest: """Request proxy properties implementation - for internal use only.""" + # pylint: disable=too-many-public-methods def __init__(self, environ, app): self.__environ = environ @@ -47,18 +48,18 @@ def __init__(self, environ, app): self.__error_handler = None # uwsgi do not sent environ variables to apps environ - if 'uwsgi.version' in self.__environ or 'poor.Version' in os.environ: + if "uwsgi.version" in self.__environ or "poor.Version" in os.environ: self.__poor_environ = os.environ else: self.__poor_environ = self.__environ - var = self.__poor_environ.get('poor_Debug') + var = self.__poor_environ.get("poor_Debug") if var: - self.__debug = var.lower() == 'on' + self.__debug = var.lower() == "on" else: self.__debug = app.debug - self.__start_time = environ['REQUEST_STARTTIME'] + self.__start_time = environ["REQUEST_STARTTIME"] self.__end_time = time() @property @@ -140,29 +141,30 @@ def error_handler(self, value: Callable): @property def hostname(self): """Host, as set by full URI or Host: header without port.""" - return self.__environ.get('HTTP_HOST', - self.server_hostname).split(':')[0] + return self.__environ.get("HTTP_HOST", self.server_hostname).split( + ":" + )[0] @property def host_port(self): """Port, as set by full URI or Host.""" - host = self.__environ.get('HTTP_HOST', '') - if ':' in host: - return int(host.split(':')[1]) - if self.server_scheme == 'https': + host = self.__environ.get("HTTP_HOST", "") + if ":" in host: + return int(host.split(":")[1]) + if self.server_scheme == "https": return 443 return 80 @property def method(self): """String containing the method, ``GET, HEAD, POST``, etc.""" - return self.__environ.get('REQUEST_METHOD') + return self.__environ.get("REQUEST_METHOD") @property def method_number(self): """Method number constant from state module.""" if self.method not in methods: - return methods['GET'] + return methods["GET"] return methods[self.method] @property @@ -173,77 +175,76 @@ def uri(self): @property def path(self): """Path part of url.""" - return self.__environ.get('PATH_INFO').encode('iso-8859-1').decode() + return self.__environ.get("PATH_INFO").encode("iso-8859-1").decode() @property def query(self): """The QUERY_STRING environment variable.""" - return self.__environ.get('QUERY_STRING', '').strip() + return self.__environ.get("QUERY_STRING", "").strip() @property def full_path(self): """Path with query, if it exist, from url.""" query = self.query - return self.path + ('?'+query if query else '') + return self.path + ("?" + query if query else "") @property def remote_host(self): """Remote hostname.""" - return self.__environ.get('REMOTE_HOST', '') + return self.__environ.get("REMOTE_HOST", "") @property def remote_addr(self): """Remote address.""" - return self.__environ.get('REMOTE_ADDR') + return self.__environ.get("REMOTE_ADDR") @property def referer(self): """Request referer if is available or None.""" - return self.__environ.get('HTTP_REFERER') + return self.__environ.get("HTTP_REFERER") @property def user_agent(self): """Browser user agent string.""" - return self.__environ.get('HTTP_USER_AGENT') + return self.__environ.get("HTTP_USER_AGENT") @property def server_scheme(self): """Request scheme, typical ``http`` or ``https``.""" - return self.__environ.get('wsgi.url_scheme') + return self.__environ.get("wsgi.url_scheme") @property def scheme(self): """Alias for server_scheme property.""" - return self.__environ.get('wsgi.url_scheme') + return self.__environ.get("wsgi.url_scheme") @property def server_software(self): """Server software.""" - soft = self.__environ.get('SERVER_SOFTWARE', 'Unknown') - if soft == 'Unknown' and 'uwsgi.version' in self.__environ: - soft = 'uWsgi' + soft = self.__environ.get("SERVER_SOFTWARE", "Unknown") + if soft == "Unknown" and "uwsgi.version" in self.__environ: + soft = "uWsgi" return soft @property def server_admin(self): """Server admin if set, or ``webmaster@hostname``.""" - return self.__environ.get('SERVER_ADMIN', - f'webmaster@{self.hostname}') + return self.__environ.get("SERVER_ADMIN", f"webmaster@{self.hostname}") @property def server_hostname(self): """Server name variable.""" - return self.__environ.get('SERVER_NAME') + return self.__environ.get("SERVER_NAME") @property def server_port(self): """Server port.""" - return int(self.__environ.get('SERVER_PORT')) + return int(self.__environ.get("SERVER_PORT")) @property def port(self): """Alias for ``server_port`` property.""" - return int(self.__environ.get('SERVER_PORT')) + return int(self.__environ.get("SERVER_PORT")) @property def server_protocol(self): @@ -251,43 +252,43 @@ def server_protocol(self): In ``HTTP/0.9``. cgi ``SERVER_PROTOCOL`` value. """ - return self.__environ.get('SERVER_PROTOCOL') + return self.__environ.get("SERVER_PROTOCOL") @property def protocol(self): """Alias for ``server_protocol`` property""" - return self.__environ.get('SERVER_PROTOCOL') + return self.__environ.get("SERVER_PROTOCOL") @property def forwarded_for(self): """``X-Forward-For`` http header if exists.""" - return self.__environ.get('HTTP_X_FORWARDED_FOR') + return self.__environ.get("HTTP_X_FORWARDED_FOR") @property def forwarded_host(self): """``X-Forward-Host`` http header without port if exists.""" - host = self.__environ.get('HTTP_X_FORWARDED_HOST') + host = self.__environ.get("HTTP_X_FORWARDED_HOST") if host: - host = host.split(':')[0] + host = host.split(":")[0] return host @property def forwarded_port(self): """Port from ``X-Forward-Host`` or ``X-Forward-Proto`` header.""" - host = self.__environ.get('HTTP_X_FORWARDED_HOST') - if host and ':' in host: - return int(host.split(':')[1]) + host = self.__environ.get("HTTP_X_FORWARDED_HOST") + if host and ":" in host: + return int(host.split(":")[1]) proto = self.forwarded_proto - if proto == 'https': + if proto == "https": return 443 - if proto == 'http': + if proto == "http": return 80 return None @property def forwarded_proto(self): """``X-Forward-Proto`` http header if exists.""" - return self.__environ.get('HTTP_X_FORWARDED_PROTO') + return self.__environ.get("HTTP_X_FORWARDED_PROTO") @property def secret_key(self): @@ -297,9 +298,7 @@ def secret_key(self): some server variables, and the best way is set programmatically by Application.secret_key from random data. """ - return self.__poor_environ.get( - 'poor_SecretKey', - self.__app.secret_key) + return self.__poor_environ.get("poor_SecretKey", self.__app.secret_key) @property def document_index(self): @@ -308,17 +307,17 @@ def document_index(self): Variable is used to generate index html page, when poor_DocumentRoot is set. """ - var = self.__poor_environ.get('poor_DocumentIndex') + var = self.__poor_environ.get("poor_DocumentIndex") if var: - return var.lower() == 'on' + return var.lower() == "on" return self.__app.document_index @property def document_root(self): """Returns DocumentRoot setting.""" return self.__poor_environ.get( - 'poor_DocumentRoot', - self.__app.document_root) + "poor_DocumentRoot", self.__app.document_root + ) @property def start_time(self): @@ -342,15 +341,17 @@ def get_options(self): app_db_server = localhost # application variable db_server app_templates = app/templ # application variable templates """ - warnings.warn("Call to deprecated Request.get_options." - "Use Application.get_options instead.", - category=DeprecationWarning, - stacklevel=1) + warnings.warn( + "Call to deprecated Request.get_options." + "Use Application.get_options instead.", + category=DeprecationWarning, + stacklevel=1, + ) options = {} for key, val in self.__poor_environ.items(): key = key.strip() - if key[:4].lower() == 'app_': + if key[:4].lower() == "app_": options[key[4:].lower()] = val.strip() return options @@ -366,8 +367,10 @@ def construct_url(self, uri: str): scheme = self.forwarded_proto or self.server_scheme host = self.forwarded_host or self.hostname port = self.forwarded_port or self.host_port - if not ((port == 80 and scheme == 'http') or - (port == 443 and scheme == 'https')): + if not ( + (port == 80 and scheme == "http") + or (port == 443 and scheme == "https") + ): return f"{scheme}://{host}:{port}{uri}" return f"{scheme}://{host}{uri}" return uri @@ -379,6 +382,7 @@ class Request(SimpleRequest): It could be compatible as soon as possible with mod_python.apache.request. Special variables for user use are prefixed with ``app_``. """ + # pylint: disable=too-many-public-methods def __init__(self, environ, app): @@ -390,27 +394,28 @@ def __init__(self, environ, app): # pylint: disable=too-many-branches, too-many-statements super().__init__(environ, app) - if environ.get('PATH_INFO') is None: + if environ.get("PATH_INFO") is None: raise ConnectionError( - "PATH_INFO not set, probably bad HTTP protocol used.") + "PATH_INFO not set, probably bad HTTP protocol used." + ) # A table object containing headers sent by the client. tmp = [] for key, val in environ.items(): - if key[:5] == 'HTTP_': - key = '-'.join(map(lambda x: x.capitalize(), - key[5:].split('_'))) + if key[:5] == "HTTP_": + key = "-".join( + map(lambda x: x.capitalize(), key[5:].split("_")) + ) tmp.append((key, val)) elif key in ("CONTENT_LENGTH", "CONTENT_TYPE"): - key = '-'.join(map(lambda x: x.capitalize(), - key.split('_'))) + key = "-".join(map(lambda x: x.capitalize(), key.split("_"))) tmp.append((key, val)) self.__headers = Headers(tmp, False) # do not convert to iso-8859-1 - ctype, pdict = parse_header(self.__headers.get('Content-Type', '')) + ctype, pdict = parse_header(self.__headers.get("Content-Type", "")) self.__mime_type = ctype - self.__charset = pdict.get('charset', 'utf-8') + self.__charset = pdict.get("charset", "utf-8") self.__content_length = int(self.__headers.get("Content-Length") or -1) # will be set with first property call @@ -423,8 +428,7 @@ def __init__(self, environ, app): self.__file = environ.get("wsgi.input") self._errors = environ.get("wsgi.errors") - if app.auto_data and \ - 0 <= self.__content_length <= app.data_size: + if app.auto_data and 0 <= self.__content_length <= app.data_size: self.__file = BytesIO(self.__file.read(self.__content_length)) self.__file.seek(0) @@ -437,35 +441,40 @@ def __init__(self, environ, app): # args if app.auto_args: - self.__args = Args(self, app.keep_blank_values, - app.strict_parsing) + self.__args = Args(self, app.keep_blank_values, app.strict_parsing) else: self.__args = EmptyForm() # test auto json parsing - if app.auto_json and \ - (self.is_body_request or self.server_protocol == "HTTP/0.9") \ - and self.__mime_type in app.json_mime_types: + if ( + app.auto_json + and (self.is_body_request or self.server_protocol == "HTTP/0.9") + and self.__mime_type in app.json_mime_types + ): self.__json = parse_json_request(self.read(), self.__charset) self.__form = EmptyForm() # test auto form parsing - elif app.auto_form and \ - (self.is_body_request or self.server_protocol == "HTTP/0.9") \ - and self.__mime_type in app.form_mime_types: + elif ( + app.auto_form + and (self.is_body_request or self.server_protocol == "HTTP/0.9") + and self.__mime_type in app.form_mime_types + ): form_parser = fieldstorage.FieldStorageParser( - self.input, self.headers, + self.input, + self.headers, keep_blank_values=app.keep_blank_values, strict_parsing=app.strict_parsing, - file_callback=app.file_callback) + file_callback=app.file_callback, + ) self.__form = form_parser.parse() self.__json = EmptyForm() else: self.__form = EmptyForm() self.__json = EmptyForm() - if app.auto_cookies and 'Cookie' in self.__headers: + if app.auto_cookies and "Cookie" in self.__headers: self.__cookies = SimpleCookie() - self.__cookies.load(self.__headers['Cookie']) + self.__cookies.load(self.__headers["Cookie"]) else: self.__cookies = None @@ -477,6 +486,7 @@ def __init__(self, environ, app): # ugly hack # pylint: disable=invalid-name self._SimpleRequest__end_time = time() + # enddef # -------------------------- Properties --------------------------- # @@ -504,52 +514,56 @@ def headers(self): def accept(self) -> tuple: """Tuple of client supported mime types from Accept header.""" if self.__accept is None: - self.__accept = tuple(parse_negotiation( - self.__headers.get("Accept", ''))) + self.__accept = tuple( + parse_negotiation(self.__headers.get("Accept", "")) + ) return self.__accept @property def accept_charset(self) -> tuple: """Tuple of client supported charset from Accept-Charset header.""" if self.__accept_charset is None: - self.__accept_charset = tuple(parse_negotiation( - self.__headers.get("Accept-Charset", ''))) + self.__accept_charset = tuple( + parse_negotiation(self.__headers.get("Accept-Charset", "")) + ) return self.__accept_charset @property def accept_encoding(self) -> tuple: """Tuple of client supported charset from Accept-Encoding header.""" if self.__accept_encoding is None: - self.__accept_encoding = tuple(parse_negotiation( - self.__headers.get("Accept-Encoding", ''))) + self.__accept_encoding = tuple( + parse_negotiation(self.__headers.get("Accept-Encoding", "")) + ) return self.__accept_encoding @property def accept_language(self) -> tuple: """List of client supported languages from Accept-Language header.""" if self.__accept_language is None: - self.__accept_language = tuple(parse_negotiation( - self.__headers.get("Accept-Language", ''))) + self.__accept_language = tuple( + parse_negotiation(self.__headers.get("Accept-Language", "")) + ) return self.__accept_language @property def accept_html(self) -> bool: """Return true if ``text/html`` mime type is in accept negotiations - values. + values. """ return "text/html" in dict(self.accept) @property def accept_xhtml(self) -> bool: """Return true if ``text/xhtml`` mime type is in accept negotiations - values. + values. """ return "text/xhtml" in dict(self.accept) @property def accept_json(self) -> bool: """Return true if ``application/json`` mime type is in accept - negotiations values. + negotiations values. """ return "application/json" in dict(self.accept) @@ -557,21 +571,22 @@ def accept_json(self) -> bool: def authorization(self) -> dict: """Return Authorization header parsed to dictionary.""" if self.__authorization is None: - auth = self.__headers.get('Authorization', '').strip() + auth = self.__headers.get("Authorization", "").strip() self.__authorization = dict( - (key, Headers.utf8(val.strip('"'))) for key, val in - RE_AUTHORIZATION.findall(auth)) - self.__authorization['type'] = auth[:auth.find(' ')].capitalize() - username_ = self.__authorization.get('username*') + (key, Headers.utf8(val.strip('"'))) + for key, val in RE_AUTHORIZATION.findall(auth) + ) + self.__authorization["type"] = auth[: auth.find(" ")].capitalize() + username_ = self.__authorization.get("username*") if username_ and username_.startswith("UTF-8''"): - self.__authorization['username'] = unquote(username_[7:]) + self.__authorization["username"] = unquote(username_[7:]) return self.__authorization.copy() @property def is_xhr(self) -> bool: """If ``X-Requested-With`` header is set with ``XMLHttpRequest`` value. """ - return self.__headers.get('X-Requested-With') == 'XMLHttpRequest' + return self.__headers.get("X-Requested-With") == "XMLHttpRequest" @property def is_body_request(self) -> bool: @@ -581,15 +596,16 @@ def is_body_request(self) -> bool: @property def is_chunked(self) -> bool: """True if has set Transfer-Encoding is chunked.""" - return self.__headers.get('Transfer-Encoding') == 'chunked' + return self.__headers.get("Transfer-Encoding") == "chunked" @property def is_chunked_request(self): """Compatibility alias for is_chunked.""" - warnings.warn("Call to deprecated is_chunked_request, " - "use is_chunked instead", - category=DeprecationWarning, - stacklevel=1) + warnings.warn( + "Call to deprecated is_chunked_request, use is_chunked instead", + category=DeprecationWarning, + stacklevel=1, + ) return self.is_chunked @property @@ -598,7 +614,7 @@ def path_args(self) -> dict: return (self.__path_args or {}).copy() @path_args.setter - def path_args(self, value: str): + def path_args(self, value: dict): if self.__path_args is None: self.__path_args = value @@ -615,7 +631,7 @@ def args(self): return self.__args @args.setter - def args(self, value: 'Args'): + def args(self, value: "Args"): if isinstance(self.__args, EmptyForm): self.__args = value @@ -682,10 +698,12 @@ def input(self): return self.__cached_input if not self.__cached_size or isinstance(self.__file, BytesIO): return self.__file - self.__cached_input = CachedInput(self.__file, - self.content_length, - self.__cached_size, - self.__read_timeout) + self.__cached_input = CachedInput( + self.__file, + self.content_length, + self.__cached_size, + self.__read_timeout, + ) return self.__cached_input @property @@ -727,7 +745,7 @@ def read(self, length=-1): # pylint: disable=method-hidden """ if not self.is_body_request and self.server_protocol != "HTTP/0.9": log.error("No Content-Length found, read was failed!") - return b'' + return b"" if -1 < length < self.__content_length: self.read = self.__read return self.read(length) @@ -755,28 +773,44 @@ def __del__(self): class EmptyForm(dict, fieldstorage.FieldStorageInterface): """Compatibility class as fallback.""" + # pylint: disable=unused-argument - def getvalue(self, key: str, default: Any = None, - func: Callable = lambda x: x): + def getvalue( + self, key: str, default: Any = None, func: Callable = lambda x: x + ): """Just return default.""" return default - def getfirst(self, key: str, default: Any = None, - func: Callable = lambda x: x, - fce: Optional[Callable] = None): + def getfirst( + self, + key: str, + default: Any = None, + func: Callable = lambda x: x, + fce: Optional[Callable] = None, + ): """Just return default.""" if fce: - warnings.warn("Using deprecated fce argument. Use func instead.", - category=DeprecationWarning, stacklevel=1) + warnings.warn( + "Using deprecated fce argument. Use func instead.", + category=DeprecationWarning, + stacklevel=1, + ) return default - def getlist(self, key: str, default: Any = None, - func: Callable = lambda x: x, - fce: Optional[Callable] = None): + def getlist( + self, + key: str, + default: Any = None, + func: Callable = lambda x: x, + fce: Optional[Callable] = None, + ): """Just return default or empty list.""" if fce: - warnings.warn("Using deprecated fce argument. Use func instead.", - category=DeprecationWarning, stacklevel=1) + warnings.warn( + "Using deprecated fce argument. Use func instead.", + category=DeprecationWarning, + stacklevel=1, + ) return default or [] @@ -786,12 +820,19 @@ class Args(dict, fieldstorage.FieldStorageInterface): Class is based on dictionary. It has getfirst and getlist methods, which can call function on values. """ + def __init__(self, req: Request, keep_blank_values=0, strict_parsing=0): query = req.query - args = parse_qs( - query, keep_blank_values, strict_parsing) if query else {} - dict.__init__(self, ((key, val[0] if len(val) < 2 else val) - for key, val in args.items())) + args = ( + parse_qs(query, keep_blank_values, strict_parsing) if query else {} + ) + dict.__init__( + self, + ( + (key, val[0] if len(val) < 2 else val) + for key, val in args.items() + ), + ) class JsonDict(dict, fieldstorage.FieldStorageInterface): @@ -825,9 +866,10 @@ class JsonList(list): """ # pylint: disable=unused-argument - def getvalue(self, key=None, default: Any = None, - func: Callable = lambda x: x): - """Returns first item or defualt if no exists. + def getvalue( + self, key=None, default: Any = None, func: Callable = lambda x: x + ): + """Returns first item or default if no exists. key : None Compatibility parametr is ignored. @@ -839,9 +881,13 @@ def getvalue(self, key=None, default: Any = None, """ return func(self[0]) if self else default - def getfirst(self, key=None, default: Any = None, - func: Callable = lambda x: x, - fce: Optional[Callable] = None): + def getfirst( + self, + key=None, + default: Any = None, + func: Callable = lambda x: x, + fce: Optional[Callable] = None, + ): """Returns first variable value or default, if no one exist. key : None @@ -854,15 +900,22 @@ def getfirst(self, key=None, default: Any = None, Use func converter just like getvalue. """ if fce: - warnings.warn("Using deprecated fce argument. Use func instead.", - category=DeprecationWarning, stacklevel=1) + warnings.warn( + "Using deprecated fce argument. Use func instead.", + category=DeprecationWarning, + stacklevel=1, + ) func = fce return self.getvalue(default=default, func=func) - def getlist(self, key: str, default: Optional[list] = None, - func: Callable = lambda x: x, - fce: Optional[Callable] = None): + def getlist( + self, + key: str, + default: Optional[list] = None, + func: Callable = lambda x: x, + fce: Optional[Callable] = None, + ): """Returns list of values key : None @@ -875,8 +928,11 @@ def getlist(self, key: str, default: Optional[list] = None, Use func converter just like getvalue. """ if fce: - warnings.warn("Using deprecated fce argument. Use func instead.", - category=DeprecationWarning, stacklevel=1) + warnings.warn( + "Using deprecated fce argument. Use func instead.", + category=DeprecationWarning, + stacklevel=1, + ) func = fce if not self: @@ -910,11 +966,17 @@ def parse_json_request(raw: bytes, charset: str = "utf-8"): raise HTTPException(HTTP_BAD_REQUEST, error=err) from err -def FieldStorage(req=Request, # noqa: N802 - headers=None, - keep_blank_values=0, strict_parsing=0, - encoding='utf-8', errors='replace', - max_num_fields=None, separator='&', file_callback=None): +def FieldStorage( # noqa: N802 + req=Request, # noqa: N802 + headers=None, + keep_blank_values=0, + strict_parsing=0, + encoding="utf-8", + errors="replace", + max_num_fields=None, + separator="&", + file_callback=None, +): """**Deprecated:** back compatibility function. This function will be deleted in next major version. @@ -924,19 +986,24 @@ def FieldStorage(req=Request, # noqa: N802 # pylint: disable=unused-argument # pylint: disable=invalid-name - warnings.warn("Call to deprecated FieldStorage parsing method." - "Use fieldstorage.FieldStorageParser instead.", - category=DeprecationWarning, stacklevel=1) + warnings.warn( + "Call to deprecated FieldStorage parsing method." + "Use fieldstorage.FieldStorageParser instead.", + category=DeprecationWarning, + stacklevel=1, + ) form_parser = fieldstorage.FieldStorageParser( - req.input, req.headers, - keep_blank_values=keep_blank_values, - strict_parsing=strict_parsing, - encoding=encoding, - errors=errors, - max_num_fields=max_num_fields, - separator=separator, - file_callback=file_callback) + req.input, + req.headers, + keep_blank_values=keep_blank_values, + strict_parsing=strict_parsing, + encoding=encoding, + errors=errors, + max_num_fields=max_num_fields, + separator=separator, + file_callback=file_callback, + ) return form_parser.parse() @@ -947,10 +1014,12 @@ class CachedInput: timeout : float how long to wait for new bytes in seconds """ - def __init__(self, file, size, block_size=32768, - timeout: Optional[float] = 10.): + + def __init__( + self, file, size, block_size=32768, timeout: Optional[float] = 10.0 + ): self.__file = file - self.__buffer = b'' + self.__buffer = b"" self.__todo = size self.__timeout = timeout self.block_size = block_size @@ -971,7 +1040,7 @@ def read(self, size=-1): size = size - b_size self.__todo -= size retval = self.__buffer + self.__file.read(size) - self.__buffer = b'' + self.__buffer = b"" return retval size = min(self.__todo, size) @@ -988,15 +1057,15 @@ def readline(self, size=-1): # noqa: C901 self.__todo -= size self.__buffer = self.__file.read(size) - line = b'' + line = b"" l_size = 0 if self.__timeout is not None: times_out_at = time() + self.__timeout seen_data = False while l_size < size: - max_size = size-l_size - pos = self.__buffer.find(b'\r\n', 0, max_size) + max_size = size - l_size + pos = self.__buffer.find(b"\r\n", 0, max_size) if pos >= 0: line += self.__buffer[:pos + 2] self.__buffer = self.__buffer[pos + 2:] diff --git a/poorwsgi/wsgi.py b/poorwsgi/wsgi.py index 2fb6f85..605a579 100644 --- a/poorwsgi/wsgi.py +++ b/poorwsgi/wsgi.py @@ -399,7 +399,7 @@ def secret_key(self): return self.__config['secret_key'] @secret_key.setter - def secret_key(self, value: str): + def secret_key(self, value: Union[str, bytes]): self.__config['secret_key'] = value @property diff --git a/setup.py b/setup.py index fb32f3a..ba06ee3 100644 --- a/setup.py +++ b/setup.py @@ -20,11 +20,11 @@ def find_data_files(directory, target_folder=""): for root, _, files in walk(directory): if target_folder: retval.append((target_folder, - list(root + '/' + f for f in files + list(path.join(root, f) for f in files if f[0] != '.' and f[-1] != '~'))) else: retval.append((root, - list(root + '/' + f for f in files + list(path.join(root, f) for f in files if f[0] != '.' and f[-1] != '~'))) return retval @@ -179,7 +179,7 @@ def doc(): 'doc/ChangeLog', 'doc/licence.txt', 'README.rst', 'CONTRIBUTION.rst' ])] + find_data_files("examples", "share/poorwsgi/examples"), license="BSD", - license_files='doc/licence.txt', + license_files=['doc/licence.txt'], long_description=doc(), long_description_content_type="text/x-rst", keywords='web wsgi development',