diff --git a/.gitignore b/.gitignore index 5298d61..7c097ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ *.pyc .komodotools *.komodoproject -build/ +docs/ dist/ MANIFEST htmlcov @@ -11,6 +11,16 @@ txweb.egg-info/* .cache/v/cache/lastfailed log.txt +bin +develop-eggs +dist +downloads +eggs +.eggs +parts +*.egg-info +lib64 + # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/.idea/txWeb.iml b/.idea/txWeb.iml index 95ef11a..3f0ad35 100644 --- a/.idea/txWeb.iml +++ b/.idea/txWeb.iml @@ -1,5 +1,8 @@ + + @@ -9,4 +12,17 @@ + + + + + + + + \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..9cde995 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,592 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10.0 + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS,helper.py,conftest.py,html.py + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns=test_* + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=0 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + line-too-long, + C0103, + too-many-instance-attributes, + too-many-ancestors + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/CHANGELOG.txt b/CHANGELOG.txt index a7b831e..45b9593 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,14 @@ +2020-11 + TxWeb (Texas web) is coming along. There are a few outstanding issues: + Fix error handling + Fix static directory serving + New direction is to overload tx.web.static.Directory to provide two features: no recursion flag and also a no directory lister option. + Cleanup code more + Documentation + More unit tests + +2020-11 + Autobahn integration is working well in auto_texas side project. 2019-11-16 There are still a lot of features to add (eg. File directory support) but I am happy with the refactor diff --git a/README.md b/README.md index bcac2b1..73b951f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,15 @@ Twisted Web extension ===================== - A routing extension to twisted.web. + A routing extension to twisted.web + +Status +====== +Super beta + +Major issues +============ +Error handling still needs to be improved/refactored Purpose & History @@ -10,7 +18,42 @@ Purpose & History This project started a few months around when Klein did and if you want a more complete web framework I would recommend that over txWeb. -TxWeb has been drastically refactored to provide a Twisted Web Resource -a long with a few other utilities to mimic the original pylons as well -as another interpretation of Flask's blueprints +TxWeb is an overlay above the twisted.web module/package along with providing a routing resource mechanism. + +```python + +from twisted.internet import reactor + +from txweb import Application + + + +app = Application(__name__) + +@app.route("/hello") +def provide_hello(request): + return "Hello World" + + +@app.route("/args") +def provide_arguments(request): + who = request.args.get("who", default="No body") + says = request.args.get("says", default="Nothing") + #Python 3.8 + return f"{who} said {says}" +# would output "DevDave said Hello" given /args?who=DevDave&says=Hello +# would output "No body said Nothing" give /args + +@app.route("/process_form") +def handle_form(request): + input1 = request.form.get("input1") + return "" + +app.listenTCP(8080) +reactor.run() + +``` + +Also, please refer to the example's directory (but not to_replace or to_update sub-dirs) for slightly +more filled out examples of use. diff --git a/dev_requirements.txt b/dev_requirements.txt new file mode 100644 index 0000000..d84528d --- /dev/null +++ b/dev_requirements.txt @@ -0,0 +1,52 @@ +alabaster==0.7.12 +argh==0.26.2 +attrs==20.2.0 +Automat==0.6.0 +Babel==2.8.0 +certifi==2019.11.28 +chardet==3.0.4 +colorama==0.3.9 +commonmark==0.9.1 +constantly==15.1.0 +coverage==4.4.1 +decorator==4.1.2 +docutils==0.15.2 +hyperlink==17.3.1 +idna==2.8 +imagesize==1.2.0 +incremental==17.5.0 +Jinja2==2.11.2 +MarkupSafe==1.0 +packaging==20.0 +pathtools==0.1.2 +pipdeptree==0.10.1 +py==1.4.34 +Pygments==2.5.2 +PyHamcrest==1.9.0 +pyparsing==2.4.6 +pytest==3.2.3 +pytest-catchlog==1.2.2 +pytest-cov==2.5.1 +pytz==2019.3 +PyYAML==5.3 +pyzmq==17.1.2 +recommonmark==0.6.0 +requests==2.22.0 +six==1.11.0 +snowballstemmer==2.0.0 +Sphinx==2.3.1 +sphinx-autodoc-typehints==1.10.3 +sphinx-rtd-theme==0.4.3 +sphinxcontrib-applehelp==1.0.1 +sphinxcontrib-devhelp==1.0.1 +sphinxcontrib-htmlhelp==1.0.2 +sphinxcontrib-jsmath==1.0.1 +sphinxcontrib-qthelp==1.0.2 +sphinxcontrib-serializinghtml==1.1.3 +Twisted==20.3.0 +txweb==0.11.2019 +txZMQ==0.8.0 +urllib3==1.25.7 +-e git+https://github.com/devdave/watchdog.git@5ee9a387e71547f09d0fe88f9087e59c3458b797#egg=watchdog +Werkzeug==0.16.0 +zope.interface==4.4.3 diff --git a/examples/chat/chat.js b/examples/chat/chat.js new file mode 100644 index 0000000..9d0a477 --- /dev/null +++ b/examples/chat/chat.js @@ -0,0 +1,50 @@ +export class ChatClient { + constructor(socket, chat_window, input, send_btn) { + this.socket = socket; + this.chat_window = document.getElementById(chat_window); + this.input = document.getElementById(input); + this.send = document.getElementById(send_btn); + this.is_registered = false; + + this.send.addEventListener("click", this.on_chat.bind(this)); + this.socket.register("client.hear", this.provide_hear.bind(this)); + + + } + + async register() { + if (this.is_registered == false) { + const username = window.prompt("What is your username?", "Anonymous") + const result = await this.socket.ask("chat.register", {username}); + this.is_registered = result + } + } + + async on_chat(evt) { + const text = this.input.value; + if(await this.socket.ask("chat.speak", {text}) == true) { + this.input.value = ""; + } + else { + this.render_output("Client", "Failed to connect with server"); + } + } + + async provide_hear({who, what}) { + console.log("Got a message from server", who, what); + this.render_output(who, what); + } + + render_output(who, what) { + const chat_line = `${who}${what}`; + const div_line = document.createElement("div") + div_line.classList.add("text-msg"); + div_line.innerHTML = chat_line; + + this.chat_window.appendChild(div_line); + } + + + + +} \ No newline at end of file diff --git a/examples/chat/home.html b/examples/chat/home.html new file mode 100644 index 0000000..a8a5a48 --- /dev/null +++ b/examples/chat/home.html @@ -0,0 +1,52 @@ + + + + + Chat client + + + +
+
+
+ +
+ +
+ + \ No newline at end of file diff --git a/examples/chat/server.py b/examples/chat/server.py index 47a94c4..be47b15 100644 --- a/examples/chat/server.py +++ b/examples/chat/server.py @@ -1,272 +1,108 @@ - -from enum import Enum +from pathlib import Path +from dataclasses import dataclass import sys -import json import typing as T -from txweb.web_views import WebSite, StrRequest -from txweb import Application - -from zope.interface import Interface, Attribute, implementer -from twisted.python.components import registerAdapter - -from twisted.web.static import File -from twisted.web import server -from twisted.web import http -from twisted.internet import reactor, defer -from twisted.web.server import NOT_DONE_YET -from twisted.python import log - - -class NoValue: - """ - Sentinel variable for use with DictSession.get's default argument - """ - pass - -class IDictSession(Interface): - _data = Attribute("A dictionary where key is a str and value is a primitive type") - -@implementer(IDictSession) -class DictSession(object): - def __init__(self, session): - self._data = {} - - def __setitem__(self, key, value): - self._data[key] = value - - def __getitem__(self, key): - return self._data[key] +from twisted.internet import reactor +from twisted.python.log import startLogging - def get(self, key, default=NoValue): - if default is NoValue: - return self[key] - else: - return self._data.get(key, default) +from txweb import Texas +from txweb.lib.message_handler import MessageHandler +from txweb.lib.wsprotocol import WSProtocol +from txweb.util.reloader import reloader - def __contains__(self, key): - return key in self._data +HERE = Path(__file__).parent -registerAdapter(DictSession, server.Session, IDictSession) +app = Texas(__name__) -# Site = WebSite() -app = Application(__name__) -#TODO Allow these to become class vars +# Serve home.html from http://{HOST}:{PORT}/ +app.add_file("/", HERE / "home.html") +# Serve chat.js from http://{HOST}:{PORT}/chat.js +app.add_file("/chat.js", HERE / "chat.js") -app.add_file("/", "./index.html") -app.add_file("/index.js", "./script.js", defaultType="text/javascript") -class EventTypes(Enum): - USER_SAYS = 1 - USER_JOINED = 2 - USER_LEFT = 3 - CONNECTION_CLOSED = 4 - -@app.add("/messageboard") -class MessageBoard(object): +@dataclass(frozen=True) +class User: """ - Provides a web message board via the connections: register, logoff, tell, and listen - + House keeping aid, keep track of user names to their connection ID's as well as + hold a reference to their connection. """ - - def __init__(self): - self.users = {} # type: T.Dict[str, T.Set[T.Callable, StrRequest]] + name: str + identity: str + emitter: WSProtocol - def _add_user(self, username: str, callback:T.Callable, request:StrRequest): - if username in self.users: - raise ValueError(f"{username} is already registered") +@app.ws_class +class Chat: + """ + A working example of a very simple chat client. - self.users[username] = (callback, request,) + This is fragile code as it doesn't make any effort to catch potential exceptions/errors + and instead just manages registering new users and then forwarding their messages to all + other registered users + """ - def _remove_user(self, username: str): - self.users.pop(username, None) + users: T.Dict[str, User] - def _check_users(self): - """ - TODO add StrRequest.onConnectionTimeout and or StrRequest.onConnectionClosed methods + def __init__(self, application: Texas): + self.app = application + self.users = {} - Watches the registered users for disconnections and connection timeouts - """ - for username, (write_event, request) in self.users.items(): # type: T.AnyStr, T.Callable, StrRequest - #TODO figure out how to know if connection is closed - pass + def on_user_leave(self, identity: str): + if identity in self.users: + user = self.users[identity] + print(f"{user}@{identity} has disconnected") + del self.users[identity] - def _announce(self, msg_type: EventTypes, message:str, username:str): - """ - Iterates over connected users and relays either a user message or - a server side event. - """ - for (write_event, connection) in self.users.values(): - write_event(msg_type, message, username) - - def _get_username(self, request): - session = request.getSession(IDictSession) - username = session.get("username", None) - if username is None: - raise ValueError("Username not set") + self.emit("SERVER", f"{user.name} has disconnected") - return username + def emit(self, who, what): + print(f"Emitting to {len(self.users)} users") + for identity, user in self.users.items(): + user: User # No magic here, just a typehint + user.emitter.tell("client.hear", who=who, what=what) - def _set_username(self, request, username): + # creates an endpoint `chat.register` + @app.ws_expose(assign_args=True) + def register(self, message:MessageHandler, username:str = None): """ - TODO replace/refactor how twisted.web does sessions - as there is a hardset 900 second time out that I - can't see any easy way to override. - :param request: + :param message: :param username: :return: """ - if username in self.users: - raise ValueError(f"{username} is already registered") - - session = request.getSession(IDictSession) - session['username'] = username - - def _on_user_logoff(self, username): - """ - For whatever reason, username has logged off - """ - self.users.pop(username, None) - self.announce(EventTypes.USER_LEFT, f"{username} has logged out") - - @app.expose("/ping/", methods=["POST","GET"]) - def do_ping(self, request:server.Request, username): - try: - session = request.GetSession(IDictSession) - if username in session: - session.touch() - except ValueError: - request.setResponseCode(http.SERVICE_UNAVAILABLE) - return f"{username} is unknown" - else: - return f"{username} pinged" - + print(f"New registration: {username} @ {message.identity}") - @app.expose("/register", methods=["POST"]) - def do_register(self, request:server.Request): - response = request.json - try: - self._set_username(request, response['username']) - except ValueError as e: - return json.dumps(dict(result="ERROR", reason= repr(e.args))) + self.users[message.identity] = User(username, message.identity, message.connection) + message.connection.on_disconnect.addCallback(self.on_user_leave) + self.emit("SERVER", f"{username} has joined.") - username = self._get_username(request) - return json.dumps(dict(result="OK",username=repr(username))) + return True - - @app.expose("/logoff") - def do_logoff(self, request:server.Request): - username = self._get_username(request) - - self._remove_user(username) - - return json.dumps(dict(result="OK", username=repr(username))) - - - @app.expose("/tell", methods=["POST"]) - def do_tell(self, request): - response = request.json - msg_type = EventTypes(response['type']) - try: - username = self._get_username(request) - except ValueError: - #TODO use log - print("Unable to find username for request") - return json.dumps(dict(result="ERROR", reason="Unable to post message as username is not set!")) - else: - if response['message'].startswith("/debug"): - self.users[username][0](EventTypes.USER_SAYS, repr(self.users), "server") - else: - self._announce(msg_type, response['message'], username) - - return json.dumps(dict(result="OK")) - - @app.expose("/listen") - def do_listen(self, request:server.Request): - """ - Sets up a persistent Server Sent Event connection to the client browser. - - """ - - - def write_response(msg_type: EventTypes, message: str, username: str = None): - """ - Just a helper function that could be part of the MessageBoard class but - I put it inline as it is only used in the listen method - - """ - data = json.dumps(dict(type=msg_type.value, message=message, username=username)) - print("Sending: ", data) - request.write(f"data: {data}\n\n".encode("utf-8")) - - - def on_event(msg_type: EventTypes, message: str, username=None): - """ - A user specific callback that sends a new SSEvent message to the connected client - """ - write_response(msg_type, message, username) - - - def on_close(reason): - """ - Handles cleanup when a user/client disconnects from the message board - """ - username = "Unknown" - - try: - username = self._get_username(request) - self._remove_user(username) - except ValueError as e: - #Do nothing - print("Got Exception") - print(repr(e)) - pass - else: - self._announce(EventTypes.USER_LEFT, f"{username!r} left", "server") - # assume connection is closed - - return True - - # Setup Server Side Event headers - request.setHeader("Cache-control", "no-cache") - # This is more important for the browser and verifies an event stream connection has been made - request.setHeader("Content-Type", "text/event-stream") - - # Catch when a client disconnects (closed browser/tab, connectivity issues, acts of god, etc) - request.notifyFinish().addErrback(on_close) - - - try: - username = self._get_username(request) - except ValueError: - write_response(EventTypes.CONNECTION_CLOSED, "Username not set", "server") - request.finish() - return NOT_DONE_YET - else: - - write_response(EventTypes.USER_JOINED, f"Welcome {username!r}", "server"); - self._announce(EventTypes.USER_JOINED, f"{username!r} joined chat", "server") - self._add_user(username, on_event, request) - - return NOT_DONE_YET + # endpoint chat.speak + @app.ws_expose(assign_args=True) + def speak(self, message: MessageHandler, text:str = None): + user = self.users[message.connection.identity] + print(f"New chat: {user.name}@{message.identity} says {text!r}") + self.emit(user.name, text) def main(): - print("Main called, starting reactor") - log.startLogging(sys.stdout) - app.reactor = reactor - app.listenTCP(8123) - # reactor.listenTCP(8123, Site) + HOST = "127.0.0.1" + PORT = 7070 + + # provide a websocket connection at ws://{HOST}:{POST}/ws for the client side + app.enable_websockets(f"ws://{HOST}:{PORT}", "/ws") + app.ws_sharelib("/lib") + app.listenTCP(PORT, HOST) + print(f"Listening on http://{HOST}:{PORT}/") reactor.run() if __name__ == "__main__": - from txweb.util.reloader import reloader - reloader(main, watch_self=True) - + startLogging(sys.stdout) + reloader(main) \ No newline at end of file diff --git a/examples/forms/main.html b/examples/forms/main.html new file mode 100644 index 0000000..18f6167 --- /dev/null +++ b/examples/forms/main.html @@ -0,0 +1,89 @@ + + + + + Texas forms + + + +
+ A simple form +
+ +
+

Radio

+ + + +
+
+ Checkboxs + + + + + +
+ + +
+
+ +
+ A POST form +
+ +
+

Radio

+ + + +
+
+ Checkboxs + + + + + +
+ + +
+
+ +
+ A file upload form +
+ +
+ + +
+ + +
+
+ +

Form result

+ + + + + \ No newline at end of file diff --git a/examples/forms/server.py b/examples/forms/server.py index b0887a1..72a5c52 100644 --- a/examples/forms/server.py +++ b/examples/forms/server.py @@ -1,75 +1,100 @@ -from txweb.web_views import WebSite -from txweb.util.reloader import reloader +from pathlib import Path +import sys +from twisted.python.log import startLogging from twisted.internet import reactor -from twisted.python import log -import sys +from txweb import Texas +from txweb.lib.str_request import StrRequest +from txweb.util.reloader import reloader + +HERE = Path(__file__).parent +PORT = 7070 +HOST = "127.0.0.1" + +app = Texas(__name__) + -site = WebSite() -index = site.add_file("/", "./index.html") +app.add_file("/", HERE / "main.html") + + +@app.route("/simple", methods=["GET"]) +def simple_form(request: StrRequest): + form_fields = list(request.args.items()) + + # Note that for scenarios where a form has multiple inputs with the same name. + check_box = request.args.getlist("check_field") -@site.add("/get_form", methods=["GET"]) -def handle_get_args(request): return f""" - {request.args.get('word')!r}
- {request.args.get('number')!r}
- {request.args.get('checked', False, type=bool)!r}
+ URL: {request.uri}
+ Arguments:
+ {repr(form_fields)}
+ Check field:
+ {repr(check_box)}
""" -@site.add("/simple", methods=["POST"]) -def handle_simple_form(request): - buffer = \ - f""" - {request.form.get('word')}
- {request.form.get('checked')}
+@app.route("/simple_posted_form", methods=["post"]) +def simple_posted(request: StrRequest): + """ + Not the difference of using texas.form versus texas.args + + The reason is that form's that use GET, append their values to the submitting + URL like /my_resource?text_field=blah&check_field=1&check_field=2 + + so .args is for URL arguments. + + while POST'd forms send the form field in the response body. + + :param request: + :return: """ - return buffer - -@site.add("/complicated", methods=["POST"]) -def handle_complicated_form(request): - debug = 1 - buffer = \ - f""" - -

Raw request

-
-
Content type
-
{request.getHeader("content-type")} - -
Content length
-
{request.getHeader("content-length")} -
- - - -

Form results

-
    -
  1. -  {request.form.get('word')!r} -
  2. -
  3. -  {request.args.get('checked','off')!r} -
  4. -

  5. - -
  6. -
+ form_fields = list(request.form.items()) + + # Note that for scenarios where a form has multiple inputs with the same name. + check_box = request.form.getlist("check_field") + + return f""" + Arguments: + {repr(form_fields)} + Check field: + {repr(check_box)} """ - return buffer +@app.route("/file_upload", methods=["POST"]) +def file_upload(request: StrRequest): + + uploaded_file = request.files.get("upload") + + file_name = uploaded_file.filename + field_name = uploaded_file.name + try: + file = uploaded_file.stream.read().decode("utf-8") + except UnicodeDecodeError: + file = "Unable to decode file to UTF-8" + + file_type = uploaded_file.content_type + + return f""" + Field Name: {field_name}
+ Source file name: {file_name}
+ File type: {file_type}
+
+ Uploaded file:
+
{file}
+ """ def main(): - log.startLogging(sys.stdout) - site.displayTracebacks = True - reactor.listenTCP(8345, site) + + startLogging(sys.stdout) + app.listenTCP(PORT, HOST) + + print(f"Hosting on http://{HOST}:{PORT}/") + reactor.run() if __name__ == "__main__": - reloader(main) + reloader(main) \ No newline at end of file diff --git a/examples/to_replace/web_views/run.py b/examples/to_replace/web_views/run.py index 828cacc..eaa31b0 100644 --- a/examples/to_replace/web_views/run.py +++ b/examples/to_replace/web_views/run.py @@ -1,4 +1,4 @@ -from txweb.web_views import website +from txweb.web_site import website from twisted.application import service, internet as app_internet from twisted.web import static, server diff --git a/examples/chat/index.html b/examples/to_update/chat/index.html similarity index 100% rename from examples/chat/index.html rename to examples/to_update/chat/index.html diff --git a/examples/chat/script.js b/examples/to_update/chat/script.js similarity index 100% rename from examples/chat/script.js rename to examples/to_update/chat/script.js diff --git a/examples/to_update/chat/server.py b/examples/to_update/chat/server.py new file mode 100644 index 0000000..b5f3ee2 --- /dev/null +++ b/examples/to_update/chat/server.py @@ -0,0 +1,272 @@ + +from enum import Enum +import sys +import json +import typing as T + +from txweb.web_site import WebSite, StrRequest +from txweb import Application + +from zope.interface import Interface, Attribute, implementer +from twisted.python.components import registerAdapter + +from twisted.web.static import File +from twisted.web import server +from twisted.web import http +from twisted.internet import reactor, defer +from twisted.web.server import NOT_DONE_YET +from twisted.python import log + + +class NoValue: + """ + Sentinel variable for use with DictSession.get's default argument + """ + pass + +class IDictSession(Interface): + _data = Attribute("A dictionary where key is a str and value is a primitive type") + +@implementer(IDictSession) +class DictSession(object): + def __init__(self, session): + self._data = {} + + def __setitem__(self, key, value): + self._data[key] = value + + def __getitem__(self, key): + return self._data[key] + + def get(self, key, default=NoValue): + if default is NoValue: + return self[key] + else: + return self._data.get(key, default) + + def __contains__(self, key): + return key in self._data + +registerAdapter(DictSession, server.Session, IDictSession) + + +# Site = WebSite() +app = Application(__name__) +#TODO Allow these to become class vars + + +app.add_file("/", "./index.html") +app.add_file("/index.js", "./script.js", defaultType="text/javascript") + +class EventTypes(Enum): + USER_SAYS = 1 + USER_JOINED = 2 + USER_LEFT = 3 + CONNECTION_CLOSED = 4 + +@app.add("/messageboard") +class MessageBoard(object): + """ + Provides a web message board via the connections: register, logoff, tell, and listen + + """ + + def __init__(self): + self.users = {} # type: T.Dict[str, T.Set[T.Callable, StrRequest]] + + + def _add_user(self, username: str, callback:T.Callable, request:StrRequest): + if username in self.users: + raise ValueError(f"{username} is already registered") + + self.users[username] = (callback, request,) + + def _remove_user(self, username: str): + self.users.pop(username, None) + + def _check_users(self): + """ + TODO add StrRequest.onConnectionTimeout and or StrRequest.onConnectionClosed methods + + Watches the registered users for disconnections and connection timeouts + """ + for username, (write_event, request) in self.users.items(): # type: T.AnyStr, T.Callable, StrRequest + #TODO figure out how to know if connection is closed + pass + + + def _announce(self, msg_type: EventTypes, message:str, username:str): + """ + Iterates over connected users and relays either a user message or + a server side event. + """ + for (write_event, connection) in self.users.values(): + write_event(msg_type, message, username) + + def _get_username(self, request): + session = request.getSession(IDictSession) + username = session.get("username", None) + if username is None: + raise ValueError("Username not set") + + return username + + def _set_username(self, request, username): + """ + TODO replace/refactor how twisted.web does sessions + as there is a hardset 900 second time out that I + can't see any easy way to override. + + :param request: + :param username: + :return: + """ + if username in self.users: + raise ValueError(f"{username} is already registered") + + session = request.getSession(IDictSession) + session['username'] = username + + def _on_user_logoff(self, username): + """ + For whatever reason, username has logged off + """ + self.users.pop(username, None) + self.announce(EventTypes.USER_LEFT, f"{username} has logged out") + + @app.expose("/ping/", methods=["POST","GET"]) + def do_ping(self, request:server.Request, username): + try: + session = request.GetSession(IDictSession) + if username in session: + session.touch() + except ValueError: + request.setResponseCode(http.SERVICE_UNAVAILABLE) + return f"{username} is unknown" + else: + return f"{username} pinged" + + + @app.expose("/register", methods=["POST"]) + def do_register(self, request:server.Request): + response = request.json + try: + self._set_username(request, response['username']) + except ValueError as e: + return json.dumps(dict(result="ERROR", reason= repr(e.args))) + + username = self._get_username(request) + return json.dumps(dict(result="OK",username=repr(username))) + + + @app.expose("/logoff") + def do_logoff(self, request:server.Request): + username = self._get_username(request) + + self._remove_user(username) + + return json.dumps(dict(result="OK", username=repr(username))) + + + @app.expose("/tell", methods=["POST"]) + def do_tell(self, request): + response = request.json + msg_type = EventTypes(response['type']) + try: + username = self._get_username(request) + except ValueError: + #TODO use log + print("Unable to find username for request") + return json.dumps(dict(result="ERROR", reason="Unable to post message as username is not set!")) + else: + if response['message'].startswith("/debug"): + self.users[username][0](EventTypes.USER_SAYS, repr(self.users), "server") + else: + self._announce(msg_type, response['message'], username) + + return json.dumps(dict(result="OK")) + + @app.expose("/listen") + def do_listen(self, request:server.Request): + """ + Sets up a persistent Server Sent Event connection to the client browser. + + """ + + + def write_response(msg_type: EventTypes, message: str, username: str = None): + """ + Just a helper function that could be part of the MessageBoard class but + I put it inline as it is only used in the listen method + + """ + data = json.dumps(dict(type=msg_type.value, message=message, username=username)) + print("Sending: ", data) + request.write(f"data: {data}\n\n".encode("utf-8")) + + + def on_event(msg_type: EventTypes, message: str, username=None): + """ + A user specific callback that sends a new SSEvent message to the connected client + """ + write_response(msg_type, message, username) + + + def on_close(reason): + """ + Handles cleanup when a user/client disconnects from the message board + """ + username = "Unknown" + + try: + username = self._get_username(request) + self._remove_user(username) + except ValueError as e: + #Do nothing + print("Got Exception") + print(repr(e)) + pass + else: + self._announce(EventTypes.USER_LEFT, f"{username!r} left", "server") + # assume connection is closed + + return True + + # Setup Server Side Event headers + request.setHeader("Cache-control", "no-cache") + # This is more important for the browser and verifies an event stream connection has been made + request.setHeader("Content-Type", "text/event-stream") + + # Catch when a client disconnects (closed browser/tab, connectivity issues, acts of god, etc) + request.notifyFinish().addErrback(on_close) + + + try: + username = self._get_username(request) + except ValueError: + write_response(EventTypes.CONNECTION_CLOSED, "Username not set", "server") + request.finish() + return NOT_DONE_YET + else: + + write_response(EventTypes.USER_JOINED, f"Welcome {username!r}", "server"); + self._announce(EventTypes.USER_JOINED, f"{username!r} joined chat", "server") + self._add_user(username, on_event, request) + + return NOT_DONE_YET + + + +def main(): + print("Main called, starting reactor") + log.startLogging(sys.stdout) + app.reactor = reactor + app.listenTCP(8123) + # reactor.listenTCP(8123, Site) + reactor.run() + + +if __name__ == "__main__": + from txweb.util.reloader import reloader + reloader(main, watch_self=True) + diff --git a/examples/forms/index.html b/examples/to_update/forms/index.html similarity index 100% rename from examples/forms/index.html rename to examples/to_update/forms/index.html diff --git a/examples/to_update/forms/server.py b/examples/to_update/forms/server.py new file mode 100644 index 0000000..037678d --- /dev/null +++ b/examples/to_update/forms/server.py @@ -0,0 +1,75 @@ +from txweb.web_site import WebSite +from txweb.util.reloader import reloader + +from twisted.internet import reactor +from twisted.python import log + +import sys + +site = WebSite() + +index = site.add_file("/", "./index.html") + +@site.add("/get_form", methods=["GET"]) +def handle_get_args(request): + return f""" + {request.args.get('word')!r}
+ {request.args.get('number')!r}
+ {request.args.get('checked', False, type=bool)!r}
+ """ + +@site.add("/simple", methods=["POST"]) +def handle_simple_form(request): + buffer = \ + f""" + {request.form.get('word')}
+ {request.form.get('checked')}
+ """ + return buffer + +@site.add("/complicated", methods=["POST"]) +def handle_complicated_form(request): + debug = 1 + buffer = \ + f""" + +

Raw request

+
+
Content type
+
{request.getHeader("content-type")} + +
Content length
+
{request.getHeader("content-length")} +
+ + + +

Form results

+
    +
  1. +  {request.form.get('word')!r} +
  2. +
  3. +  {request.args.get('checked','off')!r} +
  4. +

  5. + +
  6. +
+ """ + + return buffer + + + +def main(): + log.startLogging(sys.stdout) + site.displayTracebacks = True + reactor.listenTCP(8345, site) + reactor.run() + + +if __name__ == "__main__": + reloader(main) diff --git a/examples/tictactoe/game.py b/examples/to_update/tictactoe/game.py similarity index 100% rename from examples/tictactoe/game.py rename to examples/to_update/tictactoe/game.py diff --git a/examples/tictactoe/game_session.py b/examples/to_update/tictactoe/game_session.py similarity index 100% rename from examples/tictactoe/game_session.py rename to examples/to_update/tictactoe/game_session.py diff --git a/examples/tictactoe/response_request_types.py b/examples/to_update/tictactoe/response_request_types.py similarity index 100% rename from examples/tictactoe/response_request_types.py rename to examples/to_update/tictactoe/response_request_types.py diff --git a/examples/tictactoe/server.py b/examples/to_update/tictactoe/server.py similarity index 99% rename from examples/tictactoe/server.py rename to examples/to_update/tictactoe/server.py index 484dbbf..0d8e471 100644 --- a/examples/tictactoe/server.py +++ b/examples/to_update/tictactoe/server.py @@ -2,7 +2,7 @@ import sys from pathlib import Path -from txweb.web_views import WebSite +from txweb.web_site import WebSite from txweb.lib.str_request import StrRequest from twisted.internet import reactor diff --git a/examples/tictactoe/static/index.html b/examples/to_update/tictactoe/static/index.html similarity index 100% rename from examples/tictactoe/static/index.html rename to examples/to_update/tictactoe/static/index.html diff --git a/examples/tictactoe/static/script.js b/examples/to_update/tictactoe/static/script.js similarity index 100% rename from examples/tictactoe/static/script.js rename to examples/to_update/tictactoe/static/script.js diff --git a/examples/tictactoe/test_game.py b/examples/to_update/tictactoe/test_game.py similarity index 100% rename from examples/tictactoe/test_game.py rename to examples/to_update/tictactoe/test_game.py diff --git a/lint.bat b/lint.bat new file mode 100644 index 0000000..1046da8 --- /dev/null +++ b/lint.bat @@ -0,0 +1 @@ +pylint --ignore-patterns=test_* --ignore=helper.py,conftest.py -d W0221 -d R0903 -d R0901 -d R0902 -d R1705 -d C0103 -d C0301 -d C0305 -d C0115 -d W0603 -d C0303 txweb diff --git a/make.bat b/make.bat index 4bde88c..956d52f 100644 --- a/make.bat +++ b/make.bat @@ -8,7 +8,7 @@ if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source -set BUILDDIR=build +set BUILDDIR=docs if "%1" == "" goto help diff --git a/requirements.txt b/requirements.txt index 49cbffa..8da2c22 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,22 @@ -attrs==18.2.0 +attrs==20.2.0 Automat==0.6.0 colorama==0.3.9 constantly==15.1.0 -coverage==4.4.1 decorator==4.1.2 hyperlink==17.3.1 incremental==17.5.0 -Jinja2==2.9.6 +Jinja2==2.11.2 MarkupSafe==1.0 pipdeptree==0.10.1 py==1.4.34 PyHamcrest==1.9.0 -pytest==3.2.3 -pytest-cov==2.5.1 -pyzmq==17.1.2 six==1.11.0 -Twisted==19.10.0 -txZMQ==0.8.0 +Twisted==20.3.0 Werkzeug==0.16.0 zope.interface==4.4.3 + +setuptools~=40.4.3 +txweb~=0.11.2019 +pytest~=3.2.3 +recommonmark~=0.6.0 +autobahn~=20.7.1 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..995946a --- /dev/null +++ b/setup.cfg @@ -0,0 +1,18 @@ +[aliases] +test=pytest + + +[metadata] +name = Texas web +version = 0.2020.11 +description = An overlay/extension to twisted.web +author = DevDave +author_email = devdave@ominian.net +url = "https://github.com/devdave/txWeb" +classifiers = + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 \ No newline at end of file diff --git a/setup.py b/setup.py index 698e239..4294dee 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,19 @@ #from distutils.core import setup -from setuptools import setup +from setuptools import setup, find_packages META_DATA = dict( - name = "txweb", - version = '0.11.2019', - author = "David W.", - author_email = "devdave@ominian.net", - url = "https://github.com/devdave/txWeb", - packages = ['txweb',"txweb.util"], - package_data = { + packages = find_packages(include=["txweb", "txweb.*"]) + , package_data = { 'txweb': ['LICENSE.txt'] - }, - license = "MIT License", - keywords = "twisted web alternative routing", - description = "An alternative routing system for use with twisted.web", - requires = ["decorator", "twisted", "werkzeug"] + } + , license = "MIT License" + , keywords = "twisted web alternative routing" + , description = "An alternative routing system for use with twisted.web" + , install_requires = ["decorator", "twisted", "werkzeug", "jinja2"] + , setup_requires = ["pytest-runner"] + , tests_require = ["pytest", "pytest-catchlog"] + , extras_require = { + "development": ["pytest", "jinja2", "pytest-catchlog", "pytest-cov", "coverage"] + } ) if __name__ == '__main__': diff --git a/source/api/txweb.lib.errors.rst b/source/api/txweb.lib.errors.rst index 04ecff4..44e39b3 100644 --- a/source/api/txweb.lib.errors.rst +++ b/source/api/txweb.lib.errors.rst @@ -4,10 +4,26 @@ txweb.lib.errors package Submodules ---------- -txweb.lib.errors.handler module +txweb.lib.errors.base module +---------------------------- + +.. automodule:: txweb.lib.errors.base + :members: + :undoc-members: + :show-inheritance: + +txweb.lib.errors.debug module +----------------------------- + +.. automodule:: txweb.lib.errors.debug + :members: + :undoc-members: + :show-inheritance: + +txweb.lib.errors.default module ------------------------------- -.. automodule:: txweb.lib.errors.handler +.. automodule:: txweb.lib.errors.default :members: :undoc-members: :show-inheritance: @@ -20,7 +36,6 @@ txweb.lib.errors.html module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/source/api/txweb.lib.rst b/source/api/txweb.lib.rst index 2ab43b3..9849a2b 100644 --- a/source/api/txweb.lib.rst +++ b/source/api/txweb.lib.rst @@ -5,12 +5,29 @@ Subpackages ----------- .. toctree:: + :maxdepth: 4 txweb.lib.errors Submodules ---------- +txweb.lib.message\_handler module +--------------------------------- + +.. automodule:: txweb.lib.message_handler + :members: + :undoc-members: + :show-inheritance: + +txweb.lib.routed\_factory module +-------------------------------- + +.. automodule:: txweb.lib.routed_factory + :members: + :undoc-members: + :show-inheritance: + txweb.lib.str\_request module ----------------------------- @@ -27,6 +44,13 @@ txweb.lib.view\_class\_assembler module :undoc-members: :show-inheritance: +txweb.lib.wsprotocol module +--------------------------- + +.. automodule:: txweb.lib.wsprotocol + :members: + :undoc-members: + :show-inheritance: Module contents --------------- diff --git a/source/api/txweb.resources.rst b/source/api/txweb.resources.rst index 53cf2bf..fb34fe1 100644 --- a/source/api/txweb.resources.rst +++ b/source/api/txweb.resources.rst @@ -4,22 +4,6 @@ txweb.resources package Submodules ---------- -txweb.resources.directory module --------------------------------- - -.. automodule:: txweb.resources.directory - :members: - :undoc-members: - :show-inheritance: - -txweb.resources.error module ----------------------------- - -.. automodule:: txweb.resources.error - :members: - :undoc-members: - :show-inheritance: - txweb.resources.routing module ------------------------------ @@ -28,14 +12,6 @@ txweb.resources.routing module :undoc-members: :show-inheritance: -txweb.resources.simple\_file module ------------------------------------ - -.. automodule:: txweb.resources.simple_file - :members: - :undoc-members: - :show-inheritance: - txweb.resources.view\_class module ---------------------------------- @@ -52,7 +28,6 @@ txweb.resources.view\_function module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/source/api/txweb.rst b/source/api/txweb.rst index dbcd019..8302f16 100644 --- a/source/api/txweb.rst +++ b/source/api/txweb.rst @@ -5,6 +5,7 @@ Subpackages ----------- .. toctree:: + :maxdepth: 4 txweb.lib txweb.resources @@ -21,10 +22,10 @@ txweb.application module :undoc-members: :show-inheritance: -txweb.errors module -------------------- +txweb.http\_codes module +------------------------ -.. automodule:: txweb.errors +.. automodule:: txweb.http_codes :members: :undoc-members: :show-inheritance: @@ -37,15 +38,14 @@ txweb.log module :undoc-members: :show-inheritance: -txweb.web\_views module ------------------------ +txweb.web\_site module +---------------------- -.. automodule:: txweb.web_views +.. automodule:: txweb.web_site :members: :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/source/api/txweb.util.rst b/source/api/txweb.util.rst index 1a7015f..3af4797 100644 --- a/source/api/txweb.util.rst +++ b/source/api/txweb.util.rst @@ -20,6 +20,14 @@ txweb.util.reloader module :undoc-members: :show-inheritance: +txweb.util.templating module +---------------------------- + +.. automodule:: txweb.util.templating + :members: + :undoc-members: + :show-inheritance: + txweb.util.url\_converter module -------------------------------- @@ -28,7 +36,6 @@ txweb.util.url\_converter module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/source/conf.py b/source/conf.py index ba47010..87b70ab 100644 --- a/source/conf.py +++ b/source/conf.py @@ -40,6 +40,9 @@ "sphinx_rtd_theme" ] +# Autodoc options +autodoc_member_order = 'bysource' + # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/source/index.rst b/source/index.rst index 4c0c15b..1c0deed 100644 --- a/source/index.rst +++ b/source/index.rst @@ -7,13 +7,14 @@ Welcome to Texas web's documentation! ===================================== .. toctree:: - :maxdepth: 2 + :maxdepth: 1 :caption: Contents: - api/txweb - manual/error_handling manual/quick_start - + manual/error_handling + manual/http_view_classes + manual/static_routing + api/txweb @@ -23,3 +24,4 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` + diff --git a/source/manual/http_view_classes.md b/source/manual/http_view_classes.md new file mode 100644 index 0000000..79a73d3 --- /dev/null +++ b/source/manual/http_view_classes.md @@ -0,0 +1,76 @@ +HTTP View classes +============ + +View classes are a code style for organizing common http endpoints into one class. Some caveats apply. + + + +```python +import my_app as app +from txweb.lib.str_request import StrRequest + +@app.add_class("/widget") +class Widget: + + def __init__(self): + self.counter = 0 + + def add_widget(self): + self.counter += 1 + + def delete_widget(self): + self.counter = max(0, self.counter - 1) + + @app.expose("/add") + def add(self, request: StrRequest): + self.add_widget() + return f"There are {self.counter} widgets." + + @app.expose("/delete") + def delete(self, request: StrRequest): + self.delete_widget() + return f"One widget was deleted, there are now {self.counter}" + +``` + +URLS added to the routing map +----------------------------- + +Above would add two new URLS to the map. + +1. `/widget/add` +2. `/widget/delete` + +Both endpoints default to only accept GET and HEAD requests. + +Motivation/purpose for design +----------------------------- + +Can be used for rapidly building out an HTTP based API where +the `add_class` is provided a noun and all of the `exposed` methods are verbs against +that noun (eg CRUD ). Additionally all of the API endpoints are consolidated into +one place instead of spread out across multiple module level functions. + + +Mechanism +--------- + +As demonstrated with the `Widget` example class, the class is instantiated internally +by Texas and a hard reference is stored internally to ensure the objects persistence for +the life of the process. + + + +Warning +------- + +Though the above example uses `self.counter` as a class persistent variable, care should be taken +on a couple counts: + +1. class variables are not persistent between restarts/reloads of the application. + +2. You can create memory leaks if you are not careful to manage variables. + +3. For more complex applications where a request yields to the reactor while waiting on an + expensive/blocking resource, the view class variable can be changed by another request that runs + while the first request is yielded. \ No newline at end of file diff --git a/source/manual/quick_start.md b/source/manual/quick_start.md new file mode 100644 index 0000000..c5623ba --- /dev/null +++ b/source/manual/quick_start.md @@ -0,0 +1,18 @@ +Quick start +=========== + +```python +from twisted.internet import reactor +from txweb import Application + + +app = Application(__name__) + +@app.route("/hello") +def hello_world(request): + return "Hello World!" + +listening = app.listenTCP(PORT, interface="0.0.0.0") +reactor.start() + +``` \ No newline at end of file diff --git a/source/manual/short_history.md b/source/manual/short_history.md new file mode 100644 index 0000000..727d723 --- /dev/null +++ b/source/manual/short_history.md @@ -0,0 +1,51 @@ + +Short history +------------- + +I started on TxWeb/Texas all the way back in early 2011 and have iterated and done major +refactors of this project since then. + +Generation one of TxWeb mimicked the original CherryPy in that a website was an object graph + +```python + class Widget(): + + def index(self, request): + return "I am the list of widgets" + + def create(self, request): + pass + + def replace(self, request): + pass + + def update(self, request): + pass + + def delete(self, request): + pass + + class Root(): + + def index(self, request): + return "Hello world" + + widgets = Widget() +``` + +That created a URL heirachy like + +```python + / + /widgets/ + /widgets/create/ + /widgets/replace/ + /widgets/update/ + /widgets/delete/ +``` + +I switched that out for my own URL parser which kind of worked but for anyone familiar +with werkzeug, is a massive project to itself. + +In 2020, I gutted out my own URL parser in favor of using werkzeug (similar if not exactly like +Klein). diff --git a/source/manual/static_routing.md b/source/manual/static_routing.md new file mode 100644 index 0000000..176105b --- /dev/null +++ b/source/manual/static_routing.md @@ -0,0 +1,44 @@ +Routing static assets +===================== + +Texas can expose both individual static files and entire directory assets. + + +Static files and directories +---------------------------- + +## Exposing static files + +```python +from pathlib import Path +from txweb import Texas + +app = Texas(__name__) + +app.route_file("/my_file", Path(__file__).parent / "path" / "too" / "my_file.txt") + +``` + +The snippet above would make `./path/too/my_file.txt` accessible to a client browser at +the URI "/my_file" via GET. Internally this uses `twisted.web.static.File` which does its +best to determine the correct MIME type of the file being served, BUT it defaults to `text/html` +if it cannot. + +In that scenario you can set a default content type via + +`app.add_file("/my_file", "foo/bar.txt", defaultType="application/jsonp")` + +## Exposing static directories + +```python +from pathlib import Path +from txweb import Texas + +app = Texas(__name__) + +app.route_directory("/my_file", Path(__file__).parent / "path" / "too" / "my_static_dir") + +``` + +Similar to `app.add_file` this uses `twisted.web.static.File` but if provided a directory path +it will recursively expose all subdirectories as well which should be noted. diff --git a/txweb/__init__.py b/txweb/__init__.py index 5cbfd38..50239b6 100644 --- a/txweb/__init__.py +++ b/txweb/__init__.py @@ -1,6 +1,24 @@ +""" + Texas web (txweb). + ```python + app = Application(__name__) + + @app.route("/foo") + def bar(request): + return "Hello World!" + + app.listenTCP(7070) + + reactor.run() + ``` + + curl http://127.0.0.1/foo + Hello World! +""" from twisted.web.server import NOT_DONE_YET from txweb.application import Application App = Application -__all__ = ['NOT_DONE_YET', "App"] +Texas = Application +__all__ = ['NOT_DONE_YET', "App", "Application", "Texas"] diff --git a/txweb/application.py b/txweb/application.py index 95d3fce..7160f04 100644 --- a/txweb/application.py +++ b/txweb/application.py @@ -1,79 +1,436 @@ """ Application is a common interface to Texas's various components - The Application object logic has been broken apart into two mixin like classes and the main Application class. + The Application object logic has been broken apart into mixin like classes and combined into Application class. 1. Routing logic (adding new routes/resources to the URL router) `ApplicationRoutingHelperMixin` 2. Error handling `ApplicationErrorHandlingMixin` - 3. General utilities and resources ( website, router, reactor, etc) `Application` - + 3. Websocket support + 4. General utilities and resources ( website, router, reactor, etc) `Application` """ from __future__ import annotations -from .log import getLogger -log = getLogger(__name__) +# Stdlib from pathlib import Path import typing as T import sys - +import inspect +import functools +# 3rd party from twisted.internet.tcp import Port -from twisted.internet import reactor # type: PosixReactorBase -from twisted.python.compat import intToBytes +from twisted.internet.posixbase import PosixReactorBase +from twisted.internet import reactor # type: PosixReactorBase +# from twisted.python.compat import intToBytes +from twisted.web.server import NOT_DONE_YET from twisted.web.static import File -log.debug(f"Loaded reactor: {reactor!r}") +from twisted.python import failure -from .resources import RoutingResource, SimpleFile, Directory +try: + from autobahn.twisted.resource import WebSocketResource +except ImportError: + AUTOBAHN_MISSING = True + print("Unable to support websockets: `pip install autobahn` to enable") +else: + AUTOBAHN_MISSING = False + # from .lib.wsprotocol import WSProtocol + from .lib.message_handler import MessageHandler + from .lib.routed_factory import RoutedWSFactory + +# Application +from .log import getLogger +from .resources import RoutingResource +# from .resources import SimpleFile, Directory from .lib import StrRequest, expose_method, set_prefilter, set_postfilter -from .web_views import WebSite +from .web_site import WebSite from .http_codes import HTTPCode -from .lib.errors.handler import DefaultHandler, DebugHandler, BaseHandler +from .lib.errors.default import DefaultHandler, BaseHandler -from twisted.python import failure -from twisted.internet.posixbase import PosixReactorBase +HERE = Path(__file__).parent +WS_STATIC_LIB = HERE / "websocket_static_libraries" + +log = getLogger(__name__) + +ArbitraryListArg = T.NewType("ArbitraryListArg", T.List[T.Any]) +ArbitraryKWArguments = T.NewType("ArbitraryKWArguments", T.Optional[T.Dict[str, T.Any]]) + +WebCallable = T.NewType("WebCallable", T.Callable[[StrRequest, ArbitraryListArg], T.Union[str, bytes]]) +CallableToResourceDecorator = T.NewType("CallableToResourceDecorator", T.Callable[[WebCallable], WebCallable]) +WSEndpoint = T.Callable[[MessageHandler], T.Any] +ErrorHandler = T.NewType("ErrorHandler", T.Callable[[StrRequest, failure.Failure], T.Union[bool, None]]) + + +class ApplicationWebsocketMixin: + """ + Collection of functions and utilities specific to websocket support with Texas applications. + + """ + + WS_EXPOSED_FUNC = "WS_EXPOSED_FUNC" + + def __init__(self, *args, **kwargs): + """ + + Parameters + ---------- + args + kwargs + """ + super().__init__(*args, **kwargs) + self.ws_endpoints = {} + self.ws_factory = None + self.ws_resource = None + self.ws_instances = {} + + def enable_websockets(self, url: str, route: str): + """ + Enables a websocket, undefined behavior to have multiple sockets, resource be added to + the URL router. + + The first argument should be something like `ws://host:port/` while the second argument just a URL + + Parameters + ---------- + url : str + ws://host:port/ + route : str + The URL endpoint to connect the http side of the server to websocket - /ws + Returns + ------- + None + """ + if AUTOBAHN_MISSING is True: + raise EnvironmentError("Unable to provide websocket support without autobahn installed/present") + + self.ws_factory = RoutedWSFactory(url, self.ws_endpoints, application=self) + self.ws_resource = WebSocketResource(self.ws_factory) + self.add_resource(route, self.ws_resource) + + def ws_add(self, name: str, assign_args: bool = False) -> T.Callable[[WSEndpoint], WSEndpoint]: + """ + Add a new endpoint for use with the connected websocket. + + Example + ------- + + @ws_add("some.endpoint") + def my_websocket_endpoint(message): + ... + + Parameters + ---------- + name : str + The name the function or method should be accessed via ResilientSocket + + assign_args : bool + + + Returns + ------- + callable: A decorator which adds the added function to the ws_endpoints map + """ + + def processor(func: WSEndpoint) -> WSEndpoint: + if assign_args is True: + func = self.websocket_function_arguments_decorator(func) + + self.ws_endpoints[name] = func + return func + + return processor + + # noinspection SpellCheckingInspection + def ws_sharelib(self, route_str="/lib"): + """ + Exposes Texas web's built in javascript libraries under the `route_str` argument + + Parameters + ---------- + route_str + + Returns + ------- + + """ + self.route_directory(route_str, WS_STATIC_LIB) + + def ws_class(self, kls=None, name: str = None): + """ + Add an entire class of @ws_expose'd class methods as endpoints for the websocket. + + Example + ------- + @app.ws_class + class Foo: + @app.expose + def bar(message): + # accessible at the endpoint `foo.bar` + ... + + Parameters + ---------- + kls: object + The class to be registered as a endpoint + name: str + Optional override so in the case of the example above, providing `name=Bob` would create `Bob.bar` endpoint + instead. + + Returns + ------- + Either a processor decorator or the original class + + """ -if T.TYPE_CHECKING: + def processor(kls, name=None): + kls_name = name if name is not None else kls.__name__.lower() + if kls_name in self.ws_instances: + raise ValueError( + f"Websocket ws_class: a class with {kls_name}" + f" is already registered! Use ws_class(name=NewName) to override.") + self.ws_instances[kls_name] = kls(self) - ArbitraryListArg = T.NewType("ArbitraryListArg", T.List[T.Any]) - ArbitraryKWArguments = T.NewType("ArbitraryKWArguments", T.Optional[T.Dict[str, T.Any]]) + methods = [member + for member in inspect.getmembers(self.ws_instances[kls_name]) + if inspect.ismethod(member[1]) and hasattr(member[1], self.WS_EXPOSED_FUNC)] - WebCallable = T.NewType("WebCallable", T.Callable[[StrRequest, ArbitraryListArg], T.Union[str, bytes]]) - CallableToResourceDecorator = T.NewType("CallableToResourceDecorator", T.Callable[[WebCallable], WebCallable]) + for method_name, method in methods: + # Always use assign_args with ws_class's + self.ws_endpoints[f"{kls_name}.{method_name.lower()}"] = method - ErrorHandler = T.NewType("ErrorHandler", T.Callable[[StrRequest, failure.Failure], T.Union[bool, None]]) + return kls + if kls is None: + return functools.partial(processor, name=name) + return processor(kls, name) -class ApplicationRoutingHelperMixin(object): + @staticmethod + def _eval_annotation(statement, func): + """ + TODO - Figure out if there is a way to get the type of an annotation without eval + + Parameters + ---------- + A string to be evaluated into a callable or type object. + + Returns + ------- + type or callable + + """ + # There is no way around using eval given how I use annotation's for type casting and conversion + # pylint: disable=W0123 + return statement if not isinstance(statement, str) else eval(statement, vars(sys.modules[func.__module__])) + + @classmethod + def websocket_class_arguments_decorator(cls, func): + """ + Internal method not intended for users + :param func: + :return: + + """ + params = inspect.signature(func).parameters + arg_keys = {} + converter_keys = {} + + for param in params.values(): # type: inspect.Parameter + if param.default is not inspect.Parameter.empty: + if param.name in ["message"]: + raise TypeError(f"assign_args error: argument `message` cannot be a keyword argument: {param.name}") + arg_keys[param.name] = param.default + + if param.annotation is not inspect.Parameter.empty: + converter_keys[param.name] = cls._eval_annotation(param.annotation, func) + + if "message" not in params: + raise TypeError("ws_expose convention expects (self, message, **kwargs)") + + @functools.wraps(func) + def method_argument_decorator(parent, message): + kwargs = {} + + for arg_name, arg_default in arg_keys.items(): + raw_argument = message.args(arg_name, arg_default) + converter = converter_keys.get(arg_name, None) + if converter: + try: + kwargs[arg_name] = converter(raw_argument) + except (TypeError, ValueError,): + kwargs[arg_name] = arg_default + else: + kwargs[arg_name] = raw_argument + + return func(parent, message, **kwargs) + + return method_argument_decorator + + @classmethod + def websocket_function_arguments_decorator(cls, func): + """ + Internal method not intended for users + :param func: + :return: + + """ + params = inspect.signature(func).parameters + arg_keys = {} + converter_keys = {} + + for name, param in params.items(): # type: inspect.Parameter + name: str + if param.default is not inspect.Parameter.empty: + if param.name in ["message"]: + raise TypeError( + f"Cannot use assign_args when using keyword arguments `message`: {param.name}") + arg_keys[name] = param.default + + if param.annotation is not inspect.Parameter.empty: + converter_keys[name] = cls._eval_annotation(param.annotation, func) + + @functools.wraps(func) + def argument_decorator(message: MessageHandler): + kwargs = {} + + for name, default in arg_keys.items(): + kwargs[name] = message.args(name, default=default) + if name in converter_keys: + try: + kwargs[name] = converter_keys[name](kwargs[name]) + except (TypeError, ValueError,): + log.error( + "assign_args failed to use {converter!r} on {value!r}", + converter=converter_keys[name], + value=kwargs[name]) + + kwargs[name] = default + + return func(message, **kwargs) + + return argument_decorator + + def ws_expose(self, func: callable = None, assign_args=False): + """ + See ws_class for use + + :param func: + :param assign_args: + :return: + + """ + + if func is None and assign_args is True: + def processor(real_func): + setattr(real_func, self.WS_EXPOSED_FUNC, True) + magic_func = self.websocket_class_arguments_decorator(real_func) + return magic_func + + return processor + + setattr(func, self.WS_EXPOSED_FUNC, True) + return func + + +class ApplicationRoutingHelperMixin: """ Provides a wrapping interface around :ref: `RoutingResource` """ - router:RoutingResource + router: RoutingResource - def add(self, route_str:str, **kwargs: ArbitraryKWArguments) -> CallableToResourceDecorator: + def route(self, route_str: str, **kwargs: ArbitraryKWArguments) -> CallableToResourceDecorator: """ :param route_str: A valid URI (starts with a forward slash and no spaces) :param kwargs: + :return: + """ return self.router.add(route_str, **kwargs) - # mimic flask's API - route = add + add = route + + def route_class(self, route_str: str, **kwargs: ArbitraryKWArguments) -> CallableToResourceDecorator: + """ + Expose a class and all @app.expose'd method/functions as URL endpoints. + + + example + ------- + ``` + @app.add_class("/my_foo") + class Foo: + @app.expose("/bar") + def some_endpoint(self, request): + ... + ``` + + Parameters + ---------- + route_str : str + The base URI for all exposed methods of the class. - def add_class(self, route_str:str, **kwargs: ArbitraryKWArguments) ->CallableToResourceDecorator: + + """ return self.router.add(route_str, **kwargs) + add_class = route_class + @staticmethod + def expose(route_str, **kwargs): + """ + Refer to add_class for usage + :param route_str: + :param kwargs: + :return: + + """ + + return expose_method(route_str, **kwargs) + + @staticmethod + def set_view_prefilter(func): + """ + Experimental, sets a view class method to be called before any `expose`'d method. - def add_resource(self, route_str:str, resource, **kwargs): - return self.router._add_resource(route_str, thing=resource, route_kwargs=kwargs ) + Parameters + ---------- + func: callable + A valid callable to be marked as a prefilter. + + """ + return set_prefilter(func) + + @staticmethod + def set_view_postfilter(func): + """ + Experimental, sets a view class method to be called after any `expose`'d method. + Parameters + ---------- + func: callable + A valid callable to be marked as a psot filter on a class. - def add_file(self, route_str: str, filePath: str, defaultType="text/html") -> SimpleFile: + """ + return set_postfilter(func) + + def route_resource(self, route_str: str, resource, **kwargs): + """ + Add's a native/vanilla twisted.web.Resource object to the provided route_str + + :param route_str: + :param resource: + :param kwargs: + :return: + + """ + return self.router.add_resource(route_str, thing=resource, route_kwargs=kwargs) + + add_resource = route_resource + + def route_file(self, route_str: str, filePath: str, defaultType="text/html") -> File: """ Just a simple helper for a common task of serving individual files @@ -81,24 +438,25 @@ def add_file(self, route_str: str, filePath: str, defaultType="text/html") -> Si :param filepath: An absolute or relative path to a file to be served over HTTP :param default_type: What content type should a file be served as :return: twisted.web.static.File + """ - from twisted.web.static import File as StaticFile - file_resource = StaticFile(filePath) #SimpleFile(filePath, defaultType=defaultType) + assert Path(filePath).exists() + assert Path(filePath).is_file() + file_resource = File(filePath, defaultType=defaultType) return self.router.add(route_str)(file_resource) - def add_staticdir(self, route_str: str, dirPath: T.Union[str, Path], recurse = False) -> Directory: - - if route_str.endswith("/") is False: - route_str += "/" + add_file = route_file - directory_resource = Directory(dirPath, recurse) - - self.router.add_directory(route_str, directory_resource) - - return directory_resource + def route_directory(self, route_str: str, dirPath: T.Union[str, Path]) -> File: + """ + TODO - remove calls to `add_staticdir2` and just use `add_staticdir` + :param route_str: + :param dirPath: + :param recurse: + :return: - def add_staticdir2(self, route_str: str, dirPath: T.Union[str, Path], recurse = False) -> File: + """ if route_str.endswith("/") is False: route_str += "/" @@ -109,38 +467,30 @@ def add_staticdir2(self, route_str: str, dirPath: T.Union[str, Path], recurse = return directory_resource - - def expose(self, route_str, **kwargs): - # TODO make route_str optional somehow - return expose_method(route_str, **kwargs) - - def set_prefilter(self, func): - return set_prefilter(func) - - def set_postfilter(self, func): - return set_postfilter(func) + add_staticdir = route_directory + add_staticdir2 = route_directory -class ApplicationErrorHandlingMixin(object): +class ApplicationErrorHandlingMixin: """ The Error processing and handling aspect of Application - """ + """ error_handlers: T.Dict[str, ErrorHandler] - default_handler_cls:BaseHandler = DefaultHandler + default_handler_cls: BaseHandler = DefaultHandler enable_debug: bool site: WebSite - def __init__(self, enable_debug: bool =False, **kwargs): + def __init__(self, enable_debug: bool = False): """ :param enable_debug: Flag to decide if to use debugging tools + """ - self.error_handlers = dict(default=self.default_handler_cls(self)) self.enable_debug = enable_debug - self.site.addErrorHandler(self.processingFailed) - + self.error_handlers = dict(default=self.default_handler_cls(self)) + self.site.setErrorHandler(self.processingFailed) def handle_error(self, error_type: T.Union[HTTPCode, int, Exception, str], write_over=False) -> T.Callable: """ @@ -148,11 +498,12 @@ def handle_error(self, error_type: T.Union[HTTPCode, int, Exception, str], write Acceptable types currently is a subclass of Exception (eg error.HTTP404) or a numeric HTTP error code (eg 404,500, etc). - :param error_type: The error to catch, either the thrown exception or a numeric HTTP CODE :param write_over: Used to over ride or replace a currently set error handler. Currently the only error - handler set is the catch all 'default" handler which I don't recommend replacing. - :return: + handler set is the catch all 'default" handler which I don't recommend replacing. + + :return: T.Callable + """ def processor(func: ErrorHandler) -> ErrorHandler: @@ -165,7 +516,18 @@ def processor(func: ErrorHandler) -> ErrorHandler: return processor - def add_error_handler(self, handler:T.Callable, error_type: T.Union[HTTPCode, int, Exception, str], override=False): + def add_error_handler(self, handler: T.Callable, error_type: T.Union[HTTPCode, int, Exception, str], + override=False): + """ + TODO cull this out or justify it's existence. + + Used similar to @handle_error but instead the first argument is a callable/reference to a function. + :param handler: + :param error_type: + :param override: + :return: + + """ if error_type in self.error_handlers and override is False: old_func = self.error_handlers[error_type] @@ -173,8 +535,19 @@ def add_error_handler(self, handler:T.Callable, error_type: T.Union[HTTPCode, in self.error_handlers[error_type] = handler + def processingFailed(self, request: StrRequest, reason: failure.Failure): + """ + Internal method + + Called as part of the pipeline started when an exception occurs at Request.render and bubbles its + way up to this part. - def processingFailed(self, request:StrRequest, reason: failure.Failure): + + :param request: + :param reason: + :return: + + """ default_handler = self.error_handlers['default'] @@ -188,43 +561,43 @@ def processingFailed(self, request:StrRequest, reason: failure.Failure): if handler(request, reason) is False: if handler == default_handler: raise RuntimeError(f"Default error handler {handler} returned False but it should return True") - else: - default_handler(request, reason) + + default_handler(request, reason) request.ensureFinished() return True - - - - -class Application(ApplicationRoutingHelperMixin, ApplicationErrorHandlingMixin): +class Application(ApplicationRoutingHelperMixin, ApplicationErrorHandlingMixin, ApplicationWebsocketMixin): """ - Similar to Klein and its influence Flask, the goal is to consolidate - technical debt into one God module antipattern class. + Grand unified god module of txweb. - Purposes: - Provides a public API to Site, HTTPErrors, RoutingResource, and additional helpers. + TODO rename to TXWeb or Texas versus application to avoid confusion with `txweb` on pypi - Arguments: - namespace: the base module/package name of the application, currently intended to assist logging and debugging - twisted_reactor: currently unused - request_factory: currently unused - enable_debug: Not implemented yet, switches on extra debugging tools """ + NOT_DONE_YET = NOT_DONE_YET + def __init__(self, namespace: str = None, twisted_reactor: T.Optional[PosixReactorBase] = None, - request_factory:StrRequest=StrRequest, - enable_debug:bool=False, - base_dir = None + request_factory: StrRequest = StrRequest, + enable_debug: bool = False ): """ + Similar to Klein and its influence Flask, the goal is to consolidate + technical debt into one God module antipattern class. + + Purposes: + Provides a public API to Site, HTTPErrors, RoutingResource, and additional helpers. + :param namespace: + :param twisted_reactor: + :param request_factory: + :param enable_debug: """ + self._router = RoutingResource() self._site = WebSite(self._router, request_factory=Application.request_factory_partial(self, request_factory)) self._router.site = self._site @@ -239,26 +612,35 @@ def __init__(self, else: self.__owner_module = None - - + ApplicationWebsocketMixin.__init__(self) ApplicationRoutingHelperMixin.__init__(self) - # _ApplicationTemplateSupportMixin.__init__(self) ApplicationErrorHandlingMixin.__init__(self, enable_debug=enable_debug) - - #Hooks + # Hooks self._before_render_handlers = [] self._after_render_handlers = [] + self.__post_init__() + + def __post_init__(self) -> None: + """ + Just a stub to make it easier for subclasses that don't want to mess with + overloading __init__. + + :return: + + """ @staticmethod - def request_factory_partial(app:'Application', request_kls:StrRequest): + def request_factory_partial(app: Application, request_kls: StrRequest): """ A hack to intercept when a new http Request is created deep inside of twisted.web's protocol factory """ def partial(*args, **kwargs): + # pylint: disable=W0212 + # noinspection PyCallingNonCallable request = request_kls(*args, **kwargs) request.add_before_render(app._call_before_render) request.add_after_render(app._call_after_render) @@ -267,40 +649,44 @@ def partial(*args, **kwargs): return partial - - def __call__(self, namespace:str): + @property + def router(self) -> RoutingResource: """ - TODO sanity check if this is a good idea + Provide access to the routing resource object. - Allows the application namespace property to be overwritten during run time. """ - self.name = namespace - - @property - def router(self) -> RoutingResource: return self._router @property def site(self) -> WebSite: + """ + Provides access to the server.Site instance + + + """ return self._site @property def reactor(self) -> PosixReactorBase: - return self._reactor + """ + Provides access to the currently used reactor, used specifically to make testing easier. - @reactor.setter - def reactor(self, reactor: PosixReactorBase): - self._reactor = reactor + """ + return self._reactor + @reactor.setter + def reactor(self, active_reactor: PosixReactorBase): + self._reactor = active_reactor - def listenTCP(self, port:int, interface:str= "127.0.0.1") -> Port: + def listenTCP(self, port: int, interface: str = "127.0.0.1") -> Port: """ Convenience helper which adds the HTTP protocol factory to the reactor and set it to listen to the provided interface and port + """ - self._listening_port = self.reactor.listenTCP(port, self.site, interface=interface) + self._listening_port = self.reactor.listenTCP(port, self._site, interface=interface) return self._listening_port def before_render(self, func: T.Callable[[StrRequest], None]): @@ -308,6 +694,7 @@ def before_render(self, func: T.Callable[[StrRequest], None]): Intended as a convenience decorator to set a global before render handler Arguments: func: a callable that expects to receive the current Request + """ self._before_render_handlers.append(func) return func @@ -317,24 +704,42 @@ def after_render(self, func: T.Callable[[StrRequest], None]): Intended as a convenience decorator to set a global after render handler Arguments: func: a callable that expects to receive the current Request + """ self._after_render_handlers.append(func) return func def _call_before_render(self, request: StrRequest): + """ + Internal method that iterates over all appended pre filter functions + + TODO - add logic to abort the loop if a post filter returns anything besides None + + :param request: + :return: + + """ + # pylint: disable=W0703 for func in self._before_render_handlers: try: func(request) except Exception: - log.exception(f"Before render failed {func}") + log.error(f"Before render failed {func}") - def _call_after_render(self, request: StrRequest, body:T.Union[bytes,str,int]): + def _call_after_render(self, request: StrRequest, body: T.Union[bytes, str, int]): + """ + Internal method that iterates over all post filters. + + :param request: + :param body: + :return: + + """ + # pylint: disable=W0703 for func in self._after_render_handlers: try: - func(request) + body = func(request, body) except Exception: - log.exception(f"After render failed {func}") - - - + log.error(f"After render failed {func}") + return body diff --git a/txweb/http_codes.py b/txweb/http_codes.py index 7d4d43d..aae9f36 100644 --- a/txweb/http_codes.py +++ b/txweb/http_codes.py @@ -7,6 +7,7 @@ """ import typing as T + class HTTPCode(RuntimeError): """ Arguments: @@ -16,81 +17,213 @@ class HTTPCode(RuntimeError): errors """ def __init__(self, code:int, message:str, exc:T.Optional[Exception]=None): + super().__init__(message) + self.code = code self.message = message self.exc = exc + class HTTP3xx(HTTPCode): """ + This class of status code indicates the client must take additional action to complete the request. + Many of these status codes are used in URL redirection. + Arguments: redirect: either an absolute or relative URL to tell the client to redirect too """ - def __init__(self, code, redirect, message="3xx Choices"): + def __init__(self, code: int, redirect: T.Union[str, bytes], message: str = "3xx Choices"): self.redirect = redirect super().__init__(code, message=message) + class HTTP301(HTTP3xx): - def __init__(self, redirect): - super().__init__(301, redirect, "Moved Permanently") + """ + 301 Moved Permanently + This and all future requests should be directed to the given URI + """ + CODE = 301 + + def __init__(self, redirect: T.Union[str, bytes]): + super().__init__(self.CODE, redirect, "Moved Permanently") + class HTTP302(HTTP3xx): - def __init__(self, redirect): - super().__init__(302, redirect, "FOUND") + """ + 302 Found (Previously "Moved temporarily") + Tells the client to look at (browse to) another URL. 302 has been superseded by 303 and 307. + This is an example of industry practice contradicting the standard. The HTTP/1.0 specification + (RFC 1945) required the client to perform a temporary redirect (the original describing phrase + was "Moved Temporarily"), but popular browsers implemented 302 with the functionality of a + 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 to distinguish between the two behaviours. + However, some Web applications and frameworks use the 302 status code as if it were the 303. + """ + CODE = 302 + + def __init__(self, redirect: T.Union[str, bytes]): + super().__init__(self.CODE, redirect, "FOUND") + class HTTP303(HTTP3xx): - def __init__(self, redirect): - super(HTTP303, self).__init__(303, redirect, message="See Other") + """ + The response to the request can be found under another URI using the GET method. + When received in response to a POST (or PUT/DELETE), the client should presume that + the server has received the data and should issue a new GET request to the given URI. + """ + CODE = 303 + + def __init__(self, redirect: T.Union[str, bytes]): + super().__init__(self.CODE, redirect, message="See Other") + class HTTP304(HTTP3xx): - def __init__(self, redirect): - super().__init__(304, redirect, "Not Modified") + """ + 304 Not Modified (RFC 7232) + Indicates that the resource has not been modified since the version specified by the request headers + If-Modified-Since or If-None-Match. In such case, there is no need to retransmit the resource since the client + still has a previously-downloaded copy. + """ + CODE = 304 + + def __init__(self, redirect: T.Union[str, bytes]): + super().__init__(self.CODE, redirect, "Not Modified") + class HTTP307(HTTP3xx): - def __init__(self, redirect): - super().__init__(307, redirect, "Temporary Redirect") + """ + 307 Temporary Redirect (since HTTP/1.1) + In this case, the request should be repeated with another URI; however, future requests should still use the + original URI. In contrast to how 302 was historically implemented, the request method is not allowed to be changed + when reissuing the original request. For example, a POST request should be repeated using another POST request. + """ + CODE = 307 + + def __init__(self, redirect: T.Union[str, bytes]): + super().__init__(self.CODE, redirect, "Temporary Redirect") + class HTTP308(HTTP3xx): - def __init__(self, redirect): - super().__init__(308, redirect, "Permanent Redirect") + """ + 308 Permanent Redirect (RFC 7538) + The request and all future requests should be repeated using another URI. 307 and 308 parallel the behaviors of + 302 and 301, but do not allow the HTTP method to change. So, for example, submitting a form to a permanently + redirected resource may continue smoothly.[ + """ + CODE = 308 + + def __init__(self, redirect: T.Union[str, bytes]): + super().__init__(self.CODE, redirect, "Permanent Redirect") class HTTP4xx(HTTPCode): + """ + Base Exception for 4xx HTTP codes + + This class of status code is intended for situations in which the error seems to have been caused by the client. + """ pass + class HTTP400(HTTP4xx): + """ + 400 Bad Request + The server cannot or will not process the request due to an apparent client error + (e.g., malformed request syntax, size too large, invalid request message framing, or deceptive request routing). + """ + CODE = 400 + def __init__(self): - super().__init__(400, "Bad Request") + super().__init__(self.CODE, "Bad Request") + class HTTP401(HTTP4xx): + """ + Similar to 403 Forbidden, but specifically for use when authentication is required and has failed + or has not yet been provided. + """ + CODE = 401 + def __init__(self): - super().__init__(401, "Unauthorized") + super().__init__(self.CODE, "Unauthorized") + class HTTP403(HTTP4xx): + """ + The request contained valid data and was understood by the server, but the server is refusing action. + + """ + CODE = 403 + def __init__(self): - super().__init__(403, "Forbidden") + super().__init__(self.CODE, "Forbidden") + class HTTP404(HTTP4xx): + """ + 404 Not Found + + The requested resource could not be found but may be available in the future. + Subsequent requests by the client are permissible. + """ + CODE = 404 + def __init__(self, exc=None): - super().__init__(404, "Resource not found", exc=exc) + super().__init__(self.CODE, "Resource not found", exc=exc) + class HTTP410(HTTP4xx): + """ + 410 Gone + Indicates that the resource requested is no longer available and will not be available again. + + """ + CODE = 410 + def __init__(self): - super().__init__(410, "Gone") + super().__init__(self.CODE, "Gone") class HTTP405(HTTP4xx): + """ + A request method is not supported for the requested resource; + for example, a GET request on a form that requires data to be + presented via POST, or a PUT request on a read-only resource. + """ + CODE = 405 + def __init__(self, exc=None): - super().__init__(405, "Method not allowed", exc=exc) + super().__init__(self.CODE, "Method not allowed", exc=exc) class HTTP5xx(HTTPCode): + """ + The server failed to fulfill a request.[61] + + Response status codes beginning with the digit "5" indicate cases in which the server is aware that it has + encountered an error or is otherwise incapable of performing the request. + Except when responding to a HEAD request, the server should include an entity containing an explanation of the + error situation, and indicate whether it is a temporary or permanent condition. + Likewise, user agents should display any included entity to the user. + These response codes are applicable to any request method. + """ pass + class HTTP500(HTTP5xx): + """ + 500 Internal Server Error + A generic error message, given when an unexpected condition was encountered and no more specific message + is suitable. + """ + CODE = 500 + def __init__(self, message="Internal Server Error"): - super().__init__(message) + super().__init__(self.CODE, message) -class UnrenderableException(HTTP5xx): - def __init__(self, message): - super().__init__(500, message) +class Unrenderable(EnvironmentError): + """ + An class definition or view function's result cannot be rendered to the client. + + """ + pass diff --git a/txweb/lib/__init__.py b/txweb/lib/__init__.py index a1537d4..bc64449 100644 --- a/txweb/lib/__init__.py +++ b/txweb/lib/__init__.py @@ -1,6 +1,9 @@ +""" + TXWeb's library of fundamental classes +""" from .str_request import StrRequest from .view_class_assembler import expose as expose_method from .view_class_assembler import ViewClassResource from .view_class_assembler import set_prefilter, set_postfilter -__ALL__ = ["StrRequest", "expose_method", "ViewClassResource", "set_prefilter", "set_postfilter"] \ No newline at end of file +__ALL__ = ["StrRequest", "expose_method", "ViewClassResource", "set_prefilter", "set_postfilter"] diff --git a/txweb/lib/errors/__init__.py b/txweb/lib/errors/__init__.py index e69de29..a75f5fa 100644 --- a/txweb/lib/errors/__init__.py +++ b/txweb/lib/errors/__init__.py @@ -0,0 +1,6 @@ +""" + Error handlers for txweb +""" +from twisted.python.failure import Failure +from .default import DefaultHandler +from .debug import DebugHandler diff --git a/txweb/lib/errors/base.py b/txweb/lib/errors/base.py new file mode 100644 index 0000000..bdc8a2f --- /dev/null +++ b/txweb/lib/errors/base.py @@ -0,0 +1,62 @@ +""" + Reference example for a error handler +""" +from __future__ import annotations +import typing as T + +from twisted.python.failure import Failure + +from txweb.log import getLogger +from txweb.lib.str_request import StrRequest + +log = getLogger(__name__) + + +class BaseHandler: + """ + + Base/example class of an error handler. + """ + + def __init__(self, application): + self.application = application + + def __call__(self, request: StrRequest, error: Failure) -> T.Union[None, bool]: + """ + + Parameters + ---------- + request: StrRequest + The request and current response to a failed transaction. + + error: Failure + + + Returns + ------- + False signals the errorhandler failed to properly handle the error. + None or True signals the errorhandler was successfull + + """ + # noinspection PyBroadException + try: + return self.process(request, error) + except Exception as exc: + log.error("PANIC - There was an exception in the error handler.") + request.ensureFinished() + raise exc + + def process(self, request: StrRequest, error: Failure) -> T.Union[None, bool]: # pragma: no cover + """ + Just a stub + + Parameters + ---------- + request + error + + Returns + ------- + + """ + raise NotImplementedError("Attempting to use Base error handler") diff --git a/txweb/lib/errors/debug.py b/txweb/lib/errors/debug.py new file mode 100644 index 0000000..0c1ba65 --- /dev/null +++ b/txweb/lib/errors/debug.py @@ -0,0 +1,97 @@ +""" + In progress generic Debug error handlers for txweb. + +""" +# pylint: disable=E1101 +from __future__ import annotations +import typing as T +from pathlib import Path +import linecache +from dataclasses import dataclass + +from twisted.python.failure import Failure +# from twisted.python.compat import intToBytes + +from txweb.lib.str_request import StrRequest +from txweb.log import getLogger +from ... import http_codes +from ..str_request import StrRequest +from . import html +from .base import BaseHandler + + +@dataclass +class FormattedFrame: + name: bytes + file: Path + line_no: int + line: bytes + +@dataclass(frozen=True, unsafe_hash=True) +class StackFrame: + funcName: str + fileName: str + lineNumber: int + localsItems: T.Dict[str, T.Any] + globalsItems: T.Dict[str, T.Any] + + +log = getLogger(__name__) + +class DebugHandler(BaseHandler): # pragma: no cover + """ + TODO - To finish + + Mimic flask's exception rendering system with some minor caveats. + + Errors are held in resident/session memory if possible. + Errors can be reaccessed as necessary + Errors time out after 5 minutes of activity + No more than 3 errors can be held in memory at one time + The same error is not stored more than once to avoid repeats pushing out the other + saved errors. + + ##Cool to have + * stack trace local/global evaluations similar to flask + * pycharm integration (no idea if this is possible) + + ## Great to have + * Trim errors down to JUST application code, so tracebacks don't dive all the way into twisted and + * txweb beyond the first or second frame. + + """ + def process(self, request: StrRequest, reason: Failure) -> T.Union[None, bool]: + error_items = [] + for formatted_frame in self.format_stack(reason.frames): + error_items.append(html.ERROR_ITEM.format(**formatted_frame)) + + error_list = html.ERROR_LIST.format(error_items="\n".join(error_items)) + + content = html.ERROR_CONTENT.format(digest=repr(reason.value), error_list=error_list) + body = html.ERROR_BODY.format(content=content) + + if issubclass(reason.type, http_codes.HTTPCode): + request.setResponseCode(reason.value.code, reason.value.message) + else: + request.setResponseCode(500, "Internal server error") + + request.write(body) + request.ensureFinished() + + @staticmethod + def format_stack(frames: T.List[StackFrame]) -> T.Generator[T.Dict[str, T.Any]]: + """ + Given a twisted Failure frames list, create a generator that yields FormattedFrame's for easier + consumption by a jinja2 template. + :param frames: + :return: + """ + for frame in frames: + yield FormattedFrame( + name=frame[0].encode("UTF-8"), + file=Path(frame[1]), + line_no=frame[2], + line=linecache.get(frame[2]) + ) + + linecache.clearcache() diff --git a/txweb/lib/errors/default.py b/txweb/lib/errors/default.py new file mode 100644 index 0000000..d10acc8 --- /dev/null +++ b/txweb/lib/errors/default.py @@ -0,0 +1,85 @@ +""" + Base, Default, and generic Debug error handlers for txweb. + + +""" +from __future__ import annotations +import typing as T + +from twisted.python.failure import Failure +from twisted.python.compat import intToBytes + +from txweb.lib.str_request import StrRequest +from txweb.log import getLogger +from ... import http_codes +from . import html +from ..str_request import StrRequest +from .base import BaseHandler + + +log = getLogger(__name__) + + + + + +# noinspection PyMissingConstructor +class DefaultHandler(BaseHandler): + """ + Primarily focused with handling 3xx HTTP exception/codes thrown by the application. + + """ + + def process(self, request: StrRequest, reason: Failure) -> T.Union[None, bool]: + """ + As mentioned in class docblock, primary focus is handling HTTPCode exceptions thrown by the application. + + If the request/response factory has already started writing to the client, this halts all error processing + and throws the exception. + + else if a HTTPCode exception/error it redirects for 3xx codes + OR it writes the code and message to the client + (Eg HTTPCode500 with Internal error would throw 500 "Internal server error") + + else it sends a 500 HTTP response and then raises the exception back into the user application. + + Parameters + ---------- + request: StrRequest + reason: Failure + + Returns + ------- + False on failure to handle error + """ + + if request.startedWriting not in [0, False]: + # There is nothing we can do, the out going stream is already tainted + # noinspection PyBroadException + log.error("Failed writing error message to an active stream") + request.ensureFinished() + reason.raiseException() + + elif isinstance(http_codes.HTTPCode, reason.type) or issubclass(reason.type, http_codes.HTTPCode): + + if issubclass(reason.type, http_codes.HTTP3xx): + exc = reason.value + request.redirect(exc.redirect, exc.code) + response = html.REDIRECT_BODY.format(url=exc.redirect) + request.writeTotal(response, code=exc.code, message=exc.message) + else: + exc = reason.value # type: HTTPCode + request.setResponseCode(exc.code, exc.message) + request.setHeader("Content-length", intToBytes(len(exc.message))) + request.write(exc.message) + + else: + request.setResponseCode(500, b"Internal server error") + log.debug(f"Non-HTTPCode error was caught: {reason.type} - {reason.value}") + request.ensureFinished() + reason.raiseException() + + request.ensureFinished() + return True + + diff --git a/txweb/lib/errors/handler.py b/txweb/lib/errors/handler.py deleted file mode 100644 index 44bc0eb..0000000 --- a/txweb/lib/errors/handler.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - - -from twisted.python.failure import Failure -from twisted.python.compat import intToBytes -from twisted.python.failure import Failure - - - -from txweb.log import getLogger -from ... import http_codes -from . import html -from ..str_request import StrRequest -from txweb.lib.str_request import StrRequest - -import typing as T -from pathlib import Path -import linecache -from dataclasses import dataclass - -@dataclass -class FormattedFrame(object): - name: bytes - file: Path - line_no: int - line: bytes - -class StackFrame(T.NamedTuple): - funcName:str - fileName:str - lineNumber:int - localsItems:T.Dict[str,T.Any] - globalsItems:T.Dict[str, T.Any] - - -log = getLogger(__name__) - -class BaseHandler(object): - - def __init__(self, application): - self.application = application - - def __call__(self, request: StrRequest, reason:Failure) -> None: - try: - self.process(request, reason) - except: - log.exception("PANIC - There was an exception in the error handler.") - request.ensureFinished() - - def process(self, request: StrRequest, reason:Failure) -> None: - raise NotImplementedError("Attempting to use Base error handler") - -class DefaultHandler(BaseHandler): - """ - Goal: Delegate various errors to templates to make - a visual error system easier to view. - """ - - def __init__(self, enable_debug = False): - self.enable_debug = enable_debug - - def process(self, request: StrRequest, reason:Failure) -> None: - - # Check if this is a HTTPCode error - if request.startedWriting not in [0, False]: - # There is nothing we can do, the out going stream is already tainted - try: - request.write("!!!Internal Server Error!!!") - except: - log.exception("Failed writing error message to an active stream") - finally: - request.ensureFinished() - - return True - - elif isinstance(http_codes.HTTPCode, reason.type) or issubclass(reason.type, http_codes.HTTPCode): - - if issubclass(reason.type, http_codes.HTTP3xx): - exc = reason.value - request.redirect(exc.redirect, exc.code) - response = html.REDIRECT_BODY.format(url=exc.redirect) - request.writeTotal(response, code=exc.code, message=exc.message) - else: - exc = reason.value # type: HTTPCode - request.setResponseCode(exc.code, exc.message) - request.setHeader("Content-length", intToBytes(len(exc.message))) - request.write(exc.message) - - else: - request.setResponseCode(500, b"Internal server error") - log.debug(f"Non-HTTPCode error was caught: {reason.type} - {reason.value}") - reason.raiseException() - - request.ensureFinished() - return True - - -class DebugHandler(BaseHandler): - """ - Mimic flask's exception rendering system with some minor caveats. - - Errors are held in resident/session memory if possible. - Errors can be reaccessed as necessary - Errors time out after 5 minutes of activity - No more than 3 errors can be held in memory at one time - The same error is not stored more than once to avoid repeats pushing out the other - saved errors. - - ##Cool to have - * stack trace local/global evaluations similar to flask - * pycharm integration (no idea if this is possible) - - ## Great to have - * Trim errors down to JUST application code, so tracebacks don't dive all the way into twisted and - * txweb beyond the first or second frame. - - """ - def process(self, request: StrRequest, reason:Failure) -> None: - error_items = [] - for formated_frame in self.format_stack(reason.frames): - error_items.append(html.ERROR_ITEM.format(**formated_frame)) - - error_list = html.ERROR_LIST.format(error_items="\n".join(error_items)) - - content = html.ERROR_CONTENT.format(digest=repr(reason.value),error_list=error_list) - body = html.ERROR_BODY.format(content=content) - - if issubclass(reason.type, HTTPCode): - request.setResponseCode(reason.value.code, reason.value.message) - else: - request.setResponseCode(500, "Internal server error") - - request.write(body) - request.ensureFinished() - - def format_stack(self, frames:T.List[StackFrame]) -> T.Generator[T.Dict[str, T.Any]]: - for frame in frames: - yield FormattedFrame( - name=frame[0].encode("UTF-8"), - file=Path(frame[1]), - line_no=frame[2], - line=linecache.get(frame[2]) - ) - - linecache.clearcache() - diff --git a/txweb/lib/errors/html.py b/txweb/lib/errors/html.py index c501b94..a6be9bc 100644 --- a/txweb/lib/errors/html.py +++ b/txweb/lib/errors/html.py @@ -1,3 +1,6 @@ +""" + TODO - Deprecate this +""" DEFAULT_BODY = \ b""" @@ -58,4 +61,4 @@ -""" \ No newline at end of file +""" diff --git a/txweb/lib/message_handler.py b/txweb/lib/message_handler.py new file mode 100644 index 0000000..263048e --- /dev/null +++ b/txweb/lib/message_handler.py @@ -0,0 +1,197 @@ +""" + A wrapper around the message sent from the client + + Breaks ideal style guides as it also wraps around the connection object to make + responding and interacting with the client easier. + +""" +from __future__ import annotations +import typing as T +from collections.abc import Mapping + +from twisted.internet.defer import Deferred + + +if T.TYPE_CHECKING or False: # pragma: no cover + # pylint: disable=cyclic-import + # used for type hinting with PyCharm but this would break the app if it was ever imported. + # recursion import + from .wsprotocol import WSProtocol + + +class MessageHandler(Mapping): # pragma: no cover + + raw_message: T.Dict[T.AnyStr, T.Any] + connection: WSProtocol + + def __init__(self, raw_message: dict, connection: WSProtocol): + self.raw_message = raw_message # type: dict + self.connection = connection + + def __getitem__(self, item): # pragma: no cover + return self.raw_message[item] + + def __iter__(self): # pragma: no cover + return self.raw_message.__iter__() + + def __len__(self): # pragma: no cover + return len(self.raw_message) + + def __contains__(self, item): # pragma: no cover + return item in self.raw_message + + def keys(self): # pragma: no cover + return self.raw_message.keys() + + def items(self): # pragma: no cover + return self.raw_message.items() + + def values(self): # pragma: no cover + return self.raw_message.values() + + # pylint: disable=redefined-builtin + def get(self, key: str, default=None, vtype: type=None): + """ + + Parameters + ---------- + key + default + vtype + + Returns + ------- + + """ + + + + try: + value = self[key] + except KeyError: + return default + + if vtype: + try: + value = vtype(value) + except ValueError: + return default + + return value + + @property + def identity(self) -> str: + """ + Utility to make it quicker to access the connection's unique identifier + + Returns + ------- + A unique identifier string + + """ + return self.connection.identity + + # pylint: disable=redefined-builtin + def args(self, key: str, default=None, vtype=None): + """ + A more explicit/direct getter that looks for an `args` dictionary in the client message and + if it exists, returns the requested key. + + Parameters + ---------- + key: str + A dict key for the message's `args` subdictionary of arguments + + default: str + TODO implement a not empty default sentinel. + + vtype: type + Used to cast the requested key's value into. (eg vtype=int would cast key=foo to int or try to atleast) + + Returns + ------- + T.Any + """ + + try: + args = self['args'] + except KeyError: + return default + + try: + value = args[key] + except KeyError: + return default + except ValueError: + return default + + if vtype: + try: + value = vtype(value) + except ValueError: + return default + + return value + + def respond(self, result) -> None: + """ + For client messages that are `ask` type, send back a response to their request. + + Parameters + ---------- + result: T.Any + Anything that can be serialized by json.dumps is a valid variable type. + + Returns + ------- + None + + """ + return self.connection.respond(self.raw_message, result=result) + + def tell(self, endpoint: str, **kwargs) -> None: + """ + Tell the client to do something at the specified `endpoint` + + Parameters + ---------- + endpoint: str + Ideally a valid client side endpoint + kwargs: dict + json serializable friendly data + + Returns + ------- + None + """ + return self.connection.tell(endpoint, **kwargs) + + def ask(self, endpoint: str, **kwargs: T.Dict[str, T.Any]) -> Deferred: + """ + Ask the client for information or acknowledgement of success/failure for an action. + + Parameters + ---------- + endpoint + kwargs + + Returns + ------- + A Deferred object the server side code can add a callback to. + """ + return self.connection.ask(endpoint, type="ask", args=kwargs) + + def get_session(self, get_key: str=None) -> T.Union[T.Dict, T.Any]: + """ + See WSProtocol's get_session + + Parameters + ---------- + get_key: str + A shortcut to fetch a specific key of the session dictionary versus grabbing the whole dictionary. + + Returns + ------- + + """ + return self.connection.application.get_session(self.connection, get_key=get_key) diff --git a/txweb/lib/routed_factory.py b/txweb/lib/routed_factory.py new file mode 100644 index 0000000..fd6fec7 --- /dev/null +++ b/txweb/lib/routed_factory.py @@ -0,0 +1,42 @@ +""" + Just a simple implementation of WebSocketServerFactory to provide a basic dict based router + for server side websocket endpoints. + +""" +import typing as T + +from autobahn.twisted.websocket import WebSocketServerFactory +from .message_handler import MessageHandler +from .wsprotocol import WSProtocol + +EndpointFunc = T.Callable[[MessageHandler], None] + + +class RoutedWSFactory(WebSocketServerFactory): # pragma: no cover + + routes: T.Dict[str, T.Callable] + + def __init__(self, url, routes, protocol_cls=WSProtocol, application=None): + WebSocketServerFactory.__init__(self, url) + self.protocol = protocol_cls + + self.routes = routes + self._application = application + + def get_endpoint(self, name: str) -> EndpointFunc: + """ + Just a simple getter to return a given endpoint. + + Returns + ------- + Returns None if the endpoint isn't in the routes dictionary. + + """ + return self.routes.get(name, None) + + def get_application(self): + """ + Possible bad code smell but used primarily for debugging. + + """ + return self._application diff --git a/txweb/lib/str_request.py b/txweb/lib/str_request.py index b7eb3e7..0f90d81 100644 --- a/txweb/lib/str_request.py +++ b/txweb/lib/str_request.py @@ -13,37 +13,42 @@ and into the twisted library. Unfortunately this is a doozy of a sub-project as its not just Request but also headers logic. """ -from twisted.python import reflect -from twisted.web.error import UnsupportedMethod -from twisted.web.server import Request, NOT_DONE_YET, supportedMethods +from __future__ import annotations + +# import cgi +import json +from urllib.parse import parse_qs +import typing as T + + +from twisted.web.server import Request, NOT_DONE_YET +# from twisted.web.server import supportedMethods from twisted.web.http import FOUND from twisted.web import resource -from twisted.web import http -# noinspection PyProtectedMember -from twisted.web.http import _parseHeader -# noinspection PyProtectedMember -from twisted.python.compat import _PY3, _PY37PLUS, nativeString, escape, intToBytes +from twisted.python.compat import intToBytes from werkzeug.formparser import FormDataParser from werkzeug.datastructures import MultiDict - -import cgi -import json -from urllib.parse import parse_qs -import typing as T +from werkzeug.datastructures import FileStorage from ..log import getLogger from ..http_codes import HTTP500 log = getLogger(__name__) -if T.TYPE_CHECKING: # pragma: no cover - from werkzeug import FileStorage +class StrRequest(Request): + """ + Request is actually a merger of three different topics. + 1. StrRequest contains all of the request data: headers & request body. + 2. StrRequest holds the connection API. + 3. StrRequest holds the response headers, http code, and response body until finalization. -class StrRequest(Request): - NOT_DONE_YET: T.Union[int, bool] = NOT_DONE_YET + + """ + + NOT_DONE_YET: T.Union[int, bool] = NOT_DONE_YET def __init__(self, *args, **kwargs): @@ -56,47 +61,74 @@ def __init__(self, *args, **kwargs): self._call_before_render = None self._call_after_render = None - def getCookie(self, cookie_name:T.Union[str, bytes]): - expectBytes = isinstance(cookie_name, bytes) + def getCookie(self, cookie_name: T.Union[str, bytes]) -> T.Union[str, bytes]: + """ + Wrapper around Request's getCookie to convert to and from byte strings + to unicode/str's + + Parameters + ---------- + cookie_name: str + + Returns + ------- + If cookie_name argument is bytes, returns a byte string else returns str/unicode string + """ + expect_bytes = isinstance(cookie_name, bytes) - if expectBytes: + if expect_bytes: return Request.getCookie(self, cookie_name) else: byte_name = cookie_name.encode("ascii") retval = Request.getCookie(self, byte_name) - return retval.decode("utf-8") - + if retval is not None: + return retval.decode("utf-8") + else: + return None def add_before_render(self, func): + """ + Utility intended solely to make testing easier + + """ self._call_before_render = func return func - def add_after_render(self, func): + """ + Utility intended solely to make testing easier + + """ self._call_after_render = func return func - - def write(self, data:T.Union[bytes, str]): - + def write(self, data: T.Union[bytes, str]): + """ + Wrapper to prevent unicode/str's from going to Request's write method + """ if isinstance(data, str): data = data.encode("utf-8") - elif isinstance(data, bytes): - pass - else: + elif isinstance(data, bytes) is False: raise ValueError(f"Attempting to write to transport {type(data)}-{data!r}" " must be bytes or Str") return Request.write(self, data) - def writeTotal(self, response_body:T.Union[bytes, str], code:T.Union[int, str, bytes] = None, - message:T.Union[bytes, str]=None) -> T.NoReturn: + def writeTotal(self, response_body: T.Union[bytes, str], code: T.Union[int, str, bytes] = None, + message: T.Union[bytes, str] = None) -> T.NoReturn: """ - - :param response_body: Content intended for after headers - :param code: Optional HTTP Code to use - :param message: Optional HTTP response message to use - :return: + Utility to write and then close the connection in one go. + Especially useful for error handling events. + + Parameters + ---------- + response_body: + Content intended to be sent to the client browser + code: + Optional HTTP Code to use + message: + Optional HTTP response message to use + """ content_length = intToBytes(len(response_body)) @@ -108,10 +140,7 @@ def writeTotal(self, response_body:T.Union[bytes, str], code:T.Union[int, str, b self.write(response_body) self.ensureFinished() - - - - def writeJSON(self, data:T.Dict): + def writeJSON(self, data: T.Dict): """ Utility to take a dictionary and convert it to a JSON string """ @@ -121,13 +150,17 @@ def writeJSON(self, data:T.Dict): self.setHeader("Content-Length", content_length) return self.write(payload) - def setHeader(self, name:T.Union[str, bytes], value:T.Union[str, bytes]): + def setHeader(self, name: T.Union[str, bytes], value: T.Union[str, bytes]): """ A quick wrapper to convert unicode inputs to utf-8 bytes - - Arguments: - name: A valid HTTP header - value: Syntactically correct value for the header name + Set's a header for the RESPONSE + + Parameters + ---------- + name: + A valid HTTP header + value + Syntactically correct value for the provided header name """ if isinstance(name, str): name = name.encode("utf-8") @@ -137,13 +170,31 @@ def setHeader(self, name:T.Union[str, bytes], value:T.Union[str, bytes]): return Request.setHeader(self, name, value) - def setResponseCode(self, code:int=500, message:T.Optional[T.Union[str,bytes]]=b"Failure processing request"): - if message and not isinstance(message,bytes): + def setResponseCode(self, + code: int = 500, + message: T.Optional[T.Union[str, bytes]] = b"Failure processing request") -> T.NoReturn: + """ + Str to unicode wrapper around twisted.web's Request class. + + Parameters + ---------- + code + message + + Returns + ------- + + """ + if message and not isinstance(message, bytes): message = message.encode("utf-8") - return Request.setResponseCode(self, code, message) + Request.setResponseCode(self, code, message) + + def ensureFinished(self) -> None: + """ + Ensure's the connection has been flushed and closed without throwing an error. - def ensureFinished(self): + """ if self.finished not in [1, True]: self.finish() @@ -152,7 +203,6 @@ def requestReceived(self, command, path, version): Looks for POST'd arguments in form format (eg multipart). Allows for file uploads and adds them to .args - TODO add a files attribute to StrRequest? """ self.content.seek(0, 0) @@ -191,41 +241,67 @@ def query_iter(arguments): key = key.decode("utf-8") if isinstance(key, bytes) else key for val in values: val = val.decode("utf-8") if isinstance(val, bytes) else val - yield (key, val,) + yield key, val self.args = MultiDict(list(query_iter(query_args))) self.process() - def render(self, resrc): + @property + def methodIsPost(self) -> bool: + """ + Utility method + + Returns + ------- + bool - Is the current request a POST request """ - Ask a resource to render itself. + return self.method == b"POST" - If the resource does not support the requested method, - generate a C{NOT IMPLEMENTED} or C{NOT ALLOWED} response. + @property + def methodIsGet(self) -> bool: + """ + Utility method + + Returns + ------- + True if the current request is a HTTP GET request. + """ + return self.method == b"GET" + + def render(self, resrc: resource.Resource) -> None: + """ + Ask a resource to render itself unless a prefilter returns a string/bytes + body which will be rendered instead. - @param resrc: The resource to render. - @type resrc: L{twisted.web.resource.IResource} + Parameters + ---------- + resrc: Resource + The resource to be rendered. - @see: L{IResource.render()} + Returns + ------- + None, output is written directly to the underlying HTTP channel. """ - try: - if self._call_before_render is not None: - self._call_before_render(self) + body = None + if self._call_before_render is not None: + body = self._call_before_render(self) + + if body is None: body = resrc.render(self) - if self._call_after_render is not None: - self._call_after_render(self, body) - except: - #log.exception(f"While processing {self.method!r} {self.uri}") - raise + + if self._call_after_render is not None: + self._call_after_render(self, body) # TODO deal with HEAD requests or leave it to the Application developer to deal with? - if body is NOT_DONE_YET: #TODO replace NOT_DONE_YET with a sentinel versus integer + if body is NOT_DONE_YET: return if not isinstance(body, bytes): - log.error(f"<{type(resrc)} {resrc}> - uri={self.uri} returned {type(body)}:{len(body)} but MUST return a byte string") + log.error( + f"<{type(resrc)}{resrc!r}>" + f"- uri={self.uri} returned {type(body)}:{len(body)} but MUST return a byte string") raise HTTP500() if self.method == b"HEAD": @@ -252,22 +328,17 @@ def _processFormData(self, content_type, content_length): Thank you Cristina - http://www.cristinagreen.com/uploading-files-using-twisted-web.html - TODO this can be problematic if a binary file is being uploaded + TODO this can be problematic if a large binary file is being uploaded TODO verify Twisted HTTP channel/transport blows up if file upload size is "too big" """ options = {} - if isinstance(content_type, bytes): content_type = content_type.decode("utf-8") # type: str - if ";" in content_type: - """ - TODO Possible need to replace some of the header processing logic as boundary part of content-type - leaks through. - eg "Content-type": "some/mime_type;boundary=----BLAH" - """ + # TODO Possible need to replace some of the header processing logic as boundary part of content-type + # leaks through. eg "Content-type": "some/mime_type;boundary=----BLAH" content_type, boundary = content_type.split(";", 1) if "=" in boundary: _, boundary = boundary.split("=", 1) @@ -276,22 +347,46 @@ def _processFormData(self, content_type, content_length): content_length = int(content_length) - self.content.seek(0,0) + self.content.seek(0, 0) parser = FormDataParser() _, self.form, self.files = parser.parse(self.content, content_type, content_length, options=options) self.content.seek(0, 0) def processingFailed(self, reason): + """ + Start of the error handling chain that leads from here all the way up to Application.processingFailed + :param reason: + :return: + """ self.site.processingFailed(self, reason) @property - def json(self): - if self.getHeader("Content-Type") not in ["application/json", "text/json"]: + def json(self) -> T.Any: + """ + Is this a JSON posted request? + + Returns + ------- + Ideally returns a dict object as I cannot think of what else a sane client would send in JSON format. + + """ + if self.getHeader("Content-Type") in ["application/json", "text/json"]: return json.loads(self.content.read()) else: return None - def redirect(self, url:T.Union[str, bytes], code = FOUND): + def get_json(self) -> T.Any: + """ + Intended to mimic Flask api + + Returns + ------- + dict - a json decoded object + """ + return self.json + + + def redirect(self, url: T.Union[str, bytes], code=FOUND) -> T.NoReturn: """ Utility function that does a redirect. @@ -300,8 +395,14 @@ def redirect(self, url:T.Union[str, bytes], code = FOUND): The request should have C{finish()} called after this. - @param url: I{Location} header value. - @type url: L{bytes} or L{str} + Parameters + ---------- + url: bytes + What to set the LOCATION http response header to + code: int + What to set the HTTP response code to (eg 3xx) + """ self.setResponseCode(code) self.setHeader(b"location", url) + #self.ensureFinished() diff --git a/txweb/lib/view_class_assembler.py b/txweb/lib/view_class_assembler.py index 567b969..f49d3f0 100644 --- a/txweb/lib/view_class_assembler.py +++ b/txweb/lib/view_class_assembler.py @@ -20,19 +20,15 @@ def handle_show(self, request): """ import typing as T - -from ..resources import ViewFunctionResource, ViewClassResource -from txweb.http_codes import UnrenderableException -from txweb.util.basic import get_thing_name - from collections import namedtuple import inspect from werkzeug.routing import Rule, Submount +from txweb.util.basic import get_thing_name +from ..resources import ViewFunctionResource, ViewClassResource -import inspect EXPOSED_STR = "__exposed__" EXPOSED_RULE = "__sub_rule__" @@ -40,29 +36,70 @@ def handle_show(self, request): PREFILTER_ID = "__PREFILTER_ID__" POSTFILTER_ID = "__POSTFILTER_ID__" -def has_exposed(obj): + +def has_exposed(obj) -> bool: + """ + Does the provided object have an exposed endpoint? + + Parameters + ---------- + obj: An instance of a View class with exposed members OR a render method + + Returns + ------- + True if any method/function has been decorated with @texas.expose + """ return any([ True for m in getattr(obj, "__dict__", {}).values() if inspect.isfunction(m) and hasattr(m, EXPOSED_STR) ]) -def is_exposed(attribute): +# def is_exposed(attribute): +# """ +# Is the provided callable/thing set as exposed? +# :param attribute: +# :return: +# """ +# return has_exposed(attribute) + + +def is_viewable(attribute) -> bool: + """ + Check if whatever this is, it is callable and has been marked as expose'd - return has_exposed(attribute, EXPOSED_STR) and is_valid_callable + Parameters + ---------- + A callable that has been expose'd -def is_viewable(attribute): + Returns + ------- + True if conditions have been met. + + """ is_valid_callable = inspect.ismethod(attribute) \ or inspect.isfunction(attribute) \ or inspect.isgenerator(attribute) \ or inspect.iscoroutine(attribute) \ or inspect.iscoroutinefunction(attribute) - return is_exposed(attribute) and is_valid_callable + return has_exposed(attribute) and is_valid_callable def is_renderable(kls): + """ + Does a class definition have a valid render method/function + Generally checked if it has no exposed methods. + + Parameters + ---------- + A potential view class + + Returns + ------- + True if it has a renderable method (render or render_{HTTP REQUEST METHOD[POST, GET, HEAD, PUT, etc]) + """ return \ any([ hasattr(kls, render_name) @@ -75,7 +112,18 @@ def is_renderable(kls): def expose(route, **route_kwargs): - + """ + Decorator to set the exposed method's routing url and tag it with the exposed sentinel attribute + + Parameters + ---------- + route: str + route_kwargs: arguments intended to be passed on to werkzeug routing logic + + Returns + ------- + T.Callable[[func], func] - a decorating function to set the exposed attribute and append routing arguments + """ def processor(func): setattr(func, EXPOSED_STR, True) setattr(func, EXPOSED_RULE, ExposeSubRule(func.__name__, route, route_kwargs)) @@ -83,26 +131,93 @@ def processor(func): return processor + def set_prefilter(func): + """ + decorator used to mark a class method as a prefilter for the class. + + Parameters + ---------- + func: a valid prefilter callable + + Returns + ------- + The same callable as was passed in as an argument + """ setattr(func, PREFILTER_ID, True) return func + def set_postfilter(func): + """ + decorator used to mark a class method as a post filter for a class. + + Parameters + ---------- + func - a valid postfilter callable + + Returns + ------- + callable - the same function that was passed in as an argument + + """ setattr(func, POSTFILTER_ID, True) return func ViewAssemblerResult = namedtuple("ViewAssemblerResult", "instance,rule,endpoints") -def find_member(thing, identifier) -> T.Union[T.Callable, bool]: - for name, member in inspect.getmembers(thing, lambda v: hasattr(v, identifier)): +def find_member(thing, identifier: str) -> T.Union[T.Callable, bool]: + """ + Utility to search every member of an object for the provided `identifier` attribute + + Parameters + ---------- + thing: an instance of a view class + + Returns + ------- + The first matching instance method or attribute to have the `identifier` attribute + """ + for _, member in inspect.getmembers(thing, lambda v: hasattr(v, identifier)): return member return False -def view_assembler(prefix, kls, route_args): +def view_assembler(prefix: str, + kls, + route_args: T.Dict[str, T.Union[str, T.List[str]]]) -> T.Union[ViewAssemblerResult, None]: + """ + Given a class definition, this instantiates the class, searches it for exposed + methods and pre/post filters + + if it has no exposed methods, + and builds a ViewAssemblerResult which contains the instance, submount rules, + and references to the endpoints. + else it checks if the class def has a render/render_METHOD method. + else it throws UnrenderableException + + Parameters + ---------- + prefix: str + The view classes' URL prefix + kls: claasdef + A view class definition + route_args: dict + a dictionary of arguments intended for the werkzeug URL routing library + + Raises + ------ + EnvironmentError + Throws this error if the class defintion is not a valid View class + + Returns + ------- + A ViewAssemblerResult if `kls` is a view class or has a valid render method function. + """ + # pylint: disable=R0914 endpoints = {} rules = [] @@ -120,13 +235,14 @@ def view_assembler(prefix, kls, route_args): and hasattr(getattr(instance, name), EXPOSED_STR) } + prefilter = find_member(instance, PREFILTER_ID) + postfilter = find_member(instance, POSTFILTER_ID) + for name, bound_method in attributes.items(): - sub_rule = getattr(bound_method, EXPOSED_RULE) + sub_rule = getattr(bound_method, EXPOSED_RULE) # type: ExposeSubRule bound_endpoint = get_thing_name(bound_method) rule = Rule(sub_rule.route, **sub_rule.route_kwargs, endpoint=bound_endpoint) - prefilter = find_member(instance, PREFILTER_ID) - postfilter = find_member(instance, POSTFILTER_ID) endpoints[bound_endpoint] = ViewFunctionResource(bound_method, prefilter=prefilter, postfilter=postfilter) rules.append(rule) @@ -140,4 +256,4 @@ def view_assembler(prefix, kls, route_args): return ViewAssemblerResult(instance, rule, endpoints) else: - raise UnrenderableException(f"{kls.__name__!r} is missing exposed method(s) or a render method") + raise EnvironmentError(f"{kls.__name__!r} is missing exposed method(s) or a render method") diff --git a/txweb/lib/wsprotocol.py b/txweb/lib/wsprotocol.py new file mode 100644 index 0000000..b4d51f2 --- /dev/null +++ b/txweb/lib/wsprotocol.py @@ -0,0 +1,279 @@ +""" + Bridge/interface between the client and server. + + Provides a deferred to notify interested listeners if the connection closes. + Provides ask, tell, and respond helpers that match the same verbage/methods on the javascript ResilientSocket class. + + +""" +from __future__ import annotations +try: + import ujson as json +except ImportError: + import json + +import typing as T +from uuid import uuid4 +import warnings + + +from twisted.web.server import NOT_DONE_YET +from twisted.internet.defer import Deferred + +from autobahn.twisted.websocket import WebSocketServerProtocol + +from txweb.log import getLogger + +from .message_handler import MessageHandler + + + + +class WSProtocol(WebSocketServerProtocol): + """ + Handles new websocket connections by routing them to the requested endpoint. + + All incoming requests from the client match one of two JSON formats + + 1. The message has an `endpoint` key and `type` set to either "ask" or "tell" + If type is set to ask, a caller_id key MUST be present so the response can be properly routed back. + + 2. The message has a `type` key set to "response" + + + Attributes + ---------- + my_log: Logger + MAX_ASKS: int + The maximum number of pending asks to wait for from the client + factory: + Reference to the factory which spawned this protocol/connection + identity: str + A unique identifier for this connection which MUST be different than what is used for cookies + on_disconnect: Deferred + A deferred to allow for parts of the server application to be notified when the connection closes + deferred_asks: T.Dict[str, Deferred] + All pending deferred asks to the client. + + + """ + my_log = getLogger() + MAX_ASKS = 100 # Need to make this tunable + factory: RoutedFactory + + def __init__(self): + self.pending_responses = {} + + super().__init__() + + self.identity = None + self.on_disconnect = Deferred() + # for tracking deferred `ask` calls + self.deferred_asks = {} + + @property + def application(self): + """ + Utility intended mostly for unit-testing + + Returns + ------- + The current instance of Texas + + """ + return self.factory.get_application() + + def getCookie(self, cookie_name: str, default=None) -> str: + """ + Mirror's the behavior of StrRequest.getCookie + + Parameters + ---------- + cookie_name: str + default: str + + Returns + ------- + If the cookie exists, it returnx the cookie value ELSE returns `default` which is set to None by default + """ + raw_cookies = self.http_headers.get('cookie', "") + for params in raw_cookies.split(";"): + str_name, value = params.split("=") + if str_name.strip() == cookie_name: + return value.strip() + + return default + + def onConnect(self, request) -> T.NoReturn: + """ + Hook to the underlying websocket protocol so a unique identifier for the connection can be set. + Called externally by the protocol factory class. + + + """ + self.identity = uuid4().hex + self.my_log.debug("Client connecting: {request.peer}", request=request) + + def onClose(self, was_clean: bool, code: int, reason) -> T.NoReturn: + """ + Connection was lost, currently I don't care why but I likely should. + + Parameters + ---------- + + was_clean: bool + code: int + reason: unknown + + """ + self.on_disconnect.addErrback(self.my_log.error) + self.on_disconnect.callback(self.identity) + # Delete the Deferred to force it to be garbage collected and emit any unhandled errors as soon as possible + del self.on_disconnect + self.my_log.debug("WebSocket connection closed: {reason!r}", reason=reason) + + def sendDict(self, **values) -> T.NoReturn: + """ + Utility used by every other method that follows + + Raises + ------ + TypeError + Throws this if a key/value in values cannot be encoded to JSON (eg. Enum) + + Parameters: + ----------- + values: dict + A valid dictionary view of arguments that can be serialized by JSON + + """ + response = json.dumps(values) + # Always send synchronously for now + self.sendMessage(response.encode("utf-8"), isBinary=False, sync=True) + + def respond(self, original_message, result) -> T.NoReturn: + """ + The client asked for a result/response. + + Parameters + ---------- + original_message: MessageHandler or dict + The original request message so caller_id can be retrieved + + + + """ + self.sendDict(caller_id=original_message['caller_id'], type="response", result=result) + + def tell(self, endpoint: str, **values) -> T.NoReturn: + """ + Tell the client to do something and don't expect a response + + Parameters + ---------- + endpoint: str + The remote client side endpoint to be told to do something. + + """ + self.sendDict(endpoint=endpoint, type="tell", args=values) + + def ask(self, endpoint, **values) -> Deferred: + """ + Ask the client to do something and I should get a response back. + + Creates a Deferred object so the server side application can be notified of a response + + Parameters + ---------- + endpoint: str + values: dict + dictionary of keyname arguments to be serialized and sent to the client. + + + """ + if len(self.deferred_asks) > self.MAX_ASKS: + raise EnvironmentError("Maximum # of pending asks reached") + + request_token = uuid4().hex + d = Deferred() + self.deferred_asks[request_token] = d + self.sendDict(endpoint=endpoint, type="ask", caller_id=request_token, args=values) + return d + + + def handleResponse(self, message: MessageHandler) -> T.NoReturn: + """ + Server side asked client a question, shunt this message through the + deferred_asks collection of deferreds to the appropriate/waiting endpoint + + Parameters + ---------- + message: MessageHandler + The client request with the response to a server ask + + """ + caller_id = message.get("caller_id", None) + + if caller_id is not None and caller_id in self.deferred_asks: + d = self.deferred_asks[caller_id] # type: Deferred + d.callback(message.get("result")) + del self.deferred_asks[caller_id] + else: + warnings.warn(f"Response to ask {caller_id} arrived but was not found in deferred_asks") + + def handleEndPoint(self, message: MessageHandler) -> T.NoReturn: + """ + Handles incoming ask and tell messages. + + + + """ + endpoint_func = self.factory.get_endpoint(message['endpoint']) + self.my_log.debug("Processing {endpoint!r}", endpoint=message['endpoint']) + + if endpoint_func is None: + self.my_log.error("Bad endpoint {endpoint}", endpoint=message['endpoint']) + else: + result = endpoint_func(message) + + if result in [NOT_DONE_YET, None]: + return + elif isinstance(result, Deferred): + return + elif message.get("type", default=None) == "ask": + self.respond(message, result=result) + else: + warnings.warn(f"{endpoint_func} returned {result} but I don't know know how to handle it.") + + + + def onMessage(self, payload, is_binary) -> T.NoReturn: + """ + Entry point for incoming messages from the client + + :param payload: + :param is_binary: + + """ + + if is_binary: # pragma: no cover + warnings.warn("Received binary payload, don't know how to deal with this.") + return + + try: # pragma: no cover + payload = payload.decode("utf-8") + raw_message = json.loads(payload) + except UnicodeDecodeError: # pragma: no cover + warnings.warn(f"Failed to decode {payload}") + except json.JSONDecodeError: # pragma: no cover + warnings.warn(f"Corrupt/bad payload: {payload}") + else: + + message = MessageHandler(raw_message, self) + + if message.get("type") == "response": + self.handleResponse(message) + elif "endpoint" in message: + self.handleEndPoint(message) + else: + self.my_log.error("Got message without an endpoint or caller_id: {raw!r}", raw=message.raw_message) diff --git a/txweb/log.py b/txweb/log.py index 84d9610..230f570 100644 --- a/txweb/log.py +++ b/txweb/log.py @@ -1,5 +1,15 @@ +""" + Currently just a stupid bridge between twisted.logger and user applications -from logging import getLogger as stdlib_getLogger, Logger +""" +# from logging import getLogger as stdlib_getLogger, Logger +from twisted import logger -def getLogger(namespace: str) -> Logger: - return stdlib_getLogger(namespace) \ No newline at end of file + +def getLogger(namespace: str = None) -> logger.Logger: + """ + Just an adapted to mimic python's logging getLogger to twisted's Logger() + :param namespace: + :return: + """ + return logger.Logger(namespace) if namespace else logger.Logger() diff --git a/txweb/resources/__init__.py b/txweb/resources/__init__.py index badf661..6983168 100644 --- a/txweb/resources/__init__.py +++ b/txweb/resources/__init__.py @@ -7,9 +7,9 @@ """ from .view_class import ViewClassResource from .view_function import ViewFunctionResource -from .simple_file import SimpleFile +# from .simple_file import SimpleFile from .routing import RoutingResource -from .directory import Directory +# from .directory import Directory -__ALL__ = ["ViewClassResource", "ViewFunctionResource", "SimpleFile", "Directory", "RoutingResource"] +__ALL__ = ["ViewClassResource", "ViewFunctionResource", "RoutingResource"] diff --git a/txweb/resources/directory.py b/txweb/resources/directory.py deleted file mode 100644 index 3be990e..0000000 --- a/txweb/resources/directory.py +++ /dev/null @@ -1,102 +0,0 @@ -""" - Clean slate reimplementation of the Directory resource. - - -""" -from txweb.lib.str_request import StrRequest -from txweb.resources import SimpleFile -from txweb.http_codes import HTTP404, HTTP405 - -from twisted.web.resource import Resource - -from pathlib import Path -import typing as T - - -class Directory(Resource): - """ - TODO - This still needs a lot of work - - Non-recursive directory listing resource. To clarify, this only serves the files in a given filepath and nothing - in child directories nor their contents. - - #Goals - - * allow hooking into the directory listing response so that it can be overloaded by user land code. - * Instead of trying to take the user supplied input and then scrubbing it for ".." or similar security breaking - injects, it uses a prebuilt (Path.iterdir) allowed list of files and compares that to the path info. - - - - """ - - def __init__(self, path:T.Union[str, Path], recurse:bool=False): - """ - - :param path: str/Path an ABSOLUTE filepath to a directory to be exposed to a http client - :param recurse: bool should Directory exposed subdirectories - """ - Resource.__init__(self) - self.path = Path(path) # type: Path - self.isLeaf = False - self.recurse = False - - #dynamic helpers - - self._render_GET = self.render_GET - - def handleGet(self, func): - self._render_GET = func - - return func - - - def show_files(self, func): - self._render_GET = func - return func - - def allowedFiles(self) -> T.List[str]: - """ - Currently this mechanism is part of the basic security for Directory resource to ensure - only the files in a given directory can be served and or listed. - - - Returns: - list: an explicit list of what files can be served from this Directory resource - """ - return [file for file in self.path.glob("*") if file.exists() and file.is_file()] - - - - def getChild(self, path:bytes, request: StrRequest): - - path = path.decode("utf-8") - if path.lower() in ["/", "", "index", "index.html"]: - return self - - if path in [f.name for f in self.allowedFiles()]: - return SimpleFile(self.path / path, defaultType="text/blah") - - - raise HTTP404() - - - def render(self, request): - if request.method == b"GET": - return self._render_GET(self, request, self.allowedFiles()) - - elif request.method == b"HEAD": - # TODO fix this up - self._render_GET(self, request, self.allowedFiles()) - return b"" - else: - raise HTTP405() - - @staticmethod - def render_GET(parent, request, files): - raise NotImplementedError("TODO") - - - def __repr__(self): - return f"<{self.__class__.__name__} at {id(self)!r} path={self.path!r}/>" - diff --git a/txweb/resources/error.py b/txweb/resources/error.py deleted file mode 100644 index 7d77476..0000000 --- a/txweb/resources/error.py +++ /dev/null @@ -1,48 +0,0 @@ -# from __future__ import annotations -# -# import typing as T -# from twisted.web.resource import Resource -# -# from ..errors import HTTPCode -# from ..lib import StrRequest -# -# if T.TYPE_CHECKING: -# from twisted.python.failure import Failure -# -# class Error(Resource): -# -# def __init__(self, reason: Failure, verbose:bool = False): -# self.reason = reason -# self.verbose = verbose -# self.request = None -# self.code = 500 -# -# def make_traceback(self, stack: T.List): -# pass -# -# def render(self, request:StrRequest): -# self.request = request -# -# if isinstance(self.reason.type, HTTPCode): -# httpExc = self.reason.value # type: HTTPCode -# self.code = httpExc.code -# if self.code >= 500: -# return self.render_error() -# elif self.code >= 400: -# return self.render_bad_resource() -# elif self.code >= 300: -# # TODO we shouldn't reach here -# return self.render_redirect() -# else: -# raise Exception("Mishandled error") -# else: -# return self.render_error() -# -# -# def render_error(self): -# if self.verbose is False: -# return f"Error {self.code}".encode("UTF-8") -# else: -# pass -# -# diff --git a/txweb/resources/routing.py b/txweb/resources/routing.py index 82a0c45..147052c 100644 --- a/txweb/resources/routing.py +++ b/txweb/resources/routing.py @@ -1,14 +1,13 @@ -from txweb.http_codes import UnrenderableException -from txweb.util.url_converter import DirectoryPath -from txweb.util.basic import get_thing_name -from txweb.lib.str_request import StrRequest +""" + The centerpiece of TxWeb is the Routing Resource which wraps around werkzeug's routing system. -from .view_function import ViewFunctionResource -from .view_class import ViewClassResource -from .directory import Directory +""" +from collections import OrderedDict +import typing as T +import inspect +# import warnings -from ..lib import view_class_assembler as vca -from txweb import http_codes as HTTP_Errors +from twisted.web.static import File from twisted.web import resource from twisted.python import compat @@ -16,12 +15,21 @@ # Werkzeug routing import from werkzeug import routing as wz_routing + +from txweb.http_codes import Unrenderable +from txweb.util.url_converter import DirectoryPath +from txweb.util.basic import get_thing_name +from txweb.lib.str_request import StrRequest + +from txweb import http_codes as HTTP_Errors + +from .view_function import ViewFunctionResource +from .view_class import ViewClassResource +# from .directory import Directory +from ..lib import view_class_assembler as vca from ..log import getLogger -from collections import OrderedDict -import typing as T -import inspect -import warnings + log = getLogger(__name__) @@ -30,60 +38,94 @@ # view_function(request, foo, bar) # EndPointCallable should match `view_function` EndpointCallable = T.NewType("InstanceCallable", - T.Callable[ - [StrRequest, - T.Optional[T.Iterable], - T.Optional[T.Dict], - ], T.Union[str, int]]) - + T.Callable[[StrRequest, T.Optional[T.Iterable], T.Optional[T.Dict], ], T.Union[str, int]]) class RoutingResource(resource.Resource): + """ + The bridge between twisted's object graph routing system and werkzeug's url pattern + recognition routing system. + """ - - def __init__(self, on_error: T.Optional[resource.Resource] = None): - + def __init__(self, script_name: bytes = None): + """ + The bridge between twisted's object graph routing system and werkzeug's url pattern + recognition routing system. + + Parameters + ---------- + script_name: bytes + Optional ability to add a prefix to all routed URL's in case this application + is nested inside another web app. See CGI's SCRIPT_NAME variable. + """ resource.Resource.__init__(self) self._site = None - self._endpoints = OrderedDict() # type: typing.Dict[str, resource.Resource] - self._instances = OrderedDict() # type: typing.Dict[str, object] - self._route_map = wz_routing.Map() # type: wz_routing.Map + self._endpoints = OrderedDict() # type: typing.Dict[str, resource.Resource] + self._instances = OrderedDict() # type: typing.Dict[str, object] + self._route_map = wz_routing.Map() # type: wz_routing.Map self._route_map.converters['directory'] = DirectoryPath + self._script_name = script_name @property def site(self): # pragma: no cover + """ + Return a reference to the parent site of this resource + + Returns + ------- + web_site instance + + """ return self._site @site.setter def site(self, site): # pragma: no cover + """ + TODO - Verify this is still being used. + :param site: + :return: + """ self._site = site - return self._site - def iter_rules(self) -> T.Generator: + """ + Debug method for iterating over all of the currently set URL Rule's for the werkzeug routing map. + + """ return self._route_map.iter_rules() - def add(self, route_str:str, **kwargs:T.Dict[str, T.Any]): + def add(self, route_str: str, **kwargs: T.Dict[str, T.Any]): + """ + Possibly super overloaded + + + + """ + + if "endpoint" in kwargs: + raise ValueError("Undefined behavior to use RoutingResource.add('/some/route/', endpoint='something', ...)") + + if isinstance(route_str, str) is False: + raise ValueError(f"position 1 argument, route_str must be a str, got {route_str!r} instead") - assert "endpoint" not in kwargs, \ - "Undefined behavior to use RoutingResource.add('/some/route/', endpoint='something', ...)" - assert isinstance(route_str, str) is True, "add must be called with RoutingResource.add('/some/route/', **...)" - # todo swap object for def processor(original_thing: T.Union[EndpointCallable, object]) -> T.Union[EndpointCallable, object]: endpoint_name = get_thing_name(original_thing) - common_kwargs = {"endpoint":endpoint_name, "thing":original_thing, "route_kwargs":kwargs} + common_kwargs = {"endpoint": endpoint_name, "thing": original_thing, "route_kwargs": kwargs} + + # Is the thing to be added to the router a twisted Resource class? if inspect.isclass(original_thing) and issubclass(original_thing, resource.Resource): self._add_resource_cls(route_str, **common_kwargs) elif isinstance(original_thing, resource.Resource): - self._add_resource(route_str, **common_kwargs) + + self.add_resource(route_str, **common_kwargs) elif inspect.isclass(original_thing): self._add_class(route_str, **common_kwargs) @@ -95,19 +137,20 @@ def processor(original_thing: T.Union[EndpointCallable, object]) -> T.Union[Endp self._add_callable(route_str, **common_kwargs) else: - raise ValueError(f"Received {original_thing} but expected callable|Object|twisted.web.resource.Resource") + raise ValueError( + f"Received {original_thing} but expected callable|Object|twisted.web.resource.Resource") # return whatever was decorated unchanged - # the Resource.getChildForRequest is completely shortcircuited so + # the Resource.getChildForRequest is completely short circuited so # that a viewable class could be inherited in userland return original_thing return processor - def _add_callable(self, route_str:str, - endpoint:str=None, - thing:T.Union[EndpointCallable, object]=None, - route_kwargs:T.Dict[str,T.Any]=None): + def _add_callable(self, route_str: str, + endpoint: str = None, + thing: T.Union[EndpointCallable, object] = None, + route_kwargs: T.Dict[str, T.Any] = None): """ :param route_str: a valid path for werkzeug routing @@ -125,11 +168,14 @@ def _add_callable(self, route_str:str, def _add_class(self, route_str: T.AnyStr, endpoint: T.AnyStr = None, - thing:T.Union[object,T.Callable] = None, + thing: T.Union[object, T.Callable] = None, route_kwargs: T.Dict[str, T.Any] = None): + """ + A view class has been provided, decorate and process it into the router. + """ if vca.is_renderable(thing) is False: - raise UnrenderableException(f"{thing.__name__!r} is missing exposed methods or a render method") + raise Unrenderable(f"{thing.__name__!r} is missing exposed methods or a render method") if vca.has_exposed(thing): result = vca.view_assembler(route_str, thing, route_kwargs) @@ -137,30 +183,37 @@ def _add_class(self, route_str: T.AnyStr, self._endpoints.update(result.endpoints) self._route_map.add(result.rule) else: - instance = self._instances[endpoint] = thing(**route_kwargs.get("inits_kwargs",{})) + instance = self._instances[endpoint] = thing(**route_kwargs.get("inits_kwargs", {})) self._route_map.add(wz_routing.Rule(route_str, endpoint=endpoint)) self._endpoints[endpoint] = ViewClassResource(thing, instance) def _add_resource_cls(self, route_str, endpoint=None, thing=None, route_kwargs=None): + """ + Give the class definition of a resource, instantiate it, add it to the instances list, and then + add the instance to the routing map. + + :param self: + :param route_str: + :param endpoint: + :param thing: + :param route_kwargs: + :return: + """ route_kwargs = route_kwargs if route_kwargs is not None else {} if endpoint not in self._instances: self._instances[endpoint] = thing() - self._add_resource(route_str, endpoint=endpoint, thing=self._instances[endpoint], route_kwargs=route_kwargs) - - - # def add_resource(self, route_str, resource_object, endpoint=None, route_kwargs=None): - # route_kwargs = route_kwargs or {} - # endpoint = endpoint if endpoint is not None else get_thing_name(resource_object) - # - # new_rule = wz_routing.Rule(route_str, endpoint=endpoint, **route_kwargs) - # if endpoint not in self._endpoints: - # self._endpoints[endpoint] = resource_object - # - # self._route_map.add(new_rule) - # - # return resource_object - - def _add_resource(self, route_str, endpoint=None, thing=None, route_kwargs=None): + self.add_resource(route_str, endpoint=endpoint, thing=self._instances[endpoint], route_kwargs=route_kwargs) + + def add_resource(self, route_str, endpoint=None, thing=None, route_kwargs=None): + """ + + :param self: + :param route_str: + :param endpoint: + :param thing: + :param route_kwargs: + :return: + """ route_kwargs = route_kwargs if route_kwargs is not None else {} endpoint = endpoint or get_thing_name(thing) @@ -169,30 +222,44 @@ def _add_resource(self, route_str, endpoint=None, thing=None, route_kwargs=None) self._route_map.add(new_rule) - def add_directory(self, route_str: str, directory_resource: Directory) -> Directory: + def add_directory(self, route_str: str, directory_resource: File) -> File: + """ + TODO refactor. Since I've dropped my own custom file and directory resources + this method isn't as relevant. + :param route_str: + :param directory_resource: + :return: + """ endpoint = repr(directory_resource) if endpoint not in self._endpoints: self._endpoints[endpoint] = directory_resource - fixed_rule = wz_routing.Rule(route_str, endpoint=endpoint, methods=["GET", "HEAD"], - defaults={"postpath":""}) + defaults={"postpath": ""}) instrumented_rule = wz_routing.Rule(route_str + "", endpoint=endpoint, - methods=["GET","HEAD"]) + methods=["GET", "HEAD"]) self._route_map.add(fixed_rule) self._route_map.add(instrumented_rule) return directory_resource + def _build_map(self, request): + """ + Takes all of the information provided by the request object and adapts them to match the wsgi environment + dictionary so that werkzeug can provide a routing map. - - def _build_map(self, pathEl, request): + It feels unfortunate/excessive that I have to build this map on each request instead of just reporting + the changes in URL. + :param path_element: + :param request: + :return: + """ map_bind_kwargs = {} @@ -203,9 +270,11 @@ def _build_map(self, pathEl, request): else: map_bind_kwargs["server_name"] = request.getRequestHostname() - map_bind_kwargs["script_name"] = b"/".join(request.prepath) if request.prepath else b"/" + if self._script_name is None: + map_bind_kwargs["script_name"] = b"/" + else: + map_bind_kwargs["script_name"] = self._script_name - #TODO add strict slash check flag to here or to website.add if map_bind_kwargs["script_name"].startswith(b"/") is False: map_bind_kwargs["script_name"] = b"/" + map_bind_kwargs["script_name"] @@ -217,29 +286,29 @@ def _build_map(self, pathEl, request): return self._route_map.bind(**map_bind_kwargs) - - - def getChildWithDefault(self, pathEl: T.Union[bytes,str], request: StrRequest): + def getChildWithDefault(self, _, request: StrRequest): """ Routing resource is mostly ignorant of the larger ecosystem so it either returns a resource OR it throws up an errors.HTTPCode """ - map = self._build_map(pathEl, request) + routing = self._build_map(request) try: # TODO refactor to handle HEAD requests when the only valid match support GET # - one bad idea is to hack on werkzeug to append the URI matching rule to MethodNotAllowed - (rule, kwargs) = map.match(return_rule=True) + (rule, kwargs) = routing.match(return_rule=True) + except wz_routing.RequestRedirect as redirect: + log.debug("Werkzeug threw a redirect") + raise HTTP_Errors.HTTP3xx(redirect.code, redirect.new_url, redirect.name) from redirect except wz_routing.NotFound as exc: - # TODO remove print log.debug(f"Failed to find match for: {request.path!r}") - raise HTTP_Errors.HTTP404(exc) + raise HTTP_Errors.HTTP404(exc) from exc except wz_routing.MethodNotAllowed as exc: # TODO finish error handling log.debug(f"Unable to find a valid match for {request.path!r} with {request.method!r}") - raise HTTP_Errors.HTTP405(exc) + raise HTTP_Errors.HTTP405(exc) from exc request.rule = rule request.route_args = kwargs @@ -247,8 +316,3 @@ def getChildWithDefault(self, pathEl: T.Union[bytes,str], request: StrRequest): # Intended to help with nested Directory resources request.postpath = [el.encode("utf-8") for el in kwargs['postpath']] return self._endpoints[rule.endpoint] - - # if rule: - # - # else: - # raise HTTP_Errors.HTTP404() \ No newline at end of file diff --git a/txweb/resources/simple_file.py b/txweb/resources/simple_file.py deleted file mode 100644 index 51b7e8b..0000000 --- a/txweb/resources/simple_file.py +++ /dev/null @@ -1,95 +0,0 @@ -from txweb.lib.str_request import StrRequest - -from twisted.web.static import File, getTypeAndEncoding -from twisted.web.server import NOT_DONE_YET -from twisted.web import http - -from twisted.python import log - -import errno -from pathlib import Path -import typing as T - - -class SimpleFile(File): - """ - Duplicates tx.web.static.File but splits it apart - from serving dual purposes of being a Resource Branch and a leaf. - - Purpose: Serve only a specific file - - Research: - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range - https://tools.ietf.org/html/rfc7233#section-4.2 - - TODO There is a transient bug I am running into where the last few bytes - TODO - of the file is being truncated between server and client. - TODO - I feel like this might be part of a memory leak I am still trying to track down. - - """ - isLeaf = True # type: T.Union[bool, int] - - def __init__(self, path:T.Union[str, Path], defaultType = "text/plain"): - - if False in [Path(path).is_file(), Path(path).exists()]: - raise ValueError(f"{path} is not a file and or does not exist") - - File.__init__(self, str(path), defaultType=defaultType) - - self.isLeaf = True - - def render(self, request:StrRequest): - method = request.method.lower().decode("utf-8") - - if method == "get": - return self.render_GET(request) - elif method == "head": - return self.render_HEAD(request) - else: - log.err(request.method, "SimpleFile.render was given a bad HTTP method") - raise RuntimeError(f"{method} is not available for this resource") - - - def render_GET(self, request:StrRequest): - - request.setHeader("accept-ranges", "bytes") - - if self.type is None: - self.type, self.encoding = getTypeAndEncoding(self.basename(), - self.contentTypes, - self.contentEncodings, - self.defaultType) - - try: - fileForReading = self.openForReading() - except IOError as exc: - if exc.erno == errno.EACCESS: - # TODO Replace with a 500 exception - request.setResponseCode(500, f"Unable to read {self.path!r} due to permission error".encode("utf-8")) - else: - request.setResponseCode(500, f"Unknown error".encode("utf-8")) - return "" - - - - if self.type == "text/html": - request.setHeader("content-type", f"{self.type}; charset=utf-8") - else: - request.setHeader("content-type", self.type) - - if request.setLastModified(self.getModificationTime()) is http.CACHED: - # TODO research - return b"" - - producer = self.makeProducer(request, fileForReading) - producer.start() - - return NOT_DONE_YET - - - def render_HEAD(self, request:StrRequest): - self._setContentHeaders(request) - return b"" - - def __repr__(self): - return f"<{self.__class__.__name__} at {id(self)!r} path={self.path!r}/>" \ No newline at end of file diff --git a/txweb/resources/view_class.py b/txweb/resources/view_class.py index a7599f6..5011eee 100644 --- a/txweb/resources/view_class.py +++ b/txweb/resources/view_class.py @@ -1,14 +1,28 @@ +""" + A wrapper that takes a vanilla class and turns it into a compatible Resource. + Not intended to be used directly but instead by using + + ```python + @app.route("/my_thing") + class Thing: + def render(request): + return "I am thing!" + + ``` + +""" from __future__ import annotations import typing as T from twisted.web import resource -from twisted.web.server import NOT_DONE_YET +# from twisted.web.server import NOT_DONE_YET from txweb.util.basic import sanitize_render_output if T.TYPE_CHECKING: # pragma: no cover from txweb.lib.str_request import StrRequest -NotDoneYet = T.TypeVar(int) +NotDoneYet = int + class ViewClassResource(resource.Resource): @@ -17,9 +31,10 @@ class ViewClassResource(resource.Resource): # noinspection PyMissingConstructor def __init__(self, kls_view, instance=None): self.kls_view = kls_view - self.instance = instance # TODO is this needed? + self.instance = instance + super().__init__() - def render(self, request:StrRequest) -> T.Union[bytes, NotDoneYet]: + def render(self, request: StrRequest) -> T.Union[bytes, NotDoneYet]: """ Relays the request to the wrapped class instance with some caveats. @@ -67,7 +82,6 @@ def render(self, request:StrRequest) -> T.Union[bytes, NotDoneYet]: return sanitize_render_output(result) - - def __repr__(self): # pragma: no cover + def __repr__(self): # pragma: no cover instance_repr = f"<{self.instance.__class__.__name__} {self.instance!r}/>" - return f"<{self.__class__.__name__} at {id(self)!r} instance={instance_repr}/>" \ No newline at end of file + return f"<{self.__class__.__name__} at {id(self)!r} instance={instance_repr}/>" diff --git a/txweb/resources/view_function.py b/txweb/resources/view_function.py index 496dc39..cf70e46 100644 --- a/txweb/resources/view_function.py +++ b/txweb/resources/view_function.py @@ -1,31 +1,55 @@ +""" + Convert a simple function into a Resource. +""" from __future__ import annotations +import typing as T + +from twisted.web import resource + from txweb.util.basic import sanitize_render_output -from twisted.web import resource -import typing as T +# Prefilter takes StrRequest as first argument and second argument is the actual wrapped view function PrefilterFunc = T.NewType("PrefilterFunc", T.Callable[["StrRequest"], None]) -PostFilterFunc = T.NewType("PostFilterFunc", T.Callable[["StrRequest", T.Union[str, bytes]], T.Union[str,bytes]]) +# Post-filter takes StrRequest, wrapped view func, and the output of the view func +PostFilterFunc = T.NewType("PostFilterFunc", T.Callable[["StrRequest", T.Union[str, bytes]], T.Union[str, bytes]]) class ViewFunctionResource(resource.Resource): + """ + Given a callable, convert it into a renderable Resource instance + """ isLeaf: T.ClassVar[T.Union[bool, int]] = True # noinspection PyMissingConstructor - def __init__(self, func: T.Callable, prefilter:T.Union[PrefilterFunc, None]=None, postfilter:T.Union[PostFilterFunc, None]=None): + def __init__(self, func: T.Callable, + prefilter: T.Union[PrefilterFunc, None] = None, + postfilter: T.Union[PostFilterFunc, None] = None): self.func = func self.prefilter = prefilter self.postfilter = postfilter - + super().__init__() @classmethod def Wrap(cls, func): + """ + Utility decorator + :param func: + :return: + """ return cls(func) def render(self, request) -> T.Union[int, T.ByteString]: + """ + Called internally by twisted.web this executes the wrapped callable, first calling + assigned pre and then post filters. + + :param request: + :return: + """ request_view_kwargs = getattr(request, "route_args", {}) @@ -44,4 +68,3 @@ def render(self, request) -> T.Union[int, T.ByteString]: def __repr__(self): return f"<{self.__class__.__name__} at {id(self)!r} func={self.func!r}/>" - diff --git a/txweb/templates/debug_error.html b/txweb/templates/debug_error.html index 5120796..38e0f1f 100644 --- a/txweb/templates/debug_error.html +++ b/txweb/templates/debug_error.html @@ -5,7 +5,7 @@ HTTP Error: {{code}} -

{{code}} - {{message}}

+

Error: {{code}} - {{message}}

{% if traceback %}

diff --git a/txweb/tests/test_application_error_handling.py b/txweb/tests/test_application_error_handling.py
index d8611f7..5e0d381 100644
--- a/txweb/tests/test_application_error_handling.py
+++ b/txweb/tests/test_application_error_handling.py
@@ -48,8 +48,9 @@ def test_see_what_happens_with_bad_resources(dummy_request:RequestRetval, caplog
     def handle_foo(request):
         raise RuntimeError("Where is this caught?")
 
-    with caplog.at_level(logging.DEBUG):
-        dummy_request.request.requestReceived(B"GET", b"/foo", b"HTTTP/1.1")
+    with pytest.raises(RuntimeError):
+        with caplog.at_level(logging.DEBUG):
+            dummy_request.request.requestReceived(B"GET", b"/foo", b"HTTTP/1.1")
 
     assert dummy_request.request.code == 500
     assert dummy_request.read().startswith(b"HTTTP/1.1 500 Internal server error")
@@ -112,4 +113,6 @@ def does_redirect(request:StrRequest):
 
     dummy_request.request.requestReceived(b"GET", b"/", b"HTTP/1.1")
     response = dummy_request.read()
-    assert dummy_request.response_contains(b"302 FOUND")
\ No newline at end of file
+    assert dummy_request.response_contains(b"302 FOUND")
+    assert dummy_request.response_contains(b"Location: /foo")
+    assert dummy_request.response_contains(b"Redirecting to /foo")
\ No newline at end of file
diff --git a/txweb/tests/test_application_ws_add.py b/txweb/tests/test_application_ws_add.py
new file mode 100644
index 0000000..e87082d
--- /dev/null
+++ b/txweb/tests/test_application_ws_add.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from txweb.lib.message_handler import MessageHandler
+from txweb import Application as WSApp
+
+
+def test_ws_add_works():
+
+    app = WSApp(__name__) # type: WSApp
+
+    @app.ws_add("foo.bar")
+    def provide_foo_bar(message):
+        pass
+
+    assert "foo.bar" in app.ws_endpoints
+
+def test_ws_assign_args_flag_works():
+
+    app = WSApp(__name__)
+
+    @app.ws_add("alice.bob", assign_args=True)
+    def provide_alice_bob(message, which_one: str = None):
+        return which_one
+
+    message = MessageHandler(dict(args=dict(which_one="Steve!!!")), None)
+
+    result = provide_alice_bob(message)
+
+    assert result == "Steve!!!"
+
+
+# def test_ws_enforces_naming_conventions():
+#
+#     app = WSApp(__name__)
+#
+#     with pytest.raises(TypeError):
+#         @app.ws_add("bob", assign_args=True)
+#         def bad_endpoint(steve):
+#             pass
+
+# def test_ws_enforces_assign_args_is_kosher():
+#
+#     app = WSApp(__name__)
+#
+#     with pytest.raises(ValueError):
+#         @app.ws_add("steve", assign_args=True)
+#         def missing_kwargs(message, alice, bob):
+#             pass
+
+def ToyConverter(arg):
+    return int(arg)
+
+def test_bad_ideas_in_code__ws_add_type_casting_via_annotations():
+
+    app = WSApp(__name__)
+
+
+
+    message = MessageHandler({"args": {"ich": "weis", "flag":"1", "number":"123", "other_number": "456"}}, None)
+
+    @app.ws_add("foo", assign_args=True)
+    def bar(message, ich: str = None, flag: bool = False, number: ToyConverter = None, other_number: int = None):
+        return ich, flag, number, other_number,
+
+    actual = bar(message)
+    assert actual[0] == "weis"
+    assert actual[1] == True
+    assert actual[2] == 123
+    assert actual[3] == 456
\ No newline at end of file
diff --git a/txweb/tests/test_application_ws_class.py b/txweb/tests/test_application_ws_class.py
new file mode 100644
index 0000000..c89dc9c
--- /dev/null
+++ b/txweb/tests/test_application_ws_class.py
@@ -0,0 +1,168 @@
+import pytest
+
+from txweb import Application as WSApp
+from txweb.lib.message_handler import MessageHandler
+
+
+def quick_message_mock(**args):
+    return MessageHandler(dict(args=args), None)
+
+def test_concept():
+
+    app = WSApp(__name__)
+
+
+    @app.ws_class
+    class Dummy(object):
+
+        def __init__(self, application: WSApp):
+            self.app = application
+
+        @app.ws_expose
+        def hello(self, connection, message):
+            return "World"
+
+        @app.ws_expose
+        def sum_nums(self, connection, message):
+            return sum(message.get('arguments', []))
+
+
+    assert "dummy.hello" in app.ws_endpoints
+    assert "dummy.sum_nums" in app.ws_endpoints
+
+
+def test_persistence():
+
+    app = WSApp(__name__)
+
+    @app.ws_class
+    class Dummy2(object):
+
+        def __init__(self, app: WSApp):
+            self.app = app
+            self.counter = 0
+
+        @app.ws_expose
+        def increment(self, connection, message):
+            self.counter += 1
+            return None
+
+
+    assert app.ws_instances['dummy2'].counter == 0
+    app.ws_endpoints['dummy2.increment'](None, {})
+    assert app.ws_instances['dummy2'].counter == 1
+
+
+def test_magic_arguments():
+    app = WSApp(__name__)
+
+
+    @app.ws_class
+    class Endpoint:
+
+        def __init__(self, app):
+            self.app = app
+
+        @app.ws_expose(assign_args=True)
+        def test_function(self, message, foo=False, bar=None):
+            return foo, bar
+
+        with pytest.raises(TypeError, match="^ws_expose convention expects.*"):
+
+            @app.ws_expose(assign_args=True)
+            def test_bad_convention(self, protocol, incoming):
+                pass
+
+    assert "endpoint" in app.ws_instances
+
+    instance = app.ws_instances['endpoint']
+
+    message = MessageHandler({"args":dict(foo=True, bar="Hello World!")}, None)
+
+    foo, bar = instance.test_function(message)
+
+    assert foo == True
+    assert bar == "Hello World!"
+
+    assert hasattr(instance.test_function, app.WS_EXPOSED_FUNC) is True
+
+
+def test_name_override():
+
+    app = WSApp(__name__)
+
+    @app.ws_class(name="Bar")
+    class Foo:
+
+        def __init__(self, app):
+            self.app = app
+
+        @app.ws_expose
+        def method1(self, message):
+            pass
+
+    assert "Bar.method1" in app.ws_endpoints
+
+
+def test_assign_args():
+
+    app = WSApp(__name__)
+
+    @app.ws_class()
+    class Foo:
+
+        def __init__(self, app):
+            self.app = app
+
+        @app.ws_expose(assign_args=True)
+        def method1(self, message, numbah: int = None):
+            return numbah
+
+    message = quick_message_mock(numbah = "123")
+    method1 = app.ws_instances['foo'].method1
+    assert "foo.method1" in app.ws_endpoints
+    assert method1(message) == 123
+
+def test_assign_args_ignores_missing_args():
+    app = WSApp(__name__)
+
+    @app.ws_class()
+    class Foo:
+
+        def __init__(self, app):
+            self.app = app
+
+        @app.ws_expose(assign_args=True)
+        def method1(self, message, bar: int = None):
+            return bar
+
+    message = quick_message_mock(numbah="123")
+    method1 = app.ws_instances['foo'].method1
+    assert "foo.method1" in app.ws_endpoints
+    assert method1(message) is None
+
+
+def test_isolate_bug_with_name_argument():
+
+    import inspect
+
+    app = WSApp(__name__)
+
+    @app.ws_class(name="ec")
+    class EntityControl:
+
+        def __init__(self, app):
+            self.app = app
+
+        @app.ws_expose
+        def start(self, message):
+            pass
+
+
+    start_endpoint = app.ws_endpoints['ec.start']
+    endpoint_sig = inspect.signature(start_endpoint)
+
+
+    message = MessageHandler({}, None)
+
+    start_endpoint(message)
\ No newline at end of file
diff --git a/txweb/tests/test_errors_handlers_DebugHandler.py b/txweb/tests/test_errors_handlers_DebugHandler.py
index e1f5056..225383c 100644
--- a/txweb/tests/test_errors_handlers_DebugHandler.py
+++ b/txweb/tests/test_errors_handlers_DebugHandler.py
@@ -1,6 +1,9 @@
+import pytest
+
+from unittest.mock import MagicMock
 
 from txweb import Application
-from txweb.lib.errors.handler import DebugHandler
+from txweb.lib.errors import DebugHandler
 from txweb.lib.str_request import StrRequest
 
 from .helper import MockRequest
@@ -22,9 +25,10 @@ def test_handler_catches_error(dummy_request:RequestRetval):
 
     @app.add("/foo")
     def handle_foo(request):
-        raise Exception()
+        raise RuntimeError()
 
-    dummy_request.request.requestReceived(b"GET", b"/foo", b"HTTP/1.1")
+    with pytest.raises(RuntimeError):
+        dummy_request.request.requestReceived(b"GET", b"/foo", b"HTTP/1.1")
 
     dummy_request.request.transport.written.seek(0,0)
     content = dummy_request.request.transport.written.read()
@@ -42,7 +46,8 @@ def test_handler_catches_resources_that_return_none(dummy_request:RequestRetval)
     def handle_foo(request):
         pass
 
-    dummy_request.request.requestReceived(b"GET", b"/foo", b"HTTP/1.1")
+    with pytest.raises(RuntimeError):
+        dummy_request.request.requestReceived(b"GET", b"/foo", b"HTTP/1.1")
 
     dummy_request.request.transport.written.seek(0, 0)
     content = dummy_request.request.transport.written.read()
@@ -52,5 +57,49 @@ def handle_foo(request):
     assert dummy_request.request.code_message == b"Internal server error"
 
 
+def test_error_custom_errorhandler(dummy_request: RequestRetval):
+
+    app = Application(__name__)
+    dummy_request.site = app.site
+    fake_handler = MagicMock(return_val = True)
+
+    class TestError(Exception):
+        pass
+
+    @app.add("/throws")
+    def throws_error(request):
+        raise TestError()
+
+    app.handle_error(TestError)(fake_handler)
+
+    #  unlike the default handler, this swallows the exception
+    dummy_request.request.requestReceived(b"GET", b"/throws", b"HTTP/1.1")
+
+    # assert we caught this correctly
+    fake_handler.assert_called()
+
+
+
+def test_error_custom_errorhandler_prevents_duplicates(dummy_request: RequestRetval):
+
+    app = Application(__name__)
+    dummy_request.site = app.site
+    fake_handler = MagicMock(return_val = True)
+
+    class TestError(Exception):
+        pass
+
+    app.handle_error(TestError)(fake_handler)
+    with pytest.raises(ValueError):
+        app.handle_error(TestError)(fake_handler)
+
+
+
+
+
+
+
+
+
 
 
diff --git a/txweb/tests/test_messagehandler.py b/txweb/tests/test_messagehandler.py
new file mode 100644
index 0000000..3b89a3a
--- /dev/null
+++ b/txweb/tests/test_messagehandler.py
@@ -0,0 +1,14 @@
+from txweb.lib.message_handler import MessageHandler
+
+
+def test_type_casting():
+
+    message = MessageHandler({"foo":"1"}, None)
+    actual = message.get("foo", vtype=int)
+    assert actual == 1
+
+
+def test_type_casting_args():
+    message = MessageHandler({"args": {"foo":"1"}}, None)
+    actual = message.args("foo", vtype=int)
+    assert actual == 1
\ No newline at end of file
diff --git a/txweb/tests/test_python.py b/txweb/tests/test_python.py
new file mode 100644
index 0000000..ce53863
--- /dev/null
+++ b/txweb/tests/test_python.py
@@ -0,0 +1,64 @@
+import functools
+import pytest
+
+def test_boundmethod_decorator():
+
+
+    def decorator1(func):
+
+        @functools.wraps(func)
+        def processor(*args, **kwargs):
+            # for position, arg in enumerate(args):
+            #     print(f"{position}, {arg!r}")
+
+
+            return func(*args, **kwargs)
+        return processor
+
+    def decorator2(func):
+        def processor(arg1, arg2):
+            return func(arg1, arg2)
+
+        return processor
+
+    def decorator3(func):
+        def processor(parent, arg1, arg2):
+            return func(parent, arg1, arg2)
+
+        return processor
+
+
+    class ToyClass:
+
+        @decorator1
+        def method1(self, arg1, kw1=None):
+            pass
+
+        @decorator2
+        def method2(self, arg1, arg2):
+            pass
+
+        @decorator3
+        def method3(self, arg1, arg2):
+            pass
+
+        @decorator3
+        def method4(*args, **kwargs):
+            pass
+            # for position, arg in enumerate(args):
+            #     print(f"{position}: {arg!r}")
+
+
+    instance = ToyClass()
+    #
+    # print()
+    # print("Starting decorator types test")
+
+    instance.method1("Hello World", kw1="Foo")
+
+    with pytest.raises(TypeError):
+        instance.method2("Hello", "World")
+
+    instance.method3("Foo", "Bar")
+
+    instance.method4("Hello", "World!")
\ No newline at end of file
diff --git a/txweb/tests/test_resources_directory.py b/txweb/tests/test_resources_directory.py
index 06816d2..78a5966 100644
--- a/txweb/tests/test_resources_directory.py
+++ b/txweb/tests/test_resources_directory.py
@@ -1,126 +1,126 @@
-from txweb.resources import Directory, SimpleFile
-from txweb import Application
-from txweb.http_codes import HTTP404
-
-from pathlib import Path
-import typing as T
-from txweb.lib.str_request import StrRequest
-import pytest
-
-from .helper import MockRequest
-
-
-
-
-
-def test_sketch_out(static_dir):
-    resource = Directory(static_dir, recurse=False)
-    expected = {
-        str(file.name): (file.stat().st_size,)
-        for file in static_dir.glob("*")
-        if file.exists() and file.is_file()
-    }
-
-    @resource.show_files
-    def render_FILES(self, request: StrRequest, files: T.List[str]):
-        buffer = {}
-        for file in files:  # type: str
-            file = Path(file)
-            buffer[file.name] = (file.stat().st_size,)
-
-        return buffer
-
-    request = MockRequest([], "/")
-    request.method = b"GET"
-    actual = resource.render(request)
-    assert actual == expected
-
-
-def test_hybrid_leaf_and_branch(static_dir):
-    request = MockRequest([], b"/")
-    request.method = b"GET"
-    resource = Directory(static_dir)
-
-    child = resource.getChild(b"", request)
-    assert child == resource
-
-    request = MockRequest([], "")
-    request.method = b"GET"
-    resource = Directory(static_dir)
-    child = resource.getChild(b"LICENSE.txt", request)  # type: SimpleFile
-
-    assert child.path == str(static_dir / "LICENSE.txt")
-    assert isinstance(child, SimpleFile)
-
-
-def test_full_suite_with_routed_site_to_added_directory(static_dir):
-
-    test_app = Application()
-    test_app(__name__)
-
-
-    test_app.add_staticdir("/some/convoluted_path", static_dir)
-
-    request = MockRequest([], "/some/convoluted_path/LICENSE.txt")
-
-    resource = test_app.site.getResourceFor(request)
-
-    assert isinstance(resource, SimpleFile)
-
-
-def test_full_suite_with_empty_url(static_dir):
-    #  TODO relocate to static_dir/directory test suite
-    test_app = Application(namespace=__name__)
-    test_app.add_staticdir("/some/path", static_dir)
-
-    request = MockRequest([], "/some/path/")
-
-    resource = test_app.site.getResourceFor(request)
-    assert isinstance(resource, Directory)
-
-
-def test_full_suite_with_bad_url(static_dir):
-    test_app = Application(namespace=__name__)
-
-    test_app.add_staticdir("/some/path", static_dir)
-
-    request = MockRequest([], "/some/path/FOO.bar")
-
-    assert test_app.site._lastError is None
-    with pytest.raises(HTTP404):
-        resource = test_app.site.getResourceFor(request)
-
-
-def test_simple_security_check_ensure_allowedfiles_is_limited(static_dir):
-    resource = Directory(static_dir)
-    assert len(resource.allowedFiles()) == 2
-
-
-def test_render_Get_is_called_correctly(static_dir):
-    import json
-
-    directory = Directory(static_dir)
-    request = MockRequest([], "/")
-    with pytest.raises(NotImplementedError):
-        response = directory.render(request)
-
-    @directory.handleGet
-    def render(parent, request, allowedFiles):
-        response = {}
-        response['count'] = len(allowedFiles)
-        response['names'] = [x.name for x in allowedFiles]
-        return json.dumps(response)
-
-    response = directory.render(request)
-
-
-
-
-
-
-
-
-
-
-# TODO so many more tests needed
-# I already know that getChild is going to break
+# from txweb.resources import Directory, SimpleFile
+# from txweb import Application
+# from txweb.http_codes import HTTP404
+#
+# from pathlib import Path
+# import typing as T
+# from txweb.lib.str_request import StrRequest
+# import pytest
+#
+# from .helper import MockRequest
+#
+#
+#
+#
+#
+# def test_sketch_out(static_dir):
+#     resource = Directory(static_dir, recurse=False)
+#     expected = {
+#         str(file.name): (file.stat().st_size,)
+#         for file in static_dir.glob("*")
+#         if file.exists() and file.is_file()
+#     }
+#
+#     @resource.show_files
+#     def render_FILES(self, request: StrRequest, files: T.List[str]):
+#         buffer = {}
+#         for file in files:  # type: str
+#             file = Path(file)
+#             buffer[file.name] = (file.stat().st_size,)
+#
+#         return buffer
+#
+#     request = MockRequest([], "/")
+#     request.method = b"GET"
+#     actual = resource.render(request)
+#     assert actual == expected
+#
+#
+# def test_hybrid_leaf_and_branch(static_dir):
+#     request = MockRequest([], b"/")
+#     request.method = b"GET"
+#     resource = Directory(static_dir)
+#
+#     child = resource.getChild(b"", request)
+#     assert child == resource
+#
+#     request = MockRequest([], "")
+#     request.method = b"GET"
+#     resource = Directory(static_dir)
+#     child = resource.getChild(b"LICENSE.txt", request)  # type: SimpleFile
+#
+#     assert child.path == str(static_dir / "LICENSE.txt")
+#     assert isinstance(child, SimpleFile)
+#
+#
+# def test_full_suite_with_routed_site_to_added_directory(static_dir):
+#
+#     test_app = Application(__name__)
+#
+#
+#
+#     test_app.add_staticdir("/some/convoluted_path", static_dir)
+#
+#     request = MockRequest([], "/some/convoluted_path/LICENSE.txt")
+#
+#     resource = test_app.site.getResourceFor(request)
+#
+#     assert isinstance(resource, SimpleFile)
+#
+#
+# def test_full_suite_with_empty_url(static_dir):
+#     #  TODO relocate to static_dir/directory test suite
+#     test_app = Application(namespace=__name__)
+#     test_app.add_staticdir("/some/path", static_dir)
+#
+#     request = MockRequest([], "/some/path/")
+#
+#     resource = test_app.site.getResourceFor(request)
+#     assert isinstance(resource, Directory)
+#
+#
+# def test_full_suite_with_bad_url(static_dir):
+#     test_app = Application(namespace=__name__)
+#
+#     test_app.add_staticdir("/some/path", static_dir)
+#
+#     request = MockRequest([], "/some/path/FOO.bar")
+#
+#     assert test_app.site._lastError is None
+#     with pytest.raises(HTTP404):
+#         resource = test_app.site.getResourceFor(request)
+#
+#
+# def test_simple_security_check_ensure_allowedfiles_is_limited(static_dir):
+#     resource = Directory(static_dir)
+#     assert len(resource.allowedFiles()) == 2
+#
+#
+# def test_render_Get_is_called_correctly(static_dir):
+#     import json
+#
+#     directory = Directory(static_dir)
+#     request = MockRequest([], "/")
+#     with pytest.raises(NotImplementedError):
+#         response = directory.render(request)
+#
+#     @directory.handleGet
+#     def render(parent, request, allowedFiles):
+#         response = {}
+#         response['count'] = len(allowedFiles)
+#         response['names'] = [x.name for x in allowedFiles]
+#         return json.dumps(response)
+#
+#     response = directory.render(request)
+#
+#
+#
+#
+#
+#
+#
+#
+#
+#
+# # TODO so many more tests needed
+# # I already know that getChild is going to break
diff --git a/txweb/tests/test_resources_routing.py b/txweb/tests/test_resources_routing.py
index 749d1a2..f701bbb 100644
--- a/txweb/tests/test_resources_routing.py
+++ b/txweb/tests/test_resources_routing.py
@@ -1,8 +1,10 @@
+from unittest.mock import MagicMock
+
 from twisted.web.resource import NoResource
 
 from txweb.resources import RoutingResource
 from txweb import App
-from txweb.http_codes import UnrenderableException
+from txweb.http_codes import Unrenderable
 from txweb.resources import ViewClassResource
 
 from unittest.mock import sentinel
@@ -15,7 +17,7 @@
 
 def test_instantiates_without_error():
 
-    class FakeSite():
+    class FakeSite:
         pass
 
     fake_site = FakeSite()
@@ -33,7 +35,10 @@ def handle_foo(request):
 
     dummy_request.request.site = app.site
     dummy_request.channel.site = app.site
-    dummy_request.request.requestReceived(b"HEAD", b"/foo", b"HTTP1/1")
+    dummy_request.request.requestReceived(b"HEAD", b"/foo", b"HTTP/1.1")
+    assert dummy_request.request.code == 405
+    assert dummy_request.request.code_message == b"Method not allowed"
+
 
 
 def test_ensure_blows_up_with_a_bad_add():
@@ -52,9 +57,9 @@ def test_ensure_blowsup_with_a_class_that_has_no_way_to_render():
 
     app = App(__name__)
 
-    with pytest.raises(UnrenderableException):
+    with pytest.raises(Unrenderable):
         @app.add("/trash")
-        class BasClass(object):
+        class BaseClass(object):
             pass
 
 def test_ensure_a_classic_like_class_is_routed():
@@ -86,3 +91,23 @@ def test_ensure_resource_is_added():
     debug = 1
 
 
+def test_handle_add_slashes(dummy_request:RequestRetval):
+
+    app = App(__name__)
+
+    mock = MagicMock()
+
+    app.route("/js/")(mock)
+
+    dummy_request.request.site = app.site
+    dummy_request.channel.site = app.site
+    dummy_request.request.requestReceived(b"GET", b"/js", b"HTTP/1.1")
+
+    assert dummy_request.request.code == 308
+    assert dummy_request.request.code_message == b"Permanent Redirect"
+    assert dummy_request.request.responseHeaders.getRawHeaders(b"location") == [b"http://10.0.0.1/js/"]
+    assert mock.call_count == 0
+
+
+
+
diff --git a/txweb/tests/test_resources_simple_file.py b/txweb/tests/test_resources_simple_file.py
index dbc85c9..679a2ee 100644
--- a/txweb/tests/test_resources_simple_file.py
+++ b/txweb/tests/test_resources_simple_file.py
@@ -1,73 +1,75 @@
-
-from .helper import MockRequest
-
-from txweb.resources.simple_file import SimpleFile
-from pathlib import Path
-
-from twisted.web.server import NOT_DONE_YET
-
-import pytest
-
-@pytest.fixture
-def license():
-    file_path = Path(__file__).parent / "fixture" / "static" / "LICENSE.txt" #  type: Path
-    assert file_path.exists()
-    return file_path
-
-
-def test_instantiates(license):
-
-
-    request = MockRequest([], "/foo")
-    resource = SimpleFile(license, defaultType="text/plain")
-    response = resource.render_GET(request)
-    assert response == NOT_DONE_YET
-    assert len(request.written) == 1 #  Expect only one thing to have been written here
-    assert request.written[0] == license.read_bytes()
-
-
-def test_supports_single_ranged_read(license):
-
-
-    request = MockRequest([], "/foo")
-    resource = SimpleFile(license, defaultType="text/plain")
-    request.requestHeaders.setRawHeaders(b"range", [b"bytes=100-200"])
-
-    response = resource.render_GET(request)
-    assert response == NOT_DONE_YET
-    assert len(request.written) == 1
-    expected = license.read_bytes()
-    expected = expected[100:201]
-    actual = request.written[0]
-
-    assert len(actual) == len(expected)
-    assert actual == expected
-
-
-def test_multiple_ranges(license):
-
-
-    request = MockRequest([], "/foo")
-    resource = SimpleFile(license, defaultType="text/plain")
-
-    request.requestHeaders.setRawHeaders(b"range", [b"bytes=0-499, -500"])
-
-    assert resource.render(request) == NOT_DONE_YET
-    assert len(request.written) == 1
-
-    raw_file = license.read_bytes()
-
-    content_type = request.responseHeaders.getRawHeaders("content-type")[0]
-    ct_type, ct_boundary = content_type.split(";")
-    _, dividier =ct_boundary.split("=")
-    dividier = dividier.strip().strip("\"")
-
-    parts = request.written[0].decode("utf-8").split(dividier)
-
-    assert len(parts) == 4
-
-    expected1 = raw_file[0:500]
-    expected2 = raw_file[-501]
-
-
-
+#
+# from .helper import MockRequest
+#
+# from txweb.resources.simple_file import SimpleFile
+# from pathlib import Path
+#
+# from twisted.web.server import NOT_DONE_YET
+#
+# import pytest
+#
+# @pytest.fixture
+# def license():
+#     file_path = Path(__file__).parent / "fixture" / "static" / "LICENSE.txt" #  type: Path
+#     assert file_path.exists()
+#     return file_path
+#
+#
+# def test_instantiates(license):
+#
+#
+#     request = MockRequest([], "/foo")
+#     resource = SimpleFile(license, defaultType="text/plain")
+#     response = resource.render_GET(request)
+#     assert response == NOT_DONE_YET
+#     assert len(request.written) == 1 #  Expect only one thing to have been written here
+#     assert request.written[0] == license.read_bytes()
+#
+#
+# def test_supports_single_ranged_read(license):
+#
+#
+#     request = MockRequest([], "/foo")
+#     resource = SimpleFile(license, defaultType="text/plain")
+#     request.requestHeaders.setRawHeaders(b"range", [b"bytes=100-200"])
+#
+#     response = resource.render_GET(request)
+#     assert response == NOT_DONE_YET
+#     assert len(request.written) == 1
+#     expected = license.read_bytes()
+#     expected = expected[100:201]
+#     actual = request.written[0]
+#
+#     assert len(actual) == len(expected)
+#     assert actual == expected
+#
+#
+# def test_multiple_ranges(license):
+#
+#
+#     request = MockRequest([], "/foo")
+#     resource = SimpleFile(license, defaultType="text/plain")
+#
+#     request.requestHeaders.setRawHeaders(b"range", [b"bytes=0-499, -500"])
+#
+#     assert resource.render(request) == NOT_DONE_YET
+#     assert len(request.written) == 1
+#
+#     raw_file = license.read_bytes()
+#
+#     return # TODO what was this test trying to do again?
+#
+#     content_type = request.responseHeaders.getRawHeaders("content-type")[0]
+#     ct_type, ct_boundary = content_type.split(";")
+#     _, dividier =ct_boundary.split("=")
+#     dividier = dividier.strip().strip("\"")
+#
+#     parts = request.written[0].decode("utf-8").split(dividier)
+#
+#     assert len(parts) == 4
+#
+#     expected1 = raw_file[0:500]
+#     expected2 = raw_file[-501]
+#
+#
+#
diff --git a/txweb/tests/test_routable_classes.py b/txweb/tests/test_routable_classes.py
index 38fa27a..5a4b390 100644
--- a/txweb/tests/test_routable_classes.py
+++ b/txweb/tests/test_routable_classes.py
@@ -1,83 +1,83 @@
-
-import pytest
-
-from twisted.web.resource import NoResource
-
-from txweb.web_views import WebSite
-from txweb.http_codes import UnrenderableException
-from .helper import MockRequest
-
-def test_basic_idea():
-
-    app = WebSite()
-
-    @app.add("/nexus")
-    class PersistentObject(object):
-
-        def __init__(self):
-            self._number = 0
-
-
-        @app.expose("/number")
-        def respond_number(self, request):
-            return 1234
-
-        @app.expose("/greeting")
-        def render_response_says_hello(self, request):
-            return "Hello"
-
-
-        @app.expose("/add_one")
-        def adds_to_passed_get_argument(self, request):
-            """
-                subviews do not need to start with render_
-            """
-            input = int(request.args[b'number'][0])
-
-            return input + 1
-
-        @app.expose("/counter")
-        def increments_persistant_value(self, request):
-            self._number += 1
-            return self._number
-
-
-    assert len(app.resource._route_map._rules) == 4
-
-    number_request = MockRequest([], "/nexus/number")
-    number_resource = app.getResourceFor(number_request)
-
-    assert isinstance(number_resource, NoResource) is False
-    expected = b"1234"
-    actual = number_resource.render(number_request)
-    assert actual == expected
-
-    add_request = MockRequest([], "/nexus/add_one", {b"number":5})
-    resource = app.getResourceFor(add_request)
-    expected = b"6"
-    actual = resource.render(add_request)
-    assert actual == expected
-
-    incrementer = MockRequest([], "/nexus/counter")
-    assert app.getResourceFor(incrementer).render(incrementer) == 1 #This is a bug because NOT_DONE_YET =='s 1
-    assert app.getResourceFor(incrementer).render(incrementer) == b"2"
-    assert app.getResourceFor(incrementer).render(incrementer) == b"3"
-
-
-
-
-def test_throws_exception_on_inaccessible_view_class():
-
-
-    app = WebSite()
-
-    with pytest.raises(UnrenderableException):
-        @app.add("/base")
-        class Foo:
-            pass
-
-
-
-
-
-
+#
+# import pytest
+#
+# from twisted.web.resource import NoResource
+#
+# from txweb.web_views import WebSite
+# from txweb.http_codes import UnrenderableException
+# from .helper import MockRequest
+#
+# def test_basic_idea():
+#
+#     app = WebSite()
+#
+#     @app.add("/nexus")
+#     class PersistentObject(object):
+#
+#         def __init__(self):
+#             self._number = 0
+#
+#
+#         @app.expose("/number")
+#         def respond_number(self, request):
+#             return 1234
+#
+#         @app.expose("/greeting")
+#         def render_response_says_hello(self, request):
+#             return "Hello"
+#
+#
+#         @app.expose("/add_one")
+#         def adds_to_passed_get_argument(self, request):
+#             """
+#                 subviews do not need to start with render_
+#             """
+#             input = int(request.args[b'number'][0])
+#
+#             return input + 1
+#
+#         @app.expose("/counter")
+#         def increments_persistant_value(self, request):
+#             self._number += 1
+#             return self._number
+#
+#
+#     assert len(app.resource._route_map._rules) == 4
+#
+#     number_request = MockRequest([], "/nexus/number")
+#     number_resource = app.getResourceFor(number_request)
+#
+#     assert isinstance(number_resource, NoResource) is False
+#     expected = b"1234"
+#     actual = number_resource.render(number_request)
+#     assert actual == expected
+#
+#     add_request = MockRequest([], "/nexus/add_one", {b"number":5})
+#     resource = app.getResourceFor(add_request)
+#     expected = b"6"
+#     actual = resource.render(add_request)
+#     assert actual == expected
+#
+#     incrementer = MockRequest([], "/nexus/counter")
+#     assert app.getResourceFor(incrementer).render(incrementer) == 1 #This is a bug because NOT_DONE_YET =='s 1
+#     assert app.getResourceFor(incrementer).render(incrementer) == b"2"
+#     assert app.getResourceFor(incrementer).render(incrementer) == b"3"
+#
+#
+#
+#
+# def test_throws_exception_on_inaccessible_view_class():
+#
+#
+#     app = WebSite()
+#
+#     with pytest.raises(UnrenderableException):
+#         @app.add("/base")
+#         class Foo:
+#             pass
+#
+#
+#
+#
+#
+#
diff --git a/txweb/tests/test_site_request.py b/txweb/tests/test_site_request.py
index 717d88e..c031ca1 100644
--- a/txweb/tests/test_site_request.py
+++ b/txweb/tests/test_site_request.py
@@ -1,3 +1,5 @@
+import pytest
+
 from txweb.application import Application
 from txweb.lib import StrRequest
 from .conftest import RequestRetval
@@ -14,7 +16,9 @@ def do_foo(request):
 
     dummy_request.request.site = app.site
     dummy_request.channel.site = app.site
-    dummy_request.request.requestReceived(b"HEAD", b"/foo", b"HTTP1/1")
+
+    with pytest.raises(RuntimeError):
+        dummy_request.request.requestReceived(b"HEAD", b"/foo", b"HTTP1/1")
 
     assert dummy_request.request.code == 500
     assert dummy_request.request.code_message == b'Internal server error'
diff --git a/txweb/tests/test_static_file_resource.py b/txweb/tests/test_static_file_resource.py
index e2a2029..25e39f0 100644
--- a/txweb/tests/test_static_file_resource.py
+++ b/txweb/tests/test_static_file_resource.py
@@ -1,5 +1,5 @@
 
-from txweb.web_views import WebSite
+from txweb.web_site import WebSite
 
 from twisted.web.server import NOT_DONE_YET
 from twisted.web.static import File
diff --git a/txweb/tests/test_view_classes.py b/txweb/tests/test_view_classes.py
index 601c962..2898f34 100644
--- a/txweb/tests/test_view_classes.py
+++ b/txweb/tests/test_view_classes.py
@@ -129,11 +129,11 @@ def test_find_pre_and_post_filters():
 
     class Test(object):
 
-        @app.set_prefilter
+        @app.set_view_prefilter
         def blank_prefilter(self, request, method_name):
             return None
 
-        @app.set_postfilter
+        @app.set_view_postfilter
         def blank_postfilter(self, request, method_name, response):
             return response
 
@@ -158,10 +158,11 @@ def blank_postfilter(self, request, method_name, response):
 
 def test_setting_prefilter(dummy_request:RequestRetval):
 
-    from unittest.mock import sentinel
+    from unittest.mock import sentinel, MagicMock
 
     app = Application(__name__)
 
+
     class WasCalled(Exception):
         pass
 
@@ -172,7 +173,7 @@ class TestPrefilter(object):
         def stub(self, request):
             return ""
 
-        @app.set_prefilter
+        @app.set_view_prefilter
         def my_prefilter(self, request, method_name):
             raise WasCalled()
 
@@ -199,7 +200,7 @@ class TestPostfilter(object):
         def stub(self, request):
             return ""
 
-        @app.set_postfilter
+        @app.set_view_postfilter
         def my_postfilter(self, request, method_name, response):
             raise WasCalled()
 
@@ -219,7 +220,7 @@ class TestPostfilter(object):
         def stub(self, request):
             return ""
 
-        @app.set_postfilter
+        @app.set_view_postfilter
         def my_postfilter(self, request, method_name, response):
             assert method_name == self.stub.__qualname__
             assert method_name.endswith("stub")
@@ -238,7 +239,7 @@ class GlobalTestView(object):
     def exposed(self, request):
         return ""
 
-    @global_test_app.set_prefilter
+    @global_test_app.set_view_prefilter
     def my_prefilter(self, request, method_name):
         cls, method = method_name.split(".",1)
         assert cls == "GlobalTestView"
diff --git a/txweb/tests/test_views.py b/txweb/tests/test_views.py
index bb3b0e6..c34ef2b 100644
--- a/txweb/tests/test_views.py
+++ b/txweb/tests/test_views.py
@@ -3,7 +3,7 @@
 
 import pytest
 
-from txweb import web_views
+from txweb import web_site
 from .helper import ensureBytes, MockRequest
 
 # def getChildForRequest(resource, request):
@@ -17,71 +17,71 @@
 #     return resource
 
 
-def test__website_add__works():
-
-    test_website = web_views.WebSite()
-    expected_number_value = 890
-    request = MockRequest([], f"/foo/bar/{expected_number_value}")
-
-    @test_website.add("/foo/bar/")
-    def stub(request, a_number):
-        assert expected_number_value == a_number
-        return a_number
-
-    rsrc = test_website.getResourceFor(request)
-
-    assert rsrc.func == stub
-
-
-def test_website_add__handles_native_resources():
-
-    test_website = web_views.WebSite()
-    expected_number_value = 890
-    request = MockRequest([], f"/foo/bar/{expected_number_value}")
-
-    @test_website.add("/foo/bar/")
-    class TestResource(tw_resource.Resource):
-        isLeaf = True
-
-        def render(self, request):
-            assert "number" in request.route_args
-            assert request.route_args["number"] == expected_number_value
-            return b"Rendered TestResource"
-
-    rsrc = test_website.getResourceFor(request)
-    assert isinstance(rsrc, TestResource)
-    assert rsrc.render(request) == b"Rendered TestResource"
-
-
-def test_website__adds_resource_class():
-
-    test_website = web_views.WebSite()
-
-    test_website.add("/foo")(tw_resource.NoResource)
-
-
-    request = MockRequest([], "/foo")
-    rsrc = test_website.getResourceFor(request)
-
-    assert isinstance(rsrc, tw_resource.NoResource)
-
-
-def test_website__able_to_access_routing_rules():
-
-    site = web_views.WebSite()
-
-    @site.add("/foo")
-    def foo_view(request):
-        pass
-
-    @site.add("/bar")
-    def bar_view(request):
-        pass
-
-    rules = list(site.resource.iter_rules())
-
-    assert len(rules) == 2
-    assert rules[0].rule == "/foo"
+# def test__website_add__works():
+#
+#     test_website = web_views.WebSite()
+#     expected_number_value = 890
+#     request = MockRequest([], f"/foo/bar/{expected_number_value}")
+#
+#     @test_website.add("/foo/bar/")
+#     def stub(request, a_number):
+#         assert expected_number_value == a_number
+#         return a_number
+#
+#     rsrc = test_website.getResourceFor(request)
+#
+#     assert rsrc.func == stub
+
+
+# def test_website_add__handles_native_resources():
+#
+#     test_website = web_views.WebSite()
+#     expected_number_value = 890
+#     request = MockRequest([], f"/foo/bar/{expected_number_value}")
+#
+#     @test_website.add("/foo/bar/")
+#     class TestResource(tw_resource.Resource):
+#         isLeaf = True
+#
+#         def render(self, request):
+#             assert "number" in request.route_args
+#             assert request.route_args["number"] == expected_number_value
+#             return b"Rendered TestResource"
+#
+#     rsrc = test_website.getResourceFor(request)
+#     assert isinstance(rsrc, TestResource)
+#     assert rsrc.render(request) == b"Rendered TestResource"
+
+
+# def test_website__adds_resource_class():
+#
+#     test_website = web_views.WebSite()
+#
+#     test_website.add("/foo")(tw_resource.NoResource)
+#
+#
+#     request = MockRequest([], "/foo")
+#     rsrc = test_website.getResourceFor(request)
+#
+#     assert isinstance(rsrc, tw_resource.NoResource)
+
+
+# def test_website__able_to_access_routing_rules():
+#
+#     site = web_views.WebSite()
+#
+#     @site.add("/foo")
+#     def foo_view(request):
+#         pass
+#
+#     @site.add("/bar")
+#     def bar_view(request):
+#         pass
+#
+#     rules = list(site.resource.iter_rules())
+#
+#     assert len(rules) == 2
+#     assert rules[0].rule == "/foo"
 
 
 
diff --git a/txweb/tests/test_ws_at_wsprotocol.py b/txweb/tests/test_ws_at_wsprotocol.py
new file mode 100644
index 0000000..585af72
--- /dev/null
+++ b/txweb/tests/test_ws_at_wsprotocol.py
@@ -0,0 +1,137 @@
+from unittest.mock import MagicMock
+from dataclasses import dataclass
+
+from twisted.internet.defer import inlineCallbacks
+
+from txweb.lib.wsprotocol import WSProtocol
+
+@dataclass(frozen=True)
+class CapturedMessage:
+    payload: str
+    isBinary: bool
+    fragmentSize:int
+    sync:bool
+    doNotCompress:bool
+
+class MockFactory:
+    def __init__(self, endpoints):
+        self.endpoints = endpoints
+
+    def get_endpoint(self, endpoint):
+        return self.endpoints[endpoint]
+
+class TrackingProtocol(WSProtocol):
+
+        def __init__(self, *args, **kwargs):
+            super(TrackingProtocol, self).__init__(*args, **kwargs)
+            self.messages = []
+            self.http_headers = {}
+
+
+        def sendMessage(self,
+                    payload,
+                    isBinary=False,
+                    fragmentSize=None,
+                    sync=False,
+                    doNotCompress=False):
+            self.messages.append(CapturedMessage(payload, isBinary, fragmentSize, sync, doNotCompress))
+
+def mock_protocol(endpoints):
+    factory = MockFactory(endpoints)
+    protocol = TrackingProtocol()
+    protocol.factory = factory
+    return protocol, factory
+
+def mock_message(**kwargs):
+    from json import dumps
+    raw = dumps(kwargs)
+    return raw.encode("utf-8")
+
+
+
+def test_imports():
+    pass # catch syntax errors and cyclical imports
+
+
+def test_instantiates():
+    mock_func = MagicMock()
+    protocol, factory = mock_protocol({"foo": mock_func})
+
+def test_getCookie():
+    protocol, factory = mock_protocol({})
+
+    protocol.http_headers['cookie'] = "foo=bar; blah=123; thing=creature"
+
+
+    assert protocol.getCookie("foo") == "bar"
+    assert protocol.getCookie("blah") == "123"
+    assert protocol.getCookie("thing") == "creature"
+
+    dud = protocol.getCookie("doesn't exist", default=None)
+    assert dud is None
+
+
+def test_open_and_closed_deferred_works():
+
+    protocol, factoryt = mock_protocol({})
+
+    assert protocol.identity is None
+
+    protocol.onConnect(None)
+
+    actual_id = protocol.identity
+    track_call = MagicMock()
+
+    protocol.on_disconnect.addCallback(track_call)
+
+    protocol.onClose(True, 200, None)
+
+    track_call.assert_called_once_with(actual_id)
+
+
+def test_onMessage():
+    import json
+
+    mock_func = MagicMock()
+    mock_func.return_value = None
+    protocol, factory = mock_protocol(dict(endpoint1=mock_func))
+
+    payload = dict(endpoint="endpoint1", type="tell")
+    dumped = json.dumps(payload).encode("utf-8")
+
+    protocol.onMessage(dumped, False)
+
+    mock_func.assert_called_once()
+
+
+def test_deferred_ask():
+
+    import json
+
+    @inlineCallbacks
+    def asking_func(message):
+
+        result = yield message.ask("remote.add", first=1, second=2)
+        message.tell("result_was", logic=result)
+        assert result == 4
+
+    protocol, factory = mock_protocol({"test_endpoint": asking_func})
+    protocol.onMessage(mock_message(type="call", endpoint="test_endpoint"), False)
+    captured = protocol.messages[0] # type: CapturedMessage
+    ask_msg = json.loads(captured.payload)
+
+    assert ask_msg['type'] == "ask"
+    assert len(protocol.deferred_asks) == 1
+
+    caller_id = ask_msg['caller_id']
+
+    protocol.onMessage(mock_message(type="response", caller_id=caller_id, result=4), False)
+
+    assert len(protocol.messages) == 2
+
+    latest = protocol.messages[-1] # type: CapturedMessage
+    tell_msg = json.loads(latest.payload)
+
+    assert tell_msg['type'] == "tell"
+    assert tell_msg['args']['logic'] == 4
+
diff --git a/txweb/util/basic.py b/txweb/util/basic.py
index b8f9fda..0da679a 100644
--- a/txweb/util/basic.py
+++ b/txweb/util/basic.py
@@ -1,3 +1,7 @@
+"""
+    Basic and common utility functions
+
+"""
 import inspect
 import typing as T
 
diff --git a/txweb/util/reloader.py b/txweb/util/reloader.py
index f146a0c..fac4c9e 100644
--- a/txweb/util/reloader.py
+++ b/txweb/util/reloader.py
@@ -4,14 +4,16 @@
 
     In user script it must follow the pattern
 
-    def main():
-        my main func that starts twisted
+    ```python
+        def main():
+            # my main func that starts twisted
 
-    if __name__ == "__main__":
-        from txweb.sugar.reloader import reloader
-        reloader(main)
-    else:
-        TAC logic goes here but isn't necessary for short term dev work
+        if __name__ == "__main__":
+            from txweb.sugar.reloader import reloader
+            reloader(main)
+        else:
+            TAC logic goes here but isn't necessary for short term dev work
+    ```
 
 
     Originally found via https://blog.elsdoerfer.name/2010/03/09/twisted-twistd-autoreload/
@@ -30,12 +32,14 @@ def main():
     recopied from https://gist.github.com/devdave/05de2ed2fa2aa0a09ba931db36314e3e
 
 """
+import typing as T
 import pathlib
 import os
 import sys
 import time
 
 from logging import getLogger
+import txweb
 
 log = getLogger(__name__)
 
@@ -48,37 +52,42 @@ def main():
     except ImportError:
         try:
             import dummy_thread as thread
-        except ImportError:
-            try:
-                import _dummy_thread as thread
-            except ImportError:
-                print("Alright... so I tried importing thread, that failed, so I tried _thread, that failed too")
-                print("..so then I tried dummy_thread, then _dummy_thread.  All failed")
-                print(", at this point I am out of ideas here")
-                raise RuntimeError("Failed to import threading library")
-                sys.exit(-1)
+        except ImportError as missing_import:
+            print("Alright... so I tried importing thread, that failed, so I tried _thread, that failed too")
+            print("..so then I tried dummy_thread.  All failed")
+            print(", at this point I am out of ideas here")
+            raise RuntimeError("Failed to import threading library") from missing_import
+
 
 RUN_RELOADER = True
-SENTINEL_CODE = 7211
+SENTINEL_CODE = 7211  # I wish I remembered why I picked this number.
 SENTINEL_NAME = "RELOADER_ACTIVE"
+#  os._exit is a hard and fast exit from a process, avoiding most cleanup handlers which makes it ideal when used
+#    in a child process.
+#  https://stackoverflow.com/a/9591397/9908
 SENTINEL_OS_EXIT = True
 
-try:
-    """
-       "Reason" is here https://code.djangoproject.com/ticket/2330
 
-       TODO - Figure out why threading needs to be imported as this feels like a problem 
-       within stdlib.
-    """
+"""
+   "Reason" is here https://code.djangoproject.com/ticket/2330
+   TODO - Figure out why threading needs to be imported as this feels like a problem within stdlib.
+   
+"""
+
+try:
+    # pylint: disable=W0611
     import threading
 except ImportError:
     pass
 
-_watch_list = {}
+WATCH_LIST = {}
 _win = (sys.platform == "win32")
 
 
-def build_list(root_dir, watch_self=False):
+def build_list(
+        root_dir: T.Union[pathlib.Path, str],
+        watch_self: bool = False,
+        ignore_prefix: T.Optional[T.List[str]] = None):
     """
         Walk from root_dir down, collecting all files that end with ^*.py$ to watch
 
@@ -87,36 +96,64 @@ def build_list(root_dir, watch_self=False):
 
     :param root_dir: pathlib.Path current working dir to search
     :param watch_self: bool Watch all of txweb for changes
+    :param ignore_prefix: simple check that if provided compares file names to the prefix and skips if they match
     :return: None
+
     """
 
-    global _watch_list
+    global WATCH_LIST
 
     if watch_self is True:
-        import txweb
-        print("RELOADER: Watching self")
-        build_list(pathlib.Path(txweb.__file__).parent.absolute())
+        log.info("RELOADER: Watching self")
+        build_list(pathlib.Path(txweb.__file__).parent.absolute(), ignore_prefix=ignore_prefix)
+
+    def is_list(obj):
+        return obj is not None and isinstance(obj, list)
+
+    def is_prefixed(filepath: pathlib.Path):
+        if not filepath.is_file():
+            return False
+        elif is_list(ignore_prefix):
+            if any([filepath.name.startswith(prefix) for prefix in ignore_prefix]) is True:
+                return True
+        elif ignore_prefix is not None:
+            if filepath.name.startswith(ignore_prefix) is True:
+                return True
+
+        return False
+
+
 
 
     for pathobj in root_dir.iterdir():
         if pathobj.is_dir():
-            build_list(pathobj, watch_self=False)
+            build_list(pathobj, watch_self=False, ignore_prefix=ignore_prefix)
         elif pathobj.name.endswith(".py") and not (pathobj.name.endswith(".pyc") or pathobj.name.endswith(".pyo")):
             stat = pathobj.stat()
-            _watch_list[pathobj] = (stat.st_size, stat.st_ctime, stat.st_mtime,)
+            if is_prefixed(pathobj):
+                continue
+
+            WATCH_LIST[pathobj] = (stat.st_size, stat.st_ctime, stat.st_mtime,)
         else:
             pass
 
 
-def file_changed():
-    global _watch_list
+def file_changed() -> bool:
+    """
+        Scans the watched list of files for change in size, created & modified timestamps
+
+    :return: Returns True if a change has been detected else False
+
+    """
+    global WATCH_LIST
     change_detected = False
-    for pathname, (st_size, st_ctime, st_mtime) in _watch_list.items():
+    for pathname, (st_size, st_ctime, st_mtime) in WATCH_LIST.items():
         pathobj = pathlib.Path(pathname)
         stat = pathobj.stat()
         if pathobj.exists() is False:
             raise Exception(f"Lost track of {pathname!r}")
-        elif stat.st_size != st_size:
+
+        if stat.st_size != st_size:
             change_detected = True
         elif stat.st_ctime != st_ctime:
             change_detected = True
@@ -124,16 +161,28 @@ def file_changed():
             change_detected = True
 
         if change_detected:
-            log.debug(f"RELOADING - {pathobj} changed")
+            print(f"RELOADING - {pathobj} changed")
             break
 
     return change_detected
 
 
-def watch_thread(os_exit=SENTINEL_OS_EXIT, watch_self=False):
-    exit_func = os._exit if os_exit is True else sys.exit
+def watch_thread(os_exit: bool = SENTINEL_OS_EXIT, watch_self: bool = False, ignore_prefix=None):
+    """
+
+    :param os_exit: flag to decide whether to use sys.exit or os._exit
+    :param watch_self: Should reloader watch its own source code
+    :param ignore_prefix: Ignore files starting with this prefix
+    :return:
 
-    build_list(pathlib.Path(os.getcwd()), watch_self=watch_self)
+    """
+
+    # pylint: disable=W0212
+    exit_func = os._exit if os_exit is True else sys.exit
+    base_path = pathlib.Path(os.getcwd())
+    print(f"RELOADER: Base is {base_path}")
+    build_list(base_path, watch_self=watch_self, ignore_prefix=ignore_prefix)
+    print(f"RELOADER: Watching {len(WATCH_LIST)} files for changes")
 
     while True:
         if file_changed():
@@ -143,34 +192,49 @@ def watch_thread(os_exit=SENTINEL_OS_EXIT, watch_self=False):
 
 
 def run_reloader():
+    """
+        Respawns the user's program/script and hangs until it returns.
+
+    :return:
+    """
+    args = [sys.executable] + sys.argv
+    if _win:
+        args = ['"%s"' % arg for arg in args]
+
+    new_env = os.environ.copy()
+    new_env[SENTINEL_NAME] = "true"
+    log.info("Starting reloader process")
+
     while True:
-        args = [sys.executable] + sys.argv
-        if _win:
-            args = ['"%s"' % arg for arg in args]
-
-        new_env = os.environ.copy()
-        new_env[SENTINEL_NAME] = "true"
-        log.info("Starting reloader process")
-        # print("Running reloader process")
         exit_code = os.spawnve(os.P_WAIT, sys.executable, args, new_env)
         if exit_code != SENTINEL_CODE:
             return exit_code
 
 
-def reloader_main(main_func, *args, watch_self=False, **kwargs):
+def reloader_main(main_func, *args, watch_self=False, ignore_prefix=None, **kwargs) -> None:
     """
 
-    :param main_func:
-    :param args:
-    :param kwargs:
+        Right...  This checks to see if it is running as a child process with the sentinel flag set in its
+        process environment and if it isn't, it spawns a child process that run's the user's provided entry point
+        "main function".
+
+        Meanwhile in the child process, a watcher thread scans the user application source code files for changes.
+
+    :param main_func:  The entry point to the user application
+    :param watch_self: Should reloader also watch it's own source code for changes?
+    :param args: arguments for the main_func
+    :param ignore_prefix: Files to ignore by their prefix/starts with.
+    :param kwargs: keyword arguments for the main func
     :return:
     """
 
-
     # If it is, start watcher thread and then run the main_func in the parent process as thread 0
     if os.environ.get(SENTINEL_NAME) == "true":
 
-        thread.start_new_thread(watch_thread, (), {"os_exit": SENTINEL_OS_EXIT, "watch_self": watch_self})
+        thread.start_new_thread(
+            watch_thread,
+            (),
+            {"os_exit": SENTINEL_OS_EXIT, "watch_self": watch_self, "ignore_prefix": ignore_prefix})
         try:
             main_func(*args, **kwargs)
         except KeyboardInterrupt:
@@ -185,31 +249,24 @@ def reloader_main(main_func, *args, watch_self=False, **kwargs):
             pass
 
 
-def reloader(main_func, *args, watch_self=False, **kwargs):
+def reloader(main_func, *args, watch_self=False, ignore_prefix=None, **kwargs):
     """
-        To avoid fucking with twisted as much as possible, the watcher logic is shunted into
-        a thread while the main (twisted) reactor runs in the main thread.
+        See Reloader main for how this works.
+
+        WARNING - This assumes that the cwd (current working directory) is the base directory
+        of the project to be watched.
 
     :param main_func: The function to run in the main/primary thread
     :param args: list of arguments
+    :param watch_self: Should reloader also watch it's own src code for changes?
+    :param ignore_prefix: Files to ignore based on their prefix (eg test files)
     :param kwargs: dictionary of arguments
-    :param more_options: var trash currently
     :return: None
+
     """
     if args is None:
         args = ()
     if kwargs is None:
         kwargs = {}
 
-    reloader_main(main_func, *args, watch_self=watch_self, **kwargs)
-
-
-"""
-
-def main():
-  #startup twisted here
-
-if __name__ == "__main__":
-  reloader(main)
-
-"""
\ No newline at end of file
+    reloader_main(main_func, *args, watch_self=watch_self, ignore_prefix=ignore_prefix, **kwargs)
diff --git a/txweb/util/templating.py b/txweb/util/templating.py
index 81fc060..ea28a43 100644
--- a/txweb/util/templating.py
+++ b/txweb/util/templating.py
@@ -1,33 +1,60 @@
+"""
+    Utility to provide Jinja2 support for txweb enabled applications
+
+    TODO: look into jinja2 returning bytes by default to cut down on post-processing
+"""
+# import json
 import typing as T
 from pathlib import Path
+try:  # pragma: no cover
+    from jinja2 import FileSystemLoader, Environment, BytecodeCache
+    from jinja2.bccache import Bucket
+except ImportError as failed_import:  # pragma: no cover
+    raise EnvironmentError("Jinja2 is not install: pip install jinja2 to use the templating utility") from failed_import
+
 
-from jinja2 import FileSystemLoader, Environment, BytecodeCache
-from jinja2.bccache import Bucket
 
 
+# pragma: no cover
+JINJA2_ENV = None  # type: Environment
 
 
-class MyCache(BytecodeCache):
+class MyCache(BytecodeCache):  # pragma: no cover
+    """
+        To help alleviate some of the hanging/processing time for rendering templates, this
+         caches compiled byte cord versions of previously used templates.
 
-    def __init__(self, directory:T.Union[str, Path]):
+         https://jinja.palletsprojects.com/en/2.11.x/api/#bytecode-cache
+    """
+
+    def __init__(self, directory: T.Union[str, Path]):
         self.directory = directory
 
-    def load_bytecode(self, bucket:Bucket):
+    def load_bytecode(self, bucket: Bucket):
         filename = self.directory / bucket.key
         if filename.exists():
             with filename.open("rb") as my_file:
                 bucket.load_bytecode(my_file)
 
-    def dump_bytecode(self, bucket:Bucket):
+    def dump_bytecode(self, bucket: Bucket):
         filename = self.directory / bucket.key
         with filename.open("wb") as my_file:
             bucket.write_bytecode(my_file)
 
-import json
 
-JINJA2_ENV = None
+def initialize_jinja2(
+        template_dir: T.Union[Path,
+                              str,
+                              T.List[T.Union[Path, str]]],
+        cache_dir: T.Optional[T.Union[str, Path]] = None):  # pragma: no cover
+    """
+
+    :param template_dir:  Can be either a string, Path object, or a list of string/path objects pointing
+    to template directories
+    :param cache_dir: optional absolute path to a cache dir intended for storing compiled templates
+    :return:
 
-def initialize_jinja2(template_dir:T.Union[Path, str], cache_dir=None):
+    """
     global JINJA2_ENV
 
     env_kwargs = {}
@@ -40,10 +67,14 @@ def initialize_jinja2(template_dir:T.Union[Path, str], cache_dir=None):
     JINJA2_ENV = Environment(**env_kwargs)
 
 
+def render(template_pathname, **template_args):  # pragma: no cover
+    """
+    Utility that merges fetching a Jinja2 template and rendering it into one call.
+    :param template_pathname:
+    :param template_args:
+    :return:
+    """
+    if JINJA2_ENV is None:
+        raise EnvironmentError("Jinja2 environment not initialized, call initialize_jinja2 first")
 
-def render(template_pathname, **template_args):
     return JINJA2_ENV.get_template(template_pathname).render(**template_args)
-
-
-
-
diff --git a/txweb/util/url_converter.py b/txweb/util/url_converter.py
index adff0a8..69dfe07 100644
--- a/txweb/util/url_converter.py
+++ b/txweb/util/url_converter.py
@@ -1,22 +1,35 @@
+"""
+    URL converter for adding twisted.web.static.File resources into the routing map.
+
+"""
 import typing as T
 
 from werkzeug.routing import BaseConverter
 
 
-class DirectoryPath(BaseConverter):
+class DirectoryPath(BaseConverter):  # pragma: no cover
     """
         Rule("/whatever/") is a black hole rule in that
         whatever starts with /whatever will end up being caught by this rule.
     """
-    regex = r"(.*)" #  Got to catch them all
+    regex = r"(.*)"  # Got to catch them all
 
-    def __init__(self, url_map):
-        super(DirectoryPath, self).__init__(url_map)
+    def to_python(self, value) -> T.List[str]:
+        """
+            Convert the url segment to a list object
 
-    def to_python(self, value):
+        :param value:
+        :return:
+        """
         return value.split("/")
 
-    def to_url(self, value: T.List[str]):
+    def to_url(self, value: T.List[str]) -> str:
+        """
+        Join a list of strs into a directory path string
+
+        :param value:
+        :return:
+        """
         if isinstance(value, str):
             value = [value]
         return "/".join(value)
diff --git a/txweb/web_site.py b/txweb/web_site.py
new file mode 100644
index 0000000..7cee6cf
--- /dev/null
+++ b/txweb/web_site.py
@@ -0,0 +1,85 @@
+"""
+    Currently acts as a bridge for error handling.
+
+"""
+
+from __future__ import annotations
+
+#stdlib
+import typing as T
+# import pathlib
+# import copy
+
+# twisted imports
+from twisted.python import failure
+from twisted.web import server
+
+# txweb imports
+# import txweb
+# from txweb import resources as txw_resources
+from txweb.lib.str_request import StrRequest
+# from txweb.lib import view_class_assembler as vca
+from txweb.resources import RoutingResource
+# from txweb import http_codes as HTTP_Errors
+from txweb.log import getLogger
+
+
+log = getLogger(__name__)
+
+ResourceView = T.Type["_ResourceThing"]
+ErrorHandler = T.NewType("ErrorHandler", T.Callable[['Website', StrRequest, failure.Failure], None])
+# LIBRARY_TEMPLATE_PATH = pathlib.Path(txweb.__file__).parent / "templates"
+
+class WebSite(server.Site):
+    """
+        Public side of the web_views class collection.
+
+        Purpose: provide a hook for error handling and maybe a global template system
+
+    """
+    my_log = getLogger()
+    _errorHandler: ErrorHandler
+
+    def __init__(self, routing_resource=None, request_factory=StrRequest, siteErrorHandler=None):
+        routing_resource = routing_resource or RoutingResource()
+        request_factory = request_factory or StrRequest
+
+        # _RoutingSiteConnectors.__init__(self, routing_resource, requestFactory=request_factory)
+        super().__init__(routing_resource, requestFactory=request_factory)
+
+        self._errorHandler = siteErrorHandler
+        self._lastError = None
+
+        # self._before_request_render = None
+        # self._after_request_render = None
+
+
+    def processingFailed(self, request: StrRequest, reason: failure.Failure):
+        """
+            The crucial bridge that connects a request.processingFailed exception chain upward
+                towards the Txweb application instance for errorhandling.
+        :param request:
+        :param reason:
+        :return:
+        """
+
+        self._lastError = reason
+        # self.my_log.error("Handling exception: {reason!r}", reason=reason)
+
+        try:
+            self._errorHandler(request, reason)
+        except Exception as exc:
+            #Dear god wtf went wrong?
+            self.my_log.error("Exception {exc!r} occurred while handling {reason!r}", exc=exc, reason=reason)
+            raise
+
+    def setErrorHandler(self, func: ErrorHandler):
+        """
+            Kind of goofy, this should only be called by the Txweb application error handler so it can
+            snag the failure/exception that occurred in request.render
+        :param func:
+        :return:
+        """
+        self._errorHandler = func
+        return func
+
diff --git a/txweb/web_views.py b/txweb/web_views.py
deleted file mode 100644
index 29cb3bc..0000000
--- a/txweb/web_views.py
+++ /dev/null
@@ -1,196 +0,0 @@
-from __future__ import annotations
-
-#stdlib
-from logging import getLogger
-import pathlib
-import copy
-
-#Third party
-############
-# TODO remove this as a hardwired requirement?
-import jinja2
-
-# txweb imports
-import txweb
-from txweb import resources as txw_resources
-from txweb.lib.str_request import StrRequest
-from txweb.lib import view_class_assembler as vca
-from txweb.resources import RoutingResource
-from txweb import http_codes as HTTP_Errors
-
-
-
-
-# twisted imports
-from twisted.python import failure
-from twisted.web import server
-
-
-
-
-log = getLogger(__name__)
-
-import typing as T
-if T.TYPE_CHECKING:
-    # No executable intended for type hints only
-    import pathlib
-
-ResourceView = T.Type["_ResourceThing"]
-ErrorHandler = T.NewType("ErrorHandler", T.Callable[['Website', StrRequest, failure.Failure], None])
-
-LIBRARY_TEMPLATE_PATH = pathlib.Path(txweb.__file__).parent / "templates"
-
-class _RoutingSiteConnectors(server.Site):
-    """
-        Purpose: provide hooks to the RoutingResource assigned to self.resource
-    """
-    resource: RoutingResource
-
-    def add(self, route_str: str, **kwargs: T.Optional[T.Dict[str, T.Any]]) -> ResourceView:
-        """
-
-        :param route_str: A valid werkzeug routing url
-        :param kwargs: optional keyword arguments for werkzeug routing
-        :return:
-        """
-        return self.resource.add(route_str, **kwargs)
-
-    def add_file(self, route_str: str, filePath: str, defaultType="text/html") -> txw_resources.SimpleFile:
-        """
-        Just a simple helper for a common task of serving individual files
-
-        :param route_str: A valid URI route string
-        :param filepath: An absolute or relative path to a file to be served over HTTP
-        :param default_type: What content type should a file be served as
-        :return: twisted.web.static.File
-        """
-        return self.add_resource(route_str, txw_resources.SimpleFile(filePath, defaultType=defaultType))
-
-    def add_directory(self, route_str: str, dirPath: T.Union[str, pathlib.Path]) -> txw_resources.Directory:
-        # TODO pull add_directory OUT of RoutingResource
-        return self.resource.add_directory(route_str, dirPath)
-
-
-
-    def add_resource(self, route_str: str,
-                     rsrc: resource.Resource,
-                     **kwargs: T.Dict[str, T.Any]) -> ResourceView:
-        return self.resource.add(route_str, **kwargs)(rsrc)
-
-    def expose(self, route_str, **route_kwargs) -> T.Callable:
-        return vca.expose(route_str, **route_kwargs)
-
-
-class WebSite(_RoutingSiteConnectors, object):
-    """
-        Public side of the web_views class collection.
-
-        Purpose: provide a hook for error handling and maybe a global template system
-
-    """
-    _errorHandler: ErrorHandler
-
-    def __init__(self, routing_resource=None, request_factory=StrRequest, siteErrorHandler=None):
-        routing_resource = routing_resource or RoutingResource()
-        request_factory = request_factory or StrRequest
-
-        super(WebSite, self).__init__(routing_resource, requestFactory=request_factory)
-        self._errorHandler = siteErrorHandler or WebSite.defaultSiteErrorHandler
-        self._lastError = None
-
-        self._before_request_render = None
-        self._after_request_render = None
-
-    def processingFailed(self, request: StrRequest, reason: failure.Failure):
-
-        self._lastError = reason
-        log.error(f"Handling exception: {reason!r}")
-
-        try:
-            self._errorHandler(request, reason)
-        except Exception as exc:
-            #Dear god wtf went wrong?
-            log.exception(f"Exception occurred while handling {reason!r}")
-            raise
-
-    def addErrorHandler(self, func: ErrorHandler):
-        self._errorHandler = func
-        return func
-
-    @staticmethod
-    def defaultSiteErrorHandler(request: StrRequest, reason: failure.Failure):
-
-        site = request.site
-
-        template_path = (LIBRARY_TEMPLATE_PATH / "debug_error.html")  # type: pathlib.Path
-        assert template_path.exists and template_path.is_file(), f"Unable to find library template: {template_path}"
-        template = jinja2.Template(template_path.read_text())
-
-        traceback = reason.getTraceback() if site.displayTracebacks else None
-
-        if issubclass(reason.type, HTTP_Errors.HTTP3xx):
-            code = reason.value.code
-            message = "HTTP Redirect"
-            buffer = template.render(code=code, message=message, traceback=traceback)
-            request.setHeader("Location", reason.value.redirect)
-        elif traceback is not None and isinstance(reason.type, HTTP_Errors.HTTP405):
-            traceback = None
-        elif issubclass(reason.type, HTTP_Errors.HTTPCode):
-            code = reason.value.code
-            message = reason.value.message if traceback else "Error"
-            buffer = template.render(code=code, message=message, traceback=traceback)
-        else:
-            code = 500
-            message = "Processing aborted"
-            buffer = template.render(code=code, message=message, traceback=traceback)
-            reason.raiseException()
-
-        request.setHeader(b'content-type', b"text/html")
-        request.setHeader(b'content-length', str(len(buffer)).encode("utf-8"))
-        request.setResponseCode(code)
-        request.write(buffer)
-        request.finish()
-
-    def call_before_request_render(self, func):
-        self._before_request_render = func
-        return func
-
-    def after_resource_fetch(self, func):
-        self._after_request_render = func
-        return func
-
-    def getResourceFor(self, request: StrRequest):
-        """
-        This is probably the least convoluted way to manipulate the
-        current http request.
-        """
-
-        if self._before_request_render is not None:
-            request.add_before_render(self._before_request_render)
-
-        if self._after_request_render is not None:
-            request.add_after_render(self._after_request_render)
-
-        return server.Site.getResourceFor(self, request)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/txweb/websocket_static_libraries/cookies.js b/txweb/websocket_static_libraries/cookies.js
new file mode 100644
index 0000000..3da50cd
--- /dev/null
+++ b/txweb/websocket_static_libraries/cookies.js
@@ -0,0 +1,26 @@
+
+export {parse_cookies};
+
+function parse_cookies(raw_cookies) {
+
+    raw_cookies = raw_cookies.split(";")
+    let cookies = {}
+
+    if (raw_cookies == "") {
+        return cookies;
+    }
+
+    for(let raw_cookie of raw_cookies) {
+        raw_cookie = raw_cookie.trim();
+        let key_name, key_value
+        [key_name, key_value] = raw_cookie.split("=", 2);
+        cookies[key_name.trim()] = key_value.trim();
+        console.log(key_name, key_value);
+    }
+
+    return cookies;
+}
+
+function set_cookie(name, value, exp) {
+    alert("no");  //set cookies via server side
+}
\ No newline at end of file
diff --git a/txweb/websocket_static_libraries/deferred.js b/txweb/websocket_static_libraries/deferred.js
new file mode 100644
index 0000000..d7c1634
--- /dev/null
+++ b/txweb/websocket_static_libraries/deferred.js
@@ -0,0 +1,27 @@
+export {Deferred};
+
+class Deferred {
+
+    constructor() {
+        this.cbs = [];
+        this.ebs = [];
+        this.fired = false;
+    }
+
+    fire(result) {
+        if (this.fired == true) {
+
+        }
+        for(let pos in this.cbs) {
+            let func = this.cbs[pos];
+            console.log(pos, func)
+            result = func(result);
+        }
+    }
+
+    then(callback) {
+        this.cbs.push(callback);
+    }
+
+}
+
diff --git a/txweb/websocket_static_libraries/library.js b/txweb/websocket_static_libraries/library.js
new file mode 100644
index 0000000..f015d5c
--- /dev/null
+++ b/txweb/websocket_static_libraries/library.js
@@ -0,0 +1,7 @@
+import {Deferred} from "./deferred.js";
+import {ResilientSocket} from "./resilient_socket.js";
+
+export {ResilientSocket, Deferred};
+
+
+
diff --git a/txweb/websocket_static_libraries/resilient_socket.js b/txweb/websocket_static_libraries/resilient_socket.js
new file mode 100644
index 0000000..d0424c1
--- /dev/null
+++ b/txweb/websocket_static_libraries/resilient_socket.js
@@ -0,0 +1,206 @@
+
+export {ResilientSocket};
+import {Deferred} from "./deferred.js";
+
+
+async function sleep(time) {
+    return new Promise(resolve => {
+        window.setTimeout(resolve, time);
+    })
+}
+
+
+class ResilientSocket {
+    constructor(conString, {debug=false} = {}) {
+        this.conString = conString;
+        this.closed = true;
+        this.socket = null;
+        this.callerID = 0;
+        this.pending = {};
+
+        //misc flags
+        this.debug = debug;
+
+        this.retries = 0;  //handled on server side firewall but to keep the client from freezing, limit it on this side
+        this.retryLimit = 3;
+
+
+        this.endpoints = {};
+
+        this.connect();
+    }
+
+    async connect() {
+
+        if(this.retries > this.retryLimit) {
+            console.error("Number of connect retries reached limit!");
+            // TODO add a on_timeout or on_limit hook so the application can handle
+            // connection retries failing.
+        }
+
+        if(this.debug){
+            console.debug("connecting resilient");
+        }
+
+        this.socket = new WebSocket(this.conString);
+        this.socket.addEventListener("message", this.receiveMsg.bind(this));
+        this.socket.addEventListener("close", this.onclose.bind(this));
+        this.socket.addEventListener("open", this.onopen.bind(this));
+        this.socket.addEventListener("error", this.onerror.bind(this));
+
+        return new Promise((resolve, reject)=>{
+            const self = this;
+            async function on_open(evt){
+                if(self.debug){
+                    console.debug("Connection opened!", self.socket.readyState);
+                }
+
+                if(self.socket.readyState != self.socket.OPEN) {
+                    await sleep(1000);
+                }
+                self.retries = 0;
+                self.closed = false;
+                self.socket.removeEventListener("open", this);
+                self.socket.removeEventListener("error", on_error);
+                resolve(evt);
+            }
+
+            function on_error(evt) {
+                self.closed = true;
+                self.socket.removeEventListener("error", this);
+                reject(evt);
+            }
+
+            this.socket.addEventListener("open", on_open, {once: true});
+            this.socket.addEventListener("error", on_error, {once: true});
+
+
+
+        });
+
+    }
+
+    onopen() {
+        console.log("rs.onopen called");
+        this.closed = false;
+        this.retries = 0;
+    }
+
+    onclose() {
+        this.closed = true;
+    }
+
+    onerror(evt) {
+        console.log(evt);
+        this.retries += 1;
+
+    }
+
+    first_open(handler) {
+        this.socket.addEventListener("open", handler, {once:true});
+    }
+
+    async _sendRaw(msg) {
+        //Check if disconnected
+
+        if(this.socket.readyState != this.socket.OPEN) {
+            const result = await this.connect();
+            console.log("this.connect", result, this.socket.readyState);
+            this.socket.send(msg);
+        } else {
+            this.socket.send(msg);
+        }
+
+        if(this.debug){
+            console.debug("Sent", msg);
+        }
+
+
+
+    }
+
+    async sendMsg(endpoint, args) {
+        let msg = {type:"tell", endpoint:endpoint, args: args};
+        this._sendRaw(JSON.stringify(msg));
+    }
+
+    async sendResponse(caller_id, result){
+        let msg = {type:"response", caller_id:caller_id, result:result}
+        this._sendRaw(JSON.stringify(msg));
+    }
+
+    async receiveMsg(msg) {
+        if(this.debug) {
+            console.debug("received", msg);
+        }
+
+        let data = JSON.parse(msg.data);
+        if (data['type'] == "response") {
+            let d = this.pending[data['caller_id']];
+            delete this.pending[data['caller_id']];
+            d.fire(data.result);
+
+        }
+        else if(data['type'] == "tell") {
+            if (this.endpoints[data['endpoint']] != undefined) {
+                const endpoint = this.endpoints[data['endpoint']];
+                await endpoint(data.args);
+            } else {
+                console.error(`Tell the user that ${data['endpoint']} doesn't exist`, data);
+            }
+        } else if(data['type'] == "ask") {
+            if(this.endpoints[data['endpoint']] != undefined) {
+                const endpoint = this.endpoints[data['endpoint']];
+                let result = await endpoint(data.args);
+                await this.sendResponse(data['caller_id'], result);
+            } else{
+                console.error(`User asked for ${data['endpoint']} that doesn't exist`, data);
+            }
+
+        } else {
+            console.error("unhandled message", data);
+        }
+    }
+
+    async ask(endpoint, args) {
+        /**
+         * Expects a response using Deferred for callbacks
+         */
+        console.debug("Asking", endpoint);
+
+        let d = new Deferred()
+        this.callerID += 1;
+        this.pending[this.callerID] = d;
+
+        let msg = {type:"ask", endpoint:endpoint, args: args, caller_id: this.callerID};
+        this._sendRaw(JSON.stringify(msg));
+
+        return new Promise(function(resolve){
+            d.then(resolve);
+        });
+    }
+
+    async a_ask(endpoint, args, timeout) {
+        return this.ask(endpoint, args);
+    }
+
+    call(endpoint, args) {
+        /**
+         * Tell the server something has happened but don't expect a direct response.
+         *
+         * @type {{args: *, endpoint: *, type: string}}
+         */
+        let msg = {type:"call", endpoint:endpoint, args: args};
+        this._sendRaw(JSON.stringify(msg));
+    }
+
+    tell(endpoint, args) {
+        let msg = {type:"tell", endpoint: endpoint, args: args};
+        this._sendRaw(JSON.stringify(msg));
+    }
+
+    register(endpoint, func) {
+        this.endpoints[endpoint] = func
+    }
+
+}
\ No newline at end of file