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
+
+
+
+
+
+
+
+
+
+
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
-
-
- {request.form.get('word')!r}
-
-
- {request.args.get('checked','off')!r}
-
-
-
-
-
+ 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
+
+
+ {request.form.get('word')!r}
+
+
+ {request.args.get('checked','off')!r}
+
+
+
+
+
+ """
+
+ 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 @@