├── .coveragerc ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ └── ci-tests.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGES.txt ├── HISTORY.txt ├── MANIFEST.in ├── README.rst ├── RELEASING.rst ├── contributing.md ├── docs ├── Makefile ├── _static │ └── .empty ├── api │ ├── client.txt │ ├── cookies.txt │ ├── dec.txt │ ├── exceptions.txt │ ├── multidict.txt │ ├── request.txt │ ├── response.txt │ ├── static.txt │ └── webob.txt ├── changes.txt ├── comment-example-code │ └── example.py ├── comment-example.txt ├── conf.py ├── differences.txt ├── do-it-yourself.txt ├── doctests.py ├── experimental │ └── samesite.txt ├── file-example-code │ └── test-file.txt ├── file-example.txt ├── index.txt ├── jsonrpc-example-code │ ├── jsonrpc.py │ ├── test_jsonrpc.py │ └── test_jsonrpc.txt ├── jsonrpc-example.txt ├── license.txt ├── reference.txt ├── whatsnew-1.5.txt ├── whatsnew-1.6.txt ├── whatsnew-1.7.txt ├── whatsnew-1.8.txt ├── wiki-example-code │ └── example.py └── wiki-example.txt ├── pyproject.toml ├── setup.cfg ├── setup.py ├── src └── webob │ ├── __init__.py │ ├── acceptparse.py │ ├── byterange.py │ ├── cachecontrol.py │ ├── client.py │ ├── compat.py │ ├── cookies.py │ ├── datetime_utils.py │ ├── dec.py │ ├── descriptors.py │ ├── etag.py │ ├── exc.py │ ├── headers.py │ ├── multidict.py │ ├── request.py │ ├── response.py │ ├── static.py │ └── util.py ├── tests ├── conftest.py ├── performance_test.py ├── test_acceptparse.py ├── test_byterange.py ├── test_cachecontrol.py ├── test_client.py ├── test_client_functional.py ├── test_compat.py ├── test_cookies.py ├── test_cookies_bw.py ├── test_datetime_utils.py ├── test_dec.py ├── test_descriptors.py ├── test_etag.py ├── test_etag_nose.py ├── test_exc.py ├── test_headers.py ├── test_in_wsgiref.py ├── test_misc.py ├── test_multidict.py ├── test_request.py ├── test_response.py ├── test_static.py ├── test_transcode.py └── test_util.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | parallel = true 3 | source = 4 | webob 5 | 6 | [paths] 7 | source = 8 | src/webob 9 | */site-packages/webob 10 | 11 | [report] 12 | show_missing = true 13 | precision = 2 14 | 15 | [html] 16 | show_contexts = True 17 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | # Recommended flake8 settings while editing WebOb, we use Black for the final linting/say in how code is formatted 2 | # 3 | # pip install flake8 flake8-bugbear 4 | # 5 | # This will warn/error on things that black does not fix, on purpose. 6 | # 7 | # Run: 8 | # 9 | # tox -e run-flake8 10 | # 11 | # To have it automatically create and install the appropriate tools, and run 12 | # flake8 across the source code/tests 13 | 14 | [flake8] 15 | # max line length is set to 88 in black, here it is set to 80 and we enable bugbear's B950 warning, which is: 16 | # 17 | # B950: Line too long. This is a pragmatic equivalent of pycodestyle’s E501: it 18 | # considers “max-line-length” but only triggers when the value has been 19 | # exceeded by more than 10%. You will no longer be forced to reformat code due 20 | # to the closing parenthesis being one character too far to satisfy the linter. 21 | # At the same time, if you do significantly violate the line length, you will 22 | # receive a message that states what the actual limit is. This is inspired by 23 | # Raymond Hettinger’s “Beyond PEP 8” talk and highway patrol not stopping you 24 | # if you drive < 5mph too fast. Disable E501 to avoid duplicate warnings. 25 | max-line-length = 80 26 | max-complexity = 12 27 | select = E,F,W,C,B,B9 28 | ignore = 29 | # E123 closing bracket does not match indentation of opening bracket’s line 30 | E123 31 | # E203 whitespace before ‘:’ (Not PEP8 compliant, Python Black) 32 | E203 33 | # E501 line too long (82 > 79 characters) (replaced by B950 from flake8-bugbear, https://github.com/PyCQA/flake8-bugbear) 34 | E501 35 | # W503 line break before binary operator (Not PEP8 compliant, Python Black) 36 | W503 37 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every weekday 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci-tests.yml: -------------------------------------------------------------------------------- 1 | name: Build and test 2 | 3 | on: 4 | # Only on pushes to main or one of the release branches we build on push 5 | push: 6 | branches: 7 | - main 8 | - "[0-9].[0-9]+-branch" 9 | tags: 10 | - "*" 11 | # Build pull requests 12 | pull_request: 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | py: 19 | - "3.9" 20 | - "3.10" 21 | - "3.11" 22 | - "3.12" 23 | - "3.13" 24 | - "pypy-3.9" 25 | - "pypy-3.10" 26 | # Pre-release 27 | os: 28 | - "ubuntu-22.04" 29 | - "windows-latest" 30 | - "macos-14" # arm64 31 | - "macos-13" # x64 32 | architecture: 33 | - x64 34 | - x86 35 | - arm64 36 | include: 37 | - py: "pypy-3.9" 38 | toxenv: "pypy39" 39 | - py: "pypy-3.10" 40 | toxenv: "pypy310" 41 | exclude: 42 | # Ubuntu does not have x86/arm64 Python 43 | - os: "ubuntu-22.04" 44 | architecture: x86 45 | - os: "ubuntu-22.04" 46 | architecture: arm64 47 | # MacOS we need to make sure to remove x86 on all 48 | # We need to run no arm64 on macos-13 (Intel), but some 49 | # Python versions: 3.9/3.10 50 | # 51 | # From 3.11 onward, there is support for running x64 and 52 | # arm64 on Apple Silicon based systems (macos-14) 53 | - os: "macos-13" 54 | architecture: x86 55 | - os: "macos-13" 56 | architecture: arm64 57 | - os: "macos-14" 58 | architecture: x86 59 | - os: "macos-14" 60 | architecture: x64 61 | py: "3.9" 62 | - os: "macos-14" 63 | architecture: x64 64 | py: "3.10" 65 | # Windows does not have arm64 releases 66 | - os: "windows-latest" 67 | architecture: arm64 68 | # Don't run all PyPy versions except latest on 69 | # Windows/macOS. They are expensive to run. 70 | - os: "windows-latest" 71 | py: "pypy-3.9" 72 | - os: "macos-13" 73 | py: "pypy-3.9" 74 | - os: "macos-14" 75 | py: "pypy-3.9" 76 | 77 | name: "Python: ${{ matrix.py }}-${{ matrix.architecture }} on ${{ matrix.os }}" 78 | runs-on: ${{ matrix.os }} 79 | steps: 80 | - uses: actions/checkout@v4 81 | - name: Setup python 82 | uses: actions/setup-python@v5 83 | with: 84 | python-version: ${{ matrix.py }} 85 | architecture: ${{ matrix.architecture }} 86 | - run: pip install tox 87 | - name: Running tox with specific toxenv 88 | if: ${{ matrix.toxenv != '' }} 89 | env: 90 | TOXENV: ${{ matrix.toxenv }} 91 | run: tox 92 | - name: Running tox for current python version 93 | if: ${{ matrix.toxenv == '' }} 94 | run: tox -e py 95 | 96 | coverage: 97 | runs-on: ubuntu-22.04 98 | name: Validate coverage 99 | steps: 100 | - uses: actions/checkout@v4 101 | - name: Setup python 102 | uses: actions/setup-python@v5 103 | with: 104 | python-version: "3.13" 105 | architecture: x64 106 | 107 | - run: pip install tox 108 | - run: tox -e py313,coverage 109 | docs: 110 | runs-on: ubuntu-22.04 111 | name: Build the documentation 112 | steps: 113 | - uses: actions/checkout@v4 114 | - name: Setup python 115 | uses: actions/setup-python@v5 116 | with: 117 | python-version: "3.13" 118 | architecture: x64 119 | - run: pip install tox 120 | - run: tox -e docs 121 | lint: 122 | runs-on: ubuntu-22.04 123 | name: Lint the package 124 | steps: 125 | - uses: actions/checkout@v4 126 | - name: Setup python 127 | uses: actions/setup-python@v5 128 | with: 129 | python-version: "3.13" 130 | architecture: x64 131 | - run: pip install tox 132 | - run: tox -e lint 133 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *$py.class 2 | *.egg 3 | *.pyc 4 | *.pyo 5 | *.swp 6 | *~ 7 | .*.swp 8 | .tox/ 9 | __pycache__/ 10 | _build/ 11 | build/ 12 | dist/ 13 | env*/ 14 | .coverage 15 | .coverage.* 16 | .cache/ 17 | WebOb.egg-info/ 18 | pytest*.xml 19 | coverage*.xml 20 | .pytest_cache/ 21 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # https://docs.readthedocs.io/en/stable/config-file/v2.html 2 | version: 2 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: '3.12' 7 | sphinx: 8 | configuration: docs/conf.py 9 | python: 10 | install: 11 | - method: pip 12 | path: . 13 | extra_requirements: 14 | - docs 15 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | Unreleased 2 | ---------- 3 | 4 | Security Fix 5 | ~~~~~~~~~~~~ 6 | 7 | - The use of WebOb's Response object to redirect a request to a new location 8 | can lead to an open redirect if the Location header is not a full URI. 9 | 10 | See https://github.com/Pylons/webob/security/advisories/GHSA-mg3v-6m49-jhp3 11 | and CVE-2024-42353 12 | 13 | Thanks to Sara Gao for the report 14 | 15 | (This fix was released in WebOb 1.8.8) 16 | 17 | Feature 18 | ~~~~~~~ 19 | 20 | - Rename "master" git branch to "main" 21 | 22 | - Add support for Python 3.12. 23 | 24 | - Add Request.remote_host, exposing REMOTE_HOST environment variable. 25 | 26 | - Added ``acceptparse.Accept.parse_offer`` to codify what types of offers 27 | are compatible with ``acceptparse.AcceptValidHeader.acceptable_offers``, 28 | ``acceptparse.AcceptMissingHeader.acceptable_offers``, and 29 | ``acceptparse.AcceptInvalidHeader.acceptable_offers``. This API also 30 | normalizes the offer with lowercased type/subtype and parameter names. 31 | See https://github.com/Pylons/webob/pull/376 and 32 | https://github.com/Pylons/webob/pull/379 33 | 34 | Compatibility 35 | ~~~~~~~~~~~~~ 36 | 37 | 38 | Backwards Incompatibilities 39 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | - Drop support for Python 2.7, 3.4, 3.5, 3.6, and 3.7. 42 | 43 | Experimental Features 44 | ~~~~~~~~~~~~~~~~~~~~~ 45 | 46 | - The SameSite value now includes a new option named "None", this is a new 47 | change that was introduced in 48 | https://tools.ietf.org/html/draft-west-cookie-incrementalism-00 49 | 50 | Please be aware that older clients are incompatible with this change: 51 | https://www.chromium.org/updates/same-site/incompatible-clients, WebOb does 52 | not enable SameSite on cookies by default, so there is no backwards 53 | incompatible change here. 54 | 55 | See https://github.com/Pylons/webob/issues/406 56 | 57 | - Validation of SameSite values can be disabled by toggling a module flag. This 58 | is in anticipation of future changes in evolving cookie standards. 59 | The discussion in https://github.com/Pylons/webob/pull/407 (which initially 60 | expanded the allowed options) notes the sudden change to browser cookie 61 | implementation details may happen again. 62 | 63 | In May 2019, Google announced a new model for privacy controls in their 64 | browsers, which affected the list of valid options for the SameSite attribute 65 | of cookies. In late 2019, the company began to roll out these changes to their 66 | browsers to force developer adoption of the new specification. 67 | See https://www.chromium.org/updates/same-site and 68 | https://blog.chromium.org/2019/10/developers-get-ready-for-new.html for more 69 | details on this change. 70 | 71 | See https://github.com/Pylons/webob/pull/409 72 | 73 | 74 | Bugfix 75 | ~~~~~~ 76 | 77 | - Response.content_type now accepts unicode strings on Python 2 and encodes 78 | them to latin-1. See https://github.com/Pylons/webob/pull/389 and 79 | https://github.com/Pylons/webob/issues/388 80 | 81 | - Accept header classes now support a .copy() function that may be used to 82 | create a copy. This allows ``create_accept_header`` and other like functions 83 | to accept an pre-existing Accept header. See 84 | https://github.com/Pylons/webob/pull/386 and 85 | https://github.com/Pylons/webob/issues/385 86 | 87 | - SameSite may now be passed as str or bytes to `Response.set_cookie` and 88 | `cookies.make_cookie`. This was an oversight as all other arguments would be 89 | correctly coerced before being serialized. See 90 | https://github.com/Pylons/webob/issues/361 and 91 | https://github.com/Pylons/webob/pull/362 92 | 93 | - acceptparse.MIMEAccept which is deprecated in WebOb 1.8.0 made a backwards 94 | incompatible change that led to it raising on an invalid Accept header. This 95 | behaviour has now been reversed, as well as some other fixes to allow 96 | MIMEAccept to behave more like the old version. See 97 | https://github.com/Pylons/webob/pull/356 98 | 99 | - ``acceptparse.AcceptValidHeader``, ``acceptparse.AcceptInvalidHeader``, and 100 | ``acceptparse.AcceptNoHeader`` will now always ignore offers that do not 101 | match the required media type grammar when calling ``.acceptable_offers()``. 102 | Previous versions raised a ``ValueError`` for invalid offers in 103 | ``AcceptValidHeader`` and returned them as acceptable in the others. 104 | See https://github.com/Pylons/webob/pull/372 105 | 106 | - ``Response.body_file.write`` and ``Response.write`` now returns the written 107 | length. See https://github.com/Pylons/webob/pull/422 108 | 109 | Warnings 110 | ~~~~~~~~ 111 | 112 | - Some backslashes introduced with the new accept handling code were causing 113 | DeprecationWarnings upon compiling the source to pyc files, all of the 114 | backslashes have been reigned in as appropriate, and users should no longer 115 | see DeprecationWarnings for invalid escape sequence. See 116 | https://github.com/Pylons/webob/issues/384 117 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | graft docs 3 | prune docs/_build 4 | graft tests 5 | 6 | include README.rst 7 | include CHANGES.txt HISTORY.txt 8 | include contributing.md RELEASING.rst 9 | include pyproject.toml 10 | include .coveragerc .flake8 tox.ini 11 | include .readthedocs.yaml 12 | 13 | global-exclude __pycache__ *.py[cod] 14 | global-exclude .DS_Store 15 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | WebOb 2 | ===== 3 | 4 | .. image:: https://github.com/Pylons/webob/workflows/Build%20and%20test/badge.svg?branch=main 5 | :target: https://github.com/Pylons/webob/actions?query=workflow%3A%22Build+and+test%22 6 | :alt: main CI Status 7 | 8 | .. image:: https://readthedocs.org/projects/webob/badge/?version=stable 9 | :target: https://docs.pylonsproject.org/projects/webob/en/stable/ 10 | :alt: Documentation Status 11 | 12 | WebOb provides objects for HTTP requests and responses. Specifically 13 | it does this by wrapping the `WSGI `_ request 14 | environment and response status/headers/app_iter(body). 15 | 16 | The request and response objects provide many conveniences for parsing 17 | HTTP request and forming HTTP responses. Both objects are read/write: 18 | as a result, WebOb is also a nice way to create HTTP requests and 19 | parse HTTP responses. 20 | 21 | Support and Documentation 22 | ------------------------- 23 | 24 | See the `WebOb Documentation website `_ to view 25 | documentation, report bugs, and obtain support. 26 | 27 | License 28 | ------- 29 | 30 | WebOb is offered under the `MIT-license 31 | `_. 32 | 33 | Authors 34 | ------- 35 | 36 | WebOb was authored by Ian Bicking and is currently maintained by the `Pylons 37 | Project `_ and a team of contributors. 38 | -------------------------------------------------------------------------------- /RELEASING.rst: -------------------------------------------------------------------------------- 1 | Releasing WebOb 2 | =============== 3 | 4 | - For clarity, we define releases as follows. 5 | 6 | - Alpha, beta, dev and similar statuses do not qualify whether a release is 7 | major or minor. The term "pre-release" means alpha, beta, or dev. 8 | 9 | - A release is final when it is no longer pre-release. 10 | 11 | - A *major* release is where the first number either before or after the 12 | first dot increases. Examples: 1.6.0 to 1.7.0a1, or 1.8.0 to 2.0.0. 13 | 14 | - A *minor* or *bug fix* release is where the number after the second dot 15 | increases. Example: 1.6.0 to 1.6.1. 16 | 17 | 18 | Releasing 19 | --------- 20 | 21 | - First install the required pre-requisites:: 22 | 23 | $ pip install setuptools_git twine 24 | 25 | - Edit ``CHANGES.txt`` to add a release number and data and then modify 26 | ``setup.py`` to update the version number as well. 27 | 28 | - Run ``python setup.py sdist bdist_wheel``, then verify ``dist/*`` hasn't 29 | increased dramatically compared to previous versions (for example, 30 | accidentally including a large file in the release or pyc files). 31 | 32 | - Upload the resulting package to PyPi: ``twine upload 33 | dist/WebOb-*{whl,tar.gz}`` 34 | 35 | Marketing and communications 36 | ---------------------------- 37 | 38 | - Announce to Twitter:: 39 | 40 | WebOb 1.x released. 41 | 42 | PyPI 43 | https://pypi.python.org/pypi/webob/1.x.y 44 | 45 | Changes 46 | http://docs.webob.org/en/1.x-branch/ 47 | 48 | Issues 49 | https://github.com/Pylons/webob/issues 50 | 51 | - Announce to maillist:: 52 | 53 | WebOb 1.x.y has been released. 54 | 55 | Here are the changes: 56 | 57 | <> 58 | 59 | You can install it via PyPI: 60 | 61 | pip install webob==1.x.y 62 | 63 | Enjoy, and please report any issues you find to the issue tracker at 64 | https://github.com/Pylons/webob/issues 65 | 66 | Thanks! 67 | 68 | - WebOb developers 69 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | All projects under the Pylons Projects, including this one, follow the 5 | guidelines established at [How to 6 | Contribute](http://www.pylonsproject.org/community/how-to-contribute) and 7 | [Coding Style and 8 | Standards](http://docs.pylonsproject.org/en/latest/community/codestyle.html). 9 | 10 | You can contribute to this project in several ways. 11 | 12 | * [File an Issue on GitHub](https://github.com/Pylons/webob/issues) 13 | * Fork this project and create a branch with your suggested change. When ready, 14 | submit a pull request for consideration. [GitHub 15 | Flow](https://guides.github.com/introduction/flow/index.html) describes the 16 | workflow process and why it's a good practice. 17 | * Join the IRC channel #pyramid on irc.freenode.net. 18 | 19 | 20 | Git Branches 21 | ------------ 22 | Git branches and their purpose and status at the time of this writing are 23 | listed below. 24 | 25 | * [main](https://github.com/Pylons/webob/) - The branch on which further 26 | development takes place. The default branch on GitHub. 27 | * [1.6-branch](https://github.com/Pylons/webob/tree/1.6-branch) - The branch 28 | classified as "stable" or "latest". Actively maintained. 29 | * [1.5-branch](https://github.com/Pylons/webob/tree/1.5-branch) - The oldest 30 | actively maintained and stable branch. 31 | 32 | Older branches are not actively maintained. In general, two stable branches and 33 | one or two development branches are actively maintained. 34 | 35 | 36 | Running Tests 37 | ------------- 38 | 39 | Run `tox` from within your checkout. This will run the tests across all 40 | supported systems and attempt to build the docs. 41 | 42 | For example, to run the test suite with your current Python runtime: 43 | 44 | ```shell 45 | tox -e py 46 | ``` 47 | 48 | If you wish to run multiple targets, you can do so by separating them with a 49 | comma: 50 | 51 | ```shell 52 | tox -e py312,coverage 53 | ``` 54 | 55 | If you've already run the coverage target and want to see the results in HTML, 56 | you can override `tox.ini` settings with the `commands` option: 57 | 58 | ```shell 59 | tox -e coverage -x testenv:coverage.commands="coverage html" 60 | ``` 61 | 62 | To build the docs: 63 | 64 | ```shell 65 | tox -e docs 66 | ``` 67 | 68 | List all possible targets: 69 | 70 | ```shell 71 | tox list # or `tox l` 72 | ``` 73 | 74 | See `tox.ini` file for details, or for general `tox` usage. 75 | 76 | 77 | Building documentation for a Pylons Project project 78 | --------------------------------------------------- 79 | 80 | *Note:* These instructions might not work for Windows users. Suggestions to 81 | improve the process for Windows users are welcome by submitting an issue or a 82 | pull request. 83 | 84 | 1. Fork the repo on GitHub by clicking the [Fork] button. 85 | 2. Clone your fork into a workspace on your local machine. 86 | 87 | git clone git@github.com:/webob.git 88 | 89 | 3. Add a git remote "upstream" for the cloned fork. 90 | 91 | git remote add upstream git@github.com:Pylons/webob.git 92 | 93 | 4. Set an environment variable to your virtual environment. 94 | 95 | # Mac and Linux 96 | $ export VENV=~/hack-on-webob/env 97 | 98 | # Windows 99 | set VENV=c:\hack-on-webob\env 100 | 101 | 5. Try to build the docs in your workspace. 102 | 103 | # Mac and Linux 104 | $ make clean html SPHINXBUILD=$VENV/bin/sphinx-build 105 | 106 | # Windows 107 | c:\> make clean html SPHINXBUILD=%VENV%\bin\sphinx-build 108 | 109 | If successful, then you can make changes to the documentation. You can 110 | load the built documentation in the `/_build/html/` directory in a web 111 | browser. 112 | 113 | 6. From this point forward, follow the typical [git 114 | workflow](https://help.github.com/articles/what-is-a-good-git-workflow/). 115 | Start by pulling from the upstream to get the most current changes. 116 | 117 | git pull upstream main 118 | 119 | 7. Make a branch, make changes to the docs, and rebuild them as indicated in 120 | step 5. To speed up the build process, you can omit `clean` from the above 121 | command to rebuild only those pages that depend on the files you have 122 | changed. 123 | 124 | 8. Once you are satisfied with your changes and the documentation builds 125 | successfully without errors or warnings, then git commit and push them to 126 | your "origin" repository on GitHub. 127 | 128 | git commit -m "commit message" 129 | git push -u origin --all # first time only, subsequent can be just 'git push'. 130 | 131 | 9. Create a [pull request](https://help.github.com/articles/using-pull-requests/). 132 | 133 | 10. Repeat the process starting from Step 6. -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/WebOb.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/WebOb.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/WebOb" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/WebOb" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/_static/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Pylons/webob/39d5af3c797e7b867f152c2e8c979de42d029403/docs/_static/.empty -------------------------------------------------------------------------------- /docs/api/client.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.client` -- Send WSGI requests over HTTP 2 | =================================================== 3 | 4 | .. automodule:: webob.client 5 | 6 | Client 7 | ------ 8 | 9 | .. autoclass:: SendRequest 10 | :members: 11 | 12 | .. autoclass:: send_request_app 13 | -------------------------------------------------------------------------------- /docs/api/cookies.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.cookies` -- Cookies 2 | =============================== 3 | 4 | Cookies 5 | ------- 6 | 7 | .. autoclass:: webob.cookies.CookieProfile 8 | :members: 9 | .. autoclass:: webob.cookies.SignedCookieProfile 10 | :members: 11 | .. autoclass:: webob.cookies.SignedSerializer 12 | :members: 13 | .. autoclass:: webob.cookies.JSONSerializer 14 | :members: 15 | .. autofunction:: webob.cookies.make_cookie 16 | 17 | -------------------------------------------------------------------------------- /docs/api/dec.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.dec` -- WSGIfy decorator 2 | ==================================== 3 | 4 | .. automodule:: webob.dec 5 | 6 | Decorator 7 | --------- 8 | 9 | .. autoclass:: wsgify 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/api/exceptions.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.exc` -- WebOb Exceptions 2 | ==================================== 3 | 4 | .. automodule:: webob.exc 5 | 6 | HTTP Exceptions 7 | --------------- 8 | 9 | .. autoexception:: HTTPException 10 | .. autoexception:: WSGIHTTPException 11 | .. autoexception:: HTTPError 12 | .. autoexception:: HTTPRedirection 13 | .. autoexception:: HTTPOk 14 | .. autoexception:: HTTPCreated 15 | .. autoexception:: HTTPAccepted 16 | .. autoexception:: HTTPNonAuthoritativeInformation 17 | .. autoexception:: HTTPNoContent 18 | .. autoexception:: HTTPResetContent 19 | .. autoexception:: HTTPPartialContent 20 | .. autoexception:: _HTTPMove 21 | .. autoexception:: HTTPMultipleChoices 22 | .. autoexception:: HTTPMovedPermanently 23 | .. autoexception:: HTTPFound 24 | .. autoexception:: HTTPSeeOther 25 | .. autoexception:: HTTPNotModified 26 | .. autoexception:: HTTPUseProxy 27 | .. autoexception:: HTTPTemporaryRedirect 28 | .. autoexception:: HTTPClientError 29 | .. autoexception:: HTTPBadRequest 30 | .. autoexception:: HTTPUnauthorized 31 | .. autoexception:: HTTPPaymentRequired 32 | .. autoexception:: HTTPForbidden 33 | .. autoexception:: HTTPNotFound 34 | .. autoexception:: HTTPMethodNotAllowed 35 | .. autoexception:: HTTPNotAcceptable 36 | .. autoexception:: HTTPProxyAuthenticationRequired 37 | .. autoexception:: HTTPRequestTimeout 38 | .. autoexception:: HTTPConflict 39 | .. autoexception:: HTTPGone 40 | .. autoexception:: HTTPLengthRequired 41 | .. autoexception:: HTTPPreconditionFailed 42 | .. autoexception:: HTTPRequestEntityTooLarge 43 | .. autoexception:: HTTPRequestURITooLong 44 | .. autoexception:: HTTPUnsupportedMediaType 45 | .. autoexception:: HTTPRequestRangeNotSatisfiable 46 | .. autoexception:: HTTPExpectationFailed 47 | .. autoexception:: HTTPUnprocessableEntity 48 | .. autoexception:: HTTPLocked 49 | .. autoexception:: HTTPFailedDependency 50 | .. autoexception:: HTTPPreconditionRequired 51 | .. autoexception:: HTTPTooManyRequests 52 | .. autoexception:: HTTPRequestHeaderFieldsTooLarge 53 | .. autoexception:: HTTPUnavailableForLegalReasons 54 | .. autoexception:: HTTPServerError 55 | .. autoexception:: HTTPInternalServerError 56 | .. autoexception:: HTTPNotImplemented 57 | .. autoexception:: HTTPBadGateway 58 | .. autoexception:: HTTPServiceUnavailable 59 | .. autoexception:: HTTPGatewayTimeout 60 | .. autoexception:: HTTPVersionNotSupported 61 | .. autoexception:: HTTPInsufficientStorage 62 | .. autoexception:: HTTPNetworkAuthenticationRequired 63 | .. autoexception:: HTTPExceptionMiddleware 64 | 65 | -------------------------------------------------------------------------------- /docs/api/multidict.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.multidict` -- multi-value dictionary object 2 | ======================================================= 3 | 4 | multidict 5 | --------- 6 | 7 | Several parts of WebOb use a "multidict", which is a dictionary where a key can 8 | have multiple values. The quintessential example is a query string like 9 | ``?pref=red&pref=blue``. The ``pref`` variable has two values, ``red`` and 10 | ``blue``. 11 | 12 | In a multidict, when you do ``request.GET['pref']``, you'll get back only 13 | ``'blue'`` (the last value of ``pref``). Sometimes returning a string and 14 | other times returning a list is a cause of frequent exceptions. If you want 15 | *all* the values back, use ``request.GET.getall('pref')``. If you want to be 16 | sure there is *one and only one* value, use ``request.GET.getone('pref')``, 17 | which will raise an exception if there is zero or more than one value for 18 | ``pref``. 19 | 20 | When you use operations like ``request.GET.items()``, you'll get back something 21 | like ``[('pref', 'red'), ('pref', 'blue')]``. All the key/value pairs will 22 | show up. Similarly ``request.GET.keys()`` returns ``['pref', 'pref']``. 23 | Multidict is a view on a list of tuples; all the keys are ordered, and all the 24 | values are ordered. 25 | 26 | .. automodule:: webob.multidict 27 | .. autoclass:: MultiDict 28 | :members: 29 | :inherited-members: 30 | .. autoclass:: NestedMultiDict 31 | :members: 32 | .. autoclass:: NoVars 33 | :members: 34 | 35 | -------------------------------------------------------------------------------- /docs/api/request.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.request` -- Request 2 | =============================== 3 | 4 | Request 5 | ------- 6 | 7 | .. automodule:: webob.request 8 | .. autoclass:: webob.request.Request 9 | .. autoclass:: webob.request.BaseRequest 10 | :members: 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/api/response.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.response` -- Response 2 | ================================= 3 | 4 | Response 5 | -------- 6 | 7 | .. automodule:: webob.response 8 | .. autoclass:: webob.response.Response 9 | :members: 10 | .. autoclass:: webob.response.ResponseBodyFile 11 | :members: 12 | .. autoclass:: webob.response.AppIterRange 13 | :members: 14 | -------------------------------------------------------------------------------- /docs/api/static.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob.static` -- Serving static files 2 | =========================================== 3 | 4 | .. automodule:: webob.static 5 | 6 | .. autoclass:: webob.static.FileApp 7 | :members: 8 | 9 | .. autoclass:: webob.static.DirectoryApp 10 | :members: 11 | 12 | 13 | -------------------------------------------------------------------------------- /docs/api/webob.txt: -------------------------------------------------------------------------------- 1 | :mod:`webob` -- Request/Response objects 2 | ======================================== 3 | 4 | Headers 5 | ------- 6 | 7 | .. _acceptheader: 8 | 9 | Accept* 10 | ~~~~~~~ 11 | 12 | .. automodule:: webob.acceptparse 13 | 14 | 15 | Convenience functions to automatically create the appropriate header objects of 16 | a certain type: 17 | 18 | .. autofunction:: create_accept_header 19 | .. autofunction:: create_accept_charset_header 20 | .. autofunction:: create_accept_encoding_header 21 | .. autofunction:: create_accept_language_header 22 | 23 | The classes that may be returned by one of the functions above, and their 24 | methods: 25 | 26 | .. autoclass:: Accept 27 | :members: parse 28 | 29 | .. autoclass:: AcceptOffer 30 | :members: __str__ 31 | 32 | .. autoclass:: AcceptValidHeader 33 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 34 | __contains__, __iter__, __radd__, __repr__, __str__, 35 | accept_html, accepts_html, acceptable_offers, best_match, quality 36 | 37 | .. autoclass:: AcceptNoHeader 38 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 39 | __contains__, __iter__, __radd__, __repr__, __str__, 40 | accept_html, accepts_html, acceptable_offers, best_match, quality 41 | 42 | .. autoclass:: AcceptInvalidHeader 43 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 44 | __contains__, __iter__, __radd__, __repr__, __str__, 45 | accept_html, accepts_html, acceptable_offers, best_match, quality 46 | 47 | .. autoclass:: AcceptCharset 48 | :members: parse 49 | 50 | .. autoclass:: AcceptCharsetValidHeader 51 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 52 | __contains__, __iter__, __radd__, __repr__, __str__, 53 | acceptable_offers, best_match, quality 54 | 55 | .. autoclass:: AcceptCharsetNoHeader 56 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 57 | __contains__, __iter__, __radd__, __repr__, __str__, 58 | acceptable_offers, best_match, quality 59 | 60 | .. autoclass:: AcceptCharsetInvalidHeader 61 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 62 | __contains__, __iter__, __radd__, __repr__, __str__, 63 | acceptable_offers, best_match, quality 64 | 65 | .. autoclass:: AcceptEncoding 66 | :members: parse 67 | 68 | .. autoclass:: AcceptEncodingValidHeader 69 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 70 | __contains__, __iter__, __radd__, __repr__, __str__, 71 | acceptable_offers, best_match, quality 72 | 73 | .. autoclass:: AcceptEncodingNoHeader 74 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 75 | __contains__, __iter__, __radd__, __repr__, __str__, 76 | acceptable_offers, best_match, quality 77 | 78 | .. autoclass:: AcceptEncodingInvalidHeader 79 | :members: parse, header_value, parsed, __init__, __add__, __bool__, 80 | __contains__, __iter__, __radd__, __repr__, __str__, 81 | acceptable_offers, best_match, quality 82 | 83 | .. autoclass:: AcceptLanguage 84 | :members: parse 85 | 86 | .. autoclass:: AcceptLanguageValidHeader 87 | :members: header_value, parsed, __init__, __add__, __contains__, __iter__, 88 | __radd__, __str__, parse, basic_filtering, best_match, lookup, 89 | quality 90 | 91 | .. autoclass:: AcceptLanguageNoHeader 92 | :members: header_value, parsed, __init__, __add__, __contains__, __iter__, 93 | __radd__, __str__, parse, basic_filtering, best_match, lookup, 94 | quality 95 | 96 | .. autoclass:: AcceptLanguageInvalidHeader 97 | :members: header_value, parsed, __init__, __add__, __contains__, __iter__, 98 | __radd__, __str__, parse, basic_filtering, best_match, lookup, 99 | quality 100 | 101 | Deprecated: 102 | 103 | .. autoclass:: MIMEAccept 104 | 105 | 106 | Cache-Control 107 | ~~~~~~~~~~~~~ 108 | .. autoclass:: webob.cachecontrol.CacheControl 109 | :members: 110 | 111 | Range and related headers 112 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 113 | .. autoclass:: webob.byterange.Range 114 | :members: 115 | .. autoclass:: webob.byterange.ContentRange 116 | :members: 117 | .. autoclass:: webob.etag.IfRange 118 | :members: 119 | 120 | ETag 121 | ~~~~ 122 | .. autoclass:: webob.etag.ETagMatcher 123 | :members: 124 | 125 | Misc Functions and Internals 126 | ---------------------------- 127 | 128 | .. autofunction:: webob.html_escape 129 | 130 | .. comment: 131 | not sure what to do with these constants; not autoclass 132 | .. autoclass:: webob.day 133 | .. autoclass:: webob.week 134 | .. autoclass:: webob.hour 135 | .. autoclass:: webob.minute 136 | .. autoclass:: webob.second 137 | .. autoclass:: webob.month 138 | .. autoclass:: webob.year 139 | 140 | .. autoclass:: webob.headers.ResponseHeaders 141 | :members: 142 | .. autoclass:: webob.headers.EnvironHeaders 143 | :members: 144 | 145 | .. autoclass:: webob.cachecontrol.UpdateDict 146 | :members: 147 | 148 | 149 | .. comment: 150 | Descriptors 151 | ----------- 152 | 153 | .. autoclass:: webob.descriptors.environ_getter 154 | .. autoclass:: webob.descriptors.header_getter 155 | .. autoclass:: webob.descriptors.converter 156 | .. autoclass:: webob.descriptors.deprecated_property 157 | -------------------------------------------------------------------------------- /docs/changes.txt: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | WebOb Change History 4 | ==================== 5 | 6 | .. include:: ../CHANGES.txt 7 | 8 | .. include:: ../HISTORY.txt 9 | -------------------------------------------------------------------------------- /docs/comment-example-code/example.py: -------------------------------------------------------------------------------- 1 | import os 2 | import urllib 3 | import time 4 | import re 5 | from cPickle import load, dump 6 | from webob import Request, Response, html_escape 7 | from webob import exc 8 | 9 | 10 | class Commenter(object): 11 | def __init__(self, app, storage_dir): 12 | self.app = app 13 | self.storage_dir = storage_dir 14 | if not os.path.exists(storage_dir): 15 | os.makedirs(storage_dir) 16 | 17 | def __call__(self, environ, start_response): 18 | req = Request(environ) 19 | if req.path_info_peek() == ".comments": 20 | return self.process_comment(req)(environ, start_response) 21 | # This is the base path of *this* middleware: 22 | base_url = req.application_url 23 | resp = req.get_response(self.app) 24 | if resp.content_type != "text/html" or resp.status_code != 200: 25 | # Not an HTML response, we don't want to 26 | # do anything to it 27 | return resp(environ, start_response) 28 | # Make sure the content isn't gzipped: 29 | resp.decode_content() 30 | comments = self.get_data(req.url) 31 | body = resp.body 32 | body = self.add_to_end(body, self.format_comments(comments)) 33 | body = self.add_to_end(body, self.submit_form(base_url, req)) 34 | resp.body = body 35 | return resp(environ, start_response) 36 | 37 | def get_data(self, url): 38 | # Double-quoting makes the filename safe 39 | filename = self.url_filename(url) 40 | if not os.path.exists(filename): 41 | return [] 42 | else: 43 | f = open(filename, "rb") 44 | data = load(f) 45 | f.close() 46 | return data 47 | 48 | def save_data(self, url, data): 49 | filename = self.url_filename(url) 50 | f = open(filename, "wb") 51 | dump(data, f) 52 | f.close() 53 | 54 | def url_filename(self, url): 55 | return os.path.join(self.storage_dir, urllib.quote(url, "")) 56 | 57 | _end_body_re = re.compile(r"", re.I | re.S) 58 | 59 | def add_to_end(self, html, extra_html): 60 | """ 61 | Adds extra_html to the end of the html page (before ) 62 | """ 63 | match = self._end_body_re.search(html) 64 | if not match: 65 | return html + extra_html 66 | else: 67 | return html[: match.start()] + extra_html + html[match.start() :] 68 | 69 | def format_comments(self, comments): 70 | if not comments: 71 | return "" 72 | text = [] 73 | text.append("
") 74 | text.append( 75 | '

Comments (%s):

' % len(comments) 76 | ) 77 | for comment in comments: 78 | text.append( 79 | '

%s at %s:

' 80 | % ( 81 | html_escape(comment["homepage"]), 82 | html_escape(comment["name"]), 83 | time.strftime("%c", comment["time"]), 84 | ) 85 | ) 86 | # Susceptible to XSS attacks!: 87 | text.append(comment["comments"]) 88 | return "".join(text) 89 | 90 | def submit_form(self, base_path, req): 91 | return """

Leave a comment:

92 |
93 | 94 | 95 | 96 | 97 | 98 | 99 |
Name:
URL:
100 | Comments:
101 |
102 | 103 |
104 | """ % ( 105 | base_path, 106 | html_escape(req.url), 107 | ) 108 | 109 | def process_comment(self, req): 110 | try: 111 | url = req.params["url"] 112 | name = req.params["name"] 113 | homepage = req.params["homepage"] 114 | comments = req.params["comments"] 115 | except KeyError, e: 116 | resp = exc.HTTPBadRequest("Missing parameter: %s" % e) 117 | return resp 118 | data = self.get_data(url) 119 | data.append( 120 | dict(name=name, homepage=homepage, comments=comments, time=time.gmtime()) 121 | ) 122 | self.save_data(url, data) 123 | resp = exc.HTTPSeeOther(location=url + "#comment-area") 124 | return resp 125 | 126 | 127 | if __name__ == "__main__": 128 | import optparse 129 | 130 | parser = optparse.OptionParser(usage="%prog --port=PORT BASE_DIRECTORY") 131 | parser.add_option( 132 | "-p", 133 | "--port", 134 | default="8080", 135 | dest="port", 136 | type="int", 137 | help="Port to serve on (default 8080)", 138 | ) 139 | parser.add_option( 140 | "--comment-data", 141 | default="./comments", 142 | dest="comment_data", 143 | help="Place to put comment data into (default ./comments/)", 144 | ) 145 | options, args = parser.parse_args() 146 | if not args: 147 | parser.error("You must give a BASE_DIRECTORY") 148 | base_dir = args[0] 149 | from paste.urlparser import StaticURLParser 150 | 151 | app = StaticURLParser(base_dir) 152 | app = Commenter(app, options.comment_data) 153 | from wsgiref.simple_server import make_server 154 | 155 | httpd = make_server("localhost", options.port, app) 156 | print("Serving on http://localhost:%s" % options.port) 157 | try: 158 | httpd.serve_forever() 159 | except KeyboardInterrupt: 160 | print("^C") 161 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import pkg_resources 2 | import sys 3 | import os 4 | import shlex 5 | 6 | extensions = [ 7 | "sphinx.ext.autodoc", 8 | "sphinx.ext.intersphinx", 9 | ] 10 | 11 | # Add any paths that contain templates here, relative to this directory. 12 | templates_path = ["_templates"] 13 | 14 | # The suffix(es) of source filenames. 15 | # You can specify multiple suffix as a list of string: 16 | # source_suffix = ['.rst', '.md'] 17 | source_suffix = [".txt", ".rst"] 18 | 19 | # The encoding of source files. 20 | # source_encoding = 'utf-8-sig' 21 | 22 | # The main toctree document. 23 | master_doc = "index" 24 | 25 | # General information about the project. 26 | project = "WebOb" 27 | copyright = "2018, Ian Bicking, Pylons Project and contributors" 28 | author = "Ian Bicking, Pylons Project, and contributors" 29 | 30 | version = release = pkg_resources.get_distribution("webob").version 31 | 32 | # The language for content autogenerated by Sphinx. Refer to documentation 33 | # for a list of supported languages. 34 | # 35 | # This is also used if you do content translation via gettext catalogs. 36 | # Usually you set "language" from the command line for these cases. 37 | language = "en" 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | exclude_patterns = ["_build", "jsonrpc-example-code/*", "file-example-code/*"] 42 | 43 | # The name of the Pygments (syntax highlighting) style to use. 44 | pygments_style = "sphinx" 45 | 46 | # If true, `todo` and `todoList` produce output, else they produce nothing. 47 | todo_include_todos = False 48 | 49 | modindex_common_prefix = ["webob."] 50 | 51 | autodoc_member_order = "bysource" 52 | 53 | # -- Options for HTML output --------------------------------------------- 54 | 55 | html_theme = "alabaster" 56 | 57 | html_static_path = ["_static"] 58 | 59 | htmlhelp_basename = "WebObdoc" 60 | 61 | smartquotes = False 62 | 63 | # -- Options for LaTeX output --------------------------------------------- 64 | 65 | latex_elements = { 66 | # The paper size ('letterpaper' or 'a4paper'). 67 | #'papersize': 'letterpaper', 68 | # The font size ('10pt', '11pt' or '12pt'). 69 | #'pointsize': '10pt', 70 | # Additional stuff for the LaTeX preamble. 71 | #'preamble': '', 72 | # Latex figure (float) alignment 73 | #'figure_align': 'htbp', 74 | } 75 | 76 | # Grouping the document tree into LaTeX files. List of tuples 77 | # (source start file, target name, title, 78 | # author, documentclass [howto, manual, or own class]). 79 | latex_documents = [ 80 | ( 81 | master_doc, 82 | "WebOb.tex", 83 | "WebOb Documentation", 84 | "Ian Bicking and contributors", 85 | "manual", 86 | ), 87 | ] 88 | 89 | # The name of an image file (relative to this directory) to place at the top of 90 | # the title page. 91 | # latex_logo = None 92 | 93 | # For "manual" documents, if this is true, then toplevel headings are parts, 94 | # not chapters. 95 | # latex_use_parts = False 96 | 97 | # If true, show page references after internal links. 98 | # latex_show_pagerefs = False 99 | 100 | # If true, show URL addresses after external links. 101 | # latex_show_urls = False 102 | 103 | # Documents to append as an appendix to all manuals. 104 | # latex_appendices = [] 105 | 106 | # If false, no module index is generated. 107 | # latex_domain_indices = True 108 | 109 | 110 | # -- Options for manual page output --------------------------------------- 111 | 112 | # One entry per manual page. List of tuples 113 | # (source start file, name, description, authors, manual section). 114 | man_pages = [(master_doc, "webob", "WebOb Documentation", [author], 1)] 115 | 116 | # If true, show URL addresses after external links. 117 | # man_show_urls = False 118 | 119 | 120 | # -- Options for Texinfo output ------------------------------------------- 121 | 122 | # Grouping the document tree into Texinfo files. List of tuples 123 | # (source start file, target name, title, author, 124 | # dir menu entry, description, category) 125 | texinfo_documents = [ 126 | ( 127 | master_doc, 128 | "WebOb", 129 | "WebOb Documentation", 130 | author, 131 | "WebOb", 132 | "One line description of project.", 133 | "Miscellaneous", 134 | ), 135 | ] 136 | 137 | # Documents to append as an appendix to all manuals. 138 | # texinfo_appendices = [] 139 | 140 | # If false, no module index is generated. 141 | # texinfo_domain_indices = True 142 | 143 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 144 | # texinfo_show_urls = 'footnote' 145 | 146 | # If true, do not generate a @detailmenu in the "Top" node's menu. 147 | # texinfo_no_detailmenu = False 148 | 149 | 150 | # -- Options for Epub output ---------------------------------------------- 151 | 152 | # Bibliographic Dublin Core info. 153 | epub_title = project 154 | epub_author = author 155 | epub_publisher = author 156 | epub_copyright = copyright 157 | 158 | epub_exclude_files = ["search.html"] 159 | 160 | # Example configuration for intersphinx: refer to the Python standard library. 161 | intersphinx_mapping = { 162 | "python": ("https://docs.python.org/3", None), 163 | } 164 | -------------------------------------------------------------------------------- /docs/doctests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import doctest 3 | 4 | 5 | def test_suite(): 6 | flags = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE 7 | return unittest.TestSuite( 8 | ( 9 | doctest.DocFileSuite("test_request.txt", optionflags=flags), 10 | doctest.DocFileSuite("test_response.txt", optionflags=flags), 11 | doctest.DocFileSuite("test_dec.txt", optionflags=flags), 12 | doctest.DocFileSuite("do-it-yourself.txt", optionflags=flags), 13 | doctest.DocFileSuite("file-example.txt", optionflags=flags), 14 | doctest.DocFileSuite("index.txt", optionflags=flags), 15 | doctest.DocFileSuite("reference.txt", optionflags=flags), 16 | ) 17 | ) 18 | 19 | 20 | if __name__ == "__main__": 21 | unittest.main(defaultTest="test_suite") 22 | -------------------------------------------------------------------------------- /docs/experimental/samesite.txt: -------------------------------------------------------------------------------- 1 | .. _samesiteexp: 2 | 3 | Same-site Cookies 4 | ================= 5 | 6 | The `Same-site cookie RFC 7 | `_ updates 8 | `RFC6265 `_ to include a new cookie 9 | attribute named ``SameSite``. 10 | 11 | WebOb provides support for setting the ``SameSite`` attribute in its cookie 12 | APIs, using the ``samesite`` keyword argument. 13 | 14 | In `Incrementally Better Cookies 15 | `_ the 16 | standard was altered to add an additional option for the ``SameSite`` 17 | attribute. This new option has `known incompatible clients 18 | `_, please be 19 | aware that WebOb does not attempt to sniff the user agent to know if setting 20 | the ``SameSite`` attribute to ``None`` will cause compatibility issues. 21 | 22 | Please refer to the API documentation for :func:`webob.cookies.make_cookie` 23 | and :class:`webob.cookies.CookieProfile` for the keyword arguments. 24 | -------------------------------------------------------------------------------- /docs/file-example-code/test-file.txt: -------------------------------------------------------------------------------- 1 | This is a test. Hello test people! -------------------------------------------------------------------------------- /docs/file-example.txt: -------------------------------------------------------------------------------- 1 | WebOb File-Serving Example 2 | ========================== 3 | 4 | This document shows how you can make a static-file-serving application 5 | using WebOb. We'll quickly build this up from minimal functionality 6 | to a high-quality file serving application. 7 | 8 | .. note:: Starting from 1.2b4, WebOb ships with a :mod:`webob.static` module 9 | which implements a :class:`webob.static.FileApp` WSGI application similar to the 10 | one described below. 11 | 12 | This document stays as a didactic example how to serve files with WebOb, but 13 | you should consider using applications from :mod:`webob.static` in 14 | production. 15 | 16 | .. comment: 17 | 18 | >>> import webob, os 19 | >>> base_dir = os.path.dirname(os.path.dirname(webob.__file__)) 20 | >>> doc_dir = os.path.join(base_dir, 'docs') 21 | >>> from doctest import ELLIPSIS 22 | 23 | First we'll setup a really simple shim around our application, which 24 | we can use as we improve our application: 25 | 26 | .. code-block:: python 27 | 28 | >>> from webob import Request, Response 29 | >>> import os 30 | >>> class FileApp(object): 31 | ... def __init__(self, filename): 32 | ... self.filename = filename 33 | ... def __call__(self, environ, start_response): 34 | ... res = make_response(self.filename) 35 | ... return res(environ, start_response) 36 | >>> import mimetypes 37 | >>> def get_mimetype(filename): 38 | ... type, encoding = mimetypes.guess_type(filename) 39 | ... # We'll ignore encoding, even though we shouldn't really 40 | ... return type or 'application/octet-stream' 41 | 42 | Now we can make different definitions of ``make_response``. The 43 | simplest version: 44 | 45 | .. code-block:: python 46 | 47 | >>> def make_response(filename): 48 | ... res = Response(content_type=get_mimetype(filename)) 49 | ... res.body = open(filename, 'rb').read() 50 | ... return res 51 | 52 | We'll test it out with a file ``test-file.txt`` in the WebOb doc directory, 53 | which has the following content: 54 | 55 | .. literalinclude:: file-example-code/test-file.txt 56 | :language: text 57 | 58 | Let's give it a shot: 59 | 60 | .. code-block:: python 61 | 62 | >>> fn = os.path.join(doc_dir, 'file-example-code/test-file.txt') 63 | >>> open(fn).read() 64 | 'This is a test. Hello test people!' 65 | >>> app = FileApp(fn) 66 | >>> req = Request.blank('/') 67 | >>> print req.get_response(app) 68 | 200 OK 69 | Content-Type: text/plain; charset=UTF-8 70 | Content-Length: 35 71 | 72 | This is a test. Hello test people! 73 | 74 | Well, that worked. But it's not a very fancy object. First, it reads 75 | everything into memory, and that's bad. We'll create an iterator instead: 76 | 77 | .. code-block:: python 78 | 79 | >>> class FileIterable(object): 80 | ... def __init__(self, filename): 81 | ... self.filename = filename 82 | ... def __iter__(self): 83 | ... return FileIterator(self.filename) 84 | >>> class FileIterator(object): 85 | ... chunk_size = 4096 86 | ... def __init__(self, filename): 87 | ... self.filename = filename 88 | ... self.fileobj = open(self.filename, 'rb') 89 | ... def __iter__(self): 90 | ... return self 91 | ... def next(self): 92 | ... chunk = self.fileobj.read(self.chunk_size) 93 | ... if not chunk: 94 | ... raise StopIteration 95 | ... return chunk 96 | ... __next__ = next # py3 compat 97 | >>> def make_response(filename): 98 | ... res = Response(content_type=get_mimetype(filename)) 99 | ... res.app_iter = FileIterable(filename) 100 | ... res.content_length = os.path.getsize(filename) 101 | ... return res 102 | 103 | And testing: 104 | 105 | .. code-block:: python 106 | 107 | >>> req = Request.blank('/') 108 | >>> print req.get_response(app) 109 | 200 OK 110 | Content-Type: text/plain; charset=UTF-8 111 | Content-Length: 35 112 | 113 | This is a test. Hello test people! 114 | 115 | Well, that doesn't *look* different, but lets *imagine* that it's 116 | different because we know we changed some code. Now to add some basic 117 | metadata to the response: 118 | 119 | .. code-block:: python 120 | 121 | >>> def make_response(filename): 122 | ... res = Response(content_type=get_mimetype(filename), 123 | ... conditional_response=True) 124 | ... res.app_iter = FileIterable(filename) 125 | ... res.content_length = os.path.getsize(filename) 126 | ... res.last_modified = os.path.getmtime(filename) 127 | ... res.etag = '%s-%s-%s' % (os.path.getmtime(filename), 128 | ... os.path.getsize(filename), hash(filename)) 129 | ... return res 130 | 131 | Now, with ``conditional_response`` on, and with ``last_modified`` and 132 | ``etag`` set, we can do conditional requests: 133 | 134 | .. code-block:: python 135 | 136 | >>> req = Request.blank('/') 137 | >>> res = req.get_response(app) 138 | >>> print res 139 | 200 OK 140 | Content-Type: text/plain; charset=UTF-8 141 | Content-Length: 35 142 | Last-Modified: ... GMT 143 | ETag: ...-... 144 | 145 | This is a test. Hello test people! 146 | >>> req2 = Request.blank('/') 147 | >>> req2.if_none_match = res.etag 148 | >>> req2.get_response(app) 149 | 150 | >>> req3 = Request.blank('/') 151 | >>> req3.if_modified_since = res.last_modified 152 | >>> req3.get_response(app) 153 | 154 | 155 | We can even do Range requests, but it will currently involve iterating 156 | through the file unnecessarily. When there's a range request (and you 157 | set ``conditional_response=True``) the application will satisfy that 158 | request. But with an arbitrary iterator the only way to do that is to 159 | run through the beginning of the iterator until you get to the chunk 160 | that the client asked for. We can do better because we can use 161 | ``fileobj.seek(pos)`` to move around the file much more efficiently. 162 | 163 | So we'll add an extra method, ``app_iter_range``, that ``Response`` 164 | looks for: 165 | 166 | .. code-block:: python 167 | 168 | >>> class FileIterable(object): 169 | ... def __init__(self, filename, start=None, stop=None): 170 | ... self.filename = filename 171 | ... self.start = start 172 | ... self.stop = stop 173 | ... def __iter__(self): 174 | ... return FileIterator(self.filename, self.start, self.stop) 175 | ... def app_iter_range(self, start, stop): 176 | ... return self.__class__(self.filename, start, stop) 177 | >>> class FileIterator(object): 178 | ... chunk_size = 4096 179 | ... def __init__(self, filename, start, stop): 180 | ... self.filename = filename 181 | ... self.fileobj = open(self.filename, 'rb') 182 | ... if start: 183 | ... self.fileobj.seek(start) 184 | ... if stop is not None: 185 | ... self.length = stop - start 186 | ... else: 187 | ... self.length = None 188 | ... def __iter__(self): 189 | ... return self 190 | ... def next(self): 191 | ... if self.length is not None and self.length <= 0: 192 | ... raise StopIteration 193 | ... chunk = self.fileobj.read(self.chunk_size) 194 | ... if not chunk: 195 | ... raise StopIteration 196 | ... if self.length is not None: 197 | ... self.length -= len(chunk) 198 | ... if self.length < 0: 199 | ... # Chop off the extra: 200 | ... chunk = chunk[:self.length] 201 | ... return chunk 202 | ... __next__ = next # py3 compat 203 | 204 | Now we'll test it out: 205 | 206 | .. code-block:: python 207 | 208 | >>> req = Request.blank('/') 209 | >>> res = req.get_response(app) 210 | >>> req2 = Request.blank('/') 211 | >>> # Re-fetch the first 5 bytes: 212 | >>> req2.range = (0, 5) 213 | >>> res2 = req2.get_response(app) 214 | >>> res2 215 | 216 | >>> # Let's check it's our custom class: 217 | >>> res2.app_iter 218 | 219 | >>> res2.body 220 | 'This ' 221 | >>> # Now, conditional range support: 222 | >>> req3 = Request.blank('/') 223 | >>> req3.if_range = res.etag 224 | >>> req3.range = (0, 5) 225 | >>> req3.get_response(app) 226 | 227 | >>> req3.if_range = 'invalid-etag' 228 | >>> req3.get_response(app) 229 | 230 | -------------------------------------------------------------------------------- /docs/jsonrpc-example-code/jsonrpc.py: -------------------------------------------------------------------------------- 1 | # A reaction to: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/552751 2 | from webob import Request, Response 3 | from webob import exc 4 | from simplejson import loads, dumps 5 | import traceback 6 | import sys 7 | 8 | 9 | class JsonRpcApp(object): 10 | """ 11 | Serve the given object via json-rpc (http://json-rpc.org/) 12 | """ 13 | 14 | def __init__(self, obj): 15 | self.obj = obj 16 | 17 | def __call__(self, environ, start_response): 18 | req = Request(environ) 19 | try: 20 | resp = self.process(req) 21 | except ValueError, e: 22 | resp = exc.HTTPBadRequest(str(e)) 23 | except exc.HTTPException, e: 24 | resp = e 25 | return resp(environ, start_response) 26 | 27 | def process(self, req): 28 | if not req.method == "POST": 29 | raise exc.HTTPMethodNotAllowed("Only POST allowed", allowed="POST") 30 | try: 31 | json = loads(req.body) 32 | except ValueError, e: 33 | raise ValueError("Bad JSON: %s" % e) 34 | try: 35 | method = json["method"] 36 | params = json["params"] 37 | id = json["id"] 38 | except KeyError, e: 39 | raise ValueError("JSON body missing parameter: %s" % e) 40 | if method.startswith("_"): 41 | raise exc.HTTPForbidden( 42 | "Bad method name %s: must not start with _" % method 43 | ) 44 | if not isinstance(params, list): 45 | raise ValueError("Bad params %r: must be a list" % params) 46 | try: 47 | method = getattr(self.obj, method) 48 | except AttributeError: 49 | raise ValueError("No such method %s" % method) 50 | try: 51 | result = method(*params) 52 | except: 53 | text = traceback.format_exc() 54 | exc_value = sys.exc_info()[1] 55 | error_value = dict( 56 | name="JSONRPCError", code=100, message=str(exc_value), error=text 57 | ) 58 | return Response( 59 | status=500, 60 | content_type="application/json", 61 | body=dumps(dict(result=None, error=error_value, id=id)), 62 | ) 63 | return Response( 64 | content_type="application/json", 65 | body=dumps(dict(result=result, error=None, id=id)), 66 | ) 67 | 68 | 69 | class ServerProxy(object): 70 | """ 71 | JSON proxy to a remote service. 72 | """ 73 | 74 | def __init__(self, url, proxy=None): 75 | self._url = url 76 | if proxy is None: 77 | from wsgiproxy.exactproxy import proxy_exact_request 78 | 79 | proxy = proxy_exact_request 80 | self.proxy = proxy 81 | 82 | def __getattr__(self, name): 83 | if name.startswith("_"): 84 | raise AttributeError(name) 85 | return _Method(self, name) 86 | 87 | def __repr__(self): 88 | return "<%s for %s>" % (self.__class__.__name__, self._url) 89 | 90 | 91 | class _Method(object): 92 | def __init__(self, parent, name): 93 | self.parent = parent 94 | self.name = name 95 | 96 | def __call__(self, *args): 97 | json = dict(method=self.name, id=None, params=list(args)) 98 | req = Request.blank(self.parent._url) 99 | req.method = "POST" 100 | req.content_type = "application/json" 101 | req.body = dumps(json) 102 | resp = req.get_response(self.parent.proxy) 103 | if resp.status_code != 200 and not ( 104 | resp.status_code == 500 and resp.content_type == "application/json" 105 | ): 106 | raise ProxyError( 107 | "Error from JSON-RPC client %s: %s" % (self.parent._url, resp.status), 108 | resp, 109 | ) 110 | json = loads(resp.body) 111 | if json.get("error") is not None: 112 | e = Fault( 113 | json["error"].get("message"), 114 | json["error"].get("code"), 115 | json["error"].get("error"), 116 | resp, 117 | ) 118 | raise e 119 | return json["result"] 120 | 121 | 122 | class ProxyError(Exception): 123 | """ 124 | Raised when a request via ServerProxy breaks 125 | """ 126 | 127 | def __init__(self, message, response): 128 | Exception.__init__(self, message) 129 | self.response = response 130 | 131 | 132 | class Fault(Exception): 133 | """ 134 | Raised when there is a remote error 135 | """ 136 | 137 | def __init__(self, message, code, error, response): 138 | Exception.__init__(self, message) 139 | self.code = code 140 | self.error = error 141 | self.response = response 142 | 143 | def __str__(self): 144 | return "Method error calling %s: %s\n%s" % ( 145 | self.response.request.url, 146 | self.args[0], 147 | self.error, 148 | ) 149 | 150 | 151 | class DemoObject(object): 152 | """ 153 | Something interesting to attach to 154 | """ 155 | 156 | def add(self, *args): 157 | return sum(args) 158 | 159 | def average(self, *args): 160 | return sum(args) / float(len(args)) 161 | 162 | def divide(self, a, b): 163 | return a / b 164 | 165 | 166 | def make_app(expr): 167 | module, expression = expr.split(":", 1) 168 | __import__(module) 169 | module = sys.modules[module] 170 | obj = eval(expression, module.__dict__) 171 | return JsonRpcApp(obj) 172 | 173 | 174 | def main(args=None): 175 | import optparse 176 | from wsgiref import simple_server 177 | 178 | parser = optparse.OptionParser(usage="%prog [OPTIONS] MODULE:EXPRESSION") 179 | parser.add_option( 180 | "-p", "--port", default="8080", help="Port to serve on (default 8080)" 181 | ) 182 | parser.add_option( 183 | "-H", 184 | "--host", 185 | default="127.0.0.1", 186 | help="Host to serve on (default localhost; 0.0.0.0 to make public)", 187 | ) 188 | options, args = parser.parse_args() 189 | if not args or len(args) > 1: 190 | print("You must give a single object reference") 191 | parser.print_help() 192 | sys.exit(2) 193 | app = make_app(args[0]) 194 | server = simple_server.make_server(options.host, int(options.port), app) 195 | print("Serving on http://%s:%s" % (options.host, options.port)) 196 | server.serve_forever() 197 | # Try python jsonrpc.py 'jsonrpc:DemoObject()' 198 | 199 | 200 | if __name__ == "__main__": 201 | main() 202 | -------------------------------------------------------------------------------- /docs/jsonrpc-example-code/test_jsonrpc.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | import doctest 3 | 4 | doctest.testfile("test_jsonrpc.txt") 5 | -------------------------------------------------------------------------------- /docs/jsonrpc-example-code/test_jsonrpc.txt: -------------------------------------------------------------------------------- 1 | This is a test of the ``jsonrpc.py`` module:: 2 | 3 | >>> class Divider(object): 4 | ... def divide(self, a, b): 5 | ... return a / b 6 | >>> from jsonrpc import * 7 | >>> app = JsonRpcApp(Divider()) 8 | >>> proxy = ServerProxy('http://localhost:8080', proxy=app) 9 | >>> proxy.divide(10, 4) 10 | 2 11 | >>> proxy.divide(10, 4.0) 12 | 2.5 13 | >>> proxy.divide(10, 0) # doctest: +ELLIPSIS 14 | Traceback (most recent call last): 15 | ... 16 | Fault: Method error calling http://localhost:8080: integer division or modulo by zero 17 | Traceback (most recent call last): 18 | File ... 19 | result = method(*params) 20 | File ... 21 | return a / b 22 | ZeroDivisionError: integer division or modulo by zero 23 | 24 | >>> proxy.add(1, 1) 25 | Traceback (most recent call last): 26 | ... 27 | ProxyError: Error from JSON-RPC client http://localhost:8080: 400 Bad Request 28 | -------------------------------------------------------------------------------- /docs/license.txt: -------------------------------------------------------------------------------- 1 | License 2 | ======= 3 | 4 | Copyright (c) 2007 Ian Bicking and Contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 21 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 22 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 23 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /docs/whatsnew-1.5.txt: -------------------------------------------------------------------------------- 1 | What's New in WebOb 1.5 2 | ======================= 3 | 4 | Backwards Incompatibilities 5 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 6 | 7 | - ``Response.set_cookie`` renamed the only required parameter from "key" to 8 | "name". The code will now still accept "key" as a keyword argument, and will 9 | issue a DeprecationWarning until WebOb 1.7. 10 | 11 | - The ``status`` attribute of a ``Response`` object no longer takes a string 12 | like ``None None`` and allows that to be set as the status. It now has to at 13 | least match the pattern of `` ``. Invalid status strings will now raise a ``ValueError``. 15 | 16 | - ``Morsel`` will no longer accept a cookie value that does not meet RFC6265's 17 | cookie-octet specification. Upon calling ``Morsel.serialize`` a warning will 18 | be issued, in the future this will raise a ``ValueError``, please update your 19 | cookie handling code. See https://github.com/Pylons/webob/pull/172 20 | 21 | The cookie-octet specification in RFC6265 states the following characters are 22 | valid in a cookie value: 23 | 24 | =============== ======================================= 25 | Hex Range Actual Characters 26 | =============== ======================================= 27 | ``[0x21 ]`` ``!`` 28 | ``[0x25-0x2B]`` ``#$%&'()*+`` 29 | ``[0x2D-0x3A]`` ``-./0123456789:`` 30 | ``[0x3C-0x5B]`` ``<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[`` 31 | ``[0x5D-0x7E]`` ``]^_`abcdefghijklmnopqrstuvwxyz{|}~`` 32 | =============== ======================================= 33 | 34 | RFC6265 suggests using base 64 to serialize data before storing data in a 35 | cookie. 36 | 37 | Cookies that meet the RFC6265 standard will no longer be quoted, as this is 38 | unnecessary. This is a no-op as far as browsers and cookie storage is 39 | concerned. 40 | 41 | - ``Response.set_cookie`` now uses the internal ``make_cookie`` API, which will 42 | issue warnings if cookies are set with invalid bytes. See 43 | https://github.com/Pylons/webob/pull/172 44 | 45 | Features 46 | ~~~~~~~~ 47 | 48 | - Add support for some new caching headers, stale-while-revalidate and 49 | stale-if-error that can be used by reverse proxies to cache stale responses 50 | temporarily if the backend disappears. From RFC5861. See 51 | https://github.com/Pylons/webob/pull/189 52 | 53 | Bug Fixes 54 | ~~~~~~~~~ 55 | 56 | - Response.status now uses duck-typing for integers, and has also learned to 57 | raise a ValueError if the status isn't an integer followed by a space, and 58 | then the reason. See https://github.com/Pylons/webob/pull/191 59 | 60 | - Fixed a bug in ``webob.multidict.GetDict`` which resulted in the 61 | QUERY_STRING not being updated when changes were made to query 62 | params using ``Request.GET.extend()``. 63 | 64 | - Read the body of a request if we think it might have a body. This fixes PATCH 65 | to support bodies. See https://github.com/Pylons/webob/pull/184 66 | 67 | - Response.from_file returns HTTP headers as latin1 rather than UTF-8, this 68 | fixes the usage on Google AppEngine. See 69 | https://github.com/Pylons/webob/issues/99 and 70 | https://github.com/Pylons/webob/pull/150 71 | 72 | - Fix a bug in parsing the auth parameters that contained bad white space. This 73 | makes the parsing fall in line with what's required in RFC7235. See 74 | https://github.com/Pylons/webob/issues/158 75 | 76 | - Use '\r\n' line endings in ``Response.__str__``. See: 77 | https://github.com/Pylons/webob/pull/146 78 | 79 | Documentation Changes 80 | ~~~~~~~~~~~~~~~~~~~~~ 81 | 82 | - ``response.set_cookie`` now has proper documentation for ``max_age`` and 83 | ``expires``. The code has also been refactored to use ``cookies.make_cookie`` 84 | instead of duplicating the code. This fixes 85 | https://github.com/Pylons/webob/issues/166 and 86 | https://github.com/Pylons/webob/issues/171 87 | 88 | - Documentation didn't match the actual code for the wsgify function signature. 89 | See https://github.com/Pylons/webob/pull/167 90 | 91 | - Remove the WebDAV only from certain HTTP Exceptions, these exceptions may 92 | also be used by REST services for example. 93 | -------------------------------------------------------------------------------- /docs/whatsnew-1.6.txt: -------------------------------------------------------------------------------- 1 | What's New in WebOb 1.6 2 | ======================= 3 | 4 | Compatibility 5 | ~~~~~~~~~~~~~ 6 | 7 | - Python 3.2 is no longer a supported platform by WebOb 8 | 9 | Security 10 | ~~~~~~~~ 11 | 12 | - exc._HTTPMove and any subclasses will now raise a ValueError if the location 13 | field contains a line feed or carriage return. These values may lead to 14 | possible HTTP Response Splitting. The header_getter descriptor has also been 15 | modified to no longer accept headers with a line feed or carriage return. 16 | 17 | WebOb does not protect against all possible ways of injecting line feeds or 18 | carriage returns into headers, and should only be thought of as a single line 19 | of defense. Any user input should be sanitized. 20 | 21 | See https://github.com/Pylons/webob/pull/229 and 22 | https://github.com/Pylons/webob/issues/217 for more information. 23 | 24 | Features 25 | ~~~~~~~~ 26 | 27 | - When WebOb sends an HTTP Exception it will now lazily escape the keys in the 28 | environment, so that only those keys that are actually used in the HTTP 29 | exception are escaped. This solves the problem of keys that are not 30 | serializable as a string in the environment. See 31 | https://github.com/Pylons/webob/pull/139 for more information. 32 | 33 | - MIMEAccept now accepts comparisons against wildcards, this allows one to 34 | match on just the media type or sub-type. 35 | 36 | Example: 37 | 38 | .. code-block:: pycon 39 | 40 | >>> accept = MIMEAccept('text/html') 41 | >>> 'text/*' in accept 42 | True 43 | >>> '*/html' in accept 44 | True 45 | >>> '*' in accept 46 | True 47 | 48 | - WebOb uses the user agent's Accept header to change what type of information 49 | is returned to the client. This allows the HTTP Exception to return either 50 | HTML, text, or a JSON response. This allows WebOb HTTP Exceptions to be used 51 | in applications where the client is expecting a JSON response. See 52 | https://github.com/Pylons/webob/pull/230 and 53 | https://github.com/Pylons/webob/issues/209 for more information. 54 | 55 | Bugfixes 56 | ~~~~~~~~ 57 | 58 | - Response.from_file now parses the status line correctly when the status line 59 | contains an HTTP with version, as well as a status text that contains 60 | multiple white spaces (e.g HTTP/1.1 404 Not Found). See 61 | https://github.com/Pylons/webob/issues/250 62 | 63 | - Request.decode would attempt to read from an already consumed stream, it is 64 | now reading from the correct stream. See 65 | https://github.com/Pylons/webob/pull/183 for more information. 66 | 67 | - The ``application/json`` media type does not allow for a ``charset`` because 68 | discovery of the encoding is done at the JSON layer, and it must always be 69 | UTF-{8,16,32}. See the IANA specification at 70 | https://www.iana.org/assignments/media-types/application/json, which notes: 71 | 72 | No "charset" parameter is defined for this registration. 73 | Adding one really has no effect on compliant recipients. 74 | 75 | `IETF RFC 4627 `_ describes the method 76 | for encoding discovery using the JSON content itself. Upon initialization of 77 | a Response, WebOb will no longer add a ``charset`` if the content-type is set 78 | to JSON. See https://github.com/Pylons/webob/pull/197, 79 | https://github.com/Pylons/webob/issues/237, and 80 | https://github.com/Pylons/pyramid/issues/1611 81 | 82 | -------------------------------------------------------------------------------- /docs/whatsnew-1.7.txt: -------------------------------------------------------------------------------- 1 | What's New in WebOb 1.7 2 | ======================= 3 | 4 | Compatibility 5 | ~~~~~~~~~~~~~ 6 | 7 | - WebOb is no longer supported on Python 2.6 and PyPy3. PyPy3 support will be 8 | re-introduced as soon as it supports a Python version higher than 3.2 and pip 9 | fully supports the platform again. 10 | 11 | If you would like Python 2.6 support, please pin to WebOb 1.6, which still 12 | has Python 2.6 support. 13 | 14 | 15 | Backwards Incompatibility 16 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 17 | 18 | - :attr:`Response.content_type ` removes 19 | all existing Content-Type parameters, and if the new Content-Type is "texty" 20 | it adds a new charset (unless already provided) using the 21 | ``default_charset``, to emulate the old behaviour you may use the following: 22 | 23 | .. code-block:: python 24 | 25 | res = Response(content_type='text/html', charset='UTF-8') 26 | assert res.content_type == 'text/html' 27 | assert res.charset == 'UTF-8' 28 | 29 | params = res.content_type_params 30 | 31 | # Change the Content-Type 32 | res.content_type = 'application/unknown' 33 | assert res.content_type == 'application/unknown' 34 | assert res.charset == None 35 | 36 | # This will add the ``charset=UTF-8`` parameter to the Content-Type 37 | res.content_type_params = params 38 | 39 | assert res.headers['Content-Type'] == 'application/unknown; charset=UTF-8' 40 | 41 | See https://github.com/Pylons/webob/pull/301 for more information. 42 | 43 | - :class:`~webob.response.Response` no longer treats ``application/json`` as a 44 | special case that may also be treated as text. This means the following may 45 | no longer be used: 46 | 47 | .. code-block:: python 48 | 49 | res = Response(json.dumps({}), content_type='application/json') 50 | 51 | Since ``application/json`` does not have a ``charset``, this will now raise 52 | an error. 53 | 54 | Replacements are: 55 | 56 | .. code-block:: python 57 | 58 | res = Response(json_body={}) 59 | 60 | This will create a new :class:`~webob.response.Response` that automatically 61 | sets up the the Content-Type and converts the dictionary to a JSON object 62 | internally. 63 | 64 | If you want WebOb to the encoding but do the conversion to JSON yourself, the 65 | following would also work: 66 | 67 | .. code-block:: python 68 | 69 | res = Response(text=json.dumps({}), content_type='application/json') 70 | 71 | This uses :attr:`~webob.response.Response.default_body_encoding` to encode 72 | the text. 73 | 74 | - :func:`Response.set_cookie ` no longer 75 | accepts a key argument. This was deprecated in WebOb 1.5 and as mentioned in 76 | the deprecation, is being removed in 1.7 77 | 78 | Use: 79 | 80 | .. code-block:: python 81 | 82 | res = Response() 83 | res.set_cookie(name='cookie_name', value='val') 84 | 85 | # or 86 | 87 | res.set_cookie('cookie_name', 'val') 88 | 89 | Instead of: 90 | 91 | .. code-block:: python 92 | 93 | res = Response() 94 | res.set_cookie(key='cookie_name', value='val') 95 | 96 | - :func:`Response.__init__ ` will no longer 97 | set the default Content-Type, nor Content-Length on Responses that don't have 98 | a body. This allows WebOb to return proper responses for things like 99 | `Response(status='204 No Content')`. 100 | 101 | - :attr:`Response.text ` will no longer raise if 102 | the Content-Type does not have a charset, it will fall back to using the new 103 | default_body_encoding. To get the old behaviour back please sub-class 104 | Response and set default_body_encoding to None. See 105 | https://github.com/Pylons/webob/pull/287 106 | 107 | An example of a Response class that has the old behaviour: 108 | 109 | .. code-block:: python 110 | 111 | class MyResponse(Response): 112 | default_body_encoding = None 113 | 114 | res = MyResponse(content_type='application/json') 115 | # This will raise as application/json doesn't have a charset 116 | res.text = 'sometext' 117 | 118 | - WebOb no longer supports Chunked Encoding, this means that if you are using 119 | WebOb and need Chunked Encoding you will be required to have a proxy that 120 | unchunks the request for you. Please read 121 | https://github.com/Pylons/webob/issues/279 for more background. 122 | 123 | This changes the behaviour of ``request.is_body_readable``, it will no longer 124 | assume that a request has a body just because it is a particular HTTP verb. 125 | This change also allows any HTTP verb to be able to contain a body, which 126 | allows for example a HTTP body on DELETE or even GET. 127 | 128 | Feature 129 | ~~~~~~~ 130 | 131 | - :class:`~webob.response.Response` has a new ``default_body_encoding`` which 132 | may be used to allow getting/setting :attr:`Response.text 133 | ` when a Content-Type has no charset. See 134 | https://github.com/Pylons/webob/pull/287 135 | 136 | .. code-block:: python 137 | 138 | res = Response() 139 | res.default_body_encoding = 'latin1' 140 | res.text = 'Will be encoded as latin1 and .body will be set' 141 | 142 | res = Response() 143 | res.default_body_encoding = 'latin1' 144 | res.body = b'A valid latin-1 string' 145 | res.text == 'A valid latin-1 string' 146 | 147 | 148 | - :class:`~webob.request.Request` with any HTTP method is now allowed to have a 149 | body. This allows DELETE to have a request body for passing extra 150 | information. See https://github.com/Pylons/webob/pull/283 and 151 | https://github.com/Pylons/webob/pull/274 152 | 153 | - Add :func:`~webob.response.ResponseBodyFile.tell` to 154 | :class:`~webob.response.ResponseBodyFile` so that it may be used for example 155 | for zipfile support. See https://github.com/Pylons/webob/pull/117 156 | 157 | - Allow the return from :func:`wsgify.middleware ` to 158 | be used as a decorator. See https://github.com/Pylons/webob/pull/228 159 | 160 | .. code-block:: python 161 | 162 | @wsgify.middleware 163 | def restrict_ip(req, app, ips): 164 | if req.remote_addr not in ips: 165 | raise webob.exc.HTTPForbidden('Bad IP: %s' % req.remote_addr) 166 | return app 167 | 168 | @restrict_ip(ips=['127.0.0.1']) 169 | @wsgify 170 | def app(req): 171 | return 'hi' 172 | 173 | Bugfix 174 | ~~~~~~ 175 | 176 | - Fixup :class:`cgi.FieldStorage` on Python 3.x to work-around issue reported 177 | in Python bug report 27777 and 24764. This is currently applied for Python 178 | versions less than 3.7. See https://github.com/Pylons/webob/pull/294 179 | 180 | - :func:`Response.set_cookie ` now accepts 181 | :class:`~datetime.datetime` objects for the ``expires`` kwarg and will 182 | correctly convert them to UTC with no ``tzinfo`` for use in calculating the 183 | ``max_age``. See https://github.com/Pylons/webob/issues/254 and 184 | https://github.com/Pylons/webob/pull/292 185 | 186 | - Fixes :attr:`request.PATH_SAFE ` to contain all of 187 | the path safe characters according to RFC3986. See 188 | https://github.com/Pylons/webob/pull/291 189 | 190 | - WebOb's exceptions will lazily read underlying variables when inserted into 191 | templates to avoid expensive computations/crashes when inserting into the 192 | template. This had a bad performance regression on Py27 because of the way 193 | the lazified class was created and returned. See 194 | https://github.com/Pylons/webob/pull/284 195 | 196 | - :func:`wsgify.__call__ ` raised a ``TypeError`` 197 | with an unhelpful message, it will now return the `repr` for the wrapped 198 | function: https://github.com/Pylons/webob/issues/119 199 | 200 | - :attr:`Response.json `'s json.dumps/loads are 201 | now always UTF-8. It no longer tries to use the charset. 202 | 203 | - The :class:`~webob.response.Response` will by default no longer set the 204 | Content-Type to the default if a headerlist is provided. This fixes issues 205 | whereby `Request.get_response()` would return a Response that didn't match 206 | the actual response. See https://github.com/Pylons/webob/pull/261 and 207 | https://github.com/Pylons/webob/issues/205 208 | 209 | - Cleans up the remainder of the issues with the updated WebOb exceptions that 210 | were taught to return JSON in version 1.6. See 211 | https://github.com/Pylons/webob/issues/237 and 212 | https://github.com/Pylons/webob/issues/236 213 | 214 | - :func:`Response.from_file ` now parses the 215 | status line correctly when the status line contains an HTTP with version, as 216 | well as a status text that contains multiple white spaces (e.g HTTP/1.1 404 217 | Not Found). See https://github.com/Pylons/webob/issues/250 218 | 219 | - :class:`~webob.response.Response` now has a new property named 220 | :attr:`~webob.response.Response.has_body` that may be used to interrogate the 221 | Response to find out if the :attr:`~webob.response.Response.body` is or isn't 222 | set. 223 | 224 | This is used in the exception handling code so that if you use a WebOb HTTP 225 | Exception and pass a generator to ``app_iter`` WebOb won't attempt to read 226 | the whole thing and instead allows it to be returned to the WSGI server. See 227 | https://github.com/Pylons/webob/pull/259 228 | 229 | -------------------------------------------------------------------------------- /docs/whatsnew-1.8.txt: -------------------------------------------------------------------------------- 1 | What's New in WebOb 1.8 2 | ======================= 3 | 4 | Feature 5 | ~~~~~~~ 6 | 7 | - :attr:`Request.POST ` now supports any 8 | requests with the appropriate Content-Type. Allowing any HTTP method to 9 | access form encoded content, including DELETE, PUT, and others. See 10 | https://github.com/Pylons/webob/pull/352 11 | 12 | Compatibility 13 | ~~~~~~~~~~~~~ 14 | 15 | - WebOb is no longer officially supported on Python 3.3 which was EOL'ed on 16 | 2017-09-29. 17 | 18 | Please pin to `WebOb~=1.7` which was tested against Python 3.3, and upgrade 19 | your Python version. 20 | 21 | Backwards Incompatibilities 22 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 23 | 24 | - Many changes have been made to the way WebOb does Accept handling, not just 25 | for the ``Accept`` header itself, but also for ``Accept-Charset``, 26 | ``Accept-Encoding`` and ``Accept-Language``. This was a `Google Summer of 27 | Code `_ project completed by 28 | Whiteroses (https://github.com/whiteroses). Many thanks to Google for running 29 | GSoC, the Python Software Foundation for organising and a huge thanks to Ira 30 | for completing the work. See https://github.com/Pylons/webob/pull/338 and 31 | https://github.com/Pylons/webob/pull/335. 32 | 33 | If you were previously using the ``Accept`` class or the ``MIMEAccept`` class 34 | directly, please take a look at the documentation for 35 | :func:`~webob.acceptparse.create_accept_header`, 36 | :func:`~webob.acceptparse.create_accept_charset_header`, 37 | :func:`~webob.acceptparse.create_accept_encoding_header` and 38 | :func:`~webob.acceptparse.create_accept_language_header`. 39 | 40 | These functions will accept a header value and create the appropriate object. 41 | 42 | The :ref:`API documentation for Accept* ` provides more 43 | information on the available API. 44 | 45 | - When calling a ``@wsgify`` decorated function, the default arguments passed 46 | to ``@wsgify`` are now used when called with the request, and not as a 47 | `start_response` 48 | 49 | .. code:: 50 | 51 | def hello(req, name): 52 | return "Hello, %s!" % name 53 | app = wsgify(hello, args=("Fred",)) 54 | 55 | req = Request.blank('/') 56 | resp = req.get_response(app) # => "Hello, Fred" 57 | resp2 = app(req) # => "Hello, Fred" 58 | 59 | Previously the ``resp2`` line would have failed with a ``TypeError``. With 60 | this change there is no way to override the default arguments with no 61 | arguments. See https://github.com/Pylons/webob/pull/203 62 | 63 | - When setting ``app_iter`` on a ``Response`` object the ``content_md5`` header 64 | is no longer cleared. This behaviour is odd and disallows setting the 65 | ``content_md5`` and then returning an iterator for chunked content encoded 66 | responses. See https://github.com/Pylons/webob/issues/86 67 | 68 | Experimental Features 69 | ~~~~~~~~~~~~~~~~~~~~~ 70 | 71 | These features are experimental and may change at any point in the future. The 72 | main page provides a list of :ref:`experimental-api` supported by WebOb. 73 | 74 | - The cookie APIs now have the ability to set the SameSite attribute on a 75 | cookie in both :func:`webob.cookies.make_cookie` and 76 | :class:`webob.cookies.CookieProfile`. See 77 | https://github.com/Pylons/webob/pull/255 78 | 79 | 80 | Bugfix 81 | ~~~~~~ 82 | 83 | - :attr:`Request.host_url `, 84 | :attr:`Request.host_port `, 85 | :attr:`Request.domain ` correctly parse 86 | IPv6 Host headers as provided by a browser. See 87 | https://github.com/Pylons/webob/pull/332 88 | 89 | - :attr:`Request.authorization ` would 90 | raise :class:`ValueError` for unusual or malformed header 91 | values. Now it simply returns an empty value. See 92 | https://github.com/Pylons/webob/issues/231 93 | 94 | - Allow unnamed fields in form data to be properly transcoded when calling 95 | :func:`request.decode ` with an alternate 96 | encoding. See https://github.com/Pylons/webob/pull/309 97 | 98 | - :class:`Response.__init__ ` would discard 99 | ``app_iter`` when a ``Response`` had no body, this would cause issues when 100 | ``app_iter`` was an object that was tied to the life-cycle of a web 101 | application and had to be properly closed. ``app_iter`` is more advanced API 102 | for ``Response`` and thus even if it contains a body and is thus against the 103 | HTTP RFC's, we should let the users shoot themselves in the foot by returning 104 | a body. See https://github.com/Pylons/webob/issues/305 105 | 106 | -------------------------------------------------------------------------------- /docs/wiki-example-code/example.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from webob import Request, Response 4 | from webob import exc 5 | from tempita import HTMLTemplate 6 | 7 | VIEW_TEMPLATE = HTMLTemplate( 8 | """\ 9 | 10 | 11 | {{page.title}} 12 | 13 | 14 |

{{page.title}}

15 | {{if message}} 16 |
{{message}}
17 | {{endif}} 18 | 19 |
{{page.content|html}}
20 | 21 |
22 | Edit 23 | 24 | 25 | """ 26 | ) 27 | 28 | EDIT_TEMPLATE = HTMLTemplate( 29 | """\ 30 | 31 | 32 | Edit: {{page.title}} 33 | 34 | 35 | {{if page.exists}} 36 |

Edit: {{page.title}}

37 | {{else}} 38 |

Create: {{page.title}}

39 | {{endif}} 40 | 41 |
42 | 43 | Title:
44 | Content: 45 | Cancel 46 |
47 | 48 |
49 | 50 | Cancel 51 |
52 | 53 | """ 54 | ) 55 | 56 | 57 | class WikiApp: 58 | view_template = VIEW_TEMPLATE 59 | edit_template = EDIT_TEMPLATE 60 | 61 | def __init__(self, storage_dir): 62 | self.storage_dir = os.path.abspath(os.path.normpath(storage_dir)) 63 | 64 | def __call__(self, environ, start_response): 65 | req = Request(environ) 66 | action = req.params.get("action", "view") 67 | page = self.get_page(req.path_info) 68 | try: 69 | try: 70 | meth = getattr(self, "action_{}_{}".format(action, req.method)) 71 | except AttributeError: 72 | raise exc.HTTPBadRequest("No such action %r" % action) 73 | resp = meth(req, page) 74 | except exc.HTTPException as e: 75 | resp = e 76 | return resp(environ, start_response) 77 | 78 | def get_page(self, path): 79 | path = path.lstrip("/") 80 | if not path: 81 | path = "index" 82 | path = os.path.join(self.storage_dir, path) 83 | path = os.path.normpath(path) 84 | if path.endswith("/"): 85 | path += "index" 86 | if not path.startswith(self.storage_dir): 87 | raise exc.HTTPBadRequest("Bad path") 88 | path += ".html" 89 | return Page(path) 90 | 91 | def action_view_GET(self, req, page): 92 | if not page.exists: 93 | return exc.HTTPTemporaryRedirect(location=req.url + "?action=edit") 94 | if req.cookies.get("message"): 95 | message = req.cookies["message"] 96 | else: 97 | message = None 98 | text = self.view_template.substitute(page=page, req=req, message=message) 99 | resp = Response(text) 100 | if message: 101 | resp.delete_cookie("message") 102 | else: 103 | resp.last_modified = page.mtime 104 | resp.conditional_response = True 105 | return resp 106 | 107 | def action_view_POST(self, req, page): 108 | submit_mtime = int(req.params.get("mtime") or "0") or None 109 | if page.mtime != submit_mtime: 110 | return exc.HTTPPreconditionFailed( 111 | "The page has been updated since you started editing it" 112 | ) 113 | page.set(title=req.params["title"], content=req.params["content"]) 114 | resp = exc.HTTPSeeOther(location=req.path_url) 115 | resp.set_cookie("message", "Page updated") 116 | return resp 117 | 118 | def action_edit_GET(self, req, page): 119 | text = self.edit_template.substitute(page=page, req=req) 120 | return Response(text) 121 | 122 | 123 | class Page: 124 | def __init__(self, filename): 125 | self.filename = filename 126 | 127 | @property 128 | def exists(self): 129 | return os.path.exists(self.filename) 130 | 131 | @property 132 | def title(self): 133 | if not self.exists: 134 | # we need to guess the title 135 | basename = os.path.splitext(os.path.basename(self.filename))[0] 136 | basename = re.sub(r"[_-]", " ", basename) 137 | return basename.capitalize() 138 | content = self.full_content 139 | match = re.search(r"(.*?)", content, re.I | re.S) 140 | return match.group(1) 141 | 142 | @property 143 | def full_content(self): 144 | f = open(self.filename, "rb") 145 | try: 146 | return f.read() 147 | finally: 148 | f.close() 149 | 150 | @property 151 | def content(self): 152 | if not self.exists: 153 | return "" 154 | content = self.full_content 155 | match = re.search(r"]*>(.*?)", content, re.I | re.S) 156 | return match.group(1) 157 | 158 | @property 159 | def mtime(self): 160 | if not self.exists: 161 | return None 162 | else: 163 | return int(os.stat(self.filename).st_mtime) 164 | 165 | def set(self, title, content): 166 | dir = os.path.dirname(self.filename) 167 | if not os.path.exists(dir): 168 | os.makedirs(dir) 169 | new_content = ( 170 | """%s%s""" 171 | % (title, content) 172 | ) 173 | f = open(self.filename, "wb") 174 | f.write(new_content) 175 | f.close() 176 | 177 | 178 | if __name__ == "__main__": 179 | import optparse 180 | 181 | parser = optparse.OptionParser(usage="%prog --port=PORT") 182 | parser.add_option( 183 | "-p", 184 | "--port", 185 | default="8080", 186 | dest="port", 187 | type="int", 188 | help="Port to serve on (default 8080)", 189 | ) 190 | parser.add_option( 191 | "--wiki-data", 192 | default="./wiki", 193 | dest="wiki_data", 194 | help="Place to put wiki data into (default ./wiki/)", 195 | ) 196 | options, args = parser.parse_args() 197 | print("Writing wiki pages to %s" % options.wiki_data) 198 | app = WikiApp(options.wiki_data) 199 | from wsgiref.simple_server import make_server 200 | 201 | httpd = make_server("localhost", options.port, app) 202 | print("Serving on http://localhost:%s" % options.port) 203 | try: 204 | httpd.serve_forever() 205 | except KeyboardInterrupt: 206 | print("^C") 207 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 41"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.black] 6 | target-version = ['py39', 'py310', 'py311', 'py312', 'py313'] 7 | exclude = ''' 8 | /( 9 | \.git 10 | | .tox 11 | )/ 12 | ''' 13 | 14 | # This next section only exists for people that have their editors 15 | # automatically call isort, black already sorts entries on its own when run. 16 | [tool.isort] 17 | profile = "black" 18 | multi_line_output = 3 19 | src_paths = ["src", "tests"] 20 | skip_glob = ["docs/*"] 21 | include_trailing_comma = true 22 | force_grid_wrap = false 23 | combine_as_imports = true 24 | line_length = 88 25 | force_sort_within_sections = true 26 | default_section = "THIRDPARTY" 27 | known_first_party = "webob" 28 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = docs/license.txt 6 | 7 | [tool:pytest] 8 | python_files = test_*.py 9 | testpaths = 10 | tests 11 | addopts = -W always --cov --cov-context=test --cov-report=term-missing 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import find_packages, setup 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | try: 7 | with open(os.path.join(here, "README.rst")) as f: 8 | README = f.read() 9 | with open(os.path.join(here, "CHANGES.txt")) as f: 10 | CHANGES = f.read() 11 | except IOError: 12 | README = CHANGES = "" 13 | 14 | testing_extras = [ 15 | "pytest >= 3.1.0", # >= 3.1.0 so we can use pytest.param 16 | "coverage", 17 | "pytest-cov", 18 | "pytest-xdist", 19 | ] 20 | 21 | docs_extras = [ 22 | "Sphinx >= 1.7.5", 23 | "pylons-sphinx-themes", 24 | "setuptools", 25 | ] 26 | 27 | setup( 28 | name="WebOb", 29 | version="2.0.0dev0", 30 | description="WSGI request and response object", 31 | long_description=README + "\n\n" + CHANGES, 32 | classifiers=[ 33 | "Development Status :: 6 - Mature", 34 | "Intended Audience :: Developers", 35 | "License :: OSI Approved :: MIT License", 36 | "Topic :: Internet :: WWW/HTTP :: WSGI", 37 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", 38 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Middleware", 39 | "Programming Language :: Python :: 3.9", 40 | "Programming Language :: Python :: 3.10", 41 | "Programming Language :: Python :: 3.11", 42 | "Programming Language :: Python :: 3.12", 43 | "Programming Language :: Python :: 3.13", 44 | "Programming Language :: Python :: Implementation :: CPython", 45 | "Programming Language :: Python :: Implementation :: PyPy", 46 | ], 47 | keywords="wsgi request web http", 48 | author="Ian Bicking", 49 | author_email="ianb@colorstudy.com", 50 | maintainer="Pylons Project", 51 | url="http://webob.org/", 52 | license="MIT", 53 | packages=find_packages("src", exclude=["tests"]), 54 | package_dir={"": "src"}, 55 | python_requires=">=3.9.0", 56 | install_requires=[ 57 | "legacy-cgi>=2.6; python_version>='3.13'", 58 | ], 59 | zip_safe=True, 60 | extras_require={"testing": testing_extras, "docs": docs_extras}, 61 | ) 62 | -------------------------------------------------------------------------------- /src/webob/__init__.py: -------------------------------------------------------------------------------- 1 | from webob.datetime_utils import ( # noqa: F401 2 | UTC, 3 | day, 4 | hour, 5 | minute, 6 | month, 7 | parse_date, 8 | parse_date_delta, 9 | second, 10 | serialize_date, 11 | serialize_date_delta, 12 | timedelta_to_seconds, 13 | week, 14 | year, 15 | ) 16 | from webob.request import BaseRequest, Request 17 | from webob.response import Response 18 | from webob.util import html_escape 19 | 20 | __all__ = [ 21 | "Request", 22 | "Response", 23 | "UTC", 24 | "day", 25 | "week", 26 | "hour", 27 | "minute", 28 | "second", 29 | "month", 30 | "year", 31 | "html_escape", 32 | ] 33 | 34 | BaseRequest.ResponseClass = Response 35 | -------------------------------------------------------------------------------- /src/webob/byterange.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | __all__ = ["Range", "ContentRange"] 4 | 5 | _rx_range = re.compile(r"bytes *= *(\d*) *- *(\d*)", flags=re.I) 6 | _rx_content_range = re.compile(r"bytes (?:(\d+)-(\d+)|[*])/(?:(\d+)|[*])") 7 | 8 | 9 | class Range: 10 | """ 11 | Represents the Range header. 12 | """ 13 | 14 | def __init__(self, start, end): 15 | assert end is None or end >= 0, "Bad range end: %r" % end 16 | self.start = start 17 | self.end = end # non-inclusive 18 | 19 | def range_for_length(self, length): 20 | """ 21 | *If* there is only one range, and *if* it is satisfiable by 22 | the given length, then return a (start, end) non-inclusive range 23 | of bytes to serve. Otherwise return None 24 | """ 25 | if length is None: 26 | return None 27 | start, end = self.start, self.end 28 | if end is None: 29 | end = length 30 | if start < 0: 31 | start += length 32 | if _is_content_range_valid(start, end, length): 33 | stop = min(end, length) 34 | return (start, stop) 35 | else: 36 | return None 37 | 38 | def content_range(self, length): 39 | """ 40 | Works like range_for_length; returns None or a ContentRange object 41 | 42 | You can use it like:: 43 | 44 | response.content_range = req.range.content_range(response.content_length) 45 | 46 | Though it's still up to you to actually serve that content range! 47 | """ 48 | range = self.range_for_length(length) 49 | if range is None: 50 | return None 51 | return ContentRange(range[0], range[1], length) 52 | 53 | def __str__(self): 54 | s, e = self.start, self.end 55 | if e is None: 56 | r = "bytes=%s" % s 57 | if s >= 0: 58 | r += "-" 59 | return r 60 | return f"bytes={s}-{e - 1}" 61 | 62 | def __repr__(self): 63 | return f"<{self.__class__.__name__} bytes {self.start!r}-{self.end!r}>" 64 | 65 | def __iter__(self): 66 | return iter((self.start, self.end)) 67 | 68 | @classmethod 69 | def parse(cls, header): 70 | """ 71 | Parse the header; may return None if header is invalid 72 | """ 73 | m = _rx_range.match(header or "") 74 | if not m: 75 | return None 76 | start, end = m.groups() 77 | if not start: 78 | return cls(-int(end), None) 79 | start = int(start) 80 | if not end: 81 | return cls(start, None) 82 | end = int(end) + 1 # return val is non-inclusive 83 | if start >= end: 84 | return None 85 | return cls(start, end) 86 | 87 | 88 | class ContentRange: 89 | """ 90 | Represents the Content-Range header 91 | 92 | This header is ``start-stop/length``, where start-stop and length 93 | can be ``*`` (represented as None in the attributes). 94 | """ 95 | 96 | def __init__(self, start, stop, length): 97 | if not _is_content_range_valid(start, stop, length): 98 | raise ValueError(f"Bad start:stop/length: {start!r}-{stop!r}/{length!r}") 99 | self.start = start 100 | self.stop = stop # this is python-style range end (non-inclusive) 101 | self.length = length 102 | 103 | def __repr__(self): 104 | return f"<{self.__class__.__name__} {self}>" 105 | 106 | def __str__(self): 107 | if self.length is None: 108 | length = "*" 109 | else: 110 | length = self.length 111 | if self.start is None: 112 | assert self.stop is None 113 | return "bytes */%s" % length 114 | stop = self.stop - 1 # from non-inclusive to HTTP-style 115 | return f"bytes {self.start}-{stop}/{length}" 116 | 117 | def __iter__(self): 118 | """ 119 | Mostly so you can unpack this, like: 120 | 121 | start, stop, length = res.content_range 122 | """ 123 | return iter([self.start, self.stop, self.length]) 124 | 125 | @classmethod 126 | def parse(cls, value): 127 | """ 128 | Parse the header. May return None if it cannot parse. 129 | """ 130 | m = _rx_content_range.match(value or "") 131 | if not m: 132 | return None 133 | s, e, l = m.groups() 134 | if s: 135 | s = int(s) 136 | e = int(e) + 1 137 | l = l and int(l) 138 | if not _is_content_range_valid(s, e, l, response=True): 139 | return None 140 | return cls(s, e, l) 141 | 142 | 143 | def _is_content_range_valid(start, stop, length, response=False): 144 | if (start is None) != (stop is None): 145 | return False 146 | elif start is None: 147 | return length is None or length >= 0 148 | elif length is None: 149 | return 0 <= start < stop 150 | elif start >= stop: 151 | return False 152 | elif response and stop > length: 153 | # "content-range: bytes 0-50/10" is invalid for a response 154 | # "range: bytes 0-50" is valid for a request to a 10-bytes entity 155 | return False 156 | else: 157 | return 0 <= start < length 158 | -------------------------------------------------------------------------------- /src/webob/cachecontrol.py: -------------------------------------------------------------------------------- 1 | """ 2 | Represents the Cache-Control header 3 | """ 4 | 5 | import re 6 | 7 | 8 | class UpdateDict(dict): 9 | """ 10 | Dict that has a callback on all updates 11 | """ 12 | 13 | # these are declared as class attributes so that 14 | # we don't need to override constructor just to 15 | # set some defaults 16 | updated = None 17 | updated_args = None 18 | 19 | def _updated(self): 20 | """ 21 | Assign to new_dict.updated to track updates 22 | """ 23 | updated = self.updated 24 | if updated is not None: 25 | args = self.updated_args 26 | if args is None: 27 | args = (self,) 28 | updated(*args) 29 | 30 | def __setitem__(self, key, item): 31 | dict.__setitem__(self, key, item) 32 | self._updated() 33 | 34 | def __delitem__(self, key): 35 | dict.__delitem__(self, key) 36 | self._updated() 37 | 38 | def clear(self): 39 | dict.clear(self) 40 | self._updated() 41 | 42 | def update(self, *args, **kw): 43 | dict.update(self, *args, **kw) 44 | self._updated() 45 | 46 | def setdefault(self, key, value=None): 47 | val = dict.setdefault(self, key, value) 48 | if val is value: 49 | self._updated() 50 | return val 51 | 52 | def pop(self, *args): 53 | v = dict.pop(self, *args) 54 | self._updated() 55 | return v 56 | 57 | def popitem(self): 58 | v = dict.popitem(self) 59 | self._updated() 60 | return v 61 | 62 | 63 | token_re = re.compile(r'([a-zA-Z][a-zA-Z_-]*)\s*(?:=(?:"([^"]*)"|([^ \t",;]*)))?') 64 | need_quote_re = re.compile(r"[^a-zA-Z0-9._-]") 65 | 66 | 67 | class exists_property: 68 | """ 69 | Represents a property that either is listed in the Cache-Control 70 | header, or is not listed (has no value) 71 | """ 72 | 73 | def __init__(self, prop, type=None): 74 | self.prop = prop 75 | self.type = type 76 | 77 | def __get__(self, obj, type=None): 78 | if obj is None: 79 | return self 80 | return self.prop in obj.properties 81 | 82 | def __set__(self, obj, value): 83 | if self.type is not None and self.type != obj.type: 84 | raise AttributeError( 85 | "The property %s only applies to %s Cache-Control" 86 | % (self.prop, self.type) 87 | ) 88 | 89 | if value: 90 | obj.properties[self.prop] = None 91 | else: 92 | if self.prop in obj.properties: 93 | del obj.properties[self.prop] 94 | 95 | def __delete__(self, obj): 96 | self.__set__(obj, False) 97 | 98 | 99 | class value_property: 100 | """ 101 | Represents a property that has a value in the Cache-Control header. 102 | 103 | When no value is actually given, the value of self.none is returned. 104 | """ 105 | 106 | def __init__(self, prop, default=None, none=None, type=None): 107 | self.prop = prop 108 | self.default = default 109 | self.none = none 110 | self.type = type 111 | 112 | def __get__(self, obj, type=None): 113 | if obj is None: 114 | return self 115 | if self.prop in obj.properties: 116 | value = obj.properties[self.prop] 117 | if value is None: 118 | return self.none 119 | else: 120 | return value 121 | else: 122 | return self.default 123 | 124 | def __set__(self, obj, value): 125 | if self.type is not None and self.type != obj.type: 126 | raise AttributeError( 127 | "The property %s only applies to %s Cache-Control" 128 | % (self.prop, self.type) 129 | ) 130 | if value == self.default: 131 | if self.prop in obj.properties: 132 | del obj.properties[self.prop] 133 | elif value is True: 134 | obj.properties[self.prop] = None # Empty value, but present 135 | else: 136 | obj.properties[self.prop] = value 137 | 138 | def __delete__(self, obj): 139 | if self.prop in obj.properties: 140 | del obj.properties[self.prop] 141 | 142 | 143 | class CacheControl: 144 | """ 145 | Represents the Cache-Control header. 146 | 147 | By giving a type of ``'request'`` or ``'response'`` you can 148 | control what attributes are allowed (some Cache-Control values 149 | only apply to requests or responses). 150 | """ 151 | 152 | update_dict = UpdateDict 153 | 154 | def __init__(self, properties, type): 155 | self.properties = properties 156 | self.type = type 157 | 158 | @classmethod 159 | def parse(cls, header, updates_to=None, type=None): 160 | """ 161 | Parse the header, returning a CacheControl object. 162 | 163 | The object is bound to the request or response object 164 | ``updates_to``, if that is given. 165 | """ 166 | if updates_to: 167 | props = cls.update_dict() 168 | props.updated = updates_to 169 | else: 170 | props = {} 171 | for match in token_re.finditer(header): 172 | name = match.group(1) 173 | value = match.group(2) or match.group(3) or None 174 | if value: 175 | try: 176 | value = int(value) 177 | except ValueError: 178 | pass 179 | props[name] = value 180 | obj = cls(props, type=type) 181 | if updates_to: 182 | props.updated_args = (obj,) 183 | return obj 184 | 185 | def __repr__(self): 186 | return "" % str(self) 187 | 188 | # Request values: 189 | # no-cache shared (below) 190 | # no-store shared (below) 191 | # max-age shared (below) 192 | max_stale = value_property("max-stale", none="*", type="request") 193 | min_fresh = value_property("min-fresh", type="request") 194 | # no-transform shared (below) 195 | only_if_cached = exists_property("only-if-cached", type="request") 196 | 197 | # Response values: 198 | public = exists_property("public", type="response") 199 | private = value_property("private", none="*", type="response") 200 | no_cache = value_property("no-cache", none="*") 201 | no_store = exists_property("no-store") 202 | no_transform = exists_property("no-transform") 203 | must_revalidate = exists_property("must-revalidate", type="response") 204 | proxy_revalidate = exists_property("proxy-revalidate", type="response") 205 | max_age = value_property("max-age", none=-1) 206 | s_maxage = value_property("s-maxage", type="response") 207 | s_max_age = s_maxage 208 | stale_while_revalidate = value_property("stale-while-revalidate", type="response") 209 | stale_if_error = value_property("stale-if-error", type="response") 210 | 211 | def __str__(self): 212 | return serialize_cache_control(self.properties) 213 | 214 | def copy(self): 215 | """ 216 | Returns a copy of this object. 217 | """ 218 | return self.__class__(self.properties.copy(), type=self.type) 219 | 220 | 221 | def serialize_cache_control(properties): 222 | if isinstance(properties, CacheControl): 223 | properties = properties.properties 224 | parts = [] 225 | for name, value in sorted(properties.items()): 226 | if value is None: 227 | parts.append(name) 228 | continue 229 | value = str(value) 230 | if need_quote_re.search(value): 231 | value = '"%s"' % value 232 | parts.append(f"{name}={value}") 233 | return ", ".join(parts) 234 | -------------------------------------------------------------------------------- /src/webob/client.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import re 3 | import sys 4 | 5 | try: 6 | import httplib 7 | except ImportError: 8 | import http.client as httplib 9 | 10 | import socket 11 | from urllib.parse import quote as url_quote 12 | 13 | from webob import exc 14 | 15 | __all__ = ["send_request_app", "SendRequest"] 16 | 17 | 18 | class SendRequest: 19 | """ 20 | Sends the request, as described by the environ, over actual HTTP. 21 | All controls about how it is sent are contained in the request 22 | environ itself. 23 | 24 | This connects to the server given in SERVER_NAME:SERVER_PORT, and 25 | sends the Host header in HTTP_HOST -- they do not have to match. 26 | You can send requests to servers despite what DNS says. 27 | 28 | Set ``environ['webob.client.timeout'] = 10`` to set the timeout on 29 | the request (to, for example, 10 seconds). 30 | 31 | Does not add X-Forwarded-For or other standard headers 32 | 33 | If you use ``send_request_app`` then simple ``httplib`` 34 | connections will be used. 35 | """ 36 | 37 | def __init__( 38 | self, 39 | HTTPConnection=httplib.HTTPConnection, 40 | HTTPSConnection=httplib.HTTPSConnection, 41 | ): 42 | self.HTTPConnection = HTTPConnection 43 | self.HTTPSConnection = HTTPSConnection 44 | 45 | def __call__(self, environ, start_response): 46 | scheme = environ["wsgi.url_scheme"] 47 | 48 | if scheme == "http": 49 | ConnClass = self.HTTPConnection 50 | elif scheme == "https": 51 | ConnClass = self.HTTPSConnection 52 | else: 53 | raise ValueError("Unknown scheme: %r" % scheme) 54 | 55 | if "SERVER_NAME" not in environ: 56 | host = environ.get("HTTP_HOST") 57 | 58 | if not host: 59 | raise ValueError("environ contains neither SERVER_NAME nor HTTP_HOST") 60 | 61 | if ":" in host: 62 | host, port = host.split(":", 1) 63 | else: 64 | if scheme == "http": 65 | port = "80" 66 | else: 67 | port = "443" 68 | environ["SERVER_NAME"] = host 69 | environ["SERVER_PORT"] = port 70 | kw = {} 71 | 72 | if "webob.client.timeout" in environ and self._timeout_supported(ConnClass): 73 | kw["timeout"] = environ["webob.client.timeout"] 74 | conn = ConnClass("%(SERVER_NAME)s:%(SERVER_PORT)s" % environ, **kw) 75 | headers = {} 76 | 77 | for key, value in environ.items(): 78 | if key.startswith("HTTP_"): 79 | key = key[5:].replace("_", "-").title() 80 | headers[key] = value 81 | path = url_quote(environ.get("SCRIPT_NAME", "")) + url_quote( 82 | environ.get("PATH_INFO", "") 83 | ) 84 | 85 | if environ.get("QUERY_STRING"): 86 | path += "?" + environ["QUERY_STRING"] 87 | try: 88 | content_length = int(environ.get("CONTENT_LENGTH", "0")) 89 | except ValueError: 90 | content_length = 0 91 | # FIXME: there is no streaming of the body, and that might be useful 92 | # in some cases 93 | 94 | if content_length: 95 | body = environ["wsgi.input"].read(content_length) 96 | else: 97 | body = "" 98 | headers["Content-Length"] = content_length 99 | 100 | if environ.get("CONTENT_TYPE"): 101 | headers["Content-Type"] = environ["CONTENT_TYPE"] 102 | 103 | if not path.startswith("/"): 104 | path = "/" + path 105 | try: 106 | conn.request(environ["REQUEST_METHOD"], path, body, headers) 107 | res = conn.getresponse() 108 | except socket.timeout: 109 | resp = exc.HTTPGatewayTimeout() 110 | 111 | return resp(environ, start_response) 112 | except (OSError, socket.gaierror) as e: 113 | if (isinstance(e, socket.error) and e.args[0] == -2) or ( 114 | isinstance(e, socket.gaierror) and e.args[0] == 8 115 | ): 116 | # Name or service not known 117 | resp = exc.HTTPBadGateway( 118 | "Name or service not known (bad domain name: %s)" 119 | % environ["SERVER_NAME"] 120 | ) 121 | 122 | return resp(environ, start_response) 123 | elif e.args[0] in _e_refused: # pragma: no cover 124 | # Connection refused 125 | resp = exc.HTTPBadGateway("Connection refused") 126 | 127 | return resp(environ, start_response) 128 | raise 129 | headers_out = self.parse_headers(res.msg) 130 | status = f"{res.status} {res.reason}" 131 | start_response(status, headers_out) 132 | length = res.getheader("content-length") 133 | # FIXME: This shouldn't really read in all the content at once 134 | 135 | if length is not None: 136 | body = res.read(int(length)) 137 | else: 138 | body = res.read() 139 | conn.close() 140 | 141 | return [body] 142 | 143 | # Remove these headers from response (specify lower case header 144 | # names): 145 | filtered_headers = ("transfer-encoding",) 146 | 147 | MULTILINE_RE = re.compile(r"\r?\n\s*") 148 | 149 | def parse_headers(self, message): 150 | """ 151 | Turn a Message object into a list of WSGI-style headers. 152 | """ 153 | headers_out = [] 154 | headers = message._headers 155 | 156 | for full_header in headers: 157 | if not full_header: # pragma: no cover 158 | # Shouldn't happen, but we'll just ignore 159 | 160 | continue 161 | 162 | if full_header[0].isspace(): # pragma: no cover 163 | # Continuation line, add to the last header 164 | 165 | if not headers_out: 166 | raise ValueError( 167 | "First header starts with a space (%r)" % full_header 168 | ) 169 | last_header, last_value = headers_out.pop() 170 | value = last_value + ", " + full_header.strip() 171 | headers_out.append((last_header, value)) 172 | 173 | continue 174 | 175 | if isinstance(full_header, tuple): # pragma: no cover 176 | header, value = full_header 177 | else: # pragma: no cover 178 | try: 179 | header, value = full_header.split(":", 1) 180 | except Exception: 181 | raise ValueError(f"Invalid header: {full_header!r}") 182 | value = value.strip() 183 | 184 | if "\n" in value or "\r\n" in value: # pragma: no cover 185 | # Python 3 has multiline values for continuations, Python 2 186 | # has two items in headers 187 | value = self.MULTILINE_RE.sub(", ", value) 188 | 189 | if header.lower() not in self.filtered_headers: 190 | headers_out.append((header, value)) 191 | 192 | return headers_out 193 | 194 | def _timeout_supported(self, ConnClass): 195 | if sys.version_info < (2, 7) and ConnClass in ( 196 | httplib.HTTPConnection, 197 | httplib.HTTPSConnection, 198 | ): # pragma: no cover 199 | return False 200 | 201 | return True 202 | 203 | 204 | send_request_app = SendRequest() 205 | 206 | _e_refused = (errno.ECONNREFUSED,) 207 | 208 | if hasattr(errno, "ENODATA"): # pragma: no cover 209 | _e_refused += (errno.ENODATA,) 210 | -------------------------------------------------------------------------------- /src/webob/compat.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | import cgi 4 | from cgi import FieldStorage as _cgi_FieldStorage, parse_header 5 | from html import escape 6 | from queue import Empty, Queue 7 | import sys 8 | import tempfile 9 | import types 10 | 11 | 12 | # Various different FieldStorage work-arounds required on Python 3.x 13 | class cgi_FieldStorage(_cgi_FieldStorage): # pragma: no cover 14 | def __repr__(self): 15 | """monkey patch for FieldStorage.__repr__ 16 | 17 | Unbelievably, the default __repr__ on FieldStorage reads 18 | the entire file content instead of being sane about it. 19 | This is a simple replacement that doesn't do that 20 | """ 21 | 22 | if self.file: 23 | return f"FieldStorage({self.name!r}, {self.filename!r})" 24 | 25 | return f"FieldStorage({self.name!r}, {self.filename!r}, {self.value!r})" 26 | 27 | # Work around https://bugs.python.org/issue27777 28 | def make_file(self): 29 | if self._binary_file or self.length >= 0: 30 | return tempfile.TemporaryFile("wb+") 31 | else: 32 | return tempfile.TemporaryFile("w+", encoding=self.encoding, newline="\n") 33 | 34 | # Work around http://bugs.python.org/issue23801 35 | # This is taken exactly from Python 3.5's cgi.py module 36 | def read_multi(self, environ, keep_blank_values, strict_parsing): 37 | """Internal: read a part that is itself multipart.""" 38 | ib = self.innerboundary 39 | 40 | if not cgi.valid_boundary(ib): 41 | raise ValueError(f"Invalid boundary in multipart form: {ib!r}") 42 | self.list = [] 43 | 44 | if self.qs_on_post: 45 | query = cgi.urllib.parse.parse_qsl( 46 | self.qs_on_post, 47 | self.keep_blank_values, 48 | self.strict_parsing, 49 | encoding=self.encoding, 50 | errors=self.errors, 51 | ) 52 | 53 | for key, value in query: 54 | self.list.append(cgi.MiniFieldStorage(key, value)) 55 | 56 | klass = self.FieldStorageClass or self.__class__ 57 | first_line = self.fp.readline() # bytes 58 | 59 | if not isinstance(first_line, bytes): 60 | raise ValueError( 61 | f"{self.fp} should return bytes, got {type(first_line).__name__}" 62 | ) 63 | self.bytes_read += len(first_line) 64 | 65 | # Ensure that we consume the file until we've hit our innerboundary 66 | 67 | while first_line.strip() != (b"--" + self.innerboundary) and first_line: 68 | first_line = self.fp.readline() 69 | self.bytes_read += len(first_line) 70 | 71 | while True: 72 | parser = cgi.FeedParser() 73 | hdr_text = b"" 74 | 75 | while True: 76 | data = self.fp.readline() 77 | hdr_text += data 78 | 79 | if not data.strip(): 80 | break 81 | 82 | if not hdr_text: 83 | break 84 | # parser takes strings, not bytes 85 | self.bytes_read += len(hdr_text) 86 | parser.feed(hdr_text.decode(self.encoding, self.errors)) 87 | headers = parser.close() 88 | # Some clients add Content-Length for part headers, ignore them 89 | 90 | if "content-length" in headers: 91 | filename = None 92 | 93 | if "content-disposition" in self.headers: 94 | cdisp, pdict = parse_header(self.headers["content-disposition"]) 95 | 96 | if "filename" in pdict: 97 | filename = pdict["filename"] 98 | 99 | if filename is None: 100 | del headers["content-length"] 101 | part = klass( 102 | self.fp, 103 | headers, 104 | ib, 105 | environ, 106 | keep_blank_values, 107 | strict_parsing, 108 | self.limit - self.bytes_read, 109 | self.encoding, 110 | self.errors, 111 | ) 112 | self.bytes_read += part.bytes_read 113 | self.list.append(part) 114 | 115 | if part.done or self.bytes_read >= self.length > 0: 116 | break 117 | self.skip_lines() 118 | -------------------------------------------------------------------------------- /src/webob/datetime_utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from datetime import date, datetime, timedelta, tzinfo 3 | from email.utils import formatdate, mktime_tz, parsedate_tz 4 | import time 5 | 6 | from webob.util import text_ 7 | 8 | __all__ = [ 9 | "UTC", 10 | "timedelta_to_seconds", 11 | "year", 12 | "month", 13 | "week", 14 | "day", 15 | "hour", 16 | "minute", 17 | "second", 18 | "parse_date", 19 | "serialize_date", 20 | "parse_date_delta", 21 | "serialize_date_delta", 22 | ] 23 | 24 | _now = datetime.now # hook point for unit tests 25 | 26 | 27 | class _UTC(tzinfo): 28 | def dst(self, dt): 29 | return timedelta(0) 30 | 31 | def utcoffset(self, dt): 32 | return timedelta(0) 33 | 34 | def tzname(self, dt): 35 | return "UTC" 36 | 37 | def __repr__(self): 38 | return "UTC" 39 | 40 | 41 | UTC = _UTC() 42 | 43 | 44 | def timedelta_to_seconds(td): 45 | """ 46 | Converts a timedelta instance to seconds. 47 | """ 48 | 49 | return td.seconds + (td.days * 24 * 60 * 60) 50 | 51 | 52 | day = timedelta(days=1) 53 | week = timedelta(weeks=1) 54 | hour = timedelta(hours=1) 55 | minute = timedelta(minutes=1) 56 | second = timedelta(seconds=1) 57 | # Estimate, I know; good enough for expirations 58 | month = timedelta(days=30) 59 | year = timedelta(days=365) 60 | 61 | 62 | def parse_date(value): 63 | if not value: 64 | return None 65 | try: 66 | if not isinstance(value, str): 67 | value = str(value, "latin-1") 68 | except Exception: 69 | return None 70 | t = parsedate_tz(value) 71 | 72 | if t is None: 73 | # Could not parse 74 | 75 | return None 76 | 77 | t = mktime_tz(t) 78 | 79 | return datetime.fromtimestamp(t, UTC) 80 | 81 | 82 | def serialize_date(dt): 83 | if isinstance(dt, (bytes, str)): 84 | return text_(dt) 85 | 86 | if isinstance(dt, timedelta): 87 | dt = _now() + dt 88 | 89 | if isinstance(dt, (datetime, date)): 90 | dt = dt.timetuple() 91 | 92 | if isinstance(dt, (tuple, time.struct_time)): 93 | dt = calendar.timegm(dt) 94 | 95 | if not (isinstance(dt, float) or isinstance(dt, int)): 96 | raise ValueError( 97 | "You must pass in a datetime, date, time tuple, or integer object, " 98 | "not %r" % dt 99 | ) 100 | 101 | return formatdate(dt, usegmt=True) 102 | 103 | 104 | def parse_date_delta(value): 105 | """ 106 | like parse_date, but also handle delta seconds 107 | """ 108 | 109 | if not value: 110 | return None 111 | try: 112 | value = int(value) 113 | except ValueError: 114 | return parse_date(value) 115 | else: 116 | return _now() + timedelta(seconds=value) 117 | 118 | 119 | def serialize_date_delta(value): 120 | if isinstance(value, (float, int)): 121 | return str(int(value)) 122 | else: 123 | return serialize_date(value) 124 | -------------------------------------------------------------------------------- /src/webob/etag.py: -------------------------------------------------------------------------------- 1 | """ 2 | Does parsing of ETag-related headers: If-None-Matches, If-Matches 3 | 4 | Also If-Range parsing 5 | """ 6 | 7 | from webob.datetime_utils import parse_date, serialize_date 8 | from webob.descriptors import _rx_etag 9 | from webob.util import header_docstring 10 | 11 | __all__ = ["AnyETag", "NoETag", "ETagMatcher", "IfRange", "etag_property"] 12 | 13 | 14 | def etag_property(key, default, rfc_section, strong=True): 15 | doc = header_docstring(key, rfc_section) 16 | doc += " Converts it as a Etag." 17 | 18 | def fget(req): 19 | value = req.environ.get(key) 20 | if not value: 21 | return default 22 | else: 23 | return ETagMatcher.parse(value, strong=strong) 24 | 25 | def fset(req, val): 26 | if val is None: 27 | req.environ[key] = None 28 | else: 29 | req.environ[key] = str(val) 30 | 31 | def fdel(req): 32 | del req.environ[key] 33 | 34 | return property(fget, fset, fdel, doc=doc) 35 | 36 | 37 | class _AnyETag: 38 | """ 39 | Represents an ETag of *, or a missing ETag when matching is 'safe' 40 | """ 41 | 42 | def __repr__(self): 43 | return "" 44 | 45 | def __bool__(self): 46 | return False 47 | 48 | def __contains__(self, other): 49 | return True 50 | 51 | def __str__(self): 52 | return "*" 53 | 54 | 55 | AnyETag = _AnyETag() 56 | 57 | 58 | class _NoETag: 59 | """ 60 | Represents a missing ETag when matching is unsafe 61 | """ 62 | 63 | def __repr__(self): 64 | return "" 65 | 66 | def __bool__(self): 67 | return False 68 | 69 | def __contains__(self, other): 70 | return False 71 | 72 | def __str__(self): 73 | return "" 74 | 75 | 76 | NoETag = _NoETag() 77 | 78 | 79 | # TODO: convert into a simple tuple 80 | 81 | 82 | class ETagMatcher: 83 | def __init__(self, etags): 84 | self.etags = etags 85 | 86 | def __contains__(self, other): 87 | return other in self.etags 88 | 89 | def __repr__(self): 90 | return "" % (" or ".join(self.etags)) 91 | 92 | @classmethod 93 | def parse(cls, value, strong=True): 94 | """ 95 | Parse this from a header value 96 | """ 97 | if value == "*": 98 | return AnyETag 99 | if not value: 100 | return cls([]) 101 | matches = _rx_etag.findall(value) 102 | if not matches: 103 | return cls([value]) 104 | elif strong: 105 | return cls([t for w, t in matches if not w]) 106 | else: 107 | return cls([t for w, t in matches]) 108 | 109 | def __str__(self): 110 | return ", ".join(map('"%s"'.__mod__, self.etags)) 111 | 112 | 113 | class IfRange: 114 | def __init__(self, etag): 115 | self.etag = etag 116 | 117 | @classmethod 118 | def parse(cls, value): 119 | """ 120 | Parse this from a header value. 121 | """ 122 | if not value: 123 | return cls(AnyETag) 124 | elif value.endswith(" GMT"): 125 | # Must be a date 126 | return IfRangeDate(parse_date(value)) 127 | else: 128 | return cls(ETagMatcher.parse(value)) 129 | 130 | def __contains__(self, resp): 131 | """ 132 | Return True if the If-Range header matches the given etag or last_modified 133 | """ 134 | return resp.etag_strong in self.etag 135 | 136 | def __bool__(self): 137 | return bool(self.etag) 138 | 139 | def __repr__(self): 140 | return f"{self.__class__.__name__}({self.etag!r})" 141 | 142 | def __str__(self): 143 | return str(self.etag) if self.etag else "" 144 | 145 | 146 | class IfRangeDate: 147 | def __init__(self, date): 148 | self.date = date 149 | 150 | def __contains__(self, resp): 151 | last_modified = resp.last_modified 152 | return last_modified and (last_modified <= self.date) 153 | 154 | def __repr__(self): 155 | return f"{self.__class__.__name__}({self.date!r})" 156 | 157 | def __str__(self): 158 | return serialize_date(self.date) 159 | -------------------------------------------------------------------------------- /src/webob/headers.py: -------------------------------------------------------------------------------- 1 | from collections.abc import MutableMapping 2 | 3 | from webob.multidict import MultiDict 4 | 5 | __all__ = ["ResponseHeaders", "EnvironHeaders"] 6 | 7 | 8 | class ResponseHeaders(MultiDict): 9 | """ 10 | Dictionary view on the response headerlist. 11 | Keys are normalized for case and whitespace. 12 | """ 13 | 14 | def __getitem__(self, key): 15 | key = key.lower() 16 | 17 | for k, v in reversed(self._items): 18 | if k.lower() == key: 19 | return v 20 | raise KeyError(key) 21 | 22 | def getall(self, key): 23 | key = key.lower() 24 | 25 | return [v for (k, v) in self._items if k.lower() == key] 26 | 27 | def mixed(self): 28 | r = self.dict_of_lists() 29 | 30 | for key, val in r.items(): 31 | if len(val) == 1: 32 | r[key] = val[0] 33 | 34 | return r 35 | 36 | def dict_of_lists(self): 37 | r = {} 38 | 39 | for key, val in self.items(): 40 | r.setdefault(key.lower(), []).append(val) 41 | 42 | return r 43 | 44 | def __setitem__(self, key, value): 45 | norm_key = key.lower() 46 | self._items[:] = [(k, v) for (k, v) in self._items if k.lower() != norm_key] 47 | self._items.append((key, value)) 48 | 49 | def __delitem__(self, key): 50 | key = key.lower() 51 | items = self._items 52 | found = False 53 | 54 | for i in range(len(items) - 1, -1, -1): 55 | if items[i][0].lower() == key: 56 | del items[i] 57 | found = True 58 | 59 | if not found: 60 | raise KeyError(key) 61 | 62 | def __contains__(self, key): 63 | key = key.lower() 64 | 65 | for k, _ in self._items: 66 | if k.lower() == key: 67 | return True 68 | 69 | return False 70 | 71 | has_key = __contains__ 72 | 73 | def setdefault(self, key, default=None): 74 | c_key = key.lower() 75 | 76 | for k, v in self._items: 77 | if k.lower() == c_key: 78 | return v 79 | self._items.append((key, default)) 80 | 81 | return default 82 | 83 | def pop(self, key, *args): 84 | if len(args) > 1: 85 | raise TypeError( 86 | "pop expected at most 2 arguments, got %s" % repr(1 + len(args)) 87 | ) 88 | key = key.lower() 89 | 90 | for i in range(len(self._items)): 91 | if self._items[i][0].lower() == key: 92 | v = self._items[i][1] 93 | del self._items[i] 94 | 95 | return v 96 | 97 | if args: 98 | return args[0] 99 | else: 100 | raise KeyError(key) 101 | 102 | 103 | key2header = { 104 | "CONTENT_TYPE": "Content-Type", 105 | "CONTENT_LENGTH": "Content-Length", 106 | "HTTP_CONTENT_TYPE": "Content_Type", 107 | "HTTP_CONTENT_LENGTH": "Content_Length", 108 | } 109 | 110 | header2key = {v.upper(): k for (k, v) in key2header.items()} 111 | 112 | 113 | def _trans_key(key): 114 | if not isinstance(key, str): 115 | return None 116 | elif key in key2header: 117 | return key2header[key] 118 | elif key.startswith("HTTP_"): 119 | return key[5:].replace("_", "-").title() 120 | else: 121 | return None 122 | 123 | 124 | def _trans_name(name): 125 | name = name.upper() 126 | 127 | if name in header2key: 128 | return header2key[name] 129 | 130 | return "HTTP_" + name.replace("-", "_") 131 | 132 | 133 | class EnvironHeaders(MutableMapping): 134 | """An object that represents the headers as present in a 135 | WSGI environment. 136 | 137 | This object is a wrapper (with no internal state) for a WSGI 138 | request object, representing the CGI-style HTTP_* keys as a 139 | dictionary. Because a CGI environment can only hold one value for 140 | each key, this dictionary is single-valued (unlike outgoing 141 | headers). 142 | """ 143 | 144 | def __init__(self, environ): 145 | self.environ = environ 146 | 147 | def __getitem__(self, hname): 148 | return self.environ[_trans_name(hname)] 149 | 150 | def __setitem__(self, hname, value): 151 | self.environ[_trans_name(hname)] = value 152 | 153 | def __delitem__(self, hname): 154 | del self.environ[_trans_name(hname)] 155 | 156 | def keys(self): 157 | return filter(None, map(_trans_key, self.environ)) 158 | 159 | def __contains__(self, hname): 160 | return _trans_name(hname) in self.environ 161 | 162 | def __len__(self): 163 | return len(list(self.keys())) 164 | 165 | def __iter__(self): 166 | yield from self.keys() 167 | -------------------------------------------------------------------------------- /src/webob/static.py: -------------------------------------------------------------------------------- 1 | import mimetypes 2 | import os 3 | 4 | from webob import exc 5 | from webob.dec import wsgify 6 | from webob.response import Response 7 | 8 | __all__ = ["FileApp", "DirectoryApp"] 9 | 10 | mimetypes._winreg = None # do not load mimetypes from windows registry 11 | mimetypes.add_type( 12 | "text/javascript", ".js" 13 | ) # stdlib default is application/x-javascript 14 | mimetypes.add_type("image/x-icon", ".ico") # not among defaults 15 | 16 | BLOCK_SIZE = 1 << 16 17 | 18 | 19 | class FileApp: 20 | """An application that will send the file at the given filename. 21 | 22 | Adds a mime type based on `mimetypes.guess_type()`. 23 | """ 24 | 25 | def __init__(self, filename, **kw): 26 | self.filename = filename 27 | content_type, content_encoding = mimetypes.guess_type(filename) 28 | kw.setdefault("content_type", content_type) 29 | kw.setdefault("content_encoding", content_encoding) 30 | kw.setdefault("accept_ranges", "bytes") 31 | self.kw = kw 32 | # Used for testing purpose 33 | self._open = open 34 | 35 | @wsgify 36 | def __call__(self, req): 37 | if req.method not in ("GET", "HEAD"): 38 | return exc.HTTPMethodNotAllowed("You cannot %s a file" % req.method) 39 | try: 40 | stat = os.stat(self.filename) 41 | except OSError as e: 42 | msg = f"Can't open {self.filename!r}: {e}" 43 | return exc.HTTPNotFound(comment=msg) 44 | 45 | try: 46 | file = self._open(self.filename, "rb") 47 | except OSError as e: 48 | msg = "You are not permitted to view this file (%s)" % e 49 | return exc.HTTPForbidden(msg) 50 | 51 | if "wsgi.file_wrapper" in req.environ: 52 | app_iter = req.environ["wsgi.file_wrapper"](file, BLOCK_SIZE) 53 | else: 54 | app_iter = FileIter(file) 55 | 56 | return Response( 57 | app_iter=app_iter, 58 | content_length=stat.st_size, 59 | last_modified=stat.st_mtime, 60 | # @@ etag 61 | **self.kw, 62 | ).conditional_response_app 63 | 64 | 65 | class FileIter: 66 | def __init__(self, file): 67 | self.file = file 68 | 69 | def app_iter_range(self, seek=None, limit=None, block_size=None): 70 | """Iter over the content of the file. 71 | 72 | You can set the `seek` parameter to read the file starting from a 73 | specific position. 74 | 75 | You can set the `limit` parameter to read the file up to specific 76 | position. 77 | 78 | Finally, you can change the number of bytes read at once by setting the 79 | `block_size` parameter. 80 | """ 81 | 82 | if block_size is None: 83 | block_size = BLOCK_SIZE 84 | 85 | if seek: 86 | self.file.seek(seek) 87 | if limit is not None: 88 | limit -= seek 89 | try: 90 | while True: 91 | data = self.file.read( 92 | min(block_size, limit) if limit is not None else block_size 93 | ) 94 | if not data: 95 | return 96 | yield data 97 | if limit is not None: 98 | limit -= len(data) 99 | if limit <= 0: 100 | return 101 | finally: 102 | self.file.close() 103 | 104 | __iter__ = app_iter_range 105 | 106 | 107 | class DirectoryApp: 108 | """An application that serves up the files in a given directory. 109 | 110 | This will serve index files (by default ``index.html``), or set 111 | ``index_page=None`` to disable this. If you set 112 | ``hide_index_with_redirect=True`` (it defaults to False) then 113 | requests to, e.g., ``/index.html`` will be redirected to ``/``. 114 | 115 | To customize `FileApp` instances creation (which is what actually 116 | serves the responses), override the `make_fileapp` method. 117 | """ 118 | 119 | def __init__( 120 | self, path, index_page="index.html", hide_index_with_redirect=False, **kw 121 | ): 122 | self.path = os.path.abspath(path) 123 | if not self.path.endswith(os.path.sep): 124 | self.path += os.path.sep 125 | if not os.path.isdir(self.path): 126 | raise OSError("Path does not exist or is not directory: %r" % self.path) 127 | self.index_page = index_page 128 | self.hide_index_with_redirect = hide_index_with_redirect 129 | self.fileapp_kw = kw 130 | 131 | def make_fileapp(self, path): 132 | return FileApp(path, **self.fileapp_kw) 133 | 134 | @wsgify 135 | def __call__(self, req): 136 | path = os.path.abspath(os.path.join(self.path, req.path_info.lstrip("/"))) 137 | if os.path.isdir(path) and self.index_page: 138 | return self.index(req, path) 139 | if ( 140 | self.index_page 141 | and self.hide_index_with_redirect 142 | and path.endswith(os.path.sep + self.index_page) 143 | ): 144 | new_url = req.path_url.rsplit("/", 1)[0] 145 | new_url += "/" 146 | if req.query_string: 147 | new_url += "?" + req.query_string 148 | return Response(status=301, location=new_url) 149 | if not path.startswith(self.path): 150 | return exc.HTTPForbidden() 151 | elif not os.path.isfile(path): 152 | return exc.HTTPNotFound(comment=path) 153 | else: 154 | return self.make_fileapp(path) 155 | 156 | def index(self, req, path): 157 | index_path = os.path.join(path, self.index_page) 158 | if not os.path.isfile(index_path): 159 | return exc.HTTPNotFound(comment=index_path) 160 | if not req.path_info.endswith("/"): 161 | url = req.path_url + "/" 162 | if req.query_string: 163 | url += "?" + req.query_string 164 | return Response(status=301, location=url) 165 | return self.make_fileapp(index_path) 166 | -------------------------------------------------------------------------------- /src/webob/util.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from webob.compat import escape 4 | from webob.headers import _trans_key 5 | 6 | 7 | def unquote(string): 8 | if not string: 9 | return b"" 10 | res = string.split(b"%") 11 | 12 | if len(res) != 1: 13 | string = res[0] 14 | 15 | for item in res[1:]: 16 | string += bytes([int(item[:2], 16)]) + item[2:] 17 | 18 | return string 19 | 20 | 21 | def url_unquote(s): 22 | return unquote(s.encode("ascii")).decode("latin-1") 23 | 24 | 25 | def parse_qsl_text(qs, encoding="utf-8"): 26 | qs = qs.encode("latin-1") 27 | qs = qs.replace(b"+", b" ") 28 | pairs = [s2 for s1 in qs.split(b"&") for s2 in s1.split(b";") if s2] 29 | 30 | for name_value in pairs: 31 | nv = name_value.split(b"=", 1) 32 | 33 | if len(nv) != 2: 34 | nv.append("") 35 | name = unquote(nv[0]) 36 | value = unquote(nv[1]) 37 | yield (name.decode(encoding), value.decode(encoding)) 38 | 39 | 40 | def text_(s, encoding="latin-1", errors="strict"): 41 | if isinstance(s, bytes): 42 | return str(s, encoding, errors) 43 | 44 | return s 45 | 46 | 47 | def bytes_(s, encoding="latin-1", errors="strict"): 48 | if isinstance(s, str): 49 | return s.encode(encoding, errors) 50 | 51 | return s 52 | 53 | 54 | def html_escape(s): 55 | """HTML-escape a string or object 56 | 57 | This converts any non-string objects passed into it to strings 58 | (actually, using ``unicode()``). All values returned are 59 | non-unicode strings (using ``&#num;`` entities for all non-ASCII 60 | characters). 61 | 62 | None is treated specially, and returns the empty string. 63 | """ 64 | 65 | if s is None: 66 | return "" 67 | __html__ = getattr(s, "__html__", None) 68 | 69 | if __html__ is not None and callable(__html__): 70 | return s.__html__() 71 | 72 | if not isinstance(s, str): 73 | s = str(s) 74 | s = escape(s, True) 75 | 76 | if isinstance(s, str): 77 | s = s.encode("ascii", "xmlcharrefreplace") 78 | 79 | return text_(s) 80 | 81 | 82 | def header_docstring(header, rfc_section): 83 | if header.isupper(): 84 | header = _trans_key(header) 85 | major_section = rfc_section.split(".")[0] 86 | link = "http://www.w3.org/Protocols/rfc2616/rfc2616-sec{}.html#sec{}".format( 87 | major_section, 88 | rfc_section, 89 | ) 90 | 91 | return "Gets and sets the ``{}`` header (`HTTP spec section {} <{}>`_).".format( 92 | header, 93 | rfc_section, 94 | link, 95 | ) 96 | 97 | 98 | def warn_deprecation(text, version, stacklevel): 99 | # version specifies when to start raising exceptions instead of warnings 100 | 101 | if version in ("1.2", "1.3", "1.4", "1.5", "1.6", "1.7"): 102 | raise DeprecationWarning(text) 103 | else: 104 | cls = DeprecationWarning 105 | warnings.warn(text, cls, stacklevel=stacklevel + 1) 106 | 107 | 108 | status_reasons = { 109 | # Status Codes 110 | # Informational 111 | 100: "Continue", 112 | 101: "Switching Protocols", 113 | 102: "Processing", 114 | # Successful 115 | 200: "OK", 116 | 201: "Created", 117 | 202: "Accepted", 118 | 203: "Non-Authoritative Information", 119 | 204: "No Content", 120 | 205: "Reset Content", 121 | 206: "Partial Content", 122 | 207: "Multi Status", 123 | 226: "IM Used", 124 | # Redirection 125 | 300: "Multiple Choices", 126 | 301: "Moved Permanently", 127 | 302: "Found", 128 | 303: "See Other", 129 | 304: "Not Modified", 130 | 305: "Use Proxy", 131 | 307: "Temporary Redirect", 132 | 308: "Permanent Redirect", 133 | # Client Error 134 | 400: "Bad Request", 135 | 401: "Unauthorized", 136 | 402: "Payment Required", 137 | 403: "Forbidden", 138 | 404: "Not Found", 139 | 405: "Method Not Allowed", 140 | 406: "Not Acceptable", 141 | 407: "Proxy Authentication Required", 142 | 408: "Request Timeout", 143 | 409: "Conflict", 144 | 410: "Gone", 145 | 411: "Length Required", 146 | 412: "Precondition Failed", 147 | 413: "Request Entity Too Large", 148 | 414: "Request URI Too Long", 149 | 415: "Unsupported Media Type", 150 | 416: "Requested Range Not Satisfiable", 151 | 417: "Expectation Failed", 152 | 418: "I'm a teapot", 153 | 422: "Unprocessable Entity", 154 | 423: "Locked", 155 | 424: "Failed Dependency", 156 | 426: "Upgrade Required", 157 | 428: "Precondition Required", 158 | 429: "Too Many Requests", 159 | 451: "Unavailable for Legal Reasons", 160 | 431: "Request Header Fields Too Large", 161 | # Server Error 162 | 500: "Internal Server Error", 163 | 501: "Not Implemented", 164 | 502: "Bad Gateway", 165 | 503: "Service Unavailable", 166 | 504: "Gateway Timeout", 167 | 505: "HTTP Version Not Supported", 168 | 507: "Insufficient Storage", 169 | 510: "Not Extended", 170 | 511: "Network Authentication Required", 171 | } 172 | 173 | # generic class responses as per RFC2616 174 | status_generic_reasons = { 175 | 1: "Continue", 176 | 2: "Success", 177 | 3: "Multiple Choices", 178 | 4: "Unknown Client Error", 179 | 5: "Unknown Server Error", 180 | } 181 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | import logging 3 | import random 4 | import threading 5 | from wsgiref.simple_server import ( 6 | ServerHandler, 7 | WSGIRequestHandler, 8 | WSGIServer, 9 | make_server, 10 | ) 11 | 12 | import pytest 13 | 14 | log = logging.getLogger(__name__) 15 | ServerHandler.handle_error = lambda: None 16 | 17 | 18 | class QuietHandler(WSGIRequestHandler): 19 | def log_request(self, *args): 20 | pass 21 | 22 | 23 | class QuietServer(WSGIServer): 24 | def handle_error(self, req, addr): 25 | pass 26 | 27 | 28 | def _make_test_server(app): 29 | maxport = (1 << 16) - 1 30 | 31 | # we'll make 3 attempts to find a free port 32 | 33 | for i in range(3, 0, -1): 34 | try: 35 | port = random.randint(maxport // 2, maxport) 36 | server = make_server( 37 | "localhost", 38 | port, 39 | app, 40 | server_class=QuietServer, 41 | handler_class=QuietHandler, 42 | ) 43 | server.timeout = 5 44 | return server 45 | except BaseException: 46 | if i == 1: 47 | raise 48 | 49 | 50 | @pytest.fixture 51 | def serve(): 52 | @contextmanager 53 | def _serve(app): 54 | server = _make_test_server(app) 55 | try: 56 | worker = threading.Thread(target=server.serve_forever) 57 | worker.daemon = True 58 | worker.start() 59 | server.url = "http://localhost:%d" % server.server_port 60 | log.debug("server started on %s", server.url) 61 | 62 | yield server 63 | finally: 64 | log.debug("shutting server down") 65 | server.shutdown() 66 | worker.join(1) 67 | if worker.is_alive(): 68 | log.warning("worker is hanged") 69 | else: 70 | log.debug("server stopped") 71 | 72 | return _serve 73 | -------------------------------------------------------------------------------- /tests/performance_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from webob.response import Response 3 | 4 | 5 | def make_middleware(app): 6 | from repoze.profile.profiler import AccumulatingProfileMiddleware 7 | 8 | return AccumulatingProfileMiddleware( 9 | app, 10 | log_filename="/tmp/profile.log", 11 | discard_first_request=True, 12 | flush_at_shutdown=True, 13 | path="/__profile__", 14 | ) 15 | 16 | 17 | def simple_app(environ, start_response): 18 | resp = Response("Hello world!") 19 | return resp(environ, start_response) 20 | 21 | 22 | if __name__ == "__main__": 23 | import os 24 | import signal 25 | import sys 26 | 27 | if sys.argv[1:]: 28 | arg = sys.argv[1] 29 | else: 30 | arg = None 31 | if arg in ["open", "run"]: 32 | import subprocess 33 | import time 34 | import webbrowser 35 | 36 | os.environ["SHOW_OUTPUT"] = "0" 37 | proc = subprocess.Popen([sys.executable, __file__]) 38 | time.sleep(1) 39 | subprocess.call(["ab", "-n", "1000", "http://localhost:8080/"]) 40 | if arg == "open": 41 | webbrowser.open("http://localhost:8080/__profile__") 42 | print("Hit ^C to end") 43 | try: 44 | while 1: 45 | input() 46 | finally: 47 | os.kill(proc.pid, signal.SIGKILL) 48 | else: 49 | from paste.httpserver import serve 50 | 51 | if os.environ.get("SHOW_OUTPUT") != "0": 52 | print("Note you can also use:)") 53 | print(f" {sys.executable} {__file__} open") 54 | print('to run ab and open a browser (or "run" to just run ab)') 55 | print("Now do:") 56 | print("ab -n 1000 http://localhost:8080/") 57 | print("wget -O - http://localhost:8080/__profile__") 58 | serve(make_middleware(simple_app)) 59 | -------------------------------------------------------------------------------- /tests/test_byterange.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | 3 | import pytest 4 | 5 | from webob.byterange import ContentRange, Range, _is_content_range_valid 6 | 7 | # Range class 8 | 9 | 10 | def test_not_satisfiable(): 11 | range = Range.parse("bytes=-100") 12 | assert range.range_for_length(50) is None 13 | range = Range.parse("bytes=100-") 14 | assert range.range_for_length(50) is None 15 | 16 | 17 | def test_range_parse(): 18 | assert isinstance(Range.parse("bytes=0-99"), Range) 19 | assert isinstance(Range.parse("BYTES=0-99"), Range) 20 | assert isinstance(Range.parse("bytes = 0-99"), Range) 21 | assert isinstance(Range.parse("bytes=0 - 102"), Range) 22 | assert Range.parse("bytes=10-5") is None 23 | assert Range.parse("bytes 5-10") is None 24 | assert Range.parse("words=10-5") is None 25 | 26 | 27 | def test_range_content_range_length_none(): 28 | range = Range(0, 100) 29 | assert range.content_range(None) is None 30 | assert isinstance(range.content_range(1), ContentRange) 31 | assert tuple(range.content_range(1)) == (0, 1, 1) 32 | assert tuple(range.content_range(200)) == (0, 100, 200) 33 | 34 | 35 | def test_range_for_length_end_is_none(): 36 | # End is None 37 | range = Range(0, None) 38 | assert range.range_for_length(100) == (0, 100) 39 | 40 | 41 | def test_range_for_length_end_is_none_negative_start(): 42 | # End is None and start is negative 43 | range = Range(-5, None) 44 | assert range.range_for_length(100) == (95, 100) 45 | 46 | 47 | def test_range_start_none(): 48 | # Start is None 49 | range = Range(None, 99) 50 | assert range.range_for_length(100) is None 51 | 52 | 53 | def test_range_str_end_none(): 54 | range = Range(0, None) 55 | assert str(range) == "bytes=0-" 56 | 57 | 58 | def test_range_str_end_none_negative_start(): 59 | range = Range(-5, None) 60 | assert str(range) == "bytes=-5" 61 | 62 | 63 | def test_range_str_1(): 64 | range = Range(0, 100) 65 | assert str(range) == "bytes=0-99" 66 | 67 | 68 | def test_range_repr(): 69 | range = Range(0, 99) 70 | assert repr(range) == "" 71 | 72 | 73 | # ContentRange class 74 | 75 | 76 | def test_contentrange_bad_input(): 77 | with pytest.raises(ValueError): 78 | ContentRange(None, 99, None) 79 | 80 | 81 | def test_contentrange_repr(): 82 | contentrange = ContentRange(0, 99, 100) 83 | assert repr(contentrange) == "" 84 | 85 | 86 | def test_contentrange_str(): 87 | contentrange = ContentRange(0, 99, None) 88 | assert str(contentrange) == "bytes 0-98/*" 89 | contentrange = ContentRange(None, None, 100) 90 | assert str(contentrange) == "bytes */100" 91 | 92 | 93 | def test_contentrange_iter(): 94 | contentrange = ContentRange(0, 99, 100) 95 | assert isinstance(contentrange, Iterable) 96 | assert ContentRange.parse("bytes 0-99/100").__class__ == ContentRange 97 | assert ContentRange.parse(None) is None 98 | assert ContentRange.parse("0-99 100") is None 99 | assert ContentRange.parse("bytes 0-99 100") is None 100 | assert ContentRange.parse("bytes 0-99/xxx") is None 101 | assert ContentRange.parse("bytes 0 99/100") is None 102 | assert ContentRange.parse("bytes */100").__class__ == ContentRange 103 | assert ContentRange.parse("bytes A-99/100") is None 104 | assert ContentRange.parse("bytes 0-B/100") is None 105 | assert ContentRange.parse("bytes 99-0/100") is None 106 | assert ContentRange.parse("bytes 0 99/*") is None 107 | 108 | 109 | # _is_content_range_valid function 110 | 111 | 112 | def test_is_content_range_valid(): 113 | assert not _is_content_range_valid(None, 99, 90) 114 | assert not _is_content_range_valid(99, None, 90) 115 | assert _is_content_range_valid(None, None, 90) 116 | assert not _is_content_range_valid(None, 99, 90) 117 | assert _is_content_range_valid(0, 99, None) 118 | assert not _is_content_range_valid(0, 99, 90, response=True) 119 | assert _is_content_range_valid(0, 99, 90) 120 | -------------------------------------------------------------------------------- /tests/test_cachecontrol.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | def test_cache_control_object_max_age_None(): 5 | from webob.cachecontrol import CacheControl 6 | 7 | cc = CacheControl({}, "a") 8 | cc.properties["max-age"] = None 9 | assert cc.max_age == -1 10 | 11 | 12 | class TestUpdateDict: 13 | def setup_method(self, method): 14 | self.call_queue = [] 15 | 16 | def callback(args): 17 | self.call_queue.append("Called with: %s" % repr(args)) 18 | 19 | self.callback = callback 20 | 21 | def make_one(self, callback): 22 | from webob.cachecontrol import UpdateDict 23 | 24 | ud = UpdateDict() 25 | ud.updated = callback 26 | return ud 27 | 28 | def test_clear(self): 29 | newone = self.make_one(self.callback) 30 | newone["first"] = 1 31 | assert len(newone) == 1 32 | newone.clear() 33 | assert len(newone) == 0 34 | 35 | def test_update(self): 36 | newone = self.make_one(self.callback) 37 | d = {"one": 1} 38 | newone.update(d) 39 | assert newone == d 40 | 41 | def test_set_delete(self): 42 | newone = self.make_one(self.callback) 43 | newone["first"] = 1 44 | assert len(self.call_queue) == 1 45 | assert self.call_queue[-1] == "Called with: {'first': 1}" 46 | 47 | del newone["first"] 48 | assert len(self.call_queue) == 2 49 | assert self.call_queue[-1] == "Called with: {}" 50 | 51 | def test_setdefault(self): 52 | newone = self.make_one(self.callback) 53 | assert newone.setdefault("haters", "gonna-hate") == "gonna-hate" 54 | assert len(self.call_queue) == 1 55 | assert self.call_queue[-1] == "Called with: {'haters': 'gonna-hate'}" 56 | 57 | # no effect if failobj is not set 58 | assert newone.setdefault("haters", "gonna-love") == "gonna-hate" 59 | assert len(self.call_queue) == 1 60 | 61 | def test_pop(self): 62 | newone = self.make_one(self.callback) 63 | newone["first"] = 1 64 | newone.pop("first") 65 | assert len(self.call_queue) == 2 66 | assert self.call_queue[-1] == "Called with: {}", self.call_queue[-1] 67 | 68 | def test_popitem(self): 69 | newone = self.make_one(self.callback) 70 | newone["first"] = 1 71 | assert newone.popitem() == ("first", 1) 72 | assert len(self.call_queue) == 2 73 | assert self.call_queue[-1] == "Called with: {}", self.call_queue[-1] 74 | 75 | 76 | class TestExistProp: 77 | """ 78 | Test webob.cachecontrol.exists_property 79 | """ 80 | 81 | def setUp(self): 82 | pass 83 | 84 | def make_one(self): 85 | from webob.cachecontrol import exists_property 86 | 87 | class Dummy: 88 | properties = dict(prop=1) 89 | type = "dummy" 90 | prop = exists_property("prop", "dummy") 91 | badprop = exists_property("badprop", "big_dummy") 92 | 93 | return Dummy 94 | 95 | def test_get_on_class(self): 96 | from webob.cachecontrol import exists_property 97 | 98 | Dummy = self.make_one() 99 | assert isinstance(Dummy.prop, exists_property), Dummy.prop 100 | 101 | def test_get_on_instance(self): 102 | obj = self.make_one()() 103 | assert obj.prop is True 104 | 105 | def test_type_mismatch_raise(self): 106 | with pytest.raises(AttributeError): 107 | obj = self.make_one()() 108 | obj.badprop = True 109 | 110 | def test_set_w_value(self): 111 | obj = self.make_one()() 112 | obj.prop = True 113 | assert obj.prop is True 114 | assert obj.properties["prop"] is None 115 | 116 | def test_del_value(self): 117 | obj = self.make_one()() 118 | del obj.prop 119 | assert "prop" not in obj.properties 120 | 121 | 122 | class TestValueProp: 123 | """ 124 | Test webob.cachecontrol.exists_property 125 | """ 126 | 127 | def setUp(self): 128 | pass 129 | 130 | def make_one(self): 131 | from webob.cachecontrol import value_property 132 | 133 | class Dummy: 134 | properties = dict(prop=1) 135 | type = "dummy" 136 | prop = value_property("prop", "dummy") 137 | badprop = value_property("badprop", "big_dummy") 138 | 139 | return Dummy 140 | 141 | def test_get_on_class(self): 142 | from webob.cachecontrol import value_property 143 | 144 | Dummy = self.make_one() 145 | assert isinstance(Dummy.prop, value_property), Dummy.prop 146 | 147 | def test_get_on_instance(self): 148 | dummy = self.make_one()() 149 | assert dummy.prop, dummy.prop 150 | 151 | def test_set_on_instance(self): 152 | dummy = self.make_one()() 153 | dummy.prop = "new" 154 | assert dummy.prop == "new", dummy.prop 155 | assert dummy.properties["prop"] == "new", dict(dummy.properties) 156 | 157 | def test_set_on_instance_bad_attribute(self): 158 | dummy = self.make_one()() 159 | dummy.prop = "new" 160 | assert dummy.prop == "new", dummy.prop 161 | assert dummy.properties["prop"] == "new", dict(dummy.properties) 162 | 163 | def test_set_wrong_type(self): 164 | from webob.cachecontrol import value_property 165 | 166 | class Dummy: 167 | properties = dict(prop=1, type="fail") 168 | type = "dummy" 169 | prop = value_property("prop", "dummy", type="failingtype") 170 | 171 | dummy = Dummy() 172 | 173 | def assign(): 174 | dummy.prop = "foo" 175 | 176 | with pytest.raises(AttributeError): 177 | assign() 178 | 179 | def test_set_type_true(self): 180 | dummy = self.make_one()() 181 | dummy.prop = True 182 | assert dummy.prop is None 183 | 184 | def test_set_on_instance_w_default(self): 185 | dummy = self.make_one()() 186 | dummy.prop = "dummy" 187 | assert dummy.prop == "dummy" 188 | # TODO: this probably needs more tests 189 | 190 | def test_del(self): 191 | dummy = self.make_one()() 192 | dummy.prop = "Ian Bicking likes to skip" 193 | del dummy.prop 194 | assert dummy.prop == "dummy" 195 | 196 | 197 | def test_copy_cc(): 198 | from webob.cachecontrol import CacheControl 199 | 200 | cc = CacheControl({"header": "%", "msg": "arewerichyet?"}, "request") 201 | cc2 = cc.copy() 202 | assert cc.properties is not cc2.properties 203 | assert cc.type is cc2.type 204 | 205 | 206 | def test_serialize_cache_control_emptydict(): 207 | from webob.cachecontrol import serialize_cache_control 208 | 209 | result = serialize_cache_control(dict()) 210 | assert result == "" 211 | 212 | 213 | def test_serialize_cache_control_cache_control_object(): 214 | from webob.cachecontrol import CacheControl, serialize_cache_control 215 | 216 | result = serialize_cache_control(CacheControl({}, "request")) 217 | assert result == "" 218 | 219 | 220 | def test_serialize_cache_control_object_with_headers(): 221 | from webob.cachecontrol import CacheControl, serialize_cache_control 222 | 223 | result = serialize_cache_control(CacheControl({"header": "a"}, "request")) 224 | assert result == "header=a" 225 | 226 | 227 | def test_serialize_cache_control_value_is_None(): 228 | from webob.cachecontrol import CacheControl, serialize_cache_control 229 | 230 | result = serialize_cache_control(CacheControl({"header": None}, "request")) 231 | assert result == "header" 232 | 233 | 234 | def test_serialize_cache_control_value_needs_quote(): 235 | from webob.cachecontrol import CacheControl, serialize_cache_control 236 | 237 | result = serialize_cache_control(CacheControl({"header": '""'}, "request")) 238 | assert result == 'header=""""' 239 | 240 | 241 | class TestCacheControl: 242 | def make_one(self, props, typ): 243 | from webob.cachecontrol import CacheControl 244 | 245 | return CacheControl(props, typ) 246 | 247 | def test_ctor(self): 248 | cc = self.make_one({"a": 1}, "typ") 249 | assert cc.properties == {"a": 1} 250 | assert cc.type == "typ" 251 | 252 | def test_parse(self): 253 | from webob.cachecontrol import CacheControl 254 | 255 | cc = CacheControl.parse("public, max-age=315360000") 256 | assert type(cc) == CacheControl 257 | assert cc.max_age == 315360000 258 | assert cc.public is True 259 | 260 | def test_parse_updates_to(self): 261 | from webob.cachecontrol import CacheControl 262 | 263 | def foo(arg): 264 | return {"a": 1} 265 | 266 | cc = CacheControl.parse("public, max-age=315360000", updates_to=foo) 267 | assert type(cc) == CacheControl 268 | assert cc.max_age == 315360000 269 | 270 | def test_parse_valueerror_int(self): 271 | from webob.cachecontrol import CacheControl 272 | 273 | def foo(arg): 274 | return {"a": 1} 275 | 276 | cc = CacheControl.parse("public, max-age=abc") 277 | assert type(cc) == CacheControl 278 | assert cc.max_age == "abc" 279 | 280 | def test_repr(self): 281 | cc = self.make_one({"a": "1"}, "typ") 282 | assert repr(cc) == "" 283 | -------------------------------------------------------------------------------- /tests/test_client_functional.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from webob import Request, Response 6 | from webob.client import SendRequest 7 | from webob.dec import wsgify 8 | 9 | 10 | @wsgify 11 | def simple_app(req): 12 | data = {"headers": dict(req.headers), "body": req.text, "method": req.method} 13 | return Response(json=data) 14 | 15 | 16 | @pytest.mark.usefixtures("serve") 17 | def test_client(serve, client_app=None): 18 | with serve(simple_app) as server: 19 | req = Request.blank( 20 | server.url, method="POST", content_type="application/json", json={"test": 1} 21 | ) 22 | resp = req.send(client_app) 23 | assert resp.status_code == 200, resp.status 24 | assert resp.json["headers"]["Content-Type"] == "application/json" 25 | assert resp.json["method"] == "POST" 26 | # Test that these values get filled in: 27 | del req.environ["SERVER_NAME"] 28 | del req.environ["SERVER_PORT"] 29 | resp = req.send(client_app) 30 | assert resp.status_code == 200, resp.status 31 | req = Request.blank(server.url) 32 | del req.environ["SERVER_NAME"] 33 | del req.environ["SERVER_PORT"] 34 | assert req.send(client_app).status_code == 200 35 | req.headers["Host"] = server.url.replace("http://", "") 36 | del req.environ["SERVER_NAME"] 37 | del req.environ["SERVER_PORT"] 38 | resp = req.send(client_app) 39 | assert resp.status_code == 200, resp.status 40 | del req.environ["SERVER_NAME"] 41 | del req.environ["SERVER_PORT"] 42 | del req.headers["Host"] 43 | assert req.environ.get("SERVER_NAME") is None 44 | assert req.environ.get("SERVER_PORT") is None 45 | assert req.environ.get("HTTP_HOST") is None 46 | with pytest.raises(ValueError): 47 | req.send(client_app) 48 | req = Request.blank(server.url) 49 | req.environ["CONTENT_LENGTH"] = "not a number" 50 | assert req.send(client_app).status_code == 200 51 | 52 | 53 | def no_length_app(environ, start_response): 54 | start_response("200 OK", [("Content-type", "text/plain")]) 55 | return [b"ok"] 56 | 57 | 58 | @pytest.mark.usefixtures("serve") 59 | def test_no_content_length(serve, client_app=None): 60 | with serve(no_length_app) as server: 61 | req = Request.blank(server.url) 62 | resp = req.send(client_app) 63 | assert resp.status_code == 200, resp.status 64 | 65 | 66 | @wsgify 67 | def cookie_app(req): 68 | resp = Response("test") 69 | resp.headers.add("Set-Cookie", "a=b") 70 | resp.headers.add("Set-Cookie", "c=d") 71 | resp.headerlist.append(("X-Crazy", "value\r\n continuation")) 72 | return resp 73 | 74 | 75 | @pytest.mark.usefixtures("serve") 76 | def test_client_cookies(serve, client_app=None): 77 | with serve(cookie_app) as server: 78 | req = Request.blank(server.url + "/?test") 79 | resp = req.send(client_app) 80 | assert resp.headers.getall("Set-Cookie") == ["a=b", "c=d"] 81 | assert resp.headers["X-Crazy"] == "value, continuation", repr( 82 | resp.headers["X-Crazy"] 83 | ) 84 | 85 | 86 | @wsgify 87 | def slow_app(req): 88 | time.sleep(2) 89 | return Response("ok") 90 | 91 | 92 | @pytest.mark.usefixtures("serve") 93 | def test_client_slow(serve, client_app=None): 94 | if client_app is None: 95 | client_app = SendRequest() 96 | if not client_app._timeout_supported(client_app.HTTPConnection): 97 | # timeout isn't supported 98 | return 99 | with serve(slow_app) as server: 100 | req = Request.blank(server.url) 101 | req.environ["webob.client.timeout"] = 0.1 102 | resp = req.send(client_app) 103 | assert resp.status_code == 504, resp.status 104 | -------------------------------------------------------------------------------- /tests/test_compat.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import sys 3 | 4 | import pytest 5 | 6 | 7 | class TestText: 8 | def _callFUT(self, *arg, **kw): 9 | from webob.util import text_ 10 | 11 | return text_(*arg, **kw) 12 | 13 | def test_binary(self): 14 | result = self._callFUT(b"123") 15 | assert isinstance(result, str) 16 | assert result == str(b"123", "ascii") 17 | 18 | def test_binary_alternate_decoding(self): 19 | result = self._callFUT(b"La Pe\xc3\xb1a", "utf-8") 20 | assert isinstance(result, str) 21 | assert result == str(b"La Pe\xc3\xb1a", "utf-8") 22 | 23 | def test_binary_decoding_error(self): 24 | pytest.raises(UnicodeDecodeError, self._callFUT, b"\xff", "utf-8") 25 | 26 | def test_text(self): 27 | result = self._callFUT(str(b"123", "ascii")) 28 | assert isinstance(result, str) 29 | assert result == str(b"123", "ascii") 30 | 31 | 32 | class TestBytes: 33 | def _callFUT(self, *arg, **kw): 34 | from webob.util import bytes_ 35 | 36 | return bytes_(*arg, **kw) 37 | 38 | def test_binary(self): 39 | result = self._callFUT(b"123") 40 | assert isinstance(result, bytes) 41 | assert result == b"123" 42 | 43 | def test_text(self): 44 | val = str(b"123", "ascii") 45 | result = self._callFUT(val) 46 | assert isinstance(result, bytes) 47 | assert result == b"123" 48 | 49 | def test_text_alternate_encoding(self): 50 | val = str(b"La Pe\xc3\xb1a", "utf-8") 51 | result = self._callFUT(val, "utf-8") 52 | assert isinstance(result, bytes) 53 | assert result == b"La Pe\xc3\xb1a" 54 | 55 | 56 | class Test_cgi_FieldStorage_Py3_tests: 57 | def test_fieldstorage_not_multipart(self): 58 | from webob.compat import cgi_FieldStorage 59 | 60 | POSTDATA = b'{"name": "Bert"}' 61 | 62 | env = { 63 | "REQUEST_METHOD": "POST", 64 | "CONTENT_TYPE": "text/plain", 65 | "CONTENT_LENGTH": str(len(POSTDATA)), 66 | } 67 | fp = BytesIO(POSTDATA) 68 | fs = cgi_FieldStorage(fp, environ=env) 69 | assert fs.list is None 70 | assert fs.value == b'{"name": "Bert"}' 71 | 72 | @pytest.mark.skipif( 73 | sys.version_info < (3, 0), 74 | reason="FieldStorage on Python 2.7 is broken, see " 75 | "https://github.com/Pylons/webob/issues/293", 76 | ) 77 | def test_fieldstorage_part_content_length(self): 78 | from webob.compat import cgi_FieldStorage 79 | 80 | BOUNDARY = "JfISa01" 81 | POSTDATA = """--JfISa01 82 | Content-Disposition: form-data; name="submit-name" 83 | Content-Length: 5 84 | 85 | Larry 86 | --JfISa01""" 87 | env = { 88 | "REQUEST_METHOD": "POST", 89 | "CONTENT_TYPE": f"multipart/form-data; boundary={BOUNDARY}", 90 | "CONTENT_LENGTH": str(len(POSTDATA)), 91 | } 92 | fp = BytesIO(POSTDATA.encode("latin-1")) 93 | fs = cgi_FieldStorage(fp, environ=env) 94 | assert len(fs.list) == 1 95 | assert fs.list[0].name == "submit-name" 96 | assert fs.list[0].value == "Larry" 97 | 98 | def test_my_fieldstorage_part_content_length(self): 99 | from webob.compat import cgi_FieldStorage 100 | 101 | BOUNDARY = "4ddfd368-cb07-4b9e-b003-876010298a6c" 102 | POSTDATA = """--4ddfd368-cb07-4b9e-b003-876010298a6c 103 | Content-Disposition: form-data; name="object"; filename="file.txt" 104 | Content-Type: text/plain 105 | Content-Length: 5 106 | Content-Transfer-Encoding: 7bit 107 | 108 | ADMIN 109 | --4ddfd368-cb07-4b9e-b003-876010298a6c 110 | Content-Disposition: form-data; name="sign_date" 111 | Content-Type: application/json; charset=UTF-8 112 | Content-Length: 22 113 | Content-Transfer-Encoding: 7bit 114 | 115 | "2016-11-23T12:22:41Z" 116 | --4ddfd368-cb07-4b9e-b003-876010298a6c 117 | Content-Disposition: form-data; name="staffId" 118 | Content-Type: text/plain; charset=UTF-8 119 | Content-Length: 5 120 | Content-Transfer-Encoding: 7bit 121 | 122 | ADMIN 123 | --4ddfd368-cb07-4b9e-b003-876010298a6c--""" 124 | env = { 125 | "REQUEST_METHOD": "POST", 126 | "CONTENT_TYPE": f"multipart/form-data; boundary={BOUNDARY}", 127 | "CONTENT_LENGTH": str(len(POSTDATA)), 128 | } 129 | fp = BytesIO(POSTDATA.encode("latin-1")) 130 | fs = cgi_FieldStorage(fp, environ=env) 131 | assert len(fs.list) == 3 132 | expect = [ 133 | {"name": "object", "filename": "file.txt", "value": b"ADMIN"}, 134 | {"name": "sign_date", "filename": None, "value": '"2016-11-23T12:22:41Z"'}, 135 | {"name": "staffId", "filename": None, "value": "ADMIN"}, 136 | ] 137 | for x in range(len(fs.list)): 138 | for k, exp in expect[x].items(): 139 | got = getattr(fs.list[x], k) 140 | assert got == exp 141 | 142 | def test_fieldstorage_multipart_leading_whitespace(self): 143 | from webob.compat import cgi_FieldStorage 144 | 145 | BOUNDARY = "---------------------------721837373350705526688164684" 146 | POSTDATA = """-----------------------------721837373350705526688164684 147 | Content-Disposition: form-data; name="id" 148 | 149 | 1234 150 | -----------------------------721837373350705526688164684 151 | Content-Disposition: form-data; name="title" 152 | 153 | 154 | -----------------------------721837373350705526688164684 155 | Content-Disposition: form-data; name="file"; filename="test.txt" 156 | Content-Type: text/plain 157 | 158 | Testing 123. 159 | 160 | -----------------------------721837373350705526688164684 161 | Content-Disposition: form-data; name="submit" 162 | 163 | Add\x20 164 | -----------------------------721837373350705526688164684-- 165 | """ 166 | 167 | env = { 168 | "REQUEST_METHOD": "POST", 169 | "CONTENT_TYPE": f"multipart/form-data; boundary={BOUNDARY}", 170 | "CONTENT_LENGTH": "560", 171 | } 172 | # Add some leading whitespace to our post data that will cause the 173 | # first line to not be the innerboundary. 174 | fp = BytesIO(b"\r\n" + POSTDATA.encode("latin-1")) 175 | fs = cgi_FieldStorage(fp, environ=env) 176 | assert len(fs.list) == 4 177 | expect = [ 178 | {"name": "id", "filename": None, "value": "1234"}, 179 | {"name": "title", "filename": None, "value": ""}, 180 | {"name": "file", "filename": "test.txt", "value": b"Testing 123.\n"}, 181 | {"name": "submit", "filename": None, "value": " Add "}, 182 | ] 183 | for x in range(len(fs.list)): 184 | for k, exp in expect[x].items(): 185 | got = getattr(fs.list[x], k) 186 | assert got == exp 187 | -------------------------------------------------------------------------------- /tests/test_cookies_bw.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from webob import cookies 4 | 5 | 6 | def setup_module(module): 7 | cookies._should_raise = False 8 | 9 | 10 | def teardown_module(module): 11 | cookies._should_raise = False 12 | 13 | 14 | def test_invalid_cookie_space(): 15 | with warnings.catch_warnings(record=True) as w: 16 | # Cause all warnings to always be triggered. 17 | warnings.simplefilter("always") 18 | 19 | # Trigger a warning. 20 | cookies._value_quote(b"hello world") 21 | 22 | assert len(w) == 1 23 | assert issubclass(w[-1].category, RuntimeWarning) is True 24 | assert "ValueError" in str(w[-1].message) 25 | -------------------------------------------------------------------------------- /tests/test_datetime_utils.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import datetime 3 | from email.utils import formatdate 4 | 5 | import pytest 6 | 7 | from webob import datetime_utils 8 | 9 | 10 | def test_UTC(): 11 | """Test missing function in _UTC""" 12 | x = datetime_utils.UTC 13 | assert x.tzname(datetime.datetime.now()) == "UTC" 14 | assert x.dst(datetime.datetime.now()) == datetime.timedelta(0) 15 | assert x.utcoffset(datetime.datetime.now()) == datetime.timedelta(0) 16 | assert repr(x) == "UTC" 17 | 18 | 19 | # Testing datetime_utils.parse_date. 20 | # We need to verify the following scenarios: 21 | # * a nil submitted value 22 | # * a submitted value that cannot be parse into a date 23 | # * a valid RFC2822 date with and without timezone 24 | 25 | 26 | class Uncooperative: 27 | def __str__(self): 28 | raise NotImplementedError 29 | 30 | 31 | @pytest.mark.parametrize("invalid_date", [None, "Hi there", 1, "\xc3", Uncooperative()]) 32 | def test_parse_date_invalid(invalid_date): 33 | assert datetime_utils.parse_date(invalid_date) is None 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "valid_date, parsed_datetime", 38 | [ 39 | ( 40 | "Mon, 20 Nov 1995 19:12:08 -0500", 41 | datetime.datetime(1995, 11, 21, 0, 12, 8, tzinfo=datetime_utils.UTC), 42 | ), 43 | ( 44 | "Mon, 20 Nov 1995 19:12:08", 45 | datetime.datetime(1995, 11, 20, 19, 12, 8, tzinfo=datetime_utils.UTC), 46 | ), 47 | ], 48 | ) 49 | def test_parse_date_valid(valid_date, parsed_datetime): 50 | assert datetime_utils.parse_date(valid_date) == parsed_datetime 51 | 52 | 53 | def test_serialize_date(): 54 | """Testing datetime_utils.serialize_date 55 | We need to verify the following scenarios: 56 | * on py3, passing an binary date, return the same date but str 57 | * on py2, passing an unicode date, return the same date but str 58 | * passing a timedelta, return now plus the delta 59 | * passing an invalid object, should raise ValueError 60 | """ 61 | from webob.util import text_ 62 | 63 | ret = datetime_utils.serialize_date("Mon, 20 Nov 1995 19:12:08 GMT") 64 | assert isinstance(ret, str) 65 | assert ret == "Mon, 20 Nov 1995 19:12:08 GMT" 66 | ret = datetime_utils.serialize_date(text_("Mon, 20 Nov 1995 19:12:08 GMT")) 67 | assert isinstance(ret, str) 68 | assert ret == "Mon, 20 Nov 1995 19:12:08 GMT" 69 | dt = formatdate( 70 | calendar.timegm((datetime.datetime.now() + datetime.timedelta(1)).timetuple()), 71 | usegmt=True, 72 | ) 73 | assert dt == datetime_utils.serialize_date(datetime.timedelta(1)) 74 | with pytest.raises(ValueError): 75 | datetime_utils.serialize_date(None) 76 | 77 | 78 | def test_parse_date_delta(): 79 | """Testing datetime_utils.parse_date_delta 80 | We need to verify the following scenarios: 81 | * passing a nil value, should return nil 82 | * passing a value that fails the conversion to int, should call 83 | parse_date 84 | """ 85 | assert datetime_utils.parse_date_delta(None) is None, ( 86 | "Passing none value," "should return None" 87 | ) 88 | ret = datetime_utils.parse_date_delta("Mon, 20 Nov 1995 19:12:08 -0500") 89 | assert ret == datetime.datetime(1995, 11, 21, 0, 12, 8, tzinfo=datetime_utils.UTC) 90 | WHEN = datetime.datetime(2011, 3, 16, 10, 10, 37, tzinfo=datetime_utils.UTC) 91 | with _NowRestorer(WHEN): 92 | ret = datetime_utils.parse_date_delta(1) 93 | assert ret == WHEN + datetime.timedelta(0, 1) 94 | 95 | 96 | def test_serialize_date_delta(): 97 | """Testing datetime_utils.serialize_date_delta 98 | We need to verify the following scenarios: 99 | * if we pass something that's not an int or float, it should delegate 100 | the task to serialize_date 101 | """ 102 | assert datetime_utils.serialize_date_delta(1) == "1" 103 | assert datetime_utils.serialize_date_delta(1.5) == "1" 104 | ret = datetime_utils.serialize_date_delta("Mon, 20 Nov 1995 19:12:08 GMT") 105 | assert type(ret) is (str) 106 | assert ret == "Mon, 20 Nov 1995 19:12:08 GMT" 107 | 108 | 109 | def test_timedelta_to_seconds(): 110 | val = datetime.timedelta(86400) 111 | result = datetime_utils.timedelta_to_seconds(val) 112 | assert result == 7464960000 113 | 114 | 115 | class _NowRestorer: 116 | def __init__(self, new_now): 117 | self._new_now = new_now 118 | self._old_now = None 119 | 120 | def __enter__(self): 121 | import webob.datetime_utils 122 | 123 | self._old_now = webob.datetime_utils._now 124 | webob.datetime_utils._now = lambda: self._new_now 125 | 126 | def __exit__(self, exc_type, exc_value, traceback): 127 | import webob.datetime_utils 128 | 129 | webob.datetime_utils._now = self._old_now 130 | -------------------------------------------------------------------------------- /tests/test_etag.py: -------------------------------------------------------------------------------- 1 | from webob.etag import ETagMatcher, IfRange, etag_property 2 | 3 | 4 | class Test_etag_properties: 5 | def _makeDummyRequest(self, **kw): 6 | """ 7 | Return a DummyRequest object with attrs from kwargs. 8 | Use like: dr = _makeDummyRequest(environment={'userid': 'johngalt'}) 9 | Then you can: uid = dr.environment.get('userid', 'SomeDefault') 10 | """ 11 | 12 | class Dummy: 13 | def __init__(self, **kwargs): 14 | self.__dict__.update(**kwargs) 15 | 16 | d = Dummy(**kw) 17 | 18 | return d 19 | 20 | def test_fget_missing_key(self): 21 | ep = etag_property("KEY", "DEFAULT", "RFC_SECTION") 22 | req = self._makeDummyRequest(environ={}) 23 | assert ep.fget(req) == "DEFAULT" 24 | 25 | def test_fget_found_key(self): 26 | ep = etag_property("KEY", "DEFAULT", "RFC_SECTION") 27 | req = self._makeDummyRequest(environ={"KEY": '"VALUE"'}) 28 | res = ep.fget(req) 29 | assert res.etags == ["VALUE"] 30 | 31 | def test_fget_star_key(self): 32 | ep = etag_property("KEY", "DEFAULT", "RFC_SECTION") 33 | req = self._makeDummyRequest(environ={"KEY": "*"}) 34 | res = ep.fget(req) 35 | import webob.etag 36 | 37 | assert type(res) == webob.etag._AnyETag 38 | assert res.__dict__ == {} 39 | 40 | def test_fset_None(self): 41 | ep = etag_property("KEY", "DEFAULT", "RFC_SECTION") 42 | req = self._makeDummyRequest(environ={"KEY": "*"}) 43 | res = ep.fset(req, None) 44 | assert res is None 45 | 46 | def test_fset_not_None(self): 47 | ep = etag_property("KEY", "DEFAULT", "RFC_SECTION") 48 | req = self._makeDummyRequest(environ={"KEY": "OLDVAL"}) 49 | res = ep.fset(req, "NEWVAL") 50 | assert res is None 51 | assert req.environ["KEY"] == "NEWVAL" 52 | 53 | def test_fedl(self): 54 | ep = etag_property("KEY", "DEFAULT", "RFC_SECTION") 55 | req = self._makeDummyRequest(environ={"KEY": "VAL", "QUAY": "VALYOU"}) 56 | res = ep.fdel(req) 57 | assert res is None 58 | assert "KEY" not in req.environ 59 | assert req.environ["QUAY"] == "VALYOU" 60 | 61 | 62 | class Test_AnyETag: 63 | def _getTargetClass(self): 64 | from webob.etag import _AnyETag 65 | 66 | return _AnyETag 67 | 68 | def _makeOne(self, *args, **kw): 69 | return self._getTargetClass()(*args, **kw) 70 | 71 | def test___repr__(self): 72 | etag = self._makeOne() 73 | assert etag.__repr__() == "" 74 | 75 | def test___bool__(self): 76 | etag = self._makeOne() 77 | assert etag.__bool__() is False 78 | 79 | def test___contains__something(self): 80 | etag = self._makeOne() 81 | assert "anything" in etag 82 | 83 | def test___str__(self): 84 | etag = self._makeOne() 85 | assert str(etag) == "*" 86 | 87 | 88 | class Test_NoETag: 89 | def _getTargetClass(self): 90 | from webob.etag import _NoETag 91 | 92 | return _NoETag 93 | 94 | def _makeOne(self, *args, **kw): 95 | return self._getTargetClass()(*args, **kw) 96 | 97 | def test___repr__(self): 98 | etag = self._makeOne() 99 | assert etag.__repr__() == "" 100 | 101 | def test___bool__(self): 102 | etag = self._makeOne() 103 | assert etag.__bool__() is False 104 | 105 | def test___contains__something(self): 106 | etag = self._makeOne() 107 | assert "anything" not in etag 108 | 109 | def test___str__(self): 110 | etag = self._makeOne() 111 | assert str(etag) == "" 112 | 113 | 114 | class Test_Parse: 115 | def test_parse_None(self): 116 | et = ETagMatcher.parse(None) 117 | assert et.etags == [] 118 | 119 | def test_parse_anyetag(self): 120 | # these tests smell bad, are they useful? 121 | et = ETagMatcher.parse("*") 122 | assert et.__dict__ == {} 123 | assert et.__repr__() == "" 124 | 125 | def test_parse_one(self): 126 | et = ETagMatcher.parse('"ONE"') 127 | assert et.etags == ["ONE"] 128 | 129 | def test_parse_invalid(self): 130 | for tag in ["one", "one, two", '"one two']: 131 | et = ETagMatcher.parse(tag) 132 | assert et.etags == [tag] 133 | et = ETagMatcher.parse('"foo" and w/"weak"', strong=False) 134 | assert et.etags == ["foo"] 135 | 136 | def test_parse_commasep(self): 137 | et = ETagMatcher.parse('"ONE", "TWO"') 138 | assert et.etags, ["ONE" == "TWO"] 139 | 140 | def test_parse_commasep_w_weak(self): 141 | et = ETagMatcher.parse('"ONE", W/"TWO"') 142 | assert et.etags == ["ONE"] 143 | et = ETagMatcher.parse('"ONE", W/"TWO"', strong=False) 144 | assert et.etags, ["ONE" == "TWO"] 145 | 146 | def test_parse_quoted(self): 147 | et = ETagMatcher.parse('"ONE"') 148 | assert et.etags == ["ONE"] 149 | 150 | def test_parse_quoted_two(self): 151 | et = ETagMatcher.parse('"ONE", "TWO"') 152 | assert et.etags, ["ONE" == "TWO"] 153 | 154 | def test_parse_quoted_two_weak(self): 155 | et = ETagMatcher.parse('"ONE", W/"TWO"') 156 | assert et.etags == ["ONE"] 157 | et = ETagMatcher.parse('"ONE", W/"TWO"', strong=False) 158 | assert et.etags, ["ONE" == "TWO"] 159 | 160 | 161 | class Test_IfRange: 162 | def test___repr__(self): 163 | assert repr(IfRange(None)) == "IfRange(None)" 164 | 165 | def test___repr__etag(self): 166 | assert repr(IfRange("ETAG")) == "IfRange('ETAG')" 167 | 168 | def test___repr__date(self): 169 | ir = IfRange.parse("Fri, 09 Nov 2001 01:08:47 GMT") 170 | assert ( 171 | repr(ir) 172 | == "IfRangeDate(datetime.datetime(2001, 11, 9, 1, 8, 47, tzinfo=UTC))" 173 | ) 174 | -------------------------------------------------------------------------------- /tests/test_etag_nose.py: -------------------------------------------------------------------------------- 1 | from webob import Response 2 | from webob.etag import ETagMatcher, IfRange 3 | 4 | 5 | def test_if_range_None(): 6 | ir = IfRange.parse(None) 7 | assert str(ir) == "" 8 | assert not ir 9 | assert Response() in ir 10 | assert Response(etag="foo") in ir 11 | assert Response(etag="foo GMT") in ir 12 | 13 | 14 | def test_if_range_match_date(): 15 | date = "Fri, 09 Nov 2001 01:08:47 GMT" 16 | ir = IfRange.parse(date) 17 | assert str(ir) == date 18 | assert Response() not in ir 19 | assert Response(etag="etag") not in ir 20 | assert Response(etag=date) not in ir 21 | assert Response(last_modified="Fri, 09 Nov 2001 01:00:00 GMT") in ir 22 | assert Response(last_modified="Fri, 10 Nov 2001 01:00:00 GMT") not in ir 23 | 24 | 25 | def test_if_range_match_etag(): 26 | ir = IfRange.parse("ETAG") 27 | assert str(ir) == '"ETAG"' 28 | assert Response() not in ir 29 | assert Response(etag="other") not in ir 30 | assert Response(etag="ETAG") in ir 31 | assert Response(etag='W/"ETAG"') not in ir 32 | 33 | 34 | def test_if_range_match_etag_weak(): 35 | ir = IfRange.parse('W/"ETAG"') 36 | assert str(ir) == "" 37 | assert Response(etag="ETAG") not in ir 38 | assert Response(etag='W/"ETAG"') not in ir 39 | 40 | 41 | def test_if_range_repr(): 42 | assert repr(IfRange.parse(None)) == "IfRange()" 43 | assert str(IfRange.parse(None)) == "" 44 | 45 | 46 | def test_resp_etag(): 47 | def t(tag, res, raw, strong): 48 | assert Response(etag=tag).etag == res 49 | assert Response(etag=tag).headers.get("etag") == raw 50 | assert Response(etag=tag).etag_strong == strong 51 | 52 | t("foo", "foo", '"foo"', "foo") 53 | t('"foo"', "foo", '"foo"', "foo") 54 | t('a"b', 'a"b', '"a\\"b"', 'a"b') 55 | t('W/"foo"', "foo", 'W/"foo"', None) 56 | t('W/"a\\"b"', 'a"b', 'W/"a\\"b"', None) 57 | t(("foo", True), "foo", '"foo"', "foo") 58 | t(("foo", False), "foo", 'W/"foo"', None) 59 | t(('"foo"', True), '"foo"', r'"\"foo\""', '"foo"') 60 | t(('W/"foo"', True), 'W/"foo"', r'"W/\"foo\""', 'W/"foo"') 61 | t(('W/"foo"', False), 'W/"foo"', r'W/"W/\"foo\""', None) 62 | 63 | 64 | def test_matcher(): 65 | matcher = ETagMatcher(["ETAGS"]) 66 | matcher = ETagMatcher(["ETAGS"]) 67 | assert matcher.etags == ["ETAGS"] 68 | assert "ETAGS" in matcher 69 | assert "WEAK" not in matcher 70 | assert "BEER" not in matcher 71 | assert None not in matcher 72 | assert repr(matcher) == "" 73 | assert str(matcher) == '"ETAGS"' 74 | 75 | matcher2 = ETagMatcher(("ETAG1", "ETAG2")) 76 | assert repr(matcher2) == "" 77 | -------------------------------------------------------------------------------- /tests/test_headers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from webob import headers 4 | 5 | 6 | def test_ResponseHeaders_delitem_notpresent(): 7 | """Deleting a missing key from ResponseHeaders should raise a KeyError""" 8 | d = headers.ResponseHeaders() 9 | with pytest.raises(KeyError): 10 | d.__delitem__("b") 11 | 12 | 13 | def test_ResponseHeaders_delitem_present(): 14 | """ 15 | Deleting a present key should not raise an error at all 16 | """ 17 | d = headers.ResponseHeaders(a=1) 18 | del d["a"] 19 | assert "a" not in d 20 | 21 | 22 | def test_ResponseHeaders_setdefault(): 23 | """Testing set_default for ResponseHeaders""" 24 | d = headers.ResponseHeaders(a=1) 25 | res = d.setdefault("b", 1) 26 | assert res == d["b"] == 1 27 | res = d.setdefault("b", 10) 28 | assert res == d["b"] == 1 29 | res = d.setdefault("B", 10) 30 | assert res == d["b"] == d["B"] == 1 31 | 32 | 33 | def test_ResponseHeader_pop(): 34 | """Testing if pop return TypeError when more than len(*args)>1 plus other 35 | assorted tests""" 36 | d = headers.ResponseHeaders(a=1, b=2, c=3, d=4) 37 | with pytest.raises(TypeError): 38 | d.pop("a", "z", "y") 39 | assert d.pop("a") == 1 40 | assert "a" not in d 41 | assert d.pop("B") == 2 42 | assert "b" not in d 43 | assert d.pop("c", "u") == 3 44 | assert "c" not in d 45 | assert d.pop("e", "u") == "u" 46 | assert "e" not in d 47 | with pytest.raises(KeyError): 48 | d.pop("z") 49 | 50 | 51 | def test_ResponseHeaders_getitem_miss(): 52 | d = headers.ResponseHeaders() 53 | with pytest.raises(KeyError): 54 | d.__getitem__("a") 55 | 56 | 57 | def test_ResponseHeaders_getall(): 58 | d = headers.ResponseHeaders() 59 | d.add("a", 1) 60 | d.add("a", 2) 61 | result = d.getall("a") 62 | assert result == [1, 2] 63 | 64 | 65 | def test_ResponseHeaders_mixed(): 66 | d = headers.ResponseHeaders() 67 | d.add("a", 1) 68 | d.add("a", 2) 69 | d["b"] = 1 70 | result = d.mixed() 71 | assert result == {"a": [1, 2], "b": 1} 72 | 73 | 74 | def test_ResponseHeaders_setitem_scalar_replaces_seq(): 75 | d = headers.ResponseHeaders() 76 | d.add("a", 2) 77 | d["a"] = 1 78 | result = d.getall("a") 79 | assert result == [1] 80 | 81 | 82 | def test_ResponseHeaders_contains(): 83 | d = headers.ResponseHeaders() 84 | d["a"] = 1 85 | assert "a" in d 86 | assert "b" not in d 87 | 88 | 89 | def test_EnvironHeaders_delitem(): 90 | d = headers.EnvironHeaders({"CONTENT_LENGTH": "10"}) 91 | del d["CONTENT-LENGTH"] 92 | assert not d 93 | with pytest.raises(KeyError): 94 | d.__delitem__("CONTENT-LENGTH") 95 | 96 | 97 | def test_EnvironHeaders_getitem(): 98 | d = headers.EnvironHeaders({"CONTENT_LENGTH": "10"}) 99 | assert d["CONTENT-LENGTH"] == "10" 100 | 101 | 102 | def test_EnvironHeaders_setitem(): 103 | d = headers.EnvironHeaders({}) 104 | d["abc"] = "10" 105 | assert d["abc"] == "10" 106 | 107 | 108 | def test_EnvironHeaders_contains(): 109 | d = headers.EnvironHeaders({}) 110 | d["a"] = "10" 111 | assert "a" in d 112 | assert "b" not in d 113 | 114 | 115 | def test__trans_key_not_basestring(): 116 | result = headers._trans_key(None) 117 | assert result is None 118 | 119 | 120 | def test__trans_key_not_a_header(): 121 | result = headers._trans_key("") 122 | assert result is None 123 | 124 | 125 | def test__trans_key_key2header(): 126 | result = headers._trans_key("CONTENT_TYPE") 127 | assert result == "Content-Type" 128 | 129 | 130 | def test__trans_key_httpheader(): 131 | result = headers._trans_key("HTTP_FOO_BAR") 132 | assert result == "Foo-Bar" 133 | -------------------------------------------------------------------------------- /tests/test_in_wsgiref.py: -------------------------------------------------------------------------------- 1 | import cgi 2 | import logging 3 | import socket 4 | import sys 5 | from urllib.request import urlopen as url_open 6 | 7 | import pytest 8 | 9 | from webob.compat import Empty, Queue 10 | from webob.request import Request 11 | from webob.response import Response 12 | from webob.util import bytes_ 13 | 14 | log = logging.getLogger(__name__) 15 | 16 | 17 | @pytest.mark.usefixtures("serve") 18 | def test_request_reading(serve): 19 | """ 20 | Test actual request/response cycle in the presence of Request.copy() 21 | and other methods that can potentially hang. 22 | """ 23 | with serve(_test_app_req_reading) as server: 24 | for key in _test_ops_req_read: 25 | resp = url_open(server.url + key, timeout=3) 26 | assert resp.read() == b"ok" 27 | 28 | 29 | def _test_app_req_reading(env, sr): 30 | req = Request(env) 31 | log.debug("starting test operation: %s", req.path_info) 32 | test_op = _test_ops_req_read[req.path_info] 33 | test_op(req) 34 | log.debug("done") 35 | r = Response("ok") 36 | 37 | return r(env, sr) 38 | 39 | 40 | _test_ops_req_read = { 41 | "/copy": lambda req: req.copy(), 42 | "/read-all": lambda req: req.body_file.read(), 43 | "/read-0": lambda req: req.body_file.read(0), 44 | "/make-seekable": lambda req: req.make_body_seekable(), 45 | } 46 | 47 | 48 | @pytest.mark.usefixtures("serve") 49 | def test_interrupted_request(serve): 50 | with serve(_test_app_req_interrupt) as server: 51 | for path in _test_ops_req_interrupt: 52 | _send_interrupted_req(server, path) 53 | try: 54 | res = _global_res.get(timeout=1) 55 | except Empty: 56 | raise AssertionError("Error during test %s", path) 57 | 58 | if res is not None: 59 | print("Error during test:", path) 60 | raise res[0](res[1]).with_traceback(res[2]) 61 | 62 | 63 | _global_res = Queue() 64 | 65 | 66 | def _test_app_req_interrupt(env, sr): 67 | target_cl = 100000 68 | try: 69 | req = Request(env) 70 | cl = req.content_length 71 | 72 | if cl != target_cl: 73 | raise AssertionError( 74 | f"request.content_length is {cl} instead of {target_cl}" 75 | ) 76 | op = _test_ops_req_interrupt[req.path_info] 77 | log.info("Running test: %s", req.path_info) 78 | with pytest.raises(IOError): 79 | op(req) 80 | except BaseException: 81 | _global_res.put(sys.exc_info()) 82 | else: 83 | _global_res.put(None) 84 | sr("200 OK", []) 85 | 86 | return [] 87 | 88 | 89 | def _req_int_cgi(req): 90 | assert req.body_file.read(0) == b"" 91 | cgi.FieldStorage(fp=req.body_file, environ=req.environ) 92 | 93 | 94 | def _req_int_readline(req): 95 | try: 96 | assert req.body_file.readline() == b"a=b\n" 97 | except OSError: 98 | # too early to detect disconnect 99 | raise AssertionError("False disconnect alert") 100 | req.body_file.readline() 101 | 102 | 103 | _test_ops_req_interrupt = { 104 | "/copy": lambda req: req.copy(), 105 | "/read-body": lambda req: req.body, 106 | "/read-post": lambda req: req.POST, 107 | "/read-all": lambda req: req.body_file.read(), 108 | "/read-too-much": lambda req: req.body_file.read(1 << 22), 109 | "/readline": _req_int_readline, 110 | "/readlines": lambda req: req.body_file.readlines(), 111 | "/read-cgi": _req_int_cgi, 112 | "/make-seekable": lambda req: req.make_body_seekable(), 113 | } 114 | 115 | 116 | def _send_interrupted_req(server, path="/"): 117 | sock = socket.socket() 118 | sock.connect(("localhost", server.server_port)) 119 | f = sock.makefile("wb") 120 | f.write(bytes_(_interrupted_req % path)) 121 | f.flush() 122 | f.close() 123 | sock.close() 124 | 125 | 126 | _interrupted_req = ( 127 | "POST %s HTTP/1.0\r\n" 128 | "content-type: application/x-www-form-urlencoded\r\n" 129 | "content-length: 100000\r\n" 130 | "\r\n" 131 | ) 132 | _interrupted_req += "a=b\nz=" + "x" * 10000 133 | -------------------------------------------------------------------------------- /tests/test_misc.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from webob.util import html_escape, text_ 4 | 5 | 6 | class t_esc_HTML: 7 | def __html__(self): 8 | return "
hello
" 9 | 10 | 11 | class t_esc_Unicode: 12 | def __str__(self): 13 | return "\xe9" 14 | 15 | 16 | class t_esc_UnsafeAttrs: 17 | attr = "value" 18 | 19 | def __getattr__(self, k): 20 | return self.attr 21 | 22 | def __repr__(self): 23 | return "" 24 | 25 | 26 | class t_esc_SuperMoose: 27 | def __str__(self): 28 | return "m\xf8ose" 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "input,expected", 33 | [ 34 | ('these chars: < > & "', "these chars: < > & ""), 35 | (" ", " "), 36 | ("è", "&egrave;"), 37 | # The apostrophe is *not* escaped, which some might consider to be 38 | # a serious bug (see, e.g. http://www.cvedetails.com/cve/CVE-2010-2480/) 39 | pytest.param("'", "'"), 40 | ("the majestic m\xf8ose", "the majestic møose"), 41 | # 8-bit strings are passed through 42 | ("\xe9", "é"), 43 | # ``None`` is treated specially, and returns the empty string. 44 | (None, ""), 45 | # Objects that define a ``__html__`` method handle their own escaping 46 | (t_esc_HTML(), "
hello
"), 47 | # Things that are not strings are converted to strings and then escaped 48 | (42, "42"), 49 | (t_esc_SuperMoose(), "møose"), 50 | (t_esc_Unicode(), "é"), 51 | (t_esc_UnsafeAttrs(), "<UnsafeAttrs>"), 52 | pytest.param(Exception("expected a '<'."), "expected a '<'."), 53 | ], 54 | ) 55 | def test_html_escape(input, expected): 56 | assert expected == html_escape(input) 57 | -------------------------------------------------------------------------------- /tests/test_static.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | import os 3 | from os.path import getmtime 4 | import shutil 5 | import tempfile 6 | from time import gmtime 7 | 8 | import pytest 9 | 10 | from webob import static 11 | from webob.request import Request, environ_from_url 12 | from webob.response import Response 13 | from webob.util import bytes_ 14 | 15 | 16 | def get_response(app, path="/", **req_kw): 17 | """Convenient function to query an application""" 18 | req = Request(environ_from_url(path), **req_kw) 19 | 20 | return req.get_response(app) 21 | 22 | 23 | def create_file(content, *paths): 24 | """Convenient function to create a new file with some content""" 25 | path = os.path.join(*paths) 26 | with open(path, "wb") as fp: 27 | fp.write(bytes_(content)) 28 | 29 | return path 30 | 31 | 32 | class TestFileApp: 33 | def setup_method(self, method): 34 | fp = tempfile.NamedTemporaryFile(suffix=".py", delete=False) 35 | self.tempfile = fp.name 36 | fp.write(b"import this\n") 37 | fp.close() 38 | 39 | def tearDown(self): 40 | os.unlink(self.tempfile) 41 | 42 | def test_fileapp(self): 43 | app = static.FileApp(self.tempfile) 44 | resp1 = get_response(app) 45 | assert resp1.content_type in ("text/x-python", "text/plain") 46 | assert resp1.charset == "UTF-8" 47 | assert resp1.last_modified.timetuple() == gmtime(getmtime(self.tempfile)) 48 | assert resp1.body == b"import this\n" 49 | 50 | resp2 = get_response(app) 51 | assert resp2.content_type in ("text/x-python", "text/plain") 52 | assert resp2.last_modified.timetuple() == gmtime(getmtime(self.tempfile)) 53 | assert resp2.body == b"import this\n" 54 | 55 | resp3 = get_response(app, range=(7, 11)) 56 | assert resp3.status_code == 206 57 | assert tuple(resp3.content_range)[:2] == (7, 11) 58 | assert resp3.last_modified.timetuple() == gmtime(getmtime(self.tempfile)) 59 | assert resp3.body == bytes_("this") 60 | 61 | def test_unexisting_file(self): 62 | app = static.FileApp("/tmp/this/doesnt/exist") 63 | assert 404 == get_response(app).status_code 64 | 65 | def test_allowed_methods(self): 66 | app = static.FileApp(self.tempfile) 67 | 68 | # Alias 69 | def resp(method): 70 | return get_response(app, method=method) 71 | 72 | assert 200 == resp(method="GET").status_code 73 | assert 200 == resp(method="HEAD").status_code 74 | assert 405 == resp(method="POST").status_code 75 | # Actually any other method is not allowed 76 | assert 405 == resp(method="xxx").status_code 77 | 78 | def test_exception_while_opening_file(self): 79 | # Mock the built-in ``open()`` function to allow finner control about 80 | # what we are testing. 81 | def open_ioerror(*args, **kwargs): 82 | raise OSError() 83 | 84 | def open_oserror(*args, **kwargs): 85 | raise OSError() 86 | 87 | app = static.FileApp(self.tempfile) 88 | 89 | app._open = open_ioerror 90 | assert 403 == get_response(app).status_code 91 | 92 | app._open = open_oserror 93 | assert 403 == get_response(app).status_code 94 | 95 | def test_use_wsgi_filewrapper(self): 96 | class TestWrapper: 97 | __slots__ = ("file", "block_size") 98 | 99 | def __init__(self, file, block_size): 100 | self.file = file 101 | self.block_size = block_size 102 | 103 | environ = environ_from_url("/") 104 | environ["wsgi.file_wrapper"] = TestWrapper 105 | app = static.FileApp(self.tempfile) 106 | app_iter = Request(environ).get_response(app).app_iter 107 | 108 | assert isinstance(app_iter, TestWrapper) 109 | assert bytes_("import this\n") == app_iter.file.read() 110 | assert static.BLOCK_SIZE == app_iter.block_size 111 | 112 | 113 | class TestFileIter: 114 | def test_empty_file(self): 115 | fp = BytesIO() 116 | fi = static.FileIter(fp) 117 | pytest.raises(StopIteration, next, iter(fi)) 118 | 119 | def test_seek(self): 120 | fp = BytesIO(bytes_("0123456789")) 121 | i = static.FileIter(fp).app_iter_range(seek=4) 122 | 123 | assert bytes_("456789") == next(i) 124 | pytest.raises(StopIteration, next, i) 125 | 126 | def test_limit(self): 127 | fp = BytesIO(bytes_("0123456789")) 128 | i = static.FileIter(fp).app_iter_range(limit=4) 129 | 130 | assert bytes_("0123") == next(i) 131 | pytest.raises(StopIteration, next, i) 132 | 133 | def test_limit_and_seek(self): 134 | fp = BytesIO(bytes_("0123456789")) 135 | i = static.FileIter(fp).app_iter_range(limit=4, seek=1) 136 | 137 | assert bytes_("123") == next(i) 138 | pytest.raises(StopIteration, next, i) 139 | 140 | def test_multiple_reads(self): 141 | fp = BytesIO(bytes_("012")) 142 | i = static.FileIter(fp).app_iter_range(block_size=1) 143 | 144 | assert bytes_("0") == next(i) 145 | assert bytes_("1") == next(i) 146 | assert bytes_("2") == next(i) 147 | pytest.raises(StopIteration, next, i) 148 | 149 | def test_seek_bigger_than_limit(self): 150 | fp = BytesIO(bytes_("0123456789")) 151 | i = static.FileIter(fp).app_iter_range(limit=1, seek=2) 152 | 153 | # XXX: this should not return anything actually, since we are starting 154 | # to read after the place we wanted to stop. 155 | assert bytes_("23456789") == next(i) 156 | pytest.raises(StopIteration, next, i) 157 | 158 | def test_limit_is_zero(self): 159 | fp = BytesIO(bytes_("0123456789")) 160 | i = static.FileIter(fp).app_iter_range(limit=0) 161 | 162 | pytest.raises(StopIteration, next, i) 163 | 164 | 165 | class TestDirectoryApp: 166 | def setup_method(self, method): 167 | self.test_dir = tempfile.mkdtemp() 168 | 169 | def tearDown(self): 170 | shutil.rmtree(self.test_dir) 171 | 172 | def test_empty_directory(self): 173 | app = static.DirectoryApp(self.test_dir) 174 | assert 404 == get_response(app).status_code 175 | assert 404 == get_response(app, "/foo").status_code 176 | 177 | def test_serve_file(self): 178 | app = static.DirectoryApp(self.test_dir) 179 | create_file("abcde", self.test_dir, "bar") 180 | assert 404 == get_response(app).status_code 181 | assert 404 == get_response(app, "/foo").status_code 182 | 183 | resp = get_response(app, "/bar") 184 | assert 200 == resp.status_code 185 | assert bytes_("abcde") == resp.body 186 | 187 | def test_dont_serve_file_in_parent_directory(self): 188 | # We'll have: 189 | # /TEST_DIR/ 190 | # /TEST_DIR/bar 191 | # /TEST_DIR/foo/ <- serve this directory 192 | create_file("abcde", self.test_dir, "bar") 193 | serve_path = os.path.join(self.test_dir, "foo") 194 | os.mkdir(serve_path) 195 | app = static.DirectoryApp(serve_path) 196 | 197 | # The file exists, but is outside the served dir. 198 | assert 403 == get_response(app, "/../bar").status_code 199 | 200 | def test_dont_leak_parent_directory_file_existance(self): 201 | # We'll have: 202 | # /TEST_DIR/ 203 | # /TEST_DIR/foo/ <- serve this directory 204 | serve_path = os.path.join(self.test_dir, "foo") 205 | os.mkdir(serve_path) 206 | app = static.DirectoryApp(serve_path) 207 | 208 | # The file exists, but is outside the served dir. 209 | assert 403 == get_response(app, "/../bar2").status_code 210 | 211 | def test_file_app_arguments(self): 212 | app = static.DirectoryApp(self.test_dir, content_type="xxx/yyy") 213 | create_file("abcde", self.test_dir, "bar") 214 | 215 | resp = get_response(app, "/bar") 216 | assert 200 == resp.status_code 217 | assert "xxx/yyy" == resp.content_type 218 | 219 | def test_file_app_factory(self): 220 | def make_fileapp(*args, **kwargs): 221 | make_fileapp.called = True 222 | 223 | return Response() 224 | 225 | make_fileapp.called = False 226 | 227 | app = static.DirectoryApp(self.test_dir) 228 | app.make_fileapp = make_fileapp 229 | create_file("abcde", self.test_dir, "bar") 230 | 231 | get_response(app, "/bar") 232 | assert make_fileapp.called 233 | 234 | def test_must_serve_directory(self): 235 | serve_path = create_file("abcde", self.test_dir, "bar") 236 | pytest.raises(IOError, static.DirectoryApp, serve_path) 237 | 238 | def test_index_page(self): 239 | os.mkdir(os.path.join(self.test_dir, "index-test")) 240 | create_file(bytes_("index"), self.test_dir, "index-test", "index.html") 241 | app = static.DirectoryApp(self.test_dir) 242 | resp = get_response(app, "/index-test") 243 | assert resp.status_code == 301 244 | assert resp.location.endswith("/index-test/") 245 | resp = get_response(app, "/index-test?test") 246 | assert resp.location.endswith("/index-test/?test") 247 | resp = get_response(app, "/index-test/") 248 | assert resp.status_code == 200 249 | assert resp.body == bytes_("index") 250 | assert resp.content_type == "text/html" 251 | resp = get_response(app, "/index-test/index.html") 252 | assert resp.status_code == 200 253 | assert resp.body == bytes_("index") 254 | redir_app = static.DirectoryApp(self.test_dir, hide_index_with_redirect=True) 255 | resp = get_response(redir_app, "/index-test/index.html") 256 | assert resp.status_code == 301 257 | assert resp.location.endswith("/index-test/") 258 | resp = get_response(redir_app, "/index-test/index.html?test") 259 | assert resp.location.endswith("/index-test/?test") 260 | page_app = static.DirectoryApp(self.test_dir, index_page="something-else.html") 261 | assert get_response(page_app, "/index-test/").status_code == 404 262 | -------------------------------------------------------------------------------- /tests/test_transcode.py: -------------------------------------------------------------------------------- 1 | from webob.request import Request, Transcoder 2 | from webob.response import Response 3 | from webob.util import text_ 4 | 5 | t1 = ( 6 | b'--BOUNDARY\r\nContent-Disposition: form-data; name="a"\r\n\r\n\xea\xf3...' 7 | b"\r\n--BOUNDARY--" 8 | ) 9 | t2 = ( 10 | b'--BOUNDARY\r\nContent-Disposition: form-data; name="a"; filename="file"\r\n' 11 | b"\r\n\xea\xf3...\r\n--BOUNDARY--" 12 | ) 13 | t3 = ( 14 | b'--BOUNDARY\r\nContent-Disposition: form-data; name="a"; filename="\xea\xf3...' 15 | b'"\r\n\r\nfoo\r\n--BOUNDARY--' 16 | ) 17 | 18 | 19 | def test_transcode(): 20 | def tapp(env, sr): 21 | req = Request(env) 22 | req = req.decode() 23 | 24 | v = req.POST[req.query_string] 25 | 26 | if hasattr(v, "filename"): 27 | r = Response(text_(f"{v.filename}\n{v.value!r}")) 28 | else: 29 | r = Response(v) 30 | 31 | return r(env, sr) 32 | 33 | text = b"\xea\xf3...".decode("cp1251") 34 | 35 | def test(post): 36 | req = Request.blank("/?a", POST=post) 37 | req.environ["CONTENT_TYPE"] = ( 38 | "multipart/form-data; charset=windows-1251; boundary=BOUNDARY" 39 | ) 40 | 41 | return req.get_response(tapp) 42 | 43 | r = test(t1) 44 | assert r.text == text 45 | r = test(t2) 46 | assert r.text == "file\n%r" % text.encode("cp1251") 47 | r = test(t3) 48 | assert r.text == "{}\n{!r}".format(text, b"foo") 49 | 50 | 51 | def test_transcode_query(): 52 | req = Request.blank("/?%EF%F0%E8=%E2%E5%F2") 53 | req2 = req.decode("cp1251") 54 | assert req2.query_string == "%D0%BF%D1%80%D0%B8=%D0%B2%D0%B5%D1%82" 55 | 56 | 57 | def test_transcode_non_multipart(): 58 | req = Request.blank("/?a", POST="%EF%F0%E8=%E2%E5%F2") 59 | req._content_type_raw = "application/x-www-form-urlencoded" 60 | req2 = req.decode("cp1251") 61 | assert text_(req2.body) == "%D0%BF%D1%80%D0%B8=%D0%B2%D0%B5%D1%82" 62 | 63 | 64 | def test_transcode_non_form(): 65 | req = Request.blank("/?a", POST="%EF%F0%E8=%E2%E5%F2") 66 | req._content_type_raw = "application/x-foo" 67 | req2 = req.decode("cp1251") 68 | assert text_(req2.body) == "%EF%F0%E8=%E2%E5%F2" 69 | 70 | 71 | def test_transcode_noop(): 72 | req = Request.blank("/") 73 | assert req.decode() is req 74 | 75 | 76 | def test_transcode_query_ascii(): 77 | t = Transcoder("ascii") 78 | assert t.transcode_query("a") == "a" 79 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | import pytest 4 | 5 | from webob.response import Response 6 | from webob.util import warn_deprecation 7 | 8 | 9 | class Test_warn_deprecation: 10 | def setup_method(self, method): 11 | self.oldwarn = warnings.warn 12 | warnings.warn = self._warn 13 | self.warnings = [] 14 | 15 | def tearDown(self): 16 | warnings.warn = self.oldwarn 17 | del self.warnings 18 | 19 | def _callFUT(self, text, version, stacklevel): 20 | return warn_deprecation(text, version, stacklevel) 21 | 22 | def _warn(self, text, type, stacklevel=1): 23 | self.warnings.append(locals()) 24 | 25 | def test_multidict_update_warning(self): 26 | # test warning when duplicate keys are passed 27 | r = Response() 28 | r.headers.update([("Set-Cookie", "a=b"), ("Set-Cookie", "x=y")]) 29 | assert len(self.warnings) == 1 30 | deprecation_warning = self.warnings[0] 31 | assert deprecation_warning["type"] == UserWarning 32 | assert "Consider using .extend()" in deprecation_warning["text"] 33 | 34 | def test_multidict_update_warning_unnecessary(self): 35 | # no warning on normal operation 36 | r = Response() 37 | r.headers.update([("Set-Cookie", "a=b")]) 38 | assert len(self.warnings) == 0 39 | 40 | def test_warn_deprecation(self): 41 | v = "1.3.0" 42 | 43 | pytest.raises(DeprecationWarning, warn_deprecation, "foo", v[:3], 1) 44 | 45 | def test_warn_deprecation_future_version(self): 46 | v = "9.9.9" 47 | 48 | warn_deprecation("foo", v[:3], 1) 49 | assert len(self.warnings) == 1 50 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | lint, 4 | py39,py310,py311,py312,py313,pypy39,pypy310, 5 | coverage, 6 | docs, 7 | isolated_build = True 8 | 9 | [testenv] 10 | commands = 11 | python --version 12 | python -m pytest \ 13 | pypy39: --no-cov \ 14 | pypy310: --no-cov \ 15 | {posargs:} 16 | extras = 17 | testing 18 | setenv = 19 | COVERAGE_FILE=.coverage.{envname} 20 | 21 | [testenv:coverage] 22 | skip_install = True 23 | commands = 24 | coverage combine 25 | coverage xml 26 | coverage report --fail-under=100 27 | deps = 28 | coverage 29 | setenv = 30 | COVERAGE_FILE=.coverage 31 | 32 | [testenv:lint] 33 | skip_install = True 34 | commands = 35 | black --check --diff src/webob tests setup.py 36 | isort --check-only --df src/webob tests setup.py 37 | check-manifest 38 | # flake8 src/webob/ tests setup.py 39 | # build sdist/wheel 40 | python -m build . 41 | twine check dist/* 42 | deps = 43 | black 44 | build 45 | check-manifest 46 | flake8 47 | flake8-bugbear 48 | isort 49 | readme_renderer 50 | twine 51 | 52 | [testenv:docs] 53 | allowlist_externals = 54 | make 55 | commands = 56 | make -C docs html BUILDDIR={envdir} SPHINXOPTS="-W -E" 57 | extras = 58 | docs 59 | 60 | [testenv:run-flake8] 61 | skip_install = True 62 | commands = 63 | flake8 src/webob/ tests 64 | deps = 65 | flake8 66 | flake8-bugbear 67 | 68 | [testenv:run-format] 69 | skip_install = True 70 | commands = 71 | isort src/webob tests setup.py 72 | black src/webob tests setup.py 73 | deps = 74 | black 75 | isort 76 | 77 | [testenv:build] 78 | skip_install = true 79 | commands = 80 | # clean up build/ and dist/ folders 81 | python -c 'import shutil; shutil.rmtree("build", ignore_errors=True)' 82 | # Make sure we aren't forgetting anything 83 | check-manifest 84 | # build sdist/wheel 85 | python -m build . 86 | # Verify all is well 87 | twine check dist/* 88 | 89 | deps = 90 | build 91 | check-manifest 92 | readme_renderer 93 | twine 94 | --------------------------------------------------------------------------------