twill
Advanced tools
Sorry, the diff of this file is not supported yet
+17
-1
@@ -7,2 +7,16 @@ .. _changelog: | ||
| 3.3.1 (released 2025-09-07) | ||
| --------------------------- | ||
| * The supported Python versions are now 3.8 to 3.14. | ||
| 3.3 (released 2024-10-13) | ||
| ------------------------- | ||
| * The supported Python versions are now 3.8 to 3.13. | ||
| * Saving and loading of cookies has been made more robust, and it now also | ||
| incorporates the domains and paths of the cookies. | ||
| * Twill now uses Flask_ instead of Quixote_ as test server for running its own | ||
| test suite. | ||
| * Twill now uses 'argparse' instead of the deprecated 'optparse' to parse | ||
| options and arguments passed on the command line. | ||
| 3.2.5 (released 2024-06-28) | ||
@@ -150,3 +164,3 @@ --------------------------- | ||
| this is set to 2, so refresh intervals of 2 or more seconds are ignored. | ||
| * Moved the examples and additional stuff into an 'extras' directory. | ||
| * Moved the examples and additional stuff into an 'extras' directory. | ||
| * The documentation in the 'docs' directory has been updated and is now | ||
@@ -169,1 +183,3 @@ created with Sphinx_. | ||
| .. _tox: https://tox.readthedocs.io/ | ||
| .. _Quixote: https://github.com/nascheme/quixote | ||
| .. _Flask: https://flask.palletsprojects.com/ |
+7
-12
@@ -10,10 +10,6 @@ """Configuration file for the Sphinx documentation builder. | ||
| import os | ||
| import sys | ||
| from pathlib import Path | ||
| sys.path.append( | ||
| os.path.join( | ||
| os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src" | ||
| ) | ||
| ) | ||
| sys.path.append(str(Path(__file__).resolve().parent.parent / "src")) | ||
@@ -23,11 +19,10 @@ # -- Project information ----------------------------------------------------- | ||
| def project_version(): | ||
| def project_version() -> str: | ||
| """Fetch version from pyproject.toml file.""" | ||
| # this also works when the package is not installed | ||
| with open("../pyproject.toml") as toml_file: | ||
| with open("../pyproject.toml", encoding="utf-8") as toml_file: | ||
| for line in toml_file: | ||
| if line.startswith("version ="): | ||
| version = line.split("=")[1].strip().strip('"') | ||
| return version | ||
| raise Exception("Cannot determine project version") | ||
| return line.split("=")[1].strip().strip('"') | ||
| raise RuntimeError("Cannot determine project version") | ||
@@ -37,3 +32,3 @@ | ||
| author = "C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al" | ||
| copyright = "2024, " + author | ||
| copyright = "2025, " + author | ||
@@ -40,0 +35,0 @@ # The full version, including alpha/beta/rc tags |
@@ -14,3 +14,3 @@ .. _developer: | ||
| twill comes with several unit tests. They depend on `pytest`_ and | ||
| `Quixote`_. To run them, type 'pytest' in the top level directory. | ||
| `Flask`_. To run them, type 'pytest' in the top level directory. | ||
| To run an individual test, you can use the command | ||
@@ -20,3 +20,3 @@ ``pytest tests/test_something.py``. | ||
| .. _pytest: https://pytest.org/ | ||
| .. _Quixote: https://github.com/nascheme/quixote | ||
| .. _Flask: https://flask.palletsprojects.com/ | ||
@@ -34,3 +34,3 @@ Licensing | ||
| Newer versions 1.x, 2.x and 3.x are also Copyright (C) 2007-2024 | ||
| Newer versions 1.x, 2.x and 3.x are also Copyright (C) 2007-2025 | ||
| Ben R. Taylor, Adam V. Brandizzi, Christoph Zwerschke et al. | ||
@@ -37,0 +37,0 @@ |
+0
-2
@@ -79,3 +79,2 @@ .. _other: | ||
| .. _PyXPCOM: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Language_bindings/PyXPCOM | ||
| .. _Quixote: http://www.mems-exchange.org/software/quixote/ | ||
| .. _httpx: https://www.python-httpx.org/ | ||
@@ -92,2 +91,1 @@ .. _Selenium: http://www.seleniumhq.org/ | ||
| .. _zope.testbrowser: https://pypi.python.org/pypi/zope.testbrowser | ||
+18
-14
@@ -76,5 +76,6 @@ .. _testing: | ||
| import os | ||
| import twill | ||
| from quixote.server.simple_server import run as quixote_run | ||
| from .server import app # a Flask app used as test server | ||
| from .utils import test_dir # directory with test scripts | ||
| PORT=8090 # port to run the server on | ||
@@ -84,25 +85,28 @@ | ||
| """Function to run the server""" | ||
| quixote_run(twill.tests.server.create_publisher, port=PORT) | ||
| app.run(host=HOST, port=PORT) | ||
| def test(): | ||
| """The unit test""" | ||
| test_dir = twill.tests.utils.testdir | ||
| """The test function""" | ||
| test_dir = twill.tests.utils.test_dir | ||
| script = os.path.join(test_dir, 'test_unit_support.twill') | ||
| # create test_info object | ||
| # create a TestInfo object | ||
| test_info = twill.unit.TestInfo(script, run_server, PORT) | ||
| # run tests! | ||
| # run the tests! | ||
| twill.unit.run_test(test_info) | ||
| Here, I'm unit testing the Quixote application ``twill.tests.server``, which | ||
| is run by ``quixote_run`` (a.k.a. ``quixote.server.simple_server.run``) on | ||
| port ``PORT``, using the twill script ``test_unit_support.twill``. That | ||
| script contains this code:: | ||
| Here, I'm unit testing the Flask_ application ``.server`` in the ``tests`` | ||
| directory, which is run on the specified ``PORT``, using the twill script | ||
| ``test_unit_support.twill``. That script contains this code:: | ||
| # starting URL is provided to it by the unit test support framework. | ||
| # starting URL is provided to it by the unit test support framework. | ||
| go ./multisubmitform | ||
| code 200 | ||
| go ./multisubmitform | ||
| code 200 | ||
| fv 1 sub_a click | ||
| submit | ||
| find "used_sub_a" | ||
| A few things to note: | ||
@@ -109,0 +113,0 @@ |
+1
-1
| The MIT License, https://opensource.org/licenses/MIT | ||
| Copyright 2005-2024 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
| Copyright 2005-2025 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
@@ -5,0 +5,0 @@ Permission is hereby granted, free of charge, to any person obtaining a copy |
+13
-10
@@ -1,4 +0,4 @@ | ||
| Metadata-Version: 2.1 | ||
| Metadata-Version: 2.4 | ||
| Name: twill | ||
| Version: 3.2.5 | ||
| Version: 3.3.1 | ||
| Summary: A web browsing and testing language | ||
@@ -9,3 +9,3 @@ Author: C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
| Copyright 2005-2024 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
| Copyright 2005-2025 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
@@ -51,2 +51,4 @@ Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| Classifier: Programming Language :: Python :: 3.12 | ||
| Classifier: Programming Language :: Python :: 3.13 | ||
| Classifier: Programming Language :: Python :: 3.14 | ||
| Classifier: Programming Language :: Other Scripting Engines | ||
@@ -58,8 +60,8 @@ Classifier: Topic :: Internet :: WWW/HTTP | ||
| License-File: LICENSE.txt | ||
| Requires-Dist: lxml<6,>=5.2 | ||
| Requires-Dist: httpx<1,>=0.27.0 | ||
| Requires-Dist: lxml<7,>=6 | ||
| Requires-Dist: httpx<1,>=0.28.1 | ||
| Requires-Dist: pyparsing<4,>=3.1 | ||
| Provides-Extra: docs | ||
| Requires-Dist: sphinx<8,>=7.3; extra == "docs" | ||
| Requires-Dist: sphinx_rtd_theme<3,>=2; extra == "docs" | ||
| Requires-Dist: sphinx<9,>=8.2; extra == "docs" | ||
| Requires-Dist: sphinx_rtd_theme<4,>=3; extra == "docs" | ||
| Provides-Extra: tidy | ||
@@ -71,3 +73,4 @@ Requires-Dist: pytidylib<0.4,>=0.3; extra == "tidy" | ||
| Requires-Dist: pytidylib<0.4,>=0.3; extra == "tests" | ||
| Requires-Dist: quixote<4,>=3.6; extra == "tests" | ||
| Requires-Dist: flask<4,>=3.0; extra == "tests" | ||
| Dynamic: license-file | ||
@@ -79,3 +82,3 @@ twill: a simple scripting language for web browsing | ||
| The current version 3.2 supports Python 3.8 to 3.12. | ||
| The current version 3.2 supports Python 3.8 to 3.14. | ||
@@ -86,3 +89,3 @@ Take a look at the [changelog](https://twill-tools.github.io/twill/changelog.html) to find a list of all changes and improvements made since version 2. For a brief overview of twill's history starting from its early days, see the [acknowledgements and history](https://twill-tools.github.io/twill/overview.html#acknowledgements-and-history) section. | ||
| Copyright (c) 2005-2024 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al. | ||
| Copyright (c) 2005-2025 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al. | ||
@@ -89,0 +92,0 @@ Newer versions have been created and are maintained by [Christoph Zwerschke](https://github.com/Cito). |
+10
-8
| [project] | ||
| name = "twill" | ||
| version = "3.2.5" | ||
| version = "3.3.1" | ||
@@ -35,2 +35,4 @@ description = "A web browsing and testing language" | ||
| 'Programming Language :: Python :: 3.12', | ||
| 'Programming Language :: Python :: 3.13', | ||
| 'Programming Language :: Python :: 3.14', | ||
| 'Programming Language :: Other Scripting Engines', | ||
@@ -43,4 +45,4 @@ 'Topic :: Internet :: WWW/HTTP', | ||
| dependencies = [ | ||
| "lxml >=5.2, <6", | ||
| "httpx >=0.27.0, <1", | ||
| "lxml >=6, <7", | ||
| "httpx >=0.28.1, <1", | ||
| "pyparsing >=3.1, <4", | ||
@@ -51,4 +53,4 @@ ] | ||
| docs = [ | ||
| "sphinx >=7.3, <8", | ||
| "sphinx_rtd_theme >=2, <3" | ||
| "sphinx >=8.2, <9", | ||
| "sphinx_rtd_theme >=3, <4" | ||
| ] | ||
@@ -62,3 +64,3 @@ tidy = [ | ||
| "pytidylib >=0.3, <0.4", | ||
| "quixote >=3.6, <4", | ||
| "flask >=3.0, <4", | ||
| ] | ||
@@ -86,3 +88,3 @@ | ||
| [tool.mypy] | ||
| python_version = 3.12 | ||
| python_version = 3.13 | ||
| check_untyped_defs = true | ||
@@ -161,3 +163,2 @@ no_implicit_optional = true | ||
| "ANN002", "ANN003", # no type annotations needed for args and kwargs | ||
| "ANN101", "ANN102", # no type annotation for self and cls needed | ||
| "ANN401", # allow explicit Any | ||
@@ -169,2 +170,3 @@ "COM812", # allow trailing commas for auto-formatting | ||
| "ISC001", # allow string literal concatenation for auto-formatting | ||
| "PLW0603", # allow global statements | ||
| "PTH123", # allow builtin-open | ||
@@ -171,0 +173,0 @@ "TRY003", # allow specific messages outside the exception class |
+2
-2
@@ -6,3 +6,3 @@ twill: a simple scripting language for web browsing | ||
| The current version 3.2 supports Python 3.8 to 3.12. | ||
| The current version 3.2 supports Python 3.8 to 3.14. | ||
@@ -13,3 +13,3 @@ Take a look at the [changelog](https://twill-tools.github.io/twill/changelog.html) to find a list of all changes and improvements made since version 2. For a brief overview of twill's history starting from its early days, see the [acknowledgements and history](https://twill-tools.github.io/twill/overview.html#acknowledgements-and-history) section. | ||
| Copyright (c) 2005-2024 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al. | ||
| Copyright (c) 2005-2025 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al. | ||
@@ -16,0 +16,0 @@ Newer versions have been created and are maintained by [Christoph Zwerschke](https://github.com/Cito). |
+0
-0
@@ -0,0 +0,0 @@ [egg_info] |
@@ -1,4 +0,4 @@ | ||
| Metadata-Version: 2.1 | ||
| Metadata-Version: 2.4 | ||
| Name: twill | ||
| Version: 3.2.5 | ||
| Version: 3.3.1 | ||
| Summary: A web browsing and testing language | ||
@@ -9,3 +9,3 @@ Author: C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
| Copyright 2005-2024 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
| Copyright 2005-2025 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke | ||
@@ -51,2 +51,4 @@ Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| Classifier: Programming Language :: Python :: 3.12 | ||
| Classifier: Programming Language :: Python :: 3.13 | ||
| Classifier: Programming Language :: Python :: 3.14 | ||
| Classifier: Programming Language :: Other Scripting Engines | ||
@@ -58,8 +60,8 @@ Classifier: Topic :: Internet :: WWW/HTTP | ||
| License-File: LICENSE.txt | ||
| Requires-Dist: lxml<6,>=5.2 | ||
| Requires-Dist: httpx<1,>=0.27.0 | ||
| Requires-Dist: lxml<7,>=6 | ||
| Requires-Dist: httpx<1,>=0.28.1 | ||
| Requires-Dist: pyparsing<4,>=3.1 | ||
| Provides-Extra: docs | ||
| Requires-Dist: sphinx<8,>=7.3; extra == "docs" | ||
| Requires-Dist: sphinx_rtd_theme<3,>=2; extra == "docs" | ||
| Requires-Dist: sphinx<9,>=8.2; extra == "docs" | ||
| Requires-Dist: sphinx_rtd_theme<4,>=3; extra == "docs" | ||
| Provides-Extra: tidy | ||
@@ -71,3 +73,4 @@ Requires-Dist: pytidylib<0.4,>=0.3; extra == "tidy" | ||
| Requires-Dist: pytidylib<0.4,>=0.3; extra == "tests" | ||
| Requires-Dist: quixote<4,>=3.6; extra == "tests" | ||
| Requires-Dist: flask<4,>=3.0; extra == "tests" | ||
| Dynamic: license-file | ||
@@ -79,3 +82,3 @@ twill: a simple scripting language for web browsing | ||
| The current version 3.2 supports Python 3.8 to 3.12. | ||
| The current version 3.2 supports Python 3.8 to 3.14. | ||
@@ -86,3 +89,3 @@ Take a look at the [changelog](https://twill-tools.github.io/twill/changelog.html) to find a list of all changes and improvements made since version 2. For a brief overview of twill's history starting from its early days, see the [acknowledgements and history](https://twill-tools.github.io/twill/overview.html#acknowledgements-and-history) section. | ||
| Copyright (c) 2005-2024 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al. | ||
| Copyright (c) 2005-2025 by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al. | ||
@@ -89,0 +92,0 @@ Newer versions have been created and are maintained by [Christoph Zwerschke](https://github.com/Cito). |
@@ -1,8 +0,8 @@ | ||
| lxml<6,>=5.2 | ||
| httpx<1,>=0.27.0 | ||
| lxml<7,>=6 | ||
| httpx<1,>=0.28.1 | ||
| pyparsing<4,>=3.1 | ||
| [docs] | ||
| sphinx<8,>=7.3 | ||
| sphinx_rtd_theme<3,>=2 | ||
| sphinx<9,>=8.2 | ||
| sphinx_rtd_theme<4,>=3 | ||
@@ -13,5 +13,5 @@ [tests] | ||
| pytidylib<0.4,>=0.3 | ||
| quixote<4,>=3.6 | ||
| flask<4,>=3.0 | ||
| [tidy] | ||
| pytidylib<0.4,>=0.3 |
@@ -24,2 +24,3 @@ LICENSE.txt | ||
| extras/examples/extend-example.py | ||
| extras/examples/flaskr-demo.twill | ||
| extras/examples/quixote-demo.twill | ||
@@ -26,0 +27,0 @@ extras/examples/set-user-agent.twill |
@@ -6,3 +6,3 @@ # This file is part of the twill source distribution. | ||
| # | ||
| # Copyright (c) 2005-2024 | ||
| # Copyright (c) 2005-2025 | ||
| # by C. Titus Brown, Ben R. Taylor, Christoph Zwerschke et al. | ||
@@ -33,2 +33,5 @@ # | ||
| __all__ = [ | ||
| "TwillCommandLoop", | ||
| "__url__", | ||
| "__version__", | ||
| "browser", | ||
@@ -38,9 +41,6 @@ "execute_file", | ||
| "log", | ||
| "set_err_out", | ||
| "set_log_level", | ||
| "set_output", | ||
| "set_err_out", | ||
| "twill_ext", | ||
| "TwillCommandLoop", | ||
| "__url__", | ||
| "__version__", | ||
| ] | ||
@@ -89,3 +89,3 @@ | ||
| """ | ||
| global handler # noqa: PLW0603 | ||
| global handler | ||
| if stream is None: | ||
@@ -92,0 +92,0 @@ stream = stdout |
@@ -5,3 +5,2 @@ """Map of various User-Agent string shortcuts that can be used for testing.""" | ||
| # noinspection HttpUrlsUsage | ||
| agents: Dict[str, str] = { | ||
@@ -8,0 +7,0 @@ # Desktop |
+28
-18
@@ -57,3 +57,3 @@ """Implementation of the TwillBrowser.""" | ||
| """Set the debug level for the connection pool.""" | ||
| from http.client import HTTPConnection | ||
| from http.client import HTTPConnection # noqa: PLC0415 | ||
@@ -73,3 +73,3 @@ HTTPConnection.debuglevel = level | ||
| follow_redirects: bool = True, # noqa: FBT001, FBT002 | ||
| verify: Union[bool, str] = False, # noqa: FBT002 | ||
| verify: Union[bool, str] = False, # noqa: FBT001, FBT002 | ||
| timeout: Union[None, float, Timeout] = 10, | ||
@@ -131,3 +131,3 @@ ) -> None: | ||
| follow_redirects: bool = True, # noqa: FBT001, FBT002 | ||
| verify: Union[bool, str] = False, # noqa: FBT002 | ||
| verify: Union[bool, str] = False, # noqa: FBT001,FBT002 | ||
| timeout: Union[None, float, Timeout] = 10, | ||
@@ -205,3 +205,2 @@ ) -> None: | ||
| if not url.startswith((".", "/", "?")): | ||
| # noinspection HttpUrlsUsage | ||
| try_urls.append(f"http://{url}") | ||
@@ -327,3 +326,3 @@ try_urls.append(f"https://{url}") | ||
| """Set the request timeout in seconds.""" | ||
| self._client.timeout = timeout # type: ignore[assignment] | ||
| self._client.timeout = timeout | ||
@@ -415,3 +414,3 @@ def show_forms(self) -> None: | ||
| return CheckboxGroup( | ||
| cast(List[InputElement], match_name) | ||
| cast("List[InputElement]", match_name) | ||
| ) | ||
@@ -421,3 +420,5 @@ if all( | ||
| ): | ||
| return RadioGroup(cast(List[InputElement], match_name)) | ||
| return RadioGroup( | ||
| cast("List[InputElement]", match_name) | ||
| ) | ||
| else: | ||
@@ -482,3 +483,3 @@ match_name = None | ||
| if getattr(control, "type", None) in ("submit", "image"): | ||
| self.last_submit_button = cast(InputElement, control) | ||
| self.last_submit_button = cast("InputElement", control) | ||
@@ -521,3 +522,3 @@ def submit( | ||
| if submits: | ||
| ctl = cast(InputElement, submits[0]) | ||
| ctl = cast("InputElement", submits[0]) | ||
| else: | ||
@@ -527,3 +528,3 @@ ctl = self.last_submit_button | ||
| # field name given; find it | ||
| ctl = cast(InputElement, self.form_field(form, field_name)) | ||
| ctl = cast("InputElement", self.form_field(form, field_name)) | ||
@@ -536,4 +537,3 @@ # now set up the submission by building the request object that | ||
| log.info( | ||
| "Note: submit is using submit button:" | ||
| " name='%s', value='%s'", | ||
| "Note: submit is using submit button: name='%s', value='%s'", | ||
| ctl.get("name"), | ||
@@ -590,9 +590,17 @@ ctl.value, | ||
| """Save cookies into the given file.""" | ||
| saved_cookies = [ | ||
| (cookie.name, cookie.value, cookie.domain, cookie.path) | ||
| for cookie in self._client.cookies.jar | ||
| ] | ||
| with open(filename, "wb") as f: | ||
| pickle.dump(dict(self._client.cookies), f) | ||
| pickle.dump(saved_cookies, f) | ||
| def load_cookies(self, filename: str) -> None: | ||
| """Load cookies from the given file.""" | ||
| cookies = Cookies() | ||
| with open(filename, "rb") as f: | ||
| self._client.cookies = pickle.load(f) # noqa: S301 | ||
| loaded_cookies = pickle.load(f) # noqa: S301 | ||
| for name, value, domain, path in loaded_cookies: | ||
| cookies.set(name, value, domain=domain, path=path) | ||
| self._client.cookies = cookies | ||
@@ -614,3 +622,3 @@ def clear_cookies(self) -> None: | ||
| else: | ||
| log.info("\nThere are no cookies in the cookie jar.\n", n) | ||
| log.info("\nThere are no cookies in the cookie jar.\n") | ||
@@ -736,3 +744,3 @@ def decode(self, value: Union[bytes, str]) -> str: | ||
| break | ||
| if interval >= refresh_interval: | ||
| if interval and interval >= refresh_interval: | ||
| (log.info if self.show_refresh else log.debug)( | ||
@@ -751,5 +759,7 @@ "Meta refresh interval too long: %d", interval | ||
| if func_name in ("follow_link", "open") and ( | ||
| if ( | ||
| func_name in ("follow_link", "open") | ||
| # if we're really reloading and just didn't say so, don't store | ||
| self.result is not None and self.result.url != result.url | ||
| and self.result is not None | ||
| and self.result.url != result.url | ||
| ): | ||
@@ -756,0 +766,0 @@ self._history.append(self.result) |
+17
-35
@@ -20,3 +20,2 @@ """The twill shell commands. | ||
| # noinspection SpellCheckingInspection | ||
| __all__ = [ | ||
@@ -37,17 +36,17 @@ "add_auth", | ||
| "extend_with", | ||
| "fa", | ||
| "find", | ||
| "follow", | ||
| "form_action", | ||
| "form_clear", | ||
| "form_file", | ||
| "form_value", | ||
| "formaction", | ||
| "fa", | ||
| "form_clear", | ||
| "formclear", | ||
| "form_file", | ||
| "formfile", | ||
| "form_value", | ||
| "formvalue", | ||
| "fv", | ||
| "get_input", | ||
| "get_password", | ||
| "getinput", | ||
| "get_password", | ||
| "getpassword", | ||
@@ -66,24 +65,24 @@ "go", | ||
| "reset_output", | ||
| "rf", | ||
| "run", | ||
| "run_file", | ||
| "runfile", | ||
| "rf", | ||
| "save_cookies", | ||
| "save_html", | ||
| "set_global", | ||
| "set_local", | ||
| "setglobal", | ||
| "set_global", | ||
| "setlocal", | ||
| "set_local", | ||
| "show", | ||
| "showcookies", | ||
| "show_cookies", | ||
| "show_extra_headers", | ||
| "show_forms", | ||
| "show_history", | ||
| "show_html", | ||
| "show_links", | ||
| "showcookies", | ||
| "showforms", | ||
| "show_forms", | ||
| "showhistory", | ||
| "show_history", | ||
| "showhtml", | ||
| "show_html", | ||
| "showlinks", | ||
| "show_links", | ||
| "sleep", | ||
@@ -108,3 +107,2 @@ "submit", | ||
| # noinspection PyShadowingBuiltins | ||
| def exit(code: str = "0") -> None: | ||
@@ -193,4 +191,3 @@ """>> exit [<code>] | ||
| raise TwillAssertionError( | ||
| f"current url is '{current_url}';\n" | ||
| f"does not match '{should_be}'\n" | ||
| f"current url is '{current_url}';\ndoes not match '{should_be}'\n" | ||
| ) | ||
@@ -281,3 +278,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| notfind = not_find # backward compatibility and convenience | ||
@@ -325,3 +321,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| showhtml = show_html # backward compatibility and consistency | ||
@@ -424,3 +419,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| showforms = show_forms # backward compatibility and convenience | ||
@@ -439,3 +433,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| showlinks = show_links # backward compatibility and convenience | ||
@@ -454,3 +447,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| showhistory = show_history # backward compatibility and convenience | ||
@@ -477,3 +469,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| formclear = form_clear # backward compatibility and convenience | ||
@@ -533,3 +524,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| fv = formvalue = form_value # backward compatibility and convenience | ||
@@ -552,3 +542,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| fa = formaction = form_action # backward compatibility and convenience | ||
@@ -587,3 +576,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| formfile = form_file # backward compatibility and convenience | ||
@@ -606,3 +594,3 @@ | ||
| from . import parse, shell | ||
| from . import parse, shell # noqa: PLC0415 | ||
@@ -645,3 +633,2 @@ fn_list = getattr(mod, "__all__", None) | ||
| # noinspection SpellCheckingInspection | ||
| getinput = get_input # backward compatibility and convenience | ||
@@ -665,3 +652,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| getpassword = get_password # backward compatibility and convenience | ||
@@ -704,3 +690,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| showcookies = show_cookies # backward compatibility and convenience | ||
@@ -736,3 +721,3 @@ | ||
| """ | ||
| from . import parse | ||
| from . import parse # noqa: PLC0415 | ||
@@ -780,3 +765,3 @@ try: | ||
| """ | ||
| from . import parse | ||
| from . import parse # noqa: PLC0415 | ||
@@ -788,3 +773,2 @@ filenames = utils.gather_filenames(args) | ||
| # noinspection SpellCheckingInspection | ||
| rf = runfile = run_file # backward compatibility and convenience | ||
@@ -814,3 +798,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| setglobal = set_global # backward compatibility and convenience | ||
@@ -828,3 +811,2 @@ | ||
| # noinspection SpellCheckingInspection | ||
| setlocal = set_local # backward compatibility and convenience | ||
@@ -831,0 +813,0 @@ |
@@ -20,3 +20,3 @@ """Extension functions for parsing sys.argv. | ||
| """ | ||
| global_dict, local_dict = namespaces.get_twill_glocals() | ||
| global_dict, _local_dict = namespaces.get_twill_glocals() | ||
@@ -26,3 +26,3 @@ require = int(require) | ||
| if len(shell.twill_args) < require: | ||
| from twill.errors import TwillAssertionError | ||
| from twill.errors import TwillAssertionError # noqa: PLC0415 | ||
@@ -29,0 +29,0 @@ given = len(shell.twill_args) |
@@ -24,3 +24,3 @@ """Extension functions to check all of the links on a page. | ||
| __all__ = ["check_links", "report_bad_links", "good_urls", "bad_urls"] | ||
| __all__ = ["bad_urls", "check_links", "good_urls", "report_bad_links"] | ||
@@ -71,3 +71,2 @@ # first, set up config options & persistent 'bad links' memory... | ||
| # noinspection HttpUrlsUsage | ||
| if not url.startswith(("http://", "https://")): | ||
@@ -74,0 +73,0 @@ debug("url '%s' is not an HTTP link; ignoring", url) |
@@ -23,3 +23,3 @@ """Extension functions for easier form filling. | ||
| __all__ = ["fv_match", "fv_multi_match", "fv_multi", "fv_multi_sub"] | ||
| __all__ = ["fv_match", "fv_multi", "fv_multi_match", "fv_multi_sub"] | ||
@@ -26,0 +26,0 @@ |
@@ -78,3 +78,3 @@ """Suresh's extension for slicing and dicing using regular expressions.""" | ||
| """Evaluate an expression.""" | ||
| return eval(exp, globals(), {"m": match}) # noqa: PGH001, S307 | ||
| return eval(exp, globals(), {"m": match}) # noqa: S307 | ||
@@ -81,0 +81,0 @@ |
@@ -18,3 +18,3 @@ """A simple set of extensions to manage post-load requirements for pages. | ||
| __all__ = ["require", "skip_require", "flush_visited", "no_require"] | ||
| __all__ = ["flush_visited", "no_require", "require", "skip_require"] | ||
@@ -47,3 +47,2 @@ _requirements = [] # what requirements to satisfy | ||
| # install the post-load hook function. | ||
| # noinspection PyProtectedMember | ||
| hooks = browser._post_load_hooks # noqa: SLF001 | ||
@@ -65,3 +64,2 @@ if _require_post_load_hook not in hooks: | ||
| """ | ||
| # noinspection PyProtectedMember | ||
| hooks = browser._post_load_hooks # noqa: SLF001 | ||
@@ -79,3 +77,3 @@ hooks = [fn for fn in hooks if fn != _require_post_load_hook] | ||
| """ | ||
| from .check_links import good_urls | ||
| from .check_links import good_urls # noqa: PLC0415 | ||
@@ -103,3 +101,3 @@ good_urls.clear() | ||
| elif what == "links_ok": | ||
| from .check_links import check_links, good_urls | ||
| from .check_links import check_links, good_urls # noqa: PLC0415 | ||
@@ -106,0 +104,0 @@ Ignore.always = True |
@@ -5,3 +5,3 @@ """Used in test_shell, to test default command execution and extensions.""" | ||
| __all__ = ["set_flag", "unset_flag", "assert_flag_set", "assert_flag_unset"] | ||
| __all__ = ["assert_flag_set", "assert_flag_unset", "set_flag", "unset_flag"] | ||
@@ -8,0 +8,0 @@ |
+20
-20
@@ -6,6 +6,6 @@ """The twill multiprocess execution system.""" | ||
| import time | ||
| from optparse import OptionParser | ||
| from argparse import ArgumentParser | ||
| from pickle import dump, load | ||
| from . import execute_file, set_log_level | ||
| from twill import execute_file, set_log_level | ||
@@ -22,8 +22,7 @@ | ||
| parser = OptionParser() | ||
| add = parser.add_option | ||
| parser = ArgumentParser() | ||
| add = parser.add_argument | ||
| add( | ||
| "-u", | ||
| "--url", | ||
| nargs=1, | ||
| action="store", | ||
@@ -36,7 +35,6 @@ dest="url", | ||
| "--number", | ||
| nargs=1, | ||
| action="store", | ||
| dest="number", | ||
| default=1, | ||
| type="int", | ||
| type=int, | ||
| help="number of times to run the given script(s)", | ||
@@ -47,15 +45,17 @@ ) | ||
| "--processes", | ||
| nargs=1, | ||
| action="store", | ||
| dest="processes", | ||
| default=1, | ||
| type="int", | ||
| type=int, | ||
| help="number of processes to execute in parallel", | ||
| ) | ||
| add( | ||
| "scripts", | ||
| metavar="SCRIPT", | ||
| nargs="+", | ||
| help="one or more twill scripts to execute", | ||
| ) | ||
| options, args = parser.parse_args() | ||
| args = parser.parse_args() | ||
| if not args: | ||
| sys.exit("Error: Must specify one or more scripts to execute.") | ||
| # make sure that the current working directory is in the path | ||
@@ -65,4 +65,4 @@ if "" not in sys.path: | ||
| average_number = options.number // options.processes | ||
| last_number = average_number + options.number % options.processes | ||
| average_number = args.number // args.processes | ||
| last_number = average_number + args.number % args.processes | ||
| child_pids = [] | ||
@@ -73,3 +73,3 @@ is_parent = True | ||
| # start a bunch of child processes and record their pids in the parent | ||
| for i in range(options.processes): | ||
| for i in range(args.processes): | ||
| pid = fork() | ||
@@ -106,3 +106,3 @@ if pid: | ||
| else: # record statistics, otherwise | ||
| filename = ".status.%d" % (child_pid,) | ||
| filename = f".status.{child_pid}" | ||
| with open(filename, "rb") as fp: | ||
@@ -116,3 +116,3 @@ this_time, n_executed = load(fp) # noqa: S301 | ||
| print("\n----\n") | ||
| print(f"number of processes: {options.processes}") | ||
| print(f"number of processes: {args.processes}") | ||
| print(f"total executed: {total_exec}") | ||
@@ -135,4 +135,4 @@ print(f"total time to execute: {total_time:.2f} s") | ||
| for _i in range(repeat): | ||
| for filename in args: | ||
| execute_file(filename, initial_url=options.url) | ||
| for filename in args.scripts: | ||
| execute_file(filename, initial_url=args.url) | ||
@@ -139,0 +139,0 @@ end_time = time.time() |
@@ -16,4 +16,3 @@ """Global and local dictionaries, and initialization/utility functions.""" | ||
| """ | ||
| # noinspection PyCompatibility | ||
| from . import commands, parse | ||
| from . import commands, parse # noqa: PLC0415 | ||
@@ -20,0 +19,0 @@ cmd_list = commands.__all__ |
@@ -24,3 +24,2 @@ """Code parsing and evaluation for the twill mini-language.""" | ||
| # noinspection PyCompatibility | ||
| from . import commands, log, namespaces | ||
@@ -212,3 +211,3 @@ from .browser import browser | ||
| with ( | ||
| nullcontext(sys.stdin) # type: ignore[attr-defined] | ||
| nullcontext(sys.stdin) | ||
| if filename == "-" | ||
@@ -268,4 +267,3 @@ else open(filename, encoding="utf-8") | ||
| error_context = ( | ||
| f"{error_type} raised on line {line_no}" | ||
| f" of '{source_info}'" | ||
| f"{error_type} raised on line {line_no} of '{source_info}'" | ||
| ) | ||
@@ -304,3 +302,3 @@ if line: | ||
| """Turn printing of commands as they are executed on or off.""" | ||
| global _log_commands # noqa: PLW0603 | ||
| global _log_commands | ||
| old_flag = _log_commands is log.info | ||
@@ -307,0 +305,0 @@ _log_commands = log.info if flag else log.debug |
+56
-47
@@ -10,10 +10,9 @@ """A command-line interpreter for twill. | ||
| import traceback | ||
| from argparse import ArgumentParser | ||
| from cmd import Cmd | ||
| from contextlib import suppress | ||
| from io import TextIOWrapper | ||
| from optparse import OptionParser | ||
| from pathlib import Path | ||
| from typing import Any, Callable, List, Optional | ||
| from textwrap import dedent | ||
| from typing import IO, Any, Callable, List, Optional | ||
| # noinspection PyCompatibility | ||
| from . import ( | ||
@@ -96,2 +95,9 @@ __url__, | ||
| message = message.strip() | ||
| try: | ||
| title, details = message.split("\n", 1) | ||
| except ValueError: | ||
| pass | ||
| else: # dedent the details (for Python < 3.13) | ||
| details = dedent(details) | ||
| message = "\n".join((title, details)) | ||
| max_width = max( | ||
@@ -130,3 +136,3 @@ 7 + len(cmd), | ||
| self, | ||
| stdin: Optional[TextIOWrapper] = None, | ||
| stdin: Optional[IO[str]] = None, | ||
| initial_url: Optional[str] = None, | ||
@@ -159,3 +165,3 @@ *, | ||
| global_dict, local_dict = namespaces.get_twill_glocals() | ||
| global_dict, _local_dict = namespaces.get_twill_glocals() | ||
@@ -193,9 +199,10 @@ # add all of the commands from twill | ||
| """ | ||
| cmd, args = parse.parse_command(line + ".", {}, {}) | ||
| place = len(args) | ||
| if place == 1: | ||
| return self.provide_form_name(text) | ||
| if place == 2: # noqa: PLR2004 | ||
| form_name = args[0] | ||
| return self.provide_field_name(form_name, text) | ||
| _cmd, args = parse.parse_command(line + ".", {}, {}) | ||
| if args: | ||
| place = len(args) if args else 0 | ||
| if place == 1: | ||
| return self.provide_form_name(text) | ||
| if place == 2: # noqa: PLR2004 | ||
| form_name = args[0] | ||
| return self.provide_field_name(form_name, text) | ||
| return [] | ||
@@ -268,3 +275,3 @@ | ||
| parse.execute_command( | ||
| cmd, args, global_dict, local_dict, "<shell>" | ||
| cmd, args or (), global_dict, local_dict, "<shell>" | ||
| ) | ||
@@ -330,3 +337,3 @@ except SystemExit: | ||
| """Run as shell script.""" | ||
| global interactive # noqa: PLW0603 | ||
| global interactive | ||
@@ -343,4 +350,4 @@ # show the shorthand name for usage | ||
| parser = OptionParser() | ||
| add = parser.add_option | ||
| parser = ArgumentParser() | ||
| add = parser.add_argument | ||
@@ -371,3 +378,2 @@ add( | ||
| "--loglevel", | ||
| nargs=1, | ||
| action="store", | ||
@@ -387,3 +393,2 @@ dest="loglevel", | ||
| "--output", | ||
| nargs=1, | ||
| action="store", | ||
@@ -403,3 +408,2 @@ dest="outfile", | ||
| "--url", | ||
| nargs=1, | ||
| action="store", | ||
@@ -423,24 +427,31 @@ dest="url", | ||
| ) | ||
| add( | ||
| "scripts", | ||
| metavar="SCRIPT", | ||
| nargs="*", | ||
| help="the twill script to execute", | ||
| ) | ||
| # parse arguments | ||
| args = argv[1:] | ||
| if "--" in args: | ||
| for last in range(len(args) - 1, -1, -1): | ||
| if args[last] == "--": | ||
| twill_args[:] = args[last + 1 :] | ||
| args = args[:last] | ||
| raw_args = argv[1:] | ||
| if "--" in raw_args: | ||
| for last in reversed(range(len(raw_args))): | ||
| if raw_args[last] == "--": | ||
| twill_args[:] = raw_args[last + 1 :] | ||
| raw_args = raw_args[:last] | ||
| break | ||
| options, args = parser.parse_args(args) | ||
| args = parser.parse_args(raw_args) | ||
| if options.show_version: | ||
| if args.show_version: | ||
| log.info(version_info) | ||
| sys.exit(0) | ||
| quiet = options.quiet | ||
| show_browser = options.show_browser | ||
| dump_file = options.dumpfile | ||
| out_file = options.outfile | ||
| log_level = options.loglevel | ||
| interactive = options.interactive or not args | ||
| quiet = args.quiet | ||
| show_browser = args.show_browser | ||
| dump_file = args.dumpfile | ||
| out_file = args.outfile | ||
| log_level = args.loglevel | ||
| scripts = args.scripts | ||
| interactive = args.interactive or not scripts | ||
@@ -455,3 +466,3 @@ if out_file: | ||
| if options.show_browser and (not dump_file or dump_file == "-"): | ||
| if show_browser and (not dump_file or dump_file == "-"): | ||
| sys.exit("Please also specify a dump file with -d") | ||
@@ -461,3 +472,3 @@ | ||
| log_level = log_level.lstrip("=").lstrip() or None | ||
| if log_level.upper() not in log_levels: | ||
| if log_level and log_level.upper() not in log_levels: | ||
| log_level_names = ", ".join(sorted(log_levels)) | ||
@@ -482,7 +493,7 @@ sys.exit(f"Valid log levels are: {log_level_names}") | ||
| failed = False | ||
| if args: | ||
| if scripts: | ||
| success = [] | ||
| failure = [] | ||
| filenames = gather_filenames(args) | ||
| filenames = gather_filenames(scripts) | ||
| dump = None | ||
@@ -495,4 +506,4 @@ | ||
| filename, | ||
| initial_url=options.url, | ||
| never_fail=options.never_fail, | ||
| initial_url=args.url, | ||
| never_fail=args.never_fail, | ||
| ) | ||
@@ -503,3 +514,3 @@ success.append(filename) | ||
| dump = browser.dump | ||
| if options.fail: | ||
| if args.fail: | ||
| raise | ||
@@ -532,3 +543,3 @@ if browser.first_error: | ||
| ) | ||
| if len(failure): | ||
| if failure: | ||
| log.error("Failed:\n\t%s", "\n\t".join(failure)) | ||
@@ -538,3 +549,3 @@ failed = True | ||
| if dump and show_browser: | ||
| import webbrowser | ||
| import webbrowser # noqa: PLC0415 | ||
@@ -548,5 +559,5 @@ url = Path(dump_file).absolute().as_uri() | ||
| if interactive: | ||
| welcome_msg = "" if args else "\n -= Welcome to twill =-\n" | ||
| welcome_msg = "" if scripts else "\n -= Welcome to twill =-\n" | ||
| shell = TwillCommandLoop(initial_url=options.url) | ||
| shell = TwillCommandLoop(initial_url=args.url) | ||
@@ -559,4 +570,2 @@ while True: | ||
| break | ||
| except SystemExit: | ||
| raise | ||
@@ -563,0 +572,0 @@ welcome_msg = "" |
@@ -31,2 +31,3 @@ """Support functionality for using twill in unit tests.""" | ||
| server_fn: Callable[[], None], | ||
| host: str = HOST, | ||
| port: int = PORT, | ||
@@ -38,2 +39,3 @@ sleep: float = SLEEP, | ||
| self.server_fn = server_fn | ||
| self.host = host | ||
| self.port = port | ||
@@ -68,4 +70,3 @@ self.stdout: Optional[TextIO] = None | ||
| """Get the test server URL.""" | ||
| # noinspection HttpUrlsUsage | ||
| return f"http://{HOST}:{self.port}/" | ||
| return f"http://{self.host}:{self.port}/" | ||
@@ -72,0 +73,0 @@ |
+18
-18
@@ -38,2 +38,15 @@ """Various ugly utility functions for twill. | ||
| __all__ = [ | ||
| "CheckboxGroup", | ||
| "FieldElement", | ||
| "FormElement", | ||
| "HtmlElement", | ||
| "InputElement", | ||
| "Link", | ||
| "RadioGroup", | ||
| "Response", | ||
| "ResultWrapper", | ||
| "SelectElement", | ||
| "Singleton", | ||
| "TextareaElement", | ||
| "UrlWithRealm", | ||
| "gather_filenames", | ||
@@ -44,6 +57,6 @@ "get_equiv_refresh_interval", | ||
| "is_twill_filename", | ||
| "print_form", | ||
| "make_boolean", | ||
| "make_int", | ||
| "make_twill_filename", | ||
| "print_form", | ||
| "run_tidy", | ||
@@ -53,15 +66,2 @@ "tree_to_html", | ||
| "unique_match", | ||
| "CheckboxGroup", | ||
| "FieldElement", | ||
| "FormElement", | ||
| "HtmlElement", | ||
| "InputElement", | ||
| "Link", | ||
| "RadioGroup", | ||
| "ResultWrapper", | ||
| "SelectElement", | ||
| "Singleton", | ||
| "TextareaElement", | ||
| "UrlWithRealm", | ||
| "Response", | ||
| ] | ||
@@ -435,3 +435,3 @@ | ||
| """ | ||
| from .commands import options | ||
| from .commands import options # noqa: PLC0415 | ||
@@ -457,7 +457,7 @@ require_tidy = options.get("require_tidy") | ||
| def get_equiv_refresh_interval() -> Optional[int]: | ||
| """Get the smallest interval for which the browser should follow redirects. | ||
| """Get the longest interval for which the browser should follow redirects. | ||
| Redirection happens if the given interval is smaller than this. | ||
| Redirection happens if the given interval is shorter than this. | ||
| """ | ||
| from .commands import options | ||
| from .commands import options # noqa: PLC0415 | ||
@@ -464,0 +464,0 @@ return options.get("equiv_refresh_interval") |
@@ -19,4 +19,4 @@ """Shared test configuration for pytest.""" | ||
| from twill import set_output | ||
| from twill.commands import find, go | ||
| from twill import set_output # noqa: PLC0415 | ||
| from twill.commands import find, go # noqa: PLC0415 | ||
@@ -44,6 +44,6 @@ set_output() | ||
| @pytest.fixture() | ||
| @pytest.fixture | ||
| def output() -> Generator[StringIO, None, None]: | ||
| """Get output from the test.""" | ||
| from twill import set_output | ||
| from twill import set_output # noqa: PLC0415 | ||
@@ -50,0 +50,0 @@ with StringIO() as output: |
@@ -39,3 +39,3 @@ """Simple mock implementation of dnspython to test the twill DNS extension.""" | ||
| sys.modules["dns"] = package | ||
| for module in "ipv4 name rdatatype resolver".split(): | ||
| for module in ["ipv4", "name", "rdatatype", "resolver"]: | ||
| sys.modules[f"dns.{module}"] = package | ||
@@ -42,0 +42,0 @@ setattr(package, module, package) |
+491
-571
| #!/usr/bin/env python3 | ||
| """Quixote test app for twill.""" | ||
| """Flask based test app for twill.""" | ||
@@ -8,531 +8,364 @@ import os | ||
| from time import sleep | ||
| from typing import Optional | ||
| from typing import Any, MutableMapping, NoReturn, Optional | ||
| from quixote import ( # type: ignore[import-untyped] | ||
| get_path, | ||
| get_request, | ||
| get_response, | ||
| get_session, | ||
| get_session_manager, | ||
| from flask import ( | ||
| Flask, | ||
| Response, | ||
| abort, | ||
| make_response, | ||
| redirect, | ||
| request, | ||
| session, | ||
| ) | ||
| from quixote.directory import ( # type: ignore[import-untyped] | ||
| AccessControlled, | ||
| Directory, | ||
| ) | ||
| from quixote.errors import AccessError # type: ignore[import-untyped] | ||
| from quixote.form import widget # type: ignore[import-untyped] | ||
| from quixote.publish import Publisher # type: ignore[import-untyped] | ||
| from quixote.session import ( # type: ignore[import-untyped] | ||
| Session, | ||
| SessionManager, | ||
| ) | ||
| HOST = "127.0.0.1" | ||
| PORT = 8080 | ||
| DEBUG = False | ||
| RELOAD = False | ||
| app = Flask(__name__) | ||
| class AlwaysSession(Session): | ||
| """Session that always saves.""" | ||
| app.secret_key = "not-a-secret-since-this-is-a-test-app-only" # noqa: S105 | ||
| def __init__(self, session_id: str) -> None: | ||
| """Initialize the session.""" | ||
| Session.__init__(self, session_id) | ||
| self.visit = 0 | ||
| def has_info(self) -> bool: | ||
| """Return true to indicate that it should always save.""" | ||
| return True | ||
| class SessionManager: | ||
| """Session manager.""" | ||
| is_dirty = has_info | ||
| _next_session_id = 1 | ||
| @classmethod | ||
| def message(cls, session: MutableMapping) -> str: | ||
| """Create a message with session information.""" | ||
| sid = cls.session_id(session) or "undefined" | ||
| visit = session.get("visit", 0) | ||
| user = session.get("user", "guest") | ||
| return f"""\ | ||
| <html> | ||
| <head> | ||
| <title>Hello, world!</title> | ||
| </head> | ||
| <body> | ||
| <p>Hello, world!</p> | ||
| <p>These are the twill tests.</p> | ||
| <p>Your session ID is {sid}; this is visit #{visit}.</p> | ||
| <p>You are logged in as {user}.</p> | ||
| <p> | ||
| <a href="increment">increment</a> | | ||
| <a href="incrementfail">incrementfail</a> | ||
| </p> | ||
| <p><a href="logout">log out</a></p> | ||
| <p> | ||
| (<a href="test spaces">test spaces</a> / | ||
| <a href="test_spaces">test spaces2</a>) | ||
| </p> | ||
| </body> | ||
| </html> | ||
| """ | ||
| class UnauthorizedError(AccessError): | ||
| """Error used for Basic Authentication. | ||
| @classmethod | ||
| def session_id(cls, session: MutableMapping) -> Optional[int]: | ||
| """Get the session ID or set it if it does not exist.""" | ||
| sid = session.get("sid") | ||
| if sid is None: | ||
| session["sid"] = cls._next_session_id | ||
| cls._next_session_id += 1 | ||
| return sid | ||
| The request requires user authentication. | ||
| This subclass of AccessError sends a 401 instead of a 403, | ||
| hinting that the client should try again with authentication. | ||
| (from http://quixote.ca/qx/HttpBasicAuthentication) | ||
| """ | ||
| status_code = 401 | ||
| title = "Unauthorized" | ||
| description = "You are not authorized to access this resource." | ||
| message = SessionManager.message | ||
| def __init__( | ||
| self, | ||
| realm: str = "Protected", | ||
| public_msg: Optional[str] = None, | ||
| private_msg: Optional[str] = None, | ||
| ) -> None: | ||
| """Initialize the error.""" | ||
| self.realm = realm | ||
| AccessError.__init__(self, public_msg, private_msg) | ||
| def format(self) -> str: | ||
| """Format the error.""" | ||
| request = get_request() | ||
| request.response.set_header( | ||
| "WWW-Authenticate", f'Basic realm="{self.realm}"' | ||
| ) | ||
| return AccessError.format(self) | ||
| # HTML helpers | ||
| def create_publisher() -> None: | ||
| """Create a publisher for TwillTest, with session management added on.""" | ||
| session_manager = SessionManager(session_class=AlwaysSession) | ||
| return Publisher( | ||
| TwillTest(), | ||
| session_manager=session_manager, | ||
| display_exceptions="plain", | ||
| ) | ||
| def field(type_: str = "text", name: str = "", value: str = "") -> str: | ||
| """Create an HTML field.""" | ||
| return f'<input type="{type_}" name="{name}" value="{value}">' | ||
| def message(session: AlwaysSession) -> str: | ||
| """Create a message with session information.""" | ||
| return f"""\ | ||
| <html> | ||
| <head> | ||
| <title>Hello, world!</title> | ||
| </head> | ||
| <body> | ||
| Hello, world! | ||
| <p> | ||
| These are the twill tests. | ||
| <p> | ||
| Your session ID is {session.id}; this is visit #{session.visit}. | ||
| <p> | ||
| You are logged in as "{session.user}". | ||
| <p> | ||
| <a href="./increment">increment</a> | | ||
| <a href="./incrementfail">incrementfail</a> | ||
| <p> | ||
| <a href="logout">log out</a> | ||
| <p> | ||
| (<a href="test spaces">test spaces</a> / | ||
| <a href="test_spaces">test spaces2</a>) | ||
| </body> | ||
| </html> | ||
| """ | ||
| def par(text: str = "") -> str: | ||
| """Create an HTML paragraph.""" | ||
| return f"<p>{text}</p>" | ||
| class TwillTest(Directory): | ||
| """The actual test app.""" | ||
| # Flask routes | ||
| _q_exports = ( | ||
| "", | ||
| "logout", | ||
| "increment", | ||
| "incrementfail", | ||
| "restricted", | ||
| "login", | ||
| ("test spaces", "test_spaces"), | ||
| "test_spaces", | ||
| "simpleform", | ||
| "getform", | ||
| "upload_file", | ||
| "http_auth", | ||
| "formpostredirect", | ||
| "exit", | ||
| "multisubmitform", | ||
| "exception", | ||
| "plaintext", | ||
| "xml", | ||
| "sleep", | ||
| "testform", | ||
| "testformaction", | ||
| "test_radiobuttons", | ||
| "test_refresh", | ||
| "test_refresh2", | ||
| "test_refresh3", | ||
| "test_refresh4", | ||
| "test_refresh5", | ||
| "test_checkbox", | ||
| "test_simple_checkbox", | ||
| "echo", | ||
| "test_checkboxes", | ||
| "test_global_form", | ||
| "two_forms", | ||
| "broken_form_1", | ||
| "broken_form_2", | ||
| "broken_form_3", | ||
| "broken_form_4", | ||
| "broken_form_5", | ||
| "broken_linktext", | ||
| "exit", | ||
| "display_post", | ||
| "display_environ", | ||
| ) | ||
| def __init__(self) -> None: | ||
| """Initialize the application.""" | ||
| self.restricted = Restricted() | ||
| self.http_auth = HttpAuthRestricted() | ||
| @app.route("/") | ||
| def view_root() -> str: | ||
| """Show index page.""" | ||
| return message(session) | ||
| def exit(self) -> None: | ||
| """Exit the application.""" | ||
| raise SystemExit | ||
| def test_global_form(self) -> str: | ||
| """Test the global form.""" | ||
| return """ | ||
| <html> | ||
| <head> | ||
| <title>Broken</title> | ||
| </head> | ||
| <body> | ||
| <div> | ||
| <input name="global_form_entry" type="text"> | ||
| <input name="global_entry_2" type="text"> | ||
| </div> | ||
| @app.route("/exception") | ||
| def view_exception() -> NoReturn: | ||
| """Raise a server error.""" | ||
| raise RuntimeError("500 error -- fail out!") | ||
| <form name="login" method="post"> | ||
| <input type=text name=hello> | ||
| <input type=submit> | ||
| </form> | ||
| <form name="login" method="post" | ||
| action="http://iorich.caltech.edu:8080/display_post"> | ||
| <input type=text name=hello> | ||
| <input type=submit> | ||
| </form> | ||
| @app.route("/test_spaces") | ||
| @app.route("/test spaces") | ||
| def view_test_spaces() -> str: | ||
| """Test spaces.""" | ||
| return "success" | ||
| </body> | ||
| </html> | ||
| """ | ||
| def display_post(self) -> str: | ||
| """Show the form items.""" | ||
| return "".join( | ||
| f"k: '''{k}''' : '''{v}'''<p>\n" | ||
| for k, v in get_request().form.items() | ||
| ) | ||
| @app.route("/sleep") | ||
| def view_sleep() -> str: | ||
| """Test timeouts.""" | ||
| sleep(0.5) | ||
| return "sorry for the delay" | ||
| def display_environ(self) -> str: | ||
| """Show the environment variables.""" | ||
| return "".join( | ||
| f"k: '''{k}''' : '''{v}'''<p>\n" | ||
| for k, v in get_request().environ.items() | ||
| ) | ||
| def _q_index(self) -> str: | ||
| """Show index page.""" | ||
| return message(get_session()) | ||
| @app.route("/increment") | ||
| def view_increment() -> str: | ||
| """Visit session.""" | ||
| session["visit"] = session.get("visit", 0) + 1 | ||
| return message(session) | ||
| def broken_form_1(self) -> str: | ||
| """Get broken form 1.""" | ||
| return """\ | ||
| <form> | ||
| <input type=text name=blah value=thus> | ||
| """ | ||
| def broken_form_2(self) -> str: | ||
| """Get broken form 2.""" | ||
| return """\ | ||
| <form> | ||
| <table> | ||
| <tr><td> | ||
| <input name='broken'> | ||
| </td> | ||
| </form> | ||
| </tr> | ||
| </form> | ||
| """ | ||
| @app.route("/incrementfail") | ||
| def view_incrementfail() -> NoReturn: | ||
| """Visit session with failure.""" | ||
| session["visit"] = session.get("visit", 0) + 1 | ||
| raise RuntimeError(message(session)) | ||
| def broken_form_3(self) -> str: | ||
| """Get broken form 3.""" | ||
| return """\ | ||
| <table> | ||
| <tr><td> | ||
| <input name='broken'> | ||
| </td> | ||
| </form> | ||
| </tr> | ||
| </form> | ||
| """ | ||
| def broken_form_4(self) -> str: | ||
| """Get broken form 4.""" | ||
| return """\ | ||
| <font> | ||
| <INPUT> | ||
| @app.route("/simpleform", methods=["GET", "POST"]) | ||
| def view_simpleform() -> str: | ||
| """Test non-existing submit button.""" | ||
| s1, s2 = field(name="n"), field(name="n2") | ||
| values = par(" ".join(request.form.values())) | ||
| return f'{values}</p><form method="POST">{s1} {s2}</form>' | ||
| <FORM> | ||
| <input type="blah"> | ||
| </form> | ||
| """ | ||
| def broken_form_5(self) -> str: | ||
| """Get broken form 5.""" | ||
| return """\ | ||
| <div id="loginform"> | ||
| <form method="post" name="loginform" action="ChkLogin"> | ||
| <h3>ARINC Direct Login</h3> | ||
| <br/> | ||
| <strong>User ID</strong><br/> | ||
| <input name="username" id="username" type="text" style="width:80%"><br/> | ||
| <strong>Password</strong><br/> | ||
| <input name="password" type="password" style="width:80%"><br/> | ||
| <div id="buttonbar"> | ||
| <input value="Login" name="login" class="button" type="submit"> | ||
| </div> | ||
| </form> | ||
| </div> | ||
| """ | ||
| @app.route("/getform") | ||
| def view_getform() -> str: | ||
| """Test form with get method.""" | ||
| s = field("hidden", name="n", value="v") | ||
| return f'<form method="GET">{s}<input type="submit"></form>' | ||
| def broken_linktext(self) -> str: | ||
| """Get broken link text.""" | ||
| return """ | ||
| <a href="/"> | ||
| <span>some text</span> | ||
| </a> | ||
| """ | ||
| def test_refresh(self) -> str: | ||
| """Test simple refresh.""" | ||
| return """\ | ||
| <meta http-equiv="refresh" content="2; url=./login"> | ||
| hello, world. | ||
| """ | ||
| @app.route("/login", methods=["GET", "POST"]) | ||
| def view_login() -> Any: | ||
| """Test login.""" | ||
| if request.method == "POST": | ||
| username = request.form.get("username") | ||
| if username: | ||
| session["user"] = username | ||
| return redirect("./") | ||
| def test_refresh2(self) -> str: | ||
| """Test refresh with upper case.""" | ||
| return """\ | ||
| <META HTTP-EQUIV="REFRESH" CONTENT="2; URL=./login"> | ||
| hello, world. | ||
| """ | ||
| login = field(name="username", value="") | ||
| s = field("submit", name="submit", value="submit me") | ||
| s2 = field("submit", name="nosubmit2", value="don't submit") | ||
| img = '<input type=image name="submit you" src="DNE.gif">' | ||
| def test_refresh3(self) -> str: | ||
| """Test circular refresh.""" | ||
| return """\ | ||
| <meta http-equiv="refresh" content="2; url=./test_refresh3"> | ||
| hello, world. | ||
| """ | ||
| return ( | ||
| '<form method="POST"><p>Log in:</p>' | ||
| f"<p>{login}</p><p>{s2} {s}</p><p>{img}</p></form>" | ||
| ) | ||
| def test_refresh4(self) -> str: | ||
| """Test refresh together with similar meta tags.""" | ||
| return """\ | ||
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> | ||
| <html> | ||
| <head> | ||
| <title>o2.ie</title> | ||
| <meta http-equiv="refresh" content="0;URL=/login"> | ||
| <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"> | ||
| </head> | ||
| <body> | ||
| </body> | ||
| </html> | ||
| hello, world. | ||
| """ | ||
| def test_refresh5(self) -> str: | ||
| """Check for situation where given URL is quoted.""" | ||
| return """\ | ||
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> | ||
| <html> | ||
| <head> | ||
| <title>o2.ie</title> | ||
| <meta http-equiv="refresh" content="0;'URL=/login'"> | ||
| <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"> | ||
| </head> | ||
| <body> | ||
| </body> | ||
| </html> | ||
| hello, world. | ||
| """ | ||
| @app.route("/logout") | ||
| def view_logout() -> Any: | ||
| """Test logout.""" | ||
| session.clear() | ||
| return redirect("./") | ||
| def exception(self) -> None: | ||
| """Raise a server error.""" | ||
| raise RuntimeError("500 error -- fail out!") | ||
| def test_spaces(self) -> str: | ||
| """Test spaces.""" | ||
| return "success" | ||
| @app.route("/upload_file", methods=["GET", "POST"]) | ||
| def view_upload_file() -> str: | ||
| """Test file upload.""" | ||
| if request.method == "POST" and "upload" in request.files: | ||
| return request.files["upload"].read() | ||
| return """\ | ||
| <form enctype="multipart/form-data" method="POST"> | ||
| <input type="file" name="upload"> | ||
| <input type="submit" value="submit"> | ||
| </form>""" | ||
| def sleep(self) -> str: | ||
| """Test timeouts.""" | ||
| sleep(0.5) | ||
| return "sorry for the delay" | ||
| def increment(self) -> str: | ||
| """Visit session.""" | ||
| session = get_session() | ||
| session.visit += 1 | ||
| return message(session) | ||
| @app.route("/formpostredirect", methods=["GET", "POST"]) | ||
| def view_formpostredirect() -> Any: | ||
| """Test redirect after a form POST.""" | ||
| if request.method != "POST": | ||
| return """\ | ||
| <form method="POST" enctype="multipart/form-data"> | ||
| <input type="text" name="test"> | ||
| <input type="submit" value="submit" name="submit"> | ||
| </form> | ||
| """ | ||
| return redirect("./") | ||
| def incrementfail(self) -> str: | ||
| """Visit session with failure.""" | ||
| session = get_session() | ||
| session.visit += 1 | ||
| raise RuntimeError(message(session)) | ||
| def login(self) -> str: | ||
| """Test login.""" | ||
| request = get_request() | ||
| username_widget = widget.StringWidget(name="username", value="") | ||
| submit_widget = widget.SubmitWidget(name="submit", value="submit me") | ||
| submit_widget2 = widget.SubmitWidget( | ||
| name="nosubmit2", value="don't submit" | ||
| ) | ||
| @app.route("/display_post", methods=["POST"]) | ||
| def view_display_post() -> str: | ||
| """Show the form items.""" | ||
| return "".join(par(f"{k!s}: {v!r}") for k, v in request.form.items()) | ||
| if request.form: | ||
| if submit_widget2.parse(request): | ||
| raise RuntimeError("Cannot parse request.") | ||
| username = username_widget.parse(request) | ||
| if username: | ||
| session = get_session() | ||
| session.set_user(username) | ||
| return redirect("./") | ||
| image_submit = """<input type=image name='submit you' src=DNE.gif>""" | ||
| @app.route("/display_environ") | ||
| def view_display_environ() -> str: | ||
| """Show the environment variables.""" | ||
| return "".join(par(f"{k!s}: {v!r}") for k, v in request.environ.items()) | ||
| login = username_widget.render() | ||
| s, s2 = submit_widget.render(), submit_widget2.render() | ||
| img = image_submit | ||
| return f"<form method=POST>Log in: {login}<p>{s2}<p>{s}<p>{img}</form>" | ||
| def simpleform(self) -> str: | ||
| """Test non-existing submit button.""" | ||
| request = get_request() | ||
| @app.route("/echo") | ||
| def view_echo() -> str: | ||
| """Show query parameters.""" | ||
| show = ", ".join(f"{k}={v}" for k, v in request.args.items()) or "nothing" | ||
| return f"<html><body>{show}</body></html>" | ||
| s1 = widget.StringWidget(name="n", value="").parse(request) | ||
| s2 = widget.StringWidget(name="n2", value="").parse(request) | ||
| return ( | ||
| f"{s1} {s2} " | ||
| "<form method=POST>" | ||
| "<input type=text name=n><input type=text name=n2>" | ||
| "</form>" | ||
| ) | ||
| @app.route("/plaintext") | ||
| def view_plaintext() -> Response: | ||
| """Test plain text response.""" | ||
| response = make_response("hello, world") | ||
| response.headers["Content-Type"] = "text/plain" | ||
| return response | ||
| def getform(self) -> str: | ||
| """Test form with get method.""" | ||
| return ( | ||
| "<form method=GET><input type=hidden name=n value=v>" | ||
| "<input type=submit value=send></form>" | ||
| ) | ||
| def multisubmitform(self) -> str: | ||
| """Test form with multiple submit buttons.""" | ||
| request = get_request() | ||
| @app.route("/xml") | ||
| def view_xml() -> Response: | ||
| """Test XML response.""" | ||
| response = make_response( | ||
| '<?xml version="1.0" encoding="utf-8" ?><foo>bår</foo>' | ||
| ) | ||
| response.headers["Content-Type"] = "text/xml" | ||
| return response | ||
| submit1 = widget.SubmitWidget("sub_a", value="sub_a") | ||
| submit2 = widget.SubmitWidget("sub_b", value="sub_b") | ||
| s = "" | ||
| if request.form: | ||
| used = False | ||
| if submit1.parse(request): | ||
| used = True | ||
| s += "used_sub_a" | ||
| if submit2.parse(request): | ||
| used = True | ||
| s += "used_sub_b" | ||
| @app.route("/restricted") | ||
| def view_restricted() -> str: | ||
| """Test restricted access.""" | ||
| if "user" not in session: | ||
| response = make_response("you must have a username", 403) | ||
| abort(response) | ||
| return "you made it!" | ||
| if not used: | ||
| raise RuntimeError("Not button was used.") | ||
| # print out the referer, too. | ||
| referer = request.environ.get("HTTP_REFERER") | ||
| if referer: | ||
| s += f"<p>referer: {referer}" | ||
| @app.route("/http_auth") | ||
| def view_http_auth() -> str: | ||
| """Test restricted access using HTTP authentication.""" | ||
| login = passwd = None | ||
| ha = request.environ.get("HTTP_AUTHORIZATION") | ||
| if ha: | ||
| auth_type, auth_string = ha.split(None, 1) | ||
| if auth_type.lower() == "basic": | ||
| auth_string = decodebytes(auth_string.encode("utf-8")) | ||
| login_bytes, passwd_bytes = auth_string.split(b":", 1) | ||
| login = login_bytes.decode("utf-8") | ||
| passwd = passwd_bytes.decode("utf-8") | ||
| if (login, passwd) != ("test", "password"): | ||
| passwd = None | ||
| s1, s2 = submit1.render(), submit2.render() | ||
| return f"<form method=POST>{s} {s1} {s2}</form>" | ||
| if passwd: | ||
| print(f"Successful login as {login}") | ||
| elif login: | ||
| print(f"Invalid login attempt as {login}") | ||
| else: | ||
| print("Access has been denied") | ||
| print() | ||
| if not passwd: | ||
| response = make_response( | ||
| "you are not authorized to access this resource", 401 | ||
| ) | ||
| response.headers["WWW-Authenticate"] = 'Basic realm="Protected"' | ||
| abort(response) | ||
| return "you made it!" | ||
| def testformaction(self) -> str: | ||
| """Test form actions.""" | ||
| request = get_request() | ||
| keys = sorted(k for k in request.form if request.form[k]) | ||
| return "==" + " AND ".join(keys) + "==" | ||
| def testform(self) -> str: | ||
| """Test form.""" | ||
| request = get_request() | ||
| @app.route("/broken_linktext") | ||
| def view_broken_linktext() -> str: | ||
| """Get broken link text.""" | ||
| return """ | ||
| <a href="/"> | ||
| <span>some text</span> | ||
| </a> | ||
| """ | ||
| s = "" | ||
| if not request.form: | ||
| s = "NO FORM" | ||
| if request.form and "selecttest" in request.form: | ||
| values = request.form["selecttest"] | ||
| if isinstance(values, str): | ||
| values = [values] | ||
| values = " AND ".join(values) | ||
| s += f"SELECTTEST: =={values}==<p>" | ||
| @app.route("/broken_form_1") | ||
| def view_broken_form_1() -> str: | ||
| """Get broken form 1.""" | ||
| return """\ | ||
| <form> | ||
| <input type="text" name="blah" value="thus"> | ||
| """ | ||
| if request.form: | ||
| items = [] | ||
| for name in ("item", "item_a", "item_b", "item_c"): | ||
| if request.form.get(name): | ||
| value = request.form[name] | ||
| items.append(f"{name}={value}") | ||
| values = " AND ".join(items) | ||
| s += f"NAMETEST: =={values}==<p>" | ||
| return f"""\ | ||
| {s} | ||
| <form method=POST id=the_form> | ||
| <select name=selecttest multiple> | ||
| <option> val | ||
| <option value='selvalue1'> value1 </option> | ||
| <option value='selvalue2'> value2 </option> | ||
| <option value='selvalue3'> value3 </option> | ||
| <option value='test.value3'> testme.val </option> | ||
| <option value=Test.Value4> testme4.val </option> | ||
| </select> | ||
| @app.route("/broken_form_2") | ||
| def view_broken_form_2() -> str: | ||
| """Get broken form 2.""" | ||
| return """\ | ||
| <form> | ||
| <table> | ||
| <tr><td> | ||
| <input name="broken"> | ||
| </td> | ||
| </form> | ||
| </tr> | ||
| </form> | ||
| """ | ||
| <input type=text name=item> | ||
| <input type=text name=item_a> | ||
| <input type=text name=item_b> | ||
| <input type=text name=item_c> | ||
| <input type=text id=some_id> | ||
| <input type=submit value=post id=submit_button> | ||
| @app.route("/broken_form_3") | ||
| def view_broken_form_3() -> str: | ||
| """Get broken form 3.""" | ||
| return """\ | ||
| <table> | ||
| <tr><td> | ||
| <input name="broken"> | ||
| </td> | ||
| </form> | ||
| </tr> | ||
| </form> | ||
| """ | ||
| def two_forms(self) -> str: | ||
| """Test two forms.""" | ||
| request = get_request() | ||
| if request.form: | ||
| form = request.form.get("form") | ||
| item = request.form.get("item") | ||
| s = f"FORM={form} ITEM={item}" | ||
| else: | ||
| s = "NO FORM" | ||
| @app.route("/broken_form_4") | ||
| def view_broken_form_4() -> str: | ||
| """Get broken form 4.""" | ||
| return """\ | ||
| <font> | ||
| <INPUT> | ||
| return f"""\ | ||
| <h1>Two Forms</h1> | ||
| <p>== {s} ==</p> | ||
| <form method=POST id=form1> | ||
| <input type=text name=item> | ||
| <input type=hidden name=form value=1> | ||
| <input type=submit value=post> | ||
| <FORM> | ||
| <input type="blah"> | ||
| </form> | ||
| <form method=POST id=form2> | ||
| <input type=text name=item> | ||
| <input type=hidden name=form value=2> | ||
| <input type=submit value=post> | ||
| """ | ||
| @app.route("/broken_form_5") | ||
| def view_broken_form_5() -> str: | ||
| """Get broken form 5.""" | ||
| return """\ | ||
| <div id="loginform"> | ||
| <form method="post" name="loginform" action="ChkLogin"> | ||
| <h3>ARINC Direct Login</h3> | ||
| <br/> | ||
| <strong>User ID</strong><br/> | ||
| <input name="username" id="username" type="text" style="width:80%"><br/> | ||
| <strong>Password</strong><br/> | ||
| <input name="password" type="password" style="width:80%"><br/> | ||
| <div id="buttonbar"> | ||
| <input value="Login" name="login" class="button" type="submit"> | ||
| </div> | ||
| </form> | ||
| </div> | ||
| """ | ||
| def test_checkbox(self) -> str: | ||
| """Test single checkbox.""" | ||
| request = get_request() | ||
| s = "" | ||
| if request.form and "checkboxtest" in request.form: | ||
| value = request.form["checkboxtest"] | ||
| if not isinstance(value, str): | ||
| value = value[0] | ||
| @app.route("/test_checkbox", methods=["GET", "POST"]) | ||
| def view_test_checkbox() -> str: | ||
| """Test single checkbox.""" | ||
| s = "" | ||
| if request.method == "POST" and "checkboxtest" in request.form: | ||
| value = request.form["checkboxtest"] | ||
| s += f"CHECKBOXTEST: =={value}==<p>" | ||
| s += par(f"CHECKBOXTEST: =={value}==") | ||
| return f"""\ | ||
| return f"""\ | ||
| {s} | ||
| <form method=POST> | ||
| <form method="POST"> | ||
@@ -542,183 +375,270 @@ <input type="checkbox" name="checkboxtest" value="True"> | ||
| <input type=submit value=post> | ||
| <input type="submit" value="post"> | ||
| </form> | ||
| """ | ||
| def test_checkboxes(self) -> str: | ||
| """Test multiple checkboxes.""" | ||
| request = get_request() | ||
| s = "" | ||
| if request.form and "checkboxtest" in request.form: | ||
| value = request.form["checkboxtest"] | ||
| if not isinstance(value, str): | ||
| value = ",".join(value) | ||
| @app.route("/test_checkboxes", methods=["GET", "POST"]) | ||
| def view_test_checkboxes() -> str: | ||
| """Test multiple checkboxes.""" | ||
| s = "" | ||
| if request.method == "POST" and "checkboxtest" in request.form: | ||
| value = ",".join(request.form.getlist("checkboxtest")) | ||
| s += f"CHECKBOXTEST: =={value}==<p>" | ||
| s += par(f"CHECKBOXTEST: =={value}==") | ||
| return f"""\ | ||
| return f"""\ | ||
| {s} | ||
| <form method=POST> | ||
| <form method="POST"> | ||
| <input type="checkbox" name="checkboxtest" value="one"> | ||
| <input type="checkbox" name="checkboxtest" value="two"> | ||
| <input type="checkbox" name="checkboxtest" value="three"> | ||
| <input type=submit value=post> | ||
| <input type="submit" value="post"> | ||
| </form> | ||
| """ | ||
| def test_simple_checkbox(self) -> str: | ||
| """Test simple checkbox.""" | ||
| request = get_request() | ||
| s = "" | ||
| if request.form and "checkboxtest" in request.form: | ||
| value = request.form["checkboxtest"] | ||
| if not isinstance(value, str): | ||
| value = value[0] | ||
| @app.route("/test_simple_checkbox", methods=["GET", "POST"]) | ||
| def view_test_simple_checkbox() -> str: | ||
| """Test simple checkbox.""" | ||
| s = "" | ||
| if request.method == "POST" and "checkboxtest" in request.form: | ||
| value = request.form["checkboxtest"] | ||
| s += f"CHECKBOXTEST: =={value}==<p>" | ||
| s += par(f"CHECKBOXTEST: =={value}==") | ||
| return f"""\ | ||
| return f"""\ | ||
| {s} | ||
| <form method=POST> | ||
| <form method="POST"> | ||
| <input type="checkbox" name="checkboxtest"> | ||
| <input type=submit value=post> | ||
| <input type="submit" value="post"> | ||
| </form> | ||
| """ | ||
| def test_radiobuttons(self) -> str: | ||
| """Test radio buttons.""" | ||
| request = get_request() | ||
| s = "" | ||
| if request.form and "radiobuttontest" in request.form: | ||
| value = request.form["radiobuttontest"] | ||
| if not isinstance(value, str): | ||
| value = ",".join(value) | ||
| @app.route("/test_radiobuttons", methods=["GET", "POST"]) | ||
| def view_test_radiobuttons() -> str: | ||
| """Test radio buttons.""" | ||
| s = "" | ||
| if request.method == "POST" and "radiobuttontest" in request.form: | ||
| value = request.form["radiobuttontest"] | ||
| s += f"RADIOBUTTONTEST: =={value}==<p>" | ||
| s += par(f"RADIOBUTTONTEST: =={value}==") | ||
| return f"""\ | ||
| return f"""\ | ||
| {s} | ||
| <form method=POST> | ||
| <input type="radio" name="radiobuttontest" value="one"> | ||
| <input type="radio" name="radiobuttontest" value="two"> | ||
| <input type="radio" name="radiobuttontest" value="three"> | ||
| <input type=submit value=post> | ||
| </form> | ||
| """ | ||
| <form method="POST"> | ||
| <input type="radio" name="radiobuttontest" value="one"> | ||
| <input type="radio" name="radiobuttontest" value="two"> | ||
| <input type="radio" name="radiobuttontest" value="three"> | ||
| <input type="submit" value="post"> | ||
| </form> | ||
| """ | ||
| def formpostredirect(self) -> str: | ||
| """Test redirect after a form POST.""" | ||
| request = get_request() | ||
| if not request.form: | ||
| return """\ | ||
| <form method=POST enctype=multipart/form-data> | ||
| <input type=text name=test> | ||
| <input type=submit value=submit name=submit> | ||
| @app.route("/testformaction", methods=["POST"]) | ||
| def view_testformaction() -> str: | ||
| """Test form actions.""" | ||
| keys = sorted(k for k in request.form if request.form[k]) | ||
| return "==" + " AND ".join(keys) + "==" | ||
| @app.route("/testform", methods=["GET", "POST"]) | ||
| def view_testform() -> str: | ||
| """Test form.""" | ||
| s = "" | ||
| if not request.form: | ||
| s = "NO FORM" | ||
| if request.form and "selecttest" in request.form: | ||
| values = " AND ".join(request.form.getlist("selecttest")) | ||
| s += par(f"SELECTTEST: =={values}==") | ||
| if request.form: | ||
| items = [] | ||
| for name in ("item", "item_a", "item_b", "item_c"): | ||
| if request.form.get(name): | ||
| value = request.form[name] | ||
| items.append(f"{name}={value}") | ||
| values = " AND ".join(items) | ||
| s += par(f"NAMETEST: =={values}==") | ||
| return f"""\ | ||
| {s} | ||
| <form method="POST" id="the_form"> | ||
| <select name="selecttest" multiple> | ||
| <option>val</option> | ||
| <option value="selvalue1">value1</option> | ||
| <option value="selvalue2">value2</option> | ||
| <option value="selvalue3">value3</option> | ||
| <option value="test.value3">testme.val</option> | ||
| <option value="Test.Value4">testme4.val</option> | ||
| </select> | ||
| <input type="text" name="item"> | ||
| <input type="text" name="item_a"> | ||
| <input type="text" name="item_b"> | ||
| <input type="text" name="item_c"> | ||
| <input type="text" id="some_id"> | ||
| <input type="submit" value="post" id="submit_button"> | ||
| </form> | ||
| """ | ||
| return redirect(get_path(1) + "/") | ||
| def logout(self) -> str: | ||
| """Test logout.""" | ||
| get_session_manager().expire_session() | ||
| return redirect(get_path(1) + "/") # back to index page | ||
| def plaintext(self) -> str: | ||
| """Test plain text response.""" | ||
| response = get_response() | ||
| response.set_content_type("text/plain") | ||
| return "hello, world" | ||
| @app.route("/multisubmitform", methods=["GET", "POST"]) | ||
| def view_multisubmitform() -> str: | ||
| """Test form with multiple submit buttons.""" | ||
| s1 = field("submit", "sub_a", value="sub_a") | ||
| s2 = field("submit", "sub_b", value="sub_b") | ||
| def xml(self) -> str: | ||
| """Test XML response.""" | ||
| response = get_response() | ||
| response.set_content_type("text/xml") | ||
| return '<?xml version="1.0" encoding="utf-8" ?><foo>bår</foo>' | ||
| s = "" | ||
| if request.method == "POST": | ||
| used = False | ||
| if "sub_a" in request.form: | ||
| used = True | ||
| s += "used_sub_a" | ||
| if "sub_b" in request.form: | ||
| used = True | ||
| s += "used_sub_b" | ||
| def echo(self) -> str: | ||
| """Show form content.""" | ||
| request = get_request() | ||
| if request.form and "q" in request.form: | ||
| return request.form["q"] | ||
| return "<html><body>No Content</body></html>" | ||
| if not used: | ||
| raise RuntimeError("Not button was used.") | ||
| def upload_file(self) -> str: | ||
| """Test file upload.""" | ||
| request = get_request() | ||
| if request.form: | ||
| return request.form["upload"].fp.read() | ||
| return """\ | ||
| <form enctype=multipart/form-data method=POST> | ||
| <input type=file name=upload> | ||
| <input type=submit value=submit> | ||
| </form>""" | ||
| referer = request.environ.get("HTTP_REFERER") | ||
| if referer: | ||
| s += par(f"referer: {referer}") | ||
| return f'<form method="POST">{s} {s1} {s2}</form>' | ||
| class Restricted(AccessControlled, Directory): | ||
| """A directory with restricted access.""" | ||
| _q_exports = ("",) | ||
| @app.route("/two_forms", methods=["GET", "POST"]) | ||
| def view_two_forms() -> str: | ||
| """Test two forms.""" | ||
| if request.form: | ||
| form = request.form.get("form") | ||
| item = request.form.get("item") | ||
| s = f"FORM={form} ITEM={item}" | ||
| else: | ||
| s = "NO FORM" | ||
| def _q_access(self) -> None: | ||
| """Check access.""" | ||
| session = get_session() | ||
| if not session.user: | ||
| raise AccessError("you must have a username") | ||
| return f"""\ | ||
| <h1>Two Forms</h1> | ||
| <p>== {s} ==</p> | ||
| <form method="POST" id="form1"> | ||
| <input type="text" name="item"> | ||
| <input type="hidden" name="form" value="1"> | ||
| <input type="submit" value="post"> | ||
| </form> | ||
| <form method="POST" id="form2"> | ||
| <input type="text" name="item"> | ||
| <input type="hidden" name="form" value="2"> | ||
| <input type="submit" value="post"> | ||
| </form> | ||
| """ | ||
| def _q_index(self) -> str: | ||
| """Show index page.""" | ||
| return "you made it!" | ||
| @app.route("/test_global_form", methods=["GET", "POST"]) | ||
| def view_test_global_form() -> str: | ||
| """Test the global form.""" | ||
| return """ | ||
| <html> | ||
| <head> | ||
| <title>Broken</title> | ||
| </head> | ||
| <body> | ||
| <div> | ||
| <input name="global_form_entry" type="text"> | ||
| <input name="global_entry_2" type="text"> | ||
| </div> | ||
| class HttpAuthRestricted(AccessControlled, Directory): | ||
| """A directory with restricted access using Basic Authentication.""" | ||
| <form name="login" method="post" action="display_post"> | ||
| <input type="text" name="hello"> | ||
| <input type="hidden" name="form" value="1"> | ||
| <input type="submit"> | ||
| </form> | ||
| _q_exports = ("",) | ||
| <form name="login" method="post" action="display_post"> | ||
| <input type="text" name="hello"> | ||
| <input type="hidden" name="form" value="2"> | ||
| <input type="submit"> | ||
| </form> | ||
| def _q_access(self) -> None: | ||
| """Check access.""" | ||
| r = get_request() | ||
| </body> | ||
| </html> | ||
| """ | ||
| login = passwd = None | ||
| ha = r.get_environ("HTTP_AUTHORIZATION", None) | ||
| if ha: | ||
| auth_type, auth_string = ha.split(None, 1) | ||
| if auth_type.lower() == "basic": | ||
| auth_string = decodebytes(auth_string.encode("utf-8")) | ||
| login, passwd = auth_string.split(b":", 1) | ||
| login = login.decode("utf-8") | ||
| passwd = passwd.decode("utf-8") | ||
| if (login, passwd) != ("test", "password"): | ||
| passwd = None | ||
| if passwd: | ||
| print(f"Successful login as '{login}'") | ||
| elif login: | ||
| print(f"Invalid login attempt as '{login}'") | ||
| else: | ||
| print("Access has been denied") | ||
| print() | ||
| if not passwd: | ||
| raise UnauthorizedError | ||
| @app.route("/test_refresh") | ||
| def view_test_refresh() -> str: | ||
| """Test simple refresh.""" | ||
| return """\ | ||
| <meta http-equiv="refresh" content="2; url=./login"> | ||
| hello, world. | ||
| """ | ||
| def _q_index(self) -> str: | ||
| """Show index page.""" | ||
| return "you made it!" | ||
| @app.route("/test_refresh2") | ||
| def view_test_refresh2() -> str: | ||
| """Test refresh with upper case.""" | ||
| return """\ | ||
| <META HTTP-EQUIV="REFRESH" CONTENT="2; URL=./login"> | ||
| hello, world. | ||
| """ | ||
| @app.route("/test_refresh3") | ||
| def view_test_refresh3() -> str: | ||
| """Test circular refresh.""" | ||
| return """\ | ||
| <meta http-equiv="refresh" content="2; url=./test_refresh3"> | ||
| hello, world. | ||
| """ | ||
| @app.route("/test_refresh4") | ||
| def view_test_refresh4() -> str: | ||
| """Test refresh together with similar meta tags.""" | ||
| return """\ | ||
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> | ||
| <html> | ||
| <head> | ||
| <title>o2.ie</title> | ||
| <meta http-equiv="refresh" content="0;URL=/login"> | ||
| <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"> | ||
| </head> | ||
| <body> | ||
| </body> | ||
| </html> | ||
| hello, world. | ||
| """ | ||
| @app.route("/test_refresh5") | ||
| def view_test_refresh5() -> str: | ||
| """Check for situation where given URL is quoted.""" | ||
| return """\ | ||
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> | ||
| <html> | ||
| <head> | ||
| <title>o2.ie</title> | ||
| <meta http-equiv="refresh" content="0;'URL=/login'"> | ||
| <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1"> | ||
| </head> | ||
| <body> | ||
| </body> | ||
| </html> | ||
| hello, world. | ||
| """ | ||
| if __name__ == "__main__": | ||
| from quixote.server.simple_server import ( # type: ignore[import-untyped] | ||
| run, | ||
| ) | ||
| port = int(os.environ.get("TWILL_TEST_PORT", PORT)) | ||
| print(f"starting twill test server on port {port}.") | ||
| try: | ||
| run(create_publisher, host=HOST, port=port) | ||
| app.run(host=HOST, port=port, debug=DEBUG, use_reloader=RELOAD) | ||
| except KeyboardInterrupt: | ||
| print("Keyboard interrupt ignored.") |
@@ -30,4 +30,6 @@ from twill import browser, commands | ||
| assert set(browser.form().inputs.keys()) == set( | ||
| "username password login".split() | ||
| ), "should get proper fields" | ||
| assert set(browser.form().inputs.keys()) == { | ||
| "username", | ||
| "password", | ||
| "login", | ||
| }, "should get proper fields" |
| import pytest | ||
| import twill | ||
@@ -48,3 +49,3 @@ from twill import browser, commands, namespaces | ||
| with pytest.raises(TwillException): | ||
| browser.title # noqa: B018s | ||
| browser.title # noqa: B018 | ||
@@ -51,0 +52,0 @@ commands.go(url) |
| import pytest | ||
| import twill | ||
@@ -3,0 +4,0 @@ from twill import commands, namespaces |
+2
-1
@@ -6,2 +6,3 @@ import sys | ||
| import pytest | ||
| import twill.parse | ||
@@ -17,3 +18,3 @@ from twill import commands, namespaces | ||
| def setup_module(): | ||
| global _log_commands # noqa: PLW0603 | ||
| global _log_commands | ||
| _log_commands = twill.parse.log_commands(True) # noqa: FBT003 | ||
@@ -20,0 +21,0 @@ |
@@ -7,2 +7,3 @@ """Test a boatload of miscellaneous functionality.""" | ||
| import pytest | ||
| from twill import browser, commands | ||
@@ -9,0 +10,0 @@ from twill.errors import TwillException |
| import pytest | ||
| from twill import browser, commands, namespaces | ||
@@ -3,0 +4,0 @@ from twill.errors import TwillException |
@@ -7,2 +7,3 @@ """Same as test_basic, but using the command interpreter.""" | ||
| import pytest | ||
| from twill import __url__ as twill_url | ||
@@ -28,3 +29,3 @@ from twill import __version__ as twill_version | ||
| ">> exit [<code>]", | ||
| ' Exit twill with given exit code (default 0, "no error").', | ||
| 'Exit twill with given exit code (default 0, "no error").', | ||
| "Print version information.", | ||
@@ -31,0 +32,0 @@ "Imported extension module 'shell_test'.", |
| import pytest | ||
| from twill import commands | ||
@@ -3,0 +4,0 @@ from twill.errors import TwillException |
@@ -8,2 +8,3 @@ """Test the utils.run_tidy function. | ||
| import pytest | ||
| from twill import utils | ||
@@ -10,0 +11,0 @@ from twill.commands import config |
@@ -5,2 +5,3 @@ from time import sleep | ||
| from httpx import ReadTimeout | ||
| from twill import commands | ||
@@ -7,0 +8,0 @@ |
| import pytest | ||
| from twill import commands | ||
@@ -3,0 +4,0 @@ from twill.errors import TwillException |
| """Test the unit-test support framework using (naturally) a unit test.""" | ||
| import os | ||
| import time | ||
| from pathlib import Path | ||
| import twill.unit | ||
| from quixote.server.simple_server import ( # type: ignore[import-untyped] | ||
| run as quixote_run, | ||
| ) | ||
| from .server import create_publisher | ||
| from .server import app | ||
| from .utils import test_dir | ||
| PORT = 8081 # default port to run the server on | ||
| SLEEP = 0.5 # time to wait for the server to start | ||
| HOST = "127.0.0.1" | ||
| PORT = 8080 | ||
| SLEEP = 0.25 | ||
| DEBUG = False | ||
| RELOAD = False | ||
| def run_server(port: int = PORT) -> None: | ||
| def run_server() -> None: | ||
| """Function to run the server""" | ||
| quixote_run(create_publisher, port=port) | ||
| port = int(os.environ.get("TWILL_TEST_PORT", PORT)) | ||
| app.run(host=HOST, port=port, debug=DEBUG, use_reloader=RELOAD) | ||
| def test(): | ||
| """The unit test""" | ||
| def just_wait() -> None: | ||
| """Just wait instead of starting the server.""" | ||
| while True: | ||
| time.sleep(SLEEP or 1) | ||
| def test(url: str) -> None: | ||
| """Test wrapper for unit support.""" | ||
| # abspath to the script | ||
| script = str(Path(test_dir, "test_unit_support.twill")) | ||
| # create test_info object | ||
| test_info = twill.unit.TestInfo(script, run_server, PORT, SLEEP) | ||
| # the port that shall be used for running the server | ||
| port = int(os.environ.get("TWILL_TEST_PORT", PORT)) | ||
| # run tests! | ||
| twill.unit.run_test(test_info) | ||
| # create a TestInfo object | ||
| test_info = twill.unit.TestInfo(script, run_server, HOST, port, SLEEP) | ||
| if test_info.url == url: | ||
| # server already running, run a dummy server function that just waits | ||
| test_info = twill.unit.TestInfo(script, just_wait, HOST, port, SLEEP) | ||
| if __name__ == "__main__": | ||
| test() | ||
| # run the tests! | ||
| twill.unit.run_test(test_info) |
| from pathlib import Path | ||
| import pytest | ||
| from twill import utils | ||
@@ -5,0 +6,0 @@ from twill.errors import TwillException |
| import pytest | ||
| from twill import parse | ||
@@ -3,0 +4,0 @@ |
+13
-11
@@ -12,3 +12,2 @@ """Utility functions for testing twill.""" | ||
| import httpx | ||
| import twill | ||
@@ -23,6 +22,7 @@ | ||
| START = True # whether to automatically start the quixote server | ||
| START = True # whether to automatically start the test server | ||
| LOG = None # name of the server log file or None | ||
| _url = None # current server url | ||
| __popen__ = None # server process | ||
@@ -114,3 +114,3 @@ | ||
| Run a Quixote simple_server on HOST:PORT with subprocess. | ||
| Run a Flask development server on HOST:PORT with subprocess. | ||
| All output is captured and thrown away. | ||
@@ -120,3 +120,3 @@ | ||
| """ | ||
| global _url # noqa: PLW0603 | ||
| global _url, __popen__ | ||
@@ -127,7 +127,8 @@ if port is None: | ||
| if START: | ||
| out = open(LOG or os.devnull, "w", buffering=1) # noqa: SIM115 | ||
| log = LOG or os.devnull | ||
| out = open(log, "w", buffering=1, encoding="utf-8") # noqa: SIM115 | ||
| print( # noqa: T201 | ||
| "Starting:", sys.executable, "tests/server.py", Path.cwd() | ||
| ) | ||
| subprocess.Popen( # noqa: S603 | ||
| __popen__ = subprocess.Popen( # noqa: S603 | ||
| [sys.executable, "-u", "server.py"], | ||
@@ -140,3 +141,2 @@ stderr=subprocess.STDOUT, | ||
| # noinspection HttpUrlsUsage | ||
| _url = f"http://{HOST}:{port}/" | ||
@@ -147,11 +147,13 @@ | ||
| """Stop a previously started test web server.""" | ||
| global _url # noqa: PLW0603 | ||
| global _url, __popen__ | ||
| if _url: | ||
| if __popen__: | ||
| if START: | ||
| try: | ||
| httpx.get(f"{_url}exit", timeout=10) | ||
| __popen__.terminate() | ||
| except Exception as error: # noqa: BLE001 | ||
| print("ERROR:", error) # noqa: T201 | ||
| print("Could not stop the server.") # noqa: T201 | ||
| _url = None | ||
| else: | ||
| print("The server has been stopped.") # noqa: T201 | ||
| _url = __popen__ = None |
+10
-10
| [tox] | ||
| envlist = py3{8,9,10,11,12}, ruff, mypy, docs, manifest | ||
| envlist = py3{8,9,10,11,12,13,14rc2}, ruff, mypy, docs, manifest | ||
| [testenv:ruff] | ||
| basepython = python3.12 | ||
| deps = ruff>=0.5,<0.6 | ||
| basepython = python3.13 | ||
| deps = ruff>=0.12,<0.13 | ||
| commands = | ||
@@ -12,6 +12,6 @@ ruff check src/twill tests extras | ||
| [testenv:mypy] | ||
| basepython = python3.12 | ||
| basepython = python3.13 | ||
| deps = | ||
| mypy >= 1.10, <1.11 | ||
| dnspython >=2.5, <3 | ||
| mypy >= 1.17.1, <1.18 | ||
| dnspython >=2.7, <3 | ||
| types-lxml | ||
@@ -23,4 +23,4 @@ types-setuptools | ||
| [testenv:docs] | ||
| basepython = python3.12 | ||
| deps = sphinx >=7.3, <8 | ||
| basepython = python3.13 | ||
| deps = sphinx >=8.2, <9 | ||
| extras = | ||
@@ -32,4 +32,4 @@ docs | ||
| [testenv:manifest] | ||
| basepython = python3.12 | ||
| deps = check-manifest>=0.49,<1 | ||
| basepython = python3.13 | ||
| deps = check-manifest>=0.50,<1 | ||
| commands = | ||
@@ -36,0 +36,0 @@ check-manifest -v |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
131
0.77%286024
-0.28%5247
-1.69%