Skip to main content

Atila Framework

Project description

Atila

Atila is simple and minimal framework integrated with Skitai App Engine. It is the easiest way to make backend API services.

# serve.py

import atila

app = atila.Atila (__name__)

@app.route ("/")
def index (was):
  return "Hello, World"

if __mame__ == "__main__":
  import skitai

  skitai.mount ("/", app)
  skitai.run (port = 5000)

And run,

python3 serve.py

And you can see Hello, World at http://localhost:5000.

Here’s a more practical example:

@app.route ("/<int:uid>/photos", methods = ["GET", "DELETE", "POST", "OPTIONS"])
@app.permission_required ()
def photos (was, uid, **DATA):
  uid = uid == "me" and was.request.JWT ["uid"] or uid

  with was.db ("@mydb") as db:
    if was.request.method == "GET":
      rows = db.select ("photo").filter (uid = uid).execute ().fetch ()
      return was.API (rows = rows) # [ {id: 1, ...}, ... ]

    elif was.request.method == "DELETE":
      db.delete ("photo").filter (uid = uid).execute ().commit ()
      return was.API ("205 No Content")

    elif was.request.method == "POST":
      if not DATA.get ("title"):
        raise was.Error ("400 Bad Request", "title required")
      DATA ["uid"] = uid
      row = db.insert ("photo").data (**DATA).returning ("id").execute ().one ()
      return was.API ("201 Created", id = row.id)

Table of Contents

Important Notice

CAUTION: Atila is base on WSGI but can be run only with Skitai App Engine.

This means if you make your app with Atila, you have no choice but Skitai as WSGI app server. And Atila’s unique and unconventional style may become very hard work to port to other framework.

I am currently enjoying to develop both Skitai and Atila, but no one can expect future.

So you should think twice before you decide to use this.

Installation

Requirements

Python 3.5+ PyPy3

Installation

Atila and other core base dependent libraries is developing on single milestone, install/upgrade all please. Otherwise it is highly possible to meet some errors.

With pip

pip3 install -U atila

With git

git clone https://gitlab.com/hansroh/atila.git
cd atila
pip3 install -e .

Core App Options

These are for later quick copying.

Debug Options

  • debug = False
  • use_reloader = False

CORS Options

  • access_control_allow_origin = None: list of origin
  • access_control_max_age = 0

Session/Authenticating Options

  • authenticate = None: basic | digest | bearer
  • securekey = None: string for encrypted session cookie
  • session_timeout = None

Sub Module Mount Options

  • enable_namespace = True

    Default value has been changed in version 0.7: False -> True

    If you didn’t use this option with True under version 0.7 you may set False in version 0.7 for for compatiblity.

    Also DO NOT use this option with False if not for compatiblity reason.

  • auto_mount

    Deprecated in version 0.7

    If you call app.mount (), this option will be disabled automatically. Otherwise Atila try to mount automatically all sub modules has __mount__ ().

Default App Configuration

Below configs are new in version 0.8.

app.config.STATIC_URL = '/'
app.config.MEDIA_URL = '/media/'
app.config.MINIFY_HTML = None | 'strip' | 'minify'
app.config.JSON_ENCODER = 'utcoffset'
app.config.PRETTY_JSON = False # if True, 2 spaces indent format

To use minify feature, you must install ‘css_html_js_minify’.

Note: below version 0.8, JSON_ENCODER works as app.config.JSON_ENCODER = ‘str’ which is str (datetime) with system time zone. If you migrate to above version 0.8 and you want keep this format, you shoud specify app.config.JSON_ENCODER = ‘str’.

App Examples

You can simply visit Atila app example for sightseeing.

Atila with Skitai App Engine

Simple App

from atila import Atila
app = Atila(__name__)

...

@app.route ("/")
def index (was):
  ...
  return was.response ("200 OK", ...)

if __name__ == "__main__":
  import skitai

  with skitai.preference () as pref:
    pref.use_reloader = True
    skitai.mount ('/', './static')
    skitai.mount ('/', app, 'app', pref)

  skitai.run ()

If atila app exists seprated file:

# serve.py

if __name__ == "__main__":
  import skitai
  from myapp import wsgi

  with skitai.preference () as pref:
    pref.use_reloader = True
    pref.set_static ('/', './static')
    skitai.mount ('/',wsgi, pref)
  skitai.run ()

Resource Structure For Larger App

If your app is simple, it can be made into single app.py and templates and static directory.

from atila import Atila

app = Atila(__name__)

app.use_reloader = True
app.debug = True

@app.route ("/")
def index (was):
  ...
  return was.response ("200 OK", ...)

if __name__ == "__main__":
  import skitai

  with skitai.preference () as pref:
    pref.use_reloader = True
    skitai.mount ('/', './static')
    skitai.mount ('/', app, pref)
  skitai.run ()

And run,

python3 app.py

But Your app is more bigger, it will be hard to make with single app file. Then, you can make services directory to seperate your app into several categories.

myapp/
  services/
  resources/
  templates/
  static/
  wsgi.py
serve.py

Fisrt of all, serve.py

import skitai
import myapp

with skitai.preference () as pref:
  pref.use_reloader = True
  skitai.mount ('/', myapp, pref)
skitai.run (ip = '0.0.0.0', port = 5000)

myapp/__init__.py

import skitai
from . import services

def __config__ (pref):
  pref.config.MYSETTTING = '...'
  pref.set_static ('/', os.path.join (os.path.dirname (__file__), 'static'))
  pref.set_media ('/media', '/var/tmp/s3-queue')

  skitai.mount (
    '/resources',
    os.path.join (os.path.dirname (__file__), 'resources')
  )

def __setup__ (app, mntopt):
  # app.mount () SHOULD be called in __setup__ ()
  app.mount ('/', services)

service/__init__.py

from . import feature1
from . import feature2

def __setup__ (app, mntopt):
  app.mount ('/feature1', feature1)
  app.mount ('/feature2', feature2)

def __mount__ (app, mntopt):
  @app.route ('/')
  def index (was):
    return '<a href="{}">Feature 1</a> | <a href="{}">Feature 2</a>'.format (
      was.urlfor ('feature1.index'),
      was.urlfor ('feature2.index')
    )

  @app.route ('/contactus')
  def contactus (was):
    return 'Contact Us'

feature1.py or feature1/__init__.py

def __mount__ (app, mntopt):
  @app.route ('') # this is mapped as '/feature1'
  def index (was):
    return 'Feature 1 Index'

  @app.route ('/intro')  # this is mapped as '/feature1/intro'
  def contactus (was):
    return 'Feature 1 Intro'

Now, in your command line,

python3 serve.py

Request Hanlding with Atila

Runtime App Preference

New in skitai version 0.26

Usally, your app preference setting is like this:

from atila import Atila

app = Atila(__name__)

app.use_reloader = True
app.debug = True
app.config ["prefA"] = 1
app.config ["prefB"] = 2

Skitai provide runtime preference setting.

import skitai

with skitai.preference () as pref:
  pref.use_reloader = True
  pref.debug = True
  pref.config ["prefA"] = 1
  pref.config.prefB = 2
  skitai.mount ("/v1", "app_v1/app.py", "app", pref)
skitai.run ()

Above pref’s all properties will be overriden on your app.

Also there’s shortcut for setting static and media url,

with skitai.preference () as pref:
  pref.set_static ('/static', 'myapp/static')
  pref.set_media ('/media', '/var/www-root/media')

As a result, below 4 key will be set and mount directories automatically.

  • pref.config.STATIC_ROOT
  • pref.config.STATIC_URL
  • pref.config.MEDIA_ROOT
  • pref.config.MEDIA_URL

Runtime preference can be used with skitai initializing or complicated initializing process for your app.

You can create __init__.py at same directory with app.py. And bootstrap () function is needed.

__init__.py

import skitai
import atila

def __config__ (pref):
  skitai.register_states ('tbl.test')

  with open (pref.config.urlfile, "r") as f:
    pref.config.urllist = []
    while 1:
      line = f.readline ().strip ()
      if not line: break
      pref.config.urllist.append (line.split ("  ", 4))

More About Atila App Initialization

Note: There’are two important things for app.__init__.

  • add skitai.register_states () if you need state management. Inter process state sharing objects should be defined before running Skitai.

Access Atila App

You can access all Atila object from app.

  • app.debug
  • app.use_reloader
  • app.config # use for custom configuration like
  • app.config.my_setting = 1
  • app.securekey
  • app.session_timeout = None
  • app.authorization = “digest”
  • app.authenticate = False
  • app.realm = None
  • app.users = {}
  • app.jinja_env
  • app.build_url () is equal to was.urlfor ()

Currently app.config has these properties and you can reconfig by setting new value:

  • app.config.MAX_UPLOAD_SIZE = 256 * 1024 * 1024
  • app.config.MEDIA_URL = ‘/media/’
  • app.config.STATIC_URL = ‘/’
  • app.config.MINIFY_HTML = False
  • app.config.MAX_UPLOAD_SIZE = 256 * 1024 * 1024
  • app.config.JSON_ENCODER = ‘utcoffset’
  • app.config.PRETTY_JSON = False

Debugging and Reloading App

If debug is True, all errors even server errors is shown on both web browser and console window, otherhwise shown only on console.

If use_reloader is True, Atila will detect file changes and reload app automatically, otherwise app will never be reloaded.

from atila import Atila

app = Atila (__name__)
app.debug = True # output exception information
app.use_reloader = True # auto realod on file changed

Hot Reloading

Atila use hot reloading which need not restart worker process. It use importlib.reload if detected file changing.

But it is recommended restart developing server forcely after significant source changes or long-run.

Kill Switch

Please see, –devel and –silent options of Skitai App Engine.

Routing

Basic routing is like this:

@app.route ("/hello")
def hello_world (was):
  return was.render ("hello.htm")

For adding some restrictions:

@app.route ("/hello", methods = ["GET"], content_types = ["text/xml"])
def hello_world (was):
  return was.render ("hello.htm")

And you can specifyt multiple routing,

@app.route ("/hello", mehotd = ["POST"])
@app.route ("/")
def hello_world (was):
  return was.render ("hello.htm")

If method is not GET, Atila will response http error code 405 (Method Not Allowed), and content-type is not text/xml, 415 (Unsupported Content Type).

And here’s a notalble routing rule.

@app.route ("")
def hello_world (was):
  return was.render ("hello.htm")

This app is mounted to “/sub” on skitai, /sub URL is valid but “/sub/” will return 404 code.

On the other hand,

@app.route ("/")
def hello_world (was):
  return was.render ("hello.htm")

“/sub” will return 301 code for “/sub/” and “/sub/” is valid URL.

For blocking routes,

app.unroute ("/examples/*", 404)

App and Request context

  • app.r is current request context namespace.
  • app.g is global app context namespace.

Request

Reqeust object provides these methods and attributes:

  • was.request.method # upper case GET, POST, …
  • was.request.command # lower case get, post, …
  • was.request.uri
  • was.request.version # HTTP Version, 1.0, 1.1, 2.0, 3.0
  • was.request.scheme # http or https
  • was.request.headers # case insensitive dictioanry
  • was.request.body # bytes object
  • was.request.args # dictionary merged with url, query string, form data and JSON
  • was.request.routed # routed function
  • was.request.routable # {‘methods’: [“POST”, “OPTIONS”], ‘content_types’: [“text/xml”], ‘options’: {…}, ‘mntopt’: {…}}
  • was.request.acceptables # {‘text/html’: {‘q’: ‘0.9’}}
  • was.request.acceptable (media) # check if acceptable media type by given media
  • was.request.split_uri () # (script, param, querystring, fragment)
  • was.request.json () # decode request body from JSON
  • was.request.form () # decode request body to dict if content-type is form data
  • was.request.dict () # decode request body as dict if content-type is compatible with dict - form data or JSON
  • was.request.get_header (“content-type”) # case insensitive
  • was.request.get_headers () # retrun header all list
  • was.request.get_body ()
  • was.request.get_scheme () # http or https
  • was.request.get_remote_addr ()
  • was.request.get_user_agent ()
  • was.request.get_content_type ()
  • was.request.get_main_type ()
  • was.request.get_sub_type ()

Getting Parameters

Atila parameters are comceptually seperated 3 groups: URL, query string and body.

Below explaination may be a bit complicated but it is enough to remember 3 things:

1. Atila resource parameters can be defined as function arguments and use theses native Python function arguments.

  1. Also you can access parameter groups by origin:
  • was.request.DEFAULT: default arguments of your resource
  • was.request.URL: url query string
  • was.request.FORM
  • was.request.JSON
  • was.request.DATA: automatically choosen one of was.request.FORM or was.request.JSON by content-type header of request
  • was.request.ARGS: eventaully was.request.ARGS contains all parameters of all origins including was.request.DEFAULT

Getting URL Parameters

URL Parameters should be arguments of resource.

@app.route ("/episode/<int:id>")
def episode (was, id):
  return id
# http://127.0.0.1:5000/episode

for fancy url building, available param types are:

  • int: integers and INCLUDING ‘me’, ‘notme’ and ‘new’
  • path: /download/<int:major_ver>/<path>, should be positioned at last like /download/1/version/1.1/win32
  • If not provided, assume as string. and all space will be replaced to “_”

At your template engine, you can access through was.request.PARAMS [“id”].

It is also possible via keywords args,

@app.route ("/episode/<int:id>")
def episode (was, \*\*karg):
  retrun was.request.ARGS.get ("id")
# http://127.0.0.1:5000/episode/100

You can set default value to id,

@app.route ("/episode/<int:id>", methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"])
def episode (was, id = None):
  if was.request.method == "POST" and id is None:
    ...
    return was.API (id = new_id)
  return ...

It makes this URL working,

http://127.0.0.1:5000/episode

And was.urlfor will behaive like as below,

 was.urlfor ("episode")
 >> /episode

was.urlfor ("episode", 100)
 >> /episode/100

Note that this does not works for root resource,

@app.route ("/<int:id>", methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"])
def episode (was, id = None):
  if was.request.method == "POST" and id is None:
    ...
    return was.API (id = new_id)
  return ...

By above code, http://127.0.0.1:5000/ will not work. You should define “/” route.

Query String Parameters

qiery string parameter can be both resource arguments but needn’t be.

@app.route ("/hello")
def hello_world (was, num = 8):
  return num
# http://127.0.0.1:5000/hello?num=100

It is same as these,

@app.route ("/hello")
def hello_world (was):
  return was.request.ARGS.get ("num")

@app.route ("/hello")
def hello_world (was, **url):
  return url.get ("num")
  # of
  return was.request.URL.get ("num)

Above 2 code blocks have a significant difference. First one can get only ‘num’ parameter. If URL query string contains other parameters, Skitai will raise 508 Error. But 2nd one can be any parameters.

Getting Form/JSON Parameters

Getting form is not different from the way for url parameters, but generally form parameters is too many to use with each function parameters, can take from single args **form or take mixed with named args and **form both.

if request header has application/json

@app.route ("/hello")
def hello (was, **form):
  return "Post %s %s" % (form.get ("userid", ""), form.get ("comment", ""))

@app.route ("/hello")
def hello_world (was, userid, **form):
  return "Post %s %s" % (userid, form.get ("comment", ""))

Note that for receiving request body via arguments, you specify keywords args like **karg or specify parameter names of body data.

If you want just handle POST body, you can use was.request.json () or was.request.form () that will return dictionary object.

Getting Composed Parameters

You can receive all type of parameters by resource arguments. Let’s assume yotu resource URL is http://127.0.0.1:5000/episode/100?topic=Python.

@app.route ("/episode/<int:id>")
def hello (was, id, topic):
  pass

if URL is http://127.0.0.1:5000/episode/100?topic=Python with Form/JSON data {“comment”: “It is good idea”}

@app.route ("/episode/<int:id>")
def hello (was, id, topic, comment):
  pass

Note that argument should be ordered by:

  • URL parameters
  • URL query string
  • Form/JSON body

And note if your request has both query string and form/JSON body, and want to receive form paramters via arguments, you should receive query string parameters first. It is not allowed to skip query string.

Also you can use keywords argument.

@app.route ("/episode/<int:id>")
def hello (was, id, \*\*karg):
  karg.get ('topic')

Note that **karg is contains both query string and form/JSON data and no retriction for parameter names.

was.requests.args is merged dictionary for all type of parameters. If parameter name is duplicated, its value will be set to form of value list (But If parameters exist both URL and form data, form data always has priority. It means URL parameter will be ignored).

Then simpletst way for getting parameters, use was.request.args.

@app.route ("/episode/<int:id>")
def hello (was, id):
  was.request.args.get ('topic')

Testing Parameters

For parameter checking,

@app.route ("/test")
@app.inspect ("ARGS", ["id"], ints = ["id"])
def test (was, id):
  return was.render ("test.html")

‘id’ is required and sholud be int type.

Spec is,

@app.inspect (
  scope, required = None, ints = None, floats = None,
  emails = None, uuids = None, nones = None, lists = None,
  strings = None, booleans = None, dicts = None,
  oneof = None, manyof = None,
  notags = None, safes = None,
  **kargs
)
  • notags: replace all < and >
  • safes: reject if find XSS possible string

scope can be:

  • URL
  • FORM
  • JSON
  • ARGS: default, all of above
  • GET
  • DELETE
  • PATCH
  • POST
  • PUT
@app.route ("/1")
@app.inspect ("GET", ints = ['offset', 'limit'])
@app.inspect ("PUT", ['id'])
def index6 (was, offset = 0, limit = 10, **DATA):
    assert isinstance (limit, int) # limit converted into int type
    if was.request.method == 'PUT':
      current = DATA [id]

Also you can use specify each paramenter types.

@app.route ("/<int:id>")
@app.inspect (offset = int, prodtype = [int, str])
def index6 (was, id, offset, prodtype):
    ...

You can test more detail using kargs.

@app.route ("/1")
@app.inspect ("ARGS", a__gte = 5, b__between = (-4, -1), c__in = (1, 2))
def index6 (was):
    return ""
  • __neq
  • __gt, __gte
  • __lt, __lte
  • __in, __notin
  • __between
  • __startswith
  • __endswith
  • __notstartwith
  • __notendwith
  • __contains
  • __notcontain

Checking parameter with regular expression,

@app.route ("/2")
@app.reqinspectuire ("ARGS", a = re.compile ("^hans"))
def index7 (was):
    return ""

Checking parameter length, use __len:

@app.route ("/3")
@app.inspect ("ARGS", a__len__between = (4, 8))
def index7 (was):
    return ""

Checking JSON nodes, use triple under bars for key and double ones for array indexing.

@app.route ("/15")
@app.require (data___scores__1__gte = 10)
def index15 (was, d):
    return ""
# {"data": {"scores": [9, 12, 16]}} will be passed

Pre-Defined Parameter Values

‘me’, ‘notme’ is special prameter value used by authentication.

  • ‘me’ can be resolved into user ID on request handling
  • ‘notme’ can ignore specific user ID for administative search purpose, BUT for your safey, ‘notme’ is allowed only with “GET” request
  • ‘new’ is dummy value especially with “POST” method. But it is not restricted by methods. Maybe you can use ‘new’ with ‘GET’ for getting newlest items.
@app.route ("/episodes/<int:uid>")
@app.permission_required (uid = ["staff"])
def episodes (uid):
  ...

Now paramter ‘uid’ is bound with permission.

Belows are all valid URI.

  • GET /episodes/me, if request user have any permission
  • DELETE /episodes/me if request user have any permission
  • GET /episodes/4, if request user have staff permission, else raise 403 error
  • PATCH /episodes/4, if request user have staff permission, else raise 403 error
  • GET /episodes/new, if request user have staff permission, else raise 403 error
  • POST /episodes/new, if request user have staff permission, else raise 403 error
  • GET /episodes/notme, if request user have staff permission, else raise 403 error

But belows are all invalid and HTTP 421 error will be raised for your safety reason. If these’re allowed, there is lot of danger delete/update all users (or all rows of database table).

  • DELETE /episodes/notme
  • POST /episodes/notme
  • PATCH /episodes/notme
  • PUT /episodes/notme

Obviously, I am sure you already know exact resource ID for above tasks.

Make Your Own Rule

The way to get parameters is little messy. But I want to try to make more pythonic style. Even all routed method can be called by another non app functions.

Initially I want to use like this.

@app.route ("/pets/<kind>")
def pets (was, kind, limit, offset = 0, **JSON):
  ...

It can be requested by requests.

requests.post (
  "http://localhost/pets/dog?limit=10",
  json = {"area": "LA"}
)

If you need to check the origin of parameters, require decorator is useful.

@app.route ("/pets/<kind>")
@app.inspect ("JSON", ["area"])
def pets (was, kind, limit, offset = 0, **JSON):
  ...

That’s just my opinion.

Building URL

If your app is mounted at “/math”,

@app.route ("/add")
def add (was, num1, num2):
  return int (num1) + int (num2)

app.build_url ("add", 10, 40) # returned '/math/add?num1=10&num2=40'

# BUT it's too long to use practically,
# was.urlfor is acronym for app.build_url
was.urlfor ("add", 10, 40) # returned '/math/add?num1=10&num2=40'
was.urlfor ("add", 10, num2=60) # returned '/math/add?num1=10&num2=60'

#You can use function directly as well,
was.urlfor (add, 10, 40) # returned '/math/add?num1=10&num2=40'

@app.route ("/hello/<name>")
def hello (was, name = "Hans Roh"):
  return "Hello, %s" % name

was.urlfor ("hello", "Your Name") # returned '/math/hello/Your_Name'

Basically, was.urlfor is same as Python function call.

If your module is,

# services/boards/index.py

def __mount__ (app):
  @app.route ('/search'):
  def search (was, q):
    ...

You can make url like this,

was.urlfor ('boards.index.search', q = 'news')

It is important that your resource contains ‘services’, it will be remove by services from function. For example, myapp.services.index is identified as index, myapp.services.events.list is identified as events.list.

But it does not contain ‘services’, full module name + function name is used.

Building URL by Updating Parameters Partially

New in skitai version 0.27

@app.route ("/navigate")
def navigate (was, limit = 20, pageno = 1):
  return ...

If this resource was requested by /naviagte?limit=100&pageno=2, and if you want to make new resource url with keep a’s value (=100), you can make URL like this,

was.urlfor ("navigate", was.request.args.limit, 3)

But you can update only changed parameters partially,

was.urlpatch ("add", pageno = 3)

Parameter a’s value will be kept with current requested parameters. Note that was.urlpatch can be recieved keyword arguments only except first resource name.

was.urlpatch is used changing partial parameters (or none) based over current parameters.

Building Base URL without Parameters

New in skitai version 0.27

Sometimes you need to know just resource’s base path info - especially client-side javascript URL building, then use was.basepath.

@app.route ("/navigate")
def navigate (was, limit, pageno = 1):
  return ...
was.basepath ("navigate")
>> return "/navigate"

For example, in your VueJS template,

<a :href="'{{ was.basepath ('navigate') }}?limit=' + limit_option + '&pageno=' + (current_page + 1)">Next Page</a>

Note that base path means for fancy Url,

@app.route ("/user/<id>")
>> base path is "/user/"

@app.route ("/user/<id>/pat")
>> base path is "/user/"

Response

Basically, just return contents.

@app.route ("/hello")
def hello_world (was):
  return was.render ("hello.htm")

If you need set additional headers or HTTP status,

@app.route ("/hello")
def hello (was):
  return was.response ("200 OK", was.render ("hello.htm"), [("Cache-Control", "max-age=60")])

def hello (was):
  return was.response (
    body = was.render ("hello.htm"),
    headers = [("Cache-Control", "max-age=60")]
  )

def hello (was):
  was.response.set_header ("Cache-Control", "max-age=60")
  return was.render ("hello.htm")

Above 3 examples will make exacltly same result.

Sending specific HTTP status code,

def hello (was):
  return was.response ("404 Not Found", was.render ("err404.htm"))

def hello (was):
  # if body is not given, automaticcally generated with default error template.
  return was.response ("404 Not Found")

If app raise exception, traceback information will be displayed only app.debug = True. But you intentionally send it inspite of app.debug = False:

# File
@app.route ("/raise_exception")
def raise_exception (was):
  try:
    raise ValueError ("Test Error")
  except:
    return was.response ("500 Internal Server Error", exc_info = sys.exc_info ())

If you use custom error handler, you can set detail explaination to error [“detail”].

@app.default_error_handler
def default_error_handler (was, error):
  return was.render ("errors/default.html", error = error)

def error (was):
  return was.response.with_explain ('503 Serivce Unavaliable', "Please Visit On Thurse Day")

You can return various objects.

# File
@app.route ("/streaming")
def streaming (was):
  return was.response ("200 OK", open ("mypicnic.mp4", "rb"), headers = [("Content-Type", "video/mp4")])

# Generator
def build_csv (was):
  def generate():
    for row in iter_all_rows():
      yield ','.join(row) + '\n'
  return was.response ("200 OK", generate (), headers = [("Content-Type", "text/csv")])

All available return types are:

  • String, Bytes, Unicode
  • File-like object has ‘read (buffer_size)’ method, optional ‘close ()’
  • Iterator/Generator object has ‘next() or _next()’ method, optional ‘close ()’ and shoud raise StopIteration if no more data exists.
  • Something object has ‘more()’ method, optional ‘close ()’
  • Classes of skitai.lib.producers
  • List/Tuple contains above objects
  • XMLRPC dumpable object for if you want to response to XMLRPC

The object has ‘close ()’ method, will be called when all data consumed, or socket is disconnected with client by any reasons.

  • was.response (status = “200 OK”, body = None, headers = None, exc_info = None)
  • was.response.throw (status = “200 OK”): abort handling request, generated contents and return http error immediatly
  • was.API (__data_dict__ = None, **kargs): return api response container
  • was.Fault (status = “200 OK”,*args, **kargs): shortcut for was.response (status, was.API (…)) if status code is 2xx and was.response (status, was.Fault (…))
  • was.response.traceback (msg = “”, code = 10001, debug = ‘see traceback’, more_info = None): return api response container with setting traceback info
  • was.response.set_status (status) # “200 OK”, “404 Not Found”
  • was.response.get_status ()
  • was.response.set_headers (headers) # [(key, value), …]
  • was.response.get_headers ()
  • was.response.set_header (k, v)
  • was.response.get_header (k)
  • was.response.del_header (k)
  • was.response.hint_promise (uri) # New in skitai version 0.16.4, only works with HTTP/2.x and will be ignored HTTP/1.x

Cache controlling,

  • was.response.set_etag (identifier, max_age = 0, as_etag = False)
  • was.response.set_mtime (mtime, length = None, max_age = 0)
  • was.response.set_etag_mtime (identifier, mtime, length = None, max_age = 0, as_etag = False)

HTTP Exception

Abort immediatly and send HTTP eroor content.

@app.route ("/<filename>")
def getfile (was, filename):
  if not os.path.isfile (filename):
    raise was.Error ("404 Not Found", "{} not exists".format (filename))
  return was.File (filename)

Using assert, you can quick send HTTP Error.

@app.route ("/<filename>")
def getfile (was, filename):
  assert filename.endswith ('.png'), was.Error ("400 Not My Fault", 'filename must be end with png')
  return was.File (filename)

Data Streaming

@app.route ("/stream")
def stream (was):
    def stream ():
        for i in range (100):
            yield '<CHUNK>'
    return was.response ("200 OK", stream (), headers = [('Content-Type', 'text/plain')])

Treaded Data Streaming

class Producer:
  def get (self, n):
    return [random.randrange (1000) for _ in range (n)]

def producing (producer):
    def produce (queue):
        while 1:
            items = producer.get (100)
            if not items:
                queue.put (None) # end of stream
                break
            queue.put (str (items))
    return produce

@app.route ("/threaproducer")
def threaproducer (was):
    return was.Queue (producing (Producer ()))

Redirecting To Static File

@app.route ("/robots.txt")
def robots (was):
    if app.debug:
        was.response ['Content-Type'] = 'text/plain'
        return "User-Agent: *\nDisallow: /\n"
    return was.Static ('/robots.real.txt')

It will handle ETag, Last-Modified, Range etc just like common static files.

File Stream On Local File System

@app.route ("/<filename>")
def getfile (was, filename):
  return was.File ('/data/%s' % filename)

API Response

New in skitai version 0.26.15.9

In cases you want to retrun JSON API reponse,

# return JSON {data: [1,2,3]}
return was.Fault ('200 OK', data = [1, 2, 3])
# return empty JSON {}
return was.Fault (201 Accept')

# and shortcut if response HTTP status code is 200 OK,
return was.API (data =  [1, 2, 3])

# return empty JSON {}
return was.API ()

For sending error response with error information,

# client will get, {"message": "parameter q required", "code": 10021}
return was.Fault ('400 Bad Request', 'missing parameter', 10021)

# with additional information,
was.Fault (
  '400 Bad Request',
  'missing parameter', 10021,
  'need parameter offset and limit', # detailed debug information
  'http://127.0.0.1/moreinfo/10021', # more detail URL something
)

You can send traceback information for debug purpose like in case app.debug = False,

try:
  do something
except:
  return was.Fault (
    '500 Internal Server Error',
    'somethig is not valid',
    10022,
    traceback = True
  )

# client see,
{
  "code": 10001,
  "message": "somethig is not valid",
  "debug": "see traceback",
  "traceback": [
    "name 'aa' is not defined",
    "in file app.py at line 276, function search"
  ]
}

Important note that this response will return with HTTP 200 OK status. If you want return 500 code, just let exception go.

But if your client send header with ‘Accept: application/json’ and app.debug is True, Skitai returns traceback information automatically.

Datetime Encoding JSON

app.config.JSON_ENCODER = 'utcoffset'
  • utcoffset: 2030-12-24 15:09:00+00 (default, utc timezone)
  • str: 2030-12-24 15:09:00 (with system timezone)
  • iso: 2030-12-04T15:09:00 (utc timezone)
  • unixepoch: 1582850951.0 (utc timezone)
  • digit: 20301224150900 (utc timezone)

Selective Media Response By Accept Header

If client’s Accept header contains ‘text/html’, respond as rendered HTML or as JSON/XML API response.

@app.route ('/')
def index (was, error):
  return was.render_or_API ("index.html", result = result)

Map / Mapped Response

New in version 0.35.1

It is comprehensive and faster API response with key mapping from corequest objects directly.

Starndard version of API response,

task = was.db ("@sqlite3").execute ("select * from test")
return was.API (result = task.fetch ())

# JSON response,
# { result: [...] }

More faster version,

def respond (was, task):
  was.API (result = task.fetch ())

task = was.db ("@sqlite3").execute ("select * from test")
return task.then (respond)

Same but using lambda for simplicity,

task = was.db ("@sqlite3").execute ("select * from test")
return task.then (lambda x = was, y = task: x.API (result = y.fetch ()))

Same but using was.Map for more simplicity,

task = was.db ("@sqlite3").execute ("select * from test")
return was.Map (result = task)

Another examples.

@app.route ("/bench/sp", methods = ['GET'])
def bench_sp (was):
  with was.db ('@mydb') as db:
    root = (db.select ("foo")
                .order_by ("-created_at")
                .limit (10)
                .filter (Q (from_wallet_id = 8) | Q (detail = 'ReturnTx')))

    return was.Map (
      was.Thread (time.sleep, args = (0.3,)), # no need map
      files = was.Subprocess ('ls /var/log'),
      result = root.clone ().execute (),
      record_count__one = root.clone ().aggregate ('count (id) as cnt').execute ()
    )

  # JSON response, 1st args had been executed but ignored in results because no map name
  # >> { result: [...],  record_count: {cnt: 123}, ls: 'syslog ...' }

Like was.Tasks, above 4 corequests will be executes concurrently. So it is equivalent below.

tasks = was.Tasks (
  was.Thread (time.sleep, args = (0.3,)), # no need map
  files = was.Subprocess ('ls /var/log'),
  result = was.db ('@mydb', filter = hide_password).execute ('select * from user')
)

_, ls, result, record_count = tasks.fetch ()
return was.API (
  files = ls,
  result = result,
  record_count = record_count [0]
)

Is it more superior choice to use was.Map than was.API?

No, was.Map () is useful only if you need NOT modify them. If you can make good and complex SQL with functions, was.Map () is suprior for the most time.

Also Instead of complex SQL, you can use filter option for modifying results,

def hide_password (rows):
  for row in rows:
    row.password = '****'
  return rows

return was.Map (
  files = was.Subprocess ('ls /var/log', filter = lammda x: x.replace (' ', '_')),
  result = was.db ('@mydb', filter = hide_password).execute ('select * from user')
)

Further more,

# __one__FIELD_NAME
record_count__one__cnt = root.aggregate ('count (id) as cnt').execute ()
# >> { record_count: 123 }

# shortly,
record_count__cnt = root.aggregate ('count (id) as cnt').execute ()

Also for returning custom HTTP status coe,

return was.Map ('210 Something', result = root.execute ())

was.Map can have these arguments.

  • __timeout: processing timeout.
  • __mtime: dot joined key names, ex) ‘result.last_update’. target value should be an timestamp or datetime type and this value used to set ‘Last-Modified’ header
  • __etag: dot joined key names, ex) ‘result.last_update’. this value used to set ‘Etag’ header
  • __maxage: int seconds

was.Mapped () is also available.

# service.py
def get_result ():
  return was.Tasks (
    files = was.Subprocess ('ls /var/log', filter = lammda x: x.replace (' ', '_')),
    result = was.db ('@mydb', filter = hide_password).execute ('select * from user')
  )

# apis.py
tasks = service.get_result ()
return was.Mapped (tasks)

Selective Media Response By Accept Header With Map Respinse

If client’s Accept header contains ‘text/html’, respond as rendered HTML or as JSON/XML API response.

@app.route ('/')
def index (was, error):
  return was.render_or_Map ("index.html", result = db.execute ('...'))

@app.route ('/')
def index (was, error):
  tasks = was.Taks (result = db.execute ('...'))
  return was.render_or_Mapped ("index.html", tasks)

Process / Thread Response

These are very same with Future response.

If you have CPU bound jobs, use was.Process.

@app.route ('...')
def foo ():
  def repond (was, task):
      return was.API (result = task.fetch (), a = task.meta ['a'])
  return was.Process (math.sqrt, args = (4.0,), meta = {'a': 1}).then (respond)

If you have I/O bound jobs, use was.Thread.

@app.route ('...')
def foo ():
  def repond (was, task):
      return was.API (result = task.fetch ())

  def sqrt (a):
    return math.sqrt (a)

  return was.Thread (sqrt, args = (4.0,)).then (respond)

ThreadPass Response

For returning request handling threads, you can use was.ThreadPass.

In ThreadPass you can also use corequest in that function.

@app.route ("/thread_future", methods = ['GET'])
def thread_future_respond (was):
    def respond (was, a):
        a = some_synchronous_task ()
        # you can make corequest
        was.db ('@mydb').execute (...).commit ()
        return was.API (
          result = a
        )
    return was.ThreadPass (respond, args = (4.0,))

Note: There isn’t was.ProcessPass ().

Proxypass Response

Skitai’s mounted proxypass is higher priority than WSGI app. If you want make this to lower priority, can use was.ProxyPass.

@app.route ("/<path:path>")
def proxy (was, path = None):
  return was.ProxyPass ("@myupstream", path)

But it is valid only if request method is GET, because it is mainly used for building integrated development environment with frontend frameworks linke Node.js.

Generator Based Coroutine

  • New in version 0.8.6*

Coroutine use @app.coroutine’ and ‘yield’ like ‘async and await’.

Coroutine

@app.route ("/coroutine/1")
def coroutine (was):
    def respond (was, task):
        return task.fetch ()

    with was.stub ("http://example.com") as stub:
        return stub.get ("/").then (respond)

@app.route ("/coroutine/2")
@app.coroutine
def coroutine2 (was):
    with was.stub ("http://example.com") as stub:
        task = yield stub.get ("/")
    return task.fetch ()

Both functions are exactly same behaivior and results.

@app.coroutine’ can replacable with @app.router parameter.

@app.route ("/coroutine/2", coroutine = True)
def coroutine2 (was):
    with was.stub ("http://example.com") as stub:
        task = yield stub.get ("/")
    return task.fetch ()

Note: DO USE was.Thread for blocking operation after first yield.

@app.route ("/coroutine")
@app.coroutine
def coroutine (was):
    with was.stub ("http://example.com") as stub:
        task1 = yield stub.get ("/")
    task2 = yield was.Mask ('mask')
    return was.API (a = task1.fetch (), b = task2.fetch ())

@app.route ("/coroutine/9", coroutine = True)
def coroutine9 (was):
  def wait_hello (timeout = 1.0):
    time.sleep (timeout)
    return 'mask'

    tasks = yield was.Tasks (
      a = was.Mask ("Example Domain"),
      b = was.Thread (wait_hello, args = (1.0,))
    )
    task3 = yield was.Thread (wait_hello, args = (1.0,))
    task4 = yield was.Subprocess ('ls')
    return was.API (d = task4.fetch (), c = task3.fetch (), **tasks.fetch ())

Response Streaming

Coroutine also yield response data as generator. This would be useful for massive and long running database query result streaming for example.

@app.route ("/download_csv", coroutine = True)
def download_csv (was):
    yield "ID, NAME\n"
    current_id = 0
    while 1:
        task = yield (was.db ('@mydb').select ('tble')
                        .get ('id, name')
                        .filter (id__gt = current_id).
                        limit (fetch_count)).execute ()
        rows = task.fetch ()
        if not rows:
          break
        current_id += fetch_count
        yield '\n'.join (['{}, "{}"'.format (row.id, row.name) for row in rows])

Note: Yielding data type SHOULD be string or bytes (or Corequest object).

Caution: If you need just data generator, DO NOT use @app.coroutine. You just use yield data. @app.coroutine try to collect data until meeting Corequest object, so it will block your routine or not release your request thread.

Request Streaming

Use was.Input ().

@app.route ("/bistreaming", methods = ['POST'], coroutine = True, input_stream = True)
def coroutine_streaming (was):
    buf = []
    while 1:
        data = yield was.Input (4096)
        if not data:
            break
        buf.append (data)
    return b''.join (buf)

Caution: Be careful to use request streaming. Request streaming need only a few specific conditions.

  1. Small chunked request data which is intermittent and need long terms connection like receiving GPS coordinate data from client device
  2. Bidirectional streaming like detectecting silence for 10~30ms segments of audio data stream. See next Bidirectional Streaming topic.

If you just want upload data, just use regular POST upload method. DO NOT use request streaming which may cause event loop blocking and also is very inefficient.

Bidirectional Streaming

Use was.Input () and yield.

@app.route ("/bistreaming", methods = ['POST'], coroutine = True, input_stream = True)
def coroutine_streaming (was):
    while 1:
        data = yield was.Input (16184)
        if not data:
            break
        yield b':' + data

Mounting Resources: Making Simpler & Modular App

New in skitai version 0.26.17

Implicit Mount Services On Your App

I already mentioned App Structure section, you can split yours views and help utilties into services directory.

Assume your application directory structure is like this,

templates/*.html
services/*.py # app library, all modules in this directory will be watched for reloading
static/images # static files
static/js
static/css

app.py # this is starter script

app.py

from services import auth

app = Atila (__name__)

app.debug = True
app.use_reloader = True

@app.default_error_handler
def default_error_handler (was, e):
  return str (e)

services/auth.py

# shared utility functions used by views

def titlize (s):
  ...
  return s

def __mount__ (app):
  @app.login_handler
  def login_handler (was):
    if was.request.session.get ("username"):
      return
    next_url = not was.request.uri.endswith ("signout") and was.request.uri or ""
    return was.redirect (was.urlfor ("signin", next_url))

  @app.route ("/signout")
  def signout (was):
    was.request.session.remove ("username")
    was.request.mbox.push ("Signed out successfully", "success")
    return was.redirect (was.urlfor ('index'))

  @app.route ("/signin")
  def signin (was, next_url = None, **form):
    if was.request.args.get ("username"):
      user = auth.authenticate (username = was.request.args ["username"], password = was.request.args ["password"])
      if user:
        was.request.session.set ("username", was.request.args ["username"])
        return was.redirect (was.request.args ["next_url"])
      else:
        was.request.mbox.push ("Invalid User Name or Password", "error", icon = "new_releases")
    return was.render ("sign/signin.html", next_url = next_url or was.urlfor ("index"))

You just import module from services. but def __mount__ (app) is core in each module. Every modules can have __mount__ (app) in services, so you can split and modulize views and utility functions. __mount__ (app) will be automatically executed on starting. If you set app.use_reloader, theses services will be automatically reloaded and re-executed on file changing. Also you can make global app sharable functions into seperate module like util.py without views.

Mounting Directories

from services import auth

app = Atila (__name__)
app.mount ('/static', '/var/www-root/static')

Mounting Services With Options

If you need additional options on decorating,

def __mount__ (app):
  @app.route ("/login")
  def login (was):
    ...

# or with mount options
def __mount__ (app, mntopt):
  @app.route ("/login")
  def login (was):
    ...

And on app,

from services import auth

app = Atila (__name__)
app.mount ('/regist', auth)

Finally, route of login is “/regist/login”.

Sometimes function names are duplicated if like you import contributed services.

from services import auth

app = Atila (__name__)
app.mount ( '/regist', auth, ns = "regist")

Now, you can import iport without name collision. But be careful when use was.urlfor () etc.

Note that options should be keyword arguments.

{{ was.urlfor ("regist.login") }}

If you want to mount only debug environment,

app.mount (auth, debug_only = True)

If you want to authentify to all services,

app.mount (auth, authenticate = "bearer")

Currently reserved arguments are:

  • authenticate
  • debug_only
  • point

Your custom options can be accessed by __mntopt__ in your module.

First, mount with redirect option.

app.mount (auth, redirect = "index")
# automatically set to auth.__mntopt__ = {"redirect": "index"}

then you can access in auth.py,

@app.route ("/regist/signout")
def signout (was):
    was.request.mbox.push ("Signed out successfully", "success")
    return was.redirect (was.urlfor (__mntopt__.get ("redirect", 'index')))

Setup Services

all service can also have __setup__ hook.

# foo.py
BASE_PATH = '/var'
def __setup__ (app):
  ...

# or with mount options
def __setup__ (app, mntopt):
  global BASE_PATH
  BASE_PATH = mntopt.get ('base_path', BASE_PATH)

def __mount__ (app):
  ...

# app.py

from services import foo
from atila import Atila

app = Atila (__name__)
app.mount ('/', foo, base_path = '/home/ubuntu')

Recursive Sub Services Mounting

Assume you have examples package in your service.

services/examples/__init__.py
services/examples/foo.py
services/examples/bar.py

You can use __setup__ hook for mounting all sub services.

# services/examples/__init__.py
from . import foo, bar

def __setup__ (app, mntopt):
  app.mount ('/foo', foo, threshold = mntopt.get ('threashold', 5))
  app.mount ('/bar', bar)

Then you can mount just top package one.

# app.py
from services import examples

app.mount ('/examples', examples, threshold = 10)

As a result, foo will be mounted on /examples/foo.

Your all sub packeges can be mounted as this way recursivley. It makes several advantages.

  • You can precise control on exact mount or umount timing
  • Managable sub packages as plugins and increse reusability

CAUTION: DO NOT app.mount () in __mount__ hook, but in __setup__ hook.

Unmounting Resources

New in skitai version 0.27

Also ‘umount’ is avaliable for cleaning up module resource.

resource = ...

def __umount__ (app):
  resource.close ()
  app.someghing = None

This will be automatically called when:

  • before module itself is reloading
  • before app is reloading
  • app unmounted from Skitai

URL Building with Namespace

New in version 0.3.3

If you want to access resources to another sub module, you can use with full module name.

For example,

# services/v1/account.py
def __mount__ (app):
  @app.route ("/register")
  def register (was):
    ...

An you can access like this,

was.urlfor ("v1.account.register")

More About Websocket

New in Skitai version 0.26.18

websocket design specs can be choosen one of 3.

This decorator spec is,

@app.websocket (
  spec,
  timeout = 60,
  onopen = None,
  onclose = None
)

WS_COROUTINE (New in version 0.8.8)

  • messaging with coroutine
@app.route ("/echo_coroutine")
@app.websocket (skitai.WS_COROUTINE, 60)
def echo_coroutine (was):
  while 1:
    msg = yield was.Input ()
    if not msg:
      break

    with was.stub ('http://example.com') as stub:
      task = yield stub.get ("/")
      yield task.fetch ()

WS_CHANNEL

  • simple request and response way like AJAX
  • with WS_THREAD, WS_SESSION, WS_NOTHREAD, WS_NOTHREAD options

websocket message handling options

WS_THREAD

  • default, function base websocket message handling
  • it treats every single websocket message as single request to resources like url requests.
  • on receiving message from client, it will call function for handling with queue and thread pool, it is basically same as request resource

WS_SESSION (New in version Skitai 0.30)

  • non-threaded generator base websocket message handling
  • cannot use this option with WS_THREADSAFE

WS_NOTHREAD

  • non-threaded function call base websocket message handling
  • it is faster than WS_THREAD

WS_THREADSAFE (New in version Skitai 0.26)

  • Mostly same as WS_THREAD
  • Message sending is thread safe
  • Most case you needn’t this option, but you create yourself one or more threads using websocket.send () method you need this for your convinience

Note: WS_NOTHREAD and WS_SESSION will block SKitai event loop while you generate message to respond. If sending messasge generation time is reltively long, use WS_THREAD or WS_THREADSAFE.

Websokect usage is already explained, but Atila provide @app.websocket decorator for more elegant way to use it.

def onopen (was):
  print ('websocket opened')

def onclose (was):
  print ('websocket closed')

@app.route ("/websocket")
@app.websocket (skitai.WS_CHANNEL, 1200, onopen, onclose)
def websocket (was, message):
  return 'you said: ' + message

In some cases, you need additional parameter for opening/closing websocket.

@app.route ("/websocket")
@app.websocket (skitai.WS_CHANNEL | skitai.WS_THREADSAFE, 1200, onopen)
def websocket (was, message, option):
  return 'you said: ' + message

Then, your onopen function must have additional parameters except message.

def onopen (was):
  print ('websocket opened with', was.request.ARGS ["option"])

Now, your websocket endpoint is “ws://127.0.0.1:5000/websocket?option=value”

Save websocket client id to session.

def onopen (was):
  was.request.session.set ("WS_ID", was.websocket.client_id)

def onclose (was):
  was.request.session.remove ("WS_ID")

@app.route ("/websocket")
@app.websocket (skitai.WS_CHANNEL | skitai.WS_FAST, 1200, onopen, onclose)
def websocket (was, message):
  return 'you said: ' + message

And push message to client.

@app.route ("/item_in_stock")
def item_in_stock (was):
  app.websocket_send (
    was.request.session.get ("WS_ID"),
    "Item In Stock!"
  )

Note:: I’m not sure it is works in all web browser.

WS_NOTHREAD

WS_NOTHREAD does not use queue or thread pool. In this case, response is more faster but if response includes IO blocking operation, entire Skitai event loop will be blocked.

@app.route ("/websocket")
@app.websocket (skitai.WS_CHANNEL | skitai.WS_NOTHREAD, 60, onopen)
def websocket (was, message):
  return 'you said: ' + message

WS_SESSION

With WS_SESSION should return Python generator object,

@app.route ("/websocket")
  @app.websocket (skitai.WS_CHANNEL | skitai.WS_SESSION, 60)
  def websocket (was):
    while 1:
      message = yield
      if not message:
        return #strop iterating
      yield "ECHO:" + message

Note: If you use WS_SESSION option, onopen and onclose should be None, because in session, you can handle open and close within your function.

WS_GROUPCHAT (New in version Skitai 0.24)

  • thread pool manages n websockets connection
  • chat room model

Building Static URL

app.config.STATIC_URL = '/static/'
app.config.MEDIA_URL = '/media/'

Note: Each url must be end with ‘/’.

@app.route ("/")
def add (was):
  was.static ('assets/style.css') # resolve to /static/assets/style.css
  was.media ('movie.mov') # resolve to /media/movie.mov

Note: was.Static is reponsible object,

@app.route ("/")
def add (was):
  return was.Static (was.static ('assets/style.css'))
  # OR shortly,
  return was.Static ('assets/style.css')

Piping

was.pipe () can call function by resource names. This make call nested function within __mount__ (app) in another module.

For accessing by resource name, See was.urlfor ().

@app.route ("/1")
@app.inspect (offset = int)
def index (was, offset = 1):
    return was.API (result = offset)

@app.route ("/2")
def index2 (was):
    return was.pipe (index)

@app.route ("/3")
def index3 (was):
    return was.pipe (index, offset = 4)

@app.route ("/4")
def index4 (was):
    return was.pipe ('index', offset = 't')

If your module is,

# services/boards/index.py

def __mount__ (app):
  @app.route ('/search'):
  def search (was, q):
    ...

You can make call by,

was.pipe ('boards.index.search', q = 'news')

Access Environment Variables

was.request.env (alias: was.env)

was.request.env is just Python dictionary object.

if "HTTP_USER_AGENT" in was.request.env:
  ...
was.request.env.get ("CONTENT_TYPE")

Access Session

was.request.session (alias: was.session)

Strictly speaking, Atila hasn’t got traditional session which some data is stored on server side. And it doesn’t provide any abstract classes or methods for storing.

Ailta’s session is just one of cookie value which contains signature for checking alternation by any other things except Atila.

was.request.session has almost dictionary methods.

To enable session for app, random string formatted securekey should be set for encrypt/decrypt session values.

WARNING: securekey should be same on all skitai apps at least within a virtual hosing group, Otherwise it will be serious disaster.

app.securekey = "ds8fdsflksdjf9879dsf;?<>Asda"
app.session_timeout = 1200 # sec

@app.route ("/session")
def hello_world (was, **form):
  if "login" not in was.request.session:
    was.request.session.set ("user_id", form.get ("hansroh"))
    # or
    was.request.session ["user_id"] = form.get ("hansroh")

If you set, alter or remove session value, session expiry is automatically extended by app.session_timeout. But just getting value will not be extended. If you extend explicit without altering value, you can use touch() or set_expiry(). session.touch() will extend by app.session_timeout. session.set_expiry (timeout) will extend by timeout value.

Once you set expiry, session auto extenstion will be disabled until expiry time become shoter than new expiry time is calculated by app.session_timeout.

  • was.request.session.set (key, val)
  • was.request.session.get (key, default = None)
  • was.request.session.source_verified (): If current IP address matches with last IP accesss session
  • was.request.session.getv (key, default = None): If not source_verified (), return default
  • was.request.session.remove (key)
  • was.request.session.clear ()
  • was.request.session.keys ()
  • was.request.session.values ()
  • was.request.session.items ()
  • was.request.session.has_key ()
  • was.request.session.set_expiry (timeout)
  • was.request.session.touch ()
  • was.request.session.expire ()
  • was.request.session.use_time ()
  • was.request.session.impending (): if session timeout remains 20%

Messaging Box

was.request.mbox (alias: was.mbox)

Like Flask’s flash feature, Skitai also provide messaging tool.

@app.route ("/msg")
def msg (was):
  was.request.mbox.send ("This is Flash Message", "flash")
  was.request.mbox.send ("This is Alert Message Kept by 60 seconds on every request", "alram", valid = 60)
  return was.redirect (was.urlfor ("showmsg", "Hans Roh"), status = "302 Object Moved")

@app.route ("/showmsg")
def showmsg (was, name):
  return was.render ("msg.htm", name=name)

A part of msg.htm is like this:

Messages To {{ name }},
<ul>
  {% for message_id, category, created, valid, msg, extra in was.request.mbox.get () %}
    <li> {{ mtype }}: {{ msg }}</li>
  {% endfor %}
</ul>

Default value of valid argument is 0, which means if page called was.request.mbox.get() is finished successfully, it is automatically deleted from mbox.

But like flash message, if messages are delayed by next request, these messages are save into secured cookie value, so delayed/long term valid messages size is limited by cookie specificatio. Then shorter and fewer messsages would be better as possible.

‘was.request.mbox’ can be used for general page creation like handling notice, alram or error messages consistently. In this case, these messages (valid=0) is consumed by current request, there’s no particular size limitation.

Also note valid argument is 0, it will be shown at next request just one time, but inspite of next request is after hundred years, it will be shown if browser has cookie values.

@app.before_request
def before_request (was):
  if has_new_item ():
    was.request.mbox.send ("New Item Arrived", "notice")

@app.route ("/main")
def main (was):
  return was.render ("news.htm")

news.htm like this:

News for {{ app.r.username }},
<ul>
  {% for mid, category, created, valid, msg, extra in was.request.mbox.get ("notice", "news") %}
    <li class="{{category}}"> {{ msg }}</li>
  {% endfor %}
</ul>
  • was.request.mbox.send (msg, category, valid_seconds, key=val, …)
  • was.request.mbox.get () return [(message_id, category, created_time, valid_seconds, msg, extra_dict)]
  • was.request.mbox.get (category) filtered by category
  • was.request.mbox.get (key, val) filtered by extra_dict
  • was.request.mbox.source_verified (): If current IP address matches with last IP accesss mbox
  • was.request.mbox.getv (…) return get () if source_verified ()
  • was.request.mbox.search (key, val): find in extra_dict. if val is not given or given None, compare with category name. return [message_id, …]
  • was.request.mbox.remove (message_id)

Named Session & Messaging Box

New in skitai version 0.15.30

You can create multiple named session and mbox objects by mount() methods.

was.request.session.mount (
  name = None,
  session_timeout = None,
  securekey = None,
  path = None,
  domain = None,
  secure = False,
  http_only = False,
  extend = True
 )

was.request.mbox.mount (
  name = None,
  securekey = None,
  path = None,
  domain = None,
  secure = False,
  http_only = False
)

For example, your app need isolated session or mbox seperated default session for any reasons, can create session named ‘ADM’ and if this session or mbox is valid at only /admin URL.

@app.route("/")
def index (was):
  was.request.session.mount ("ADM", path = '/admin')
  was.request.session.set ("admin_login", True)

  was.request.mbox.mount ("ADM", path = '/admin')
  was.request.mbox.send ("10 data has been deleted", 'warning')

SECUREKEY_STRING needn’t same with app.securekey. And path, domain, secure, http_only args is for session cookie, you can mount any named sessions or mboxes with upper cookie path and upper cookie domain. In other words, to share session or mbox with another apps, path should be closer to root (/).

@app.route("/")
def index (was):
  was.request.session.mount ("ADM", path = '/')
  was.request.session.set ("admin_login", True)

Above ‘ADM’ sesion can be accessed by all mounted apps because path is ‘/’.

Also note was.request.session.mount () is exactly same as mounting default session.

mount() is create named session or mbox if not exists, exists() is just check wheather exists named session already.

if not was.request.session.exists (None):
  return "Your session maybe expired or signed out, please sign in again"

if not was.request.session.exists ("ADM"):
  return "Your admin session maybe expired or signed out, please sign in again"

File Upload

FORM = """
  <form enctype="multipart/form-data" method="post">
  <input type="hidden" name="submit-hidden" value="Genious">
  <p></p>What is your name? <input type="text" name="submit-name" value="Hans Roh"></p>
  <p></p>What files are you sending? <br />
  <input type="file" name="file">
  </p>
  <input type="submit" value="Send">
  <input type="reset">
</form>
"""

@app.route ("/upload")
def upload (was, *form):
  if was.request.command == "get":
    return FORM
  else:
    file = form.get ("file")
    if file:
      file.save ("d:\\var\\upload", dup = "o") # overwrite

‘file’ object’s attributes are:

  • file.path: temporary saved file full path
  • file.name: original file name posted
  • file.size
  • file.mimetype
  • file.save (into, name = None, mkdir = False, dup = “u”)
  • file.remove ()
  • file.read ()
    • if name is None, used file.name
    • dup:
      • u - make unique (default)
      • o - overwrite

Using SQL Map with SQLPhile

New in Version 0.26.13

SQLPhile is SQL generator and can be accessed from was.sql.

was.sql is a instance of sqlphile.SQLPhile.

If you want to use SQL templates, create sub directory ‘sqlmaps’ and place sqlmap files.

# default engine is skitai.DB_PGSQL and also available skitai.DB_SQLITE3
# no need call for skitai.DB_PGSQL
app.setup_sqlphile (skitai.DB_SQLITE3)

@app.route ("/")
def index (was):
  q = was.sql.select (tbl_'user').get ('id, name').filter (id = 4)
  req = was.db ("@db").execute (q)
  result = req.dispatch ()

New in skitai version 0.27

>From version 0.27 SQLPhile is integrated with PostgreSQL and SQLite3.

app = Atila (__name__)
app.setup_sqlphile (skitai.DB_PGSQL)

@app.route ("/")
def query (was):
  dbo = was.db ("@mypostgres")
  req = dbo.select ("cities").get ("id, name").filter (name__like = "virginia").execute ()
  result = req.dispatch ()
  response = req.dispatch (timeout = 2)
  dbo.insert ("cities").data (name = "New York").execute ().wait_or_throw ("500 Server Error")

Please, visit SQLPhile for more detail.

Registering Per Request Calling Functions

Method decorators called automatically when each method is requested in a app.

@app.before_request
def before_request (was):
  if not login ():
    return "Not Authorized"

@app.finish_request
def finish_request (was):
  app.r.user_id
  app.r.user_status
  ...

@app.failed_request
def failed_request (was, exc_info):
  app.r.user_id
  app.r.user_status
  ...

@app.teardown_request
def teardown_request (was):
  app.r.resouce.close ()
  ...

@app.route ("/view-account")
def view_account (was, userid):
  app.r.user_id = "jerry"
  app.r.user_status = "active"
  app.r.resouce = open ()
  return ...

For this situation, ‘was’ provide app.r that is empty class instance. app.r is valid only in current request. After end of current request.

If view_account is called, Atila execute these sequence:

try:
  try:
    content = before_request (was)
    if content:
      return content
    content = view_account (was, *args, **karg)

  except:
    content = failed_request (was, sys.exc_info ())
    if content is None:
      raise

  else:
    finish_request (was)

finally:
  teardown_request (was)

return content

Be attention, failed_request’s 2nd arguments is sys.exc_info (). Also finish_request and teardown_request (NOT failed_request) should return None (or return nothing).

If you handle exception with failed_request (), return custom error content, or exception will be reraised and Atila will handle exception.

New in skitai version 0.14.13

@app.failed_request
def failed_request (was, exc_info):
  # releasing resources
  return was.response (
    "501 Server Error",
    was.render ("err501.htm", msg = "We're sorry but something's going wrong")
  )

Define Autoruns

New in skitai version 0.26.18

You can make automation for preworks and postworks.

def pre1 (was):
  ...

def pre2 (was):
  ...

def post1 (was):
  ...

@app.run_before (pre1, pre2)
@app.run_after (post1)
def index (was):
  return was.render ('index.html')

@app.run_before can return None or responsable contents for aborting all next run_before and main request.

@app.run_after return will be ignored

Define Conditional Prework

New in skitai version 0.26.18

@app.if~s are conditional executing decorators.

def reload_config (was, path):
  ...

@app.if_file_modified ('/opt/myapp/config', reload_config, interval = 1)
def index (was):
  return was.render ('index.html')

@app.if_updated need more explaination.

Inter Process Update Notification and Consequences Automation

New in skitai version 0.26.18

@app.if_updated is related with skitai.register_states (), was.setlu() and was.getlu() and these are already explained was cache contorl part. And Atila app can use more conviniently.

These’re used for mostly inter-process notification protocol.

Before skitai.run (), you should define updatable objects as string keys:

skitai.register_states ("weather-news", ...)

Then one process update object and update time by setlu ().

@app.route ("/")
def add_weather (was):
  was.db.execute ("insert into weathers ...")
  was.setlu ("weather-news")
  return ...

This update time stamp will be recorded in shared memory, then all skitai worker processes can catch this update by comparing previous last update time and automate consequences like refreshing cache.

def reload_cache (was, key):
  ...

@app.if_updated ('weather-news', reload_cache)
def index (was):
  return was.render ('index.html')

App Lifecycle Hook

These app life cycle methods will be called by this order,

  • before_mount (wac): when app imported on skitai server started
  • mounted (was): called first with was (instance of wac)
  • mounted_or_reloaded (was): called with was (instance of wac)
  • loop whenever app is reloaded,
    • oldapp.before_reload (was)
    • newapp.reloaded (was)
    • mounted_or_reloaded (was): called with was (instance of wac)
  • before_umount (was): called last with was (instance of wac), add shutting down process
  • umounted (wac): when skitai server enter shutdown process

Please note that first arg of startup, reload and shutdown is wac not was. wac is Python Class object of ‘was’, so mainly used for sharing Skitai server-wide object via was.object before instancelizing to was.

@app.before_mount
def before_mount (wac):
  logger = wac.logger.get ("app")
  # OR
  logger = wac.logger.make_logger ("login", "daily")
  config = wac.config
  wac.register ("loginengine", SNSLoginEngine (logger))
  wac.register ("searcher", FulltextSearcher (wac.numthreads))

@app.before_reload
def before_remount (wac):
  wac.loginengine.reset ()

@app.umounted
def before_umount (wac):
  wac.umounted.close ()

  wac.unregister ("loginengine")
  wac.unregister ("searcher")

You can access numthreads, logger, config from wac.

As a result, myobject can be accessed by all your current app functions even all other apps mounted on Skitai.

# app mounted to 'abc.com/register'
@app.route ("/")
def index (was):
  was.loginengine.check_user_to ("facebook")
  was.searcher.query ("ipad")

# app mounted to 'def.com/'
@app.route ("/")
def index (was):
  was.searcher.query ("news")

Note: The way to mount with host, see ‘Mounting With Virtual Host’ chapter below.

It maybe used like plugin system. If a app which should be mounted loads pulgin-like objects, theses can be used by Skitai server wide apps via was.object1, was.object2,…

New in skitai version 0.26

If you have databases or API servers, and want to create cache object on app starting, you can use @app.mounted decorator.

def create_cache (res):
  d = {}
  for row in res.data:
    d [row.code] = row.name
  app.store.set ('STATENAMES', d)

@app.mounted
def mounted (was):
  was.db ('@mydb', callback = create_cache).execute ("select code, name from states;")
  # or use REST API
  was.get ('@myapi/v1/states', callback = create_cache)
  # or use RPC
  was.rpc ('@myrpc/rpc2', callback = create_cache).get_states ()

@app.reloaded
def reloaded (was):
  mounted (was) # same as mounted

@app.before_umount
def before_umount (was):
  was.delete ('@session/v1/sessions', callback = lambda x: None)

But both are not called by request, you CAN’T use request related objects like was.request, was.request.cookie etc. And SHOULD use callback because these are executed within Main thread.

Login and Permission Helper

New in skitai version 0.26.16

You can define login & permissoin check handler,

@app.login_handler
def login_handler (was):
  if was.request.session.get ("demo_username"):
    return

  if was.request.args.get ("username"):
    if not was.verify_csrf ():
      raise was.Error ("400 Bad Request")

    if was.request.args.get ("signin"):
      user, level = authenticate (username = was.request.args ["username"], password = was.request.args ["password"])
      if user:
        was.request.session.set ("demo_username", user)
        was.request.session.set ("demo_permission", level)
        return

      else:
        was.request.mbox.send ("Invalid User Name or Password", "error")
  return was.render ("login.html", user_form = forms.DemoUserForm ())

@app.permission_check_handler
def permission_check_handler (was, perms):
  if was.request.session.get ("demo_permission") in perms:
    raise was.Error ("403 Permission Denied")

@app.staff_member_check_handler
def staff_check_handler (was):
  if was.request.session.get ("demo_permission") not in ('staff'):
    raise was.Error ("403 Staff Permission Required")

If you are using JWT you can integrate with this, And it is replacable instead of app.authorization_required.

@app.permission_check_handler
def permission_check_handler (was, perms):
    claims = was.request.JWT
    if "err" in claims: return claims ["err"]
    if not perms:
      return # permit
    for p in claims ["levels"]:
        if p in perms:
            return # permit
    raise was.Error ("403 Permission Denied")

And use it for your resources if you need,

@app.route ("/")
@app.permission_required (["admin"])
@app.login_required
def index (was):
  return "Hello"

@app.staff_member_required
def index2 (was):
  return "Hello"

If every thing is OK, it SHOULD return None, not True.

‘clarify_permission’ and ‘clarify_login’ will ignore any raise HTTP error but just try run ‘permission_check_handler’. You can set request.user object if user has permission.

@app.permission_check_handler
def permission_check_handler (was, perms):
    claims = was.request.JWT
    if "err" in claims:
      return claims ["err"]
    was.request.user = claims ['uid']
    if not perms:
      return # permit
    raise was.Error ("403 Permission Denied")

@app.clarify_permission # ignore http error on handler
def index (was):
  if not was.request.user:
    return 'permission denied'
  return 'permission granted'

Conditional Permission Control

New in version 0.3

Let’s assume you manage permission by user levels: admin, staff and user.

@app.permission_check_handler
def permission_check_handler (was, perms):
  claims = was.request.JWT
  if "err" in claims:
    return claims ["err"]

  if not perms:
    return # permit for anyone who is authorized
  if claims ["level"] == "admin":
    return # premit always
  if "admin" in perms:
    raise was.Error ("403 Permission Denied")
  if "staff" in prems and claims ["level"] != "staff":
      raise was.Error ("403 Permission Denied")
@app.route ("/animals/<id>")
@app.permission_required ([], id = ["staff"])
def animals (was, id = None):
    id = id or was.request.JWT ["userid"]

This resources required any permission for “/animals/” or “/animals/me”. But ‘/animals/100’ is required ‘staff’ permission. It may make permission control more simpler.

Also you can specify premissions per request methods.

@app.route ("/animals/<id>", methods = ["POST", "DELETE"])
@app.permission_required (['user'], id = ["staff"], DELETE = ["admin"])
def animals (was, id = None):
    id = id or was.request.JWT ["userid"]

This resources required ‘user’ permission for “/animals/” or “/animals/me”. ‘/animals/100’ is required ‘staff’ permission. It may make permission control more simpler.

Testpassing

Also you can test if user is valid,

def is_superuser (was):
  if was.user.username not in ('admin', 'root'):
    reutrn was.response ("403 Permission Denied")

@app.testpass_required (is_superuser)
def modify_profile (was):
  ...

The binded testpass_required function can return,

  • True or None: continue request
  • False: response 403 Permission Denied immediately
  • Responsable object: response object immediately

Cross Site Request Forgery Token (CSRF Token)

New in skitai version 0.26.16

At template, insert CSRF Token,

<form>
{{ was.csrf_token_input }}
...
</form>

then verify token like this,

@app.before_request
def before_request (was):
  if was.request.args.get ("username"):
    if not was.verify_csrf ():
      return was.response ("400 Bad Request")

Or use decorator,

@app.csrf_verification_required
def before_request (was):
  ...

Making JWT Token

@app.route ('/make_token')
def make_token (was)
    t = was.encode_jwt ({'iss': 'example.com', 'exp': time.time () + 3600})

@app.route ('/verify_token')
def make_token (was, token)
    payload = was.decode_jwt (token)

At your client,

from atila.was import generate_otp

generate_otp (secret_key)

Making One-Time Password

New in skitai version 0.35.0

def check_otp (was):
   if not was.verify_otp (was.request.get_header ('x-otp')):
      raise was.Error ('403 Unauthorized')

@app.route ('/admin-task')
@app.testpass_required (check_otp)
def task (was)
    ...

At your client,

from atila.was import generate_otp

generate_otp (secret_key)

Making One-Time Token

New in skitai version 0.26.17

For creatiing onetime link url, you can convert your data to signatured token string.

Note: Like JWT token, this token contains data and decode easily, then you should not contain important information like password or PIN. This token just make sure contained data is not altered by comparing signature which is generated with your app scret key.

@app.route ('/password-reset')
def password_reset (was)
  if was.request.args ('username'):
    username = "hans"
    token = was.encode_ott (username, 3600, "pwrset") # valid within 1 hour
    pw_reset_url = was.urlfor ('reset_password', token)
    # send email
    return was.render ('done.html')

  if was.request.args ('token'):
    username = was.decode_ott (was.request.args ['token'])
    if not username:
      return was.response ('400 Bad Request')
    # processing password reset
    ...

If you want to expire token explicit, add session token key

# valid within 1 hour and create session token named '_reset_token'
token = was.encode_ott ("hans", 3600, 'rset')
>> kO6EYlNE2QLNnospJ+jjOMJjzbw?fXEAKFgGAAAAb2JqZWN0...

username = was.decode_ott (token)
>> "hans"

# if processing is done and for revoke token,
was.revoke_ott (token)

App Event Handling

Most of Atila’s event handlings are implemented with excellent event-bus library.

New in skitai version 0.26.16, Availabe only on Python 3.5+

import atila

@app.on ("request:failed")
def request_failed_handler (was, exc_info):
  print ("I got it!")

There’re some app events.

  • before_mount
  • mounted
  • before_reload
  • reloaded
  • before_umount
  • umounted
  • mounted_or_reloaded
  • request:before_start
  • request:failed
  • request:success
  • request:teardown
  • request:finished

App Storage

app.store object is ditionary like object and provide thread-safe accessing.

It SHOULD be simple primitive value like string, int, float. About dictionary or class instances, It can’t give no guarantee for thread-safe.

def  (was, current_users):
  total = app.store.get ("total-user")
  app.store.set ("total-user", total + 1)
  ...

Inverval Base App Maintenancing

If you need interval base maintaining jobs,

app.config.MAINTAIN_INTERVAL = 60  # seconds, default is 60
app.store.set ("num-nodes", 0) # thread safe store

@app.maintain (10, threading = False) # execute every 10 maintaining (500 sec.)
def maintain_num_nodes (was, now, count):
  ...
  num_nodes = was.getlu ("cluster.num-nodes")
  if app.store ["num-nodes"] != num_nodes:
    app.store ["num-nodes"] = num_nodes
    app.broadcast ("cluster:num_nodes")

You can add multiple maintain jobs but maintain function names is SHOULD be unique.

Creating and Handling Custom Event

Availabe only on Python 3.5+

For creating custom event and event handler,

@app.on ("user-updated")
def user_updated (was, user):
  ...

For emitting,

@app.route ('/users', methods = ["POST"])
def users (was):
  args = was.request.json ()
  ...

  app.emit ("user-updated", args ['userid'])

  return ''

If event hasn’t args, you can use emit_after decorator,

@app.route ('/users', methods = ["POST"])
@app.emit_after ("user-updated")
def users (was):
  args = was.request.json ()
  ...
  return ''

Using this, you can build automatic excution chain,

@app.on ("photo-updated")
def photo_updated (was):
  ...

@app.on ("user-updated")
@app.emit_after ("photo-updated")
def user_updated (was):
  ...

@app.route ('/users', methods = ["POST"])
@app.emit_after ("user-updated")
def users (was):
  args = was.request.json ()
  ...
  return ''

Cross App Communication & Accessing Resources

Skitai prefer spliting apps to small microservices and mount them each. This feature make easy to move some of your mounted apps move to another machine. But this make difficult to communicate between apps.

Here’s some helpful solutions.

Accessing App Object Properties

New in skitai version 0.26.7.2

You can mount multiple app on Skitai, and maybe need to another app is mounted seperatly.

skitai.mount ("/", "main.py")
skitai.mount ("/query", "search.py")

And you can access from filename of app from each apps,

search_app = was.apps ["search"]
save_path = search_app.config.save_path

URL Building for Resource Accessing

New in skitai version 0.26.7.2

If you mount multiple apps like this,

skitai.mount ("/", "main.py")
skitai.mount ("/search", "search.py")

For building url in main.py app from a query function of search.py app, you should specify app file name with colon.

was.urlfor ('search:query', "Your Name") # returned '/search/query?q=Your%20Name'

And this is exactly same as,

was.apps [“search”].build_url (“query”, “Your Name”)

But this is only functioning between apps are mounted within same host.

Custom Error Handling

New in skitai version 0.26.7

@app.default_error_handler
def default_error_handler (was, error):
  return "<h1>{code} {message}</h1>".format (**error)

Or you can respond with JSON only.

@app.error_handler (404)
def not_found (was, error):
  return "<h1>{code} {message}</h1>".format (**error)
  • code: error code
  • message: error message
  • detail: error detail
  • mode: debug or normal
  • debug: debug info
  • time: time when error occured
  • url: request url
  • software: server name and version
  • traceback: available only if app.debug = True or None

Note that custom error templates can not be used before routing to the app.

Communication with Event

New in skitai version 0.26.10 Availabe only on Python 3.5+

‘was’ can work as an event bus using app.on_broadcast () - was.broadcast () pair. Let’s assume that an users.py app handle only user data, and another photo.py app handle only photos of users.

skitai.mount ('/users', 'users.py')
skitai.mount ('/photos', 'photos.py')

If a user update own profile, sometimes photo information should be updated.

At photos.py, you can prepare for listening to ‘user:data-added’ event and this event will be emited from ‘was’.

@app.on_broadcast ('user:data-added')
def refresh_user_cache (was, userid):
  was.sqlite3 ('@photodb').execute ('update ...').wait ()

and uses.py, you just emit ‘user:data-added’ event to ‘was’.

@app.route ('/users', methods = ["PATCH"])
def users (was):
  args = was.request.json ()
  was.sqlite3 ('@userdb').execute ('update ...').wait ()

  # broadcasting event to all mounted apps
  was.broadcast ('user:data-added', args ['userid'])

  return was.response (
    "200 OK",
    json.dumps ({}),
    [("Content-Type", "application/json")]
  )

If resource always broadcasts event without args, use broadcast_after decorator.

@app.broadcast_after ('some-event')
def users (was):
  args = was.request.json ()
  was.sqlite3 ('@userdb').execute ('update ...').wait ()

Note that this decorator cannot be routed by app.route ().

CAUTION: Do not use request specific variables - like request, cookie, session and etc in event handler.

CORS (Cross Origin Resource Sharing) and Preflight

For allowing CORS, you should do 2 things:

  • set app.access_control_allow_origin
  • allow OPTIONS methods for routing
app = Atila (__name__)
app.access_control_allow_origin = ["*"]
# OR specific origins
app.access_control_allow_origin = ["http://www.skitai.com:5001"]
app.access_control_max_age = 3600

@app.route ("/post", methods = ["POST", "OPTIONS"])
def post (was):
  args = was.request.json ()
  return was.jstream ({...})

If you want function specific CORS,

app = Atila (__name__)

@app.route (
 "/post", methods = ["POST", "OPTIONS"],
 access_control_allow_origin = ["http://www.skitai.com:5001"],
 access_control_max_age = 3600
)
def post (was):
  args = was.request.json ()
  return was.jstream ({...})

WWW-Authenticate

Changed in version 0.15.21

  • removed app.user and app.password
  • add app.users object has get(username) methods like dictionary

Atila provide simple authenticate for administration or perform access control from other system’s call.

Authentication On Specific Methods

Otherwise you can make some routes requirigng authorization like this:

@app.route ("/hello/<name>", authenticate = "digest")
def hello (was, name = "Hans Roh"):
  return "Hello, %s" % name

Or you can use @app.authorization_required decorator.

@app.route ("/hello/<name>")
@app.authorization_required ("digest")
def hello (was, name = "Hans Roh"):
  return "Hello, %s" % name

Available authorization methods are basic, digest and bearer.

Password Provider

You can provide password and user information getter by 2 ways.

First, users object

# users object shoukd have get(username) method
app.users = {"hansroh": ("1234", False)}

Second, use decorator

@app.authorization_handler
def auth_handler (was, username):
  ...
  return ("1234", False)

The return object can be:

  • (str password, boolean encrypted, obj userinfo)
  • (str password, boolean encrypted)
  • str password
  • None if authorization failed

If you use encrypted password, you should use digest authorization and password should encrypt by this way:

from hashlib import md5

encrypted_password = md5 (
  ("%s:%s:%s" % (username, realm, password)).encode ("utf8")
).hexdigest ()

If authorization is successful, app can access username and userinfo vi was.request.user.

  • was.request.user.name
  • was.request.user.realm
  • was.request.user.info

If your server run with SSL, you can use app.authorization = “basic”, otherwise recommend using “digest” for your password safety.

Authentication On Entire App

For your convinient, you can set authorization requirements to app level.

app = Atila (__name__)

app.authenticate = "digest"
app.realm = "Partner App Area of mysite.com"
app.users = {"app": ("iamyourpartnerapp", 0, {'role': 'root'})}

@app.route ("/hello/<name>")
def hello (was, name = "Hans Roh"):
  return "Hello, %s" % name

If app.authenticate is set, all routes of app require authorization (default is False).

(JWT) Bearer Authorization

To making JWT token, your app need securekey.

app.securekey = '5b2c4f18-01fd-4b85-8cfa-01827878562f'
was.encode_jwt ({"username": "hansroh", "exp": time.time () + 3600, ...})
>> eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXV...

Note: was.decode_jwt (token) is also available.

Then client should add ‘Authorization’ to API request like,

Authorization: Bearer eyJhbGciOiAiSFMyNTYiLCAidHlwIjogIkpXV...

And use bearer_handler decorators.

@app.bearer_handler
def bearer_handler (was, token):
  # if not JWT token,
  claims = parse_your_token_yourself (token)
  # if JWT, just use was.request.JWT
  claims = was.request.JWT
  if "err" in claims:
    return claims ["err"]

@app.route ("/api/v1/predict")
@app.authorization_required ("bearer")
def predict (was):
  # now you can use these
  was.request.user # hansroh
  was.request.JWT # dict {"username": "hansroh", "exp": 2900...}

For your convinient, above bearer_handler is registered as default handler, but you can still override it.

Implementing XMLRPC Service

Client Side:

import aquests

stub = aquests.rpc ("http://127.0.0.1:5000/rpc")
stub.add (10000, 5000)
fetchall ()

Server Side:

@app.route ("/add")
def index (was, num1, num2):
  return num1 + num2

Is there nothing to diffrence? Yes. Atila app methods are also used for XMLRPC service if return values are XMLRPC dumpable.

Implementing gRPC Service

Client Side:

import aquests
import route_guide_pb2

stub = aquests.grpc ("http://127.0.0.1:5000/routeguide.RouteGuide")
point = route_guide_pb2.Point (latitude=409146138, longitude=-746188906)
stub.GetFeature (point)
aquests.fetchall ()

Server Side:

import route_guide_pb2

def get_feature (feature_db, point):
  for feature in feature_db:
    if feature.location == point:
      return feature
  return None

@app.route ("/GetFeature")
def GetFeature (was, point):
  feature = get_feature(db, point)
  if feature is None:
    return route_guide_pb2.Feature(name="", location=point)
  else:
    return feature

if __name__ == "__main__":

skitai.mount = ('/routeguide.RouteGuide', app)
skitai.urn ()

For an example, here’s my tfserver for Tensor Flow Model Server.

For more about gRPC and route_guide_pb2, go to gRPC Basics - Python.

Note: I think I don’t understand about gRPC’s stream request and response. Does it means chatting style? Why does data stream has interval like GPS data be handled as stream type? If it is chat style stream, is it more efficient that use proto buffer on Websocket protocol? In this case, it is even possible collaborating between multiple gRPC clients.

Logging and Traceback

@app.route ("/")
def sum ():
  was.log ("called index", "info")
  try:
    ...
  except:
    was.log ("exception occured", "error")
    was.traceback ()
  was.log ("done index", "info")

Note inspite of you do not handle exception, all app exceptions will be logged automatically by Atila. And it includes app importing and reloading exceptions.

  • was.log (msg, category = “info”)
  • was.traceback (id = “”) # id is used as fast searching log line for debug, if not given, id will be Global transaction ID/Local transaction ID

Exposing API Specification

For debugging and helping to write API specification, Atila expose all specification of each resources.

@app.route ("/isitok/<code>/<type>", methods = ["GET", "POST", "PATCH", "OPTIONS"])
def isitok (was, code, type):
  return was.API (result = "ok")

That will return,

{"result": "ok"}

If you set like this,

app.expose_spec = True

Then will be returned with spec,

{
  "result": "ok",
  "__spec__": {
      'id': 'isitok',
      'routeopt': {
          'methods': ["GET", "POST", "PATCH", "OPTIONS"],
          'route': '/isitok/<code>/<type>',
          'args': ['code', 'type'],
          'keywords': None,
          'urlargs': 2,
          'mntopt': {
              'module_name': 'services.v1.apis',
              'point': '/v1/apis'
          }
      },
      'auth_requirements': [],
      'parameter_requirements': {},
      'doc': None,
      'current_request': {
          'http_method': 'GET',
          'http_version': '1.1',
          'uri': '/v1/apis/isitok'
      }
   }
}

Note: This will only work at your local machine (IP address starts with 127.0.0.).

App Testing & Generating API Document

For automated test, Atila use skitai.test_client (). Test client emulates client-server communication.

Step 1. you must make serve.py.

import skitai
import pwa

if __name__ == "__main__":
  with skitai.preference () as pref:
      pref.expose_spec = True
      skitai.mount ("/", pwa, pref, name = 'myapp') # mount app
  skitai.run (port = 9003, name = "myserver", threads = 8)

Step 2. run server

python3 serve.py --devel

Step 3. configure pytest.

# file name: conftest.py

import pytest
import skitai import test_client
from atila.pytest_hooks import * # must do
from functools import partial

@pytest.fixture
def client ():
  # client is actually requests.Session object
  engine_ = test_client ("../serve.py", port = 9003, ssl = False, silent = True, dry = True)
  yield engine_
  engine_.stop ()

@pytest.fixture
def axios (client):
  # mimic axios
  new_client = client.clone () # share session with client
  new_client.set_default_header ('Accept', "application/json")
  new_client.set_default_header ('Content-Type', "application/json")
  return new_client

@pytest.fixture
def launching ():
  return partial (test_client, port = 30371, silent = False)

Step 4. make tests

def test_html (client):
  resp = client.get ("/users/100")
  assert '<html>' in resp.text
  assert resp.status_code == 200

def test_api (axios):
  resp = axios.patch ('/users/100', {'last_name': 'Roh'})
  assert resp.json () ['last_name'] == 'Roh'
  assert resp.status_code == 200

def tset_api_with_lauching (launching):
  with launching ('../serve/py') as client:
    resp = client.get ("/users/100")
    assert resp.status_code == 200

For generating API document you should run Skitai with –devel option.

python3 serve.py --devel

And run pytest with option,

pytest --docs

Inheritance and Overriding

New in version 0.10

Extending

Atila can be inherited from packages.

import myapp
import atila_vue # VueJS frontend templates for Atila

with skitai.preference () as pref:
  pref.extends (atila_vue)
  skital.mount ('/', myapp, pref)

This means app intend to use all static files, templates and services of atila_vue.

And at atila_vue,

# file: atila_vue.__init__.py
import os
from . import services

BASE_DIR = os.path.dirname (__file__)

def __config__ (pref):
  pref.add_static (os.path.join (os.path.dirname (__file__), 'static'))

def __setup__ (app, mntopt):
  app.mount (services) # add services to app

Also you can override any resources.

For override static files, you just place file to your app’s static directory. App find resource at your app static first. And if not exists, find atila_vue/static directory next. This is same on templates.

For overriding services, you just overwrite route.

For example, atila_vue has route ‘/examples’.

If out want to override this,

@app.route ('/examples')
def examples (was, ...):
  ...

Overriding

For examples, you can add services to tfserver.

import skitai
import tfserver
import mytfserver

with skitai.preference () as pref:
  pref.overrides (mytfserver)
  skital.mount ('/', tfserver, pref)

All mytfserver’s services overwrite thserver’s including statics and templates if exists.

Unrouting

Or if you want to just remove this route,

app.unroute ('/examples', 404)
app.unroute ('/examples/*', 404)

Standard package structure

package_root
  L templates/
  L services/
    L __init__,py
      L __setup__ (app, mntopt)
      L __mount__ (app, mntopt)
  L static/

  __init__.py # MUST have __version__ string
    L __config__ (pref) # optional
    L __setup__ (app, mntopt)
    L __mount__ (app, mntopt) # optional

  setup.py
  README.rst

VueJS with Skito-Atila

Without Module Bundlers

For examples, I wrote Atila-Vue.

It is based on FranckFreiburger/http-vue-loader and I made some examplary templates.

With Bundlers

I prefer to build VueJS as frontend app and Atila as backend.

Basic project directory stucture is,

project root

  • frontend (vue project)
    • <dist>
    • <node_modules>
    • <src>
    • <public>
    • package.json
    • vue.config.js
  • backend
    • <services>
    • serve.py

The core line sof serve.py,

from atila import Atila
import skitai
import os
import sys
from services import api

app = Atila (__name__)
app.mount ("/api/v1", api) # for backend API service

@app.route ("/<path:path>")
def vapp (was, path = None):
    return was.File (skitai.joinpath ("../frontend", "dist", "index.html"), "text/html")

if __name__ == "__main__":
    with skitai.preference () as pref:
      pref.securekey = None
      pref.max_client_body_size = 2 << 32
      pref.access_control_allow_origin = ["127.0.0.1:5000"]

      if "---production" not in sys.argv:
          pref.debug = True
          pref.use_reloader = True
          pref.access_control_allow_origin.append ("127.0.0.1:8080")

      skitai.mount ("/", app)
      skitai.mount ("/", "../frontend/dist", pref = pref)
    skitai.run (name = "myapp", port = 5000)

This skitai starting script do these things,

  • If requested URL is one of atila routes, then routed to it
  • Otherwise all URL is routed to vue SPA (dist/index.html)
  • All static root mounted to frontend/dist directory for service compiled js and css by webpack

You can develop vue app by,

npm run serve
# generally use port 8080

And Atila app developing by,

python3 ../backend/serve.py
# use port 5000

Finally,

npm run build
python3 ../backend/serve.py

If you interest about thi stuff, you can have reference which I personally build as bolier-plate. But it is just planning stage.

Working With Jinja2 Template Engine

If you want to use Jinja2 template engine, install first.

pip3 install -U jinja2

Although You can use any template engine, Skitai provides was.render() which uses Jinja2 template engine. For providing arguments to Jinja2, use dictionary or keyword arguments.

return was.render ("index.html", choice = 2, product = "Apples")

#is same with:

return was.render ("index.html", {"choice": 2, "product": "Apples"})

#BUT CAN'T:

return was.render ("index.html", {"choice": 2}, product = "Apples")

Directory structure sould be:

  • /project_home/app.py
  • /project_home/templates/index.html

Within template, you can access was and aliases for your convinient.

  • was
  • app
  • request: alias for was.request
  • response: alias for was.response
  • context: namespace for given keyword arguments (or dictionary keys)

Note that these names cannot ne used as context variable name.

Also available registered with @app.template_global decorator and given keyword arguments (or dictionary keys).

{{ request.cookie.username }} choices item {{ request.ARGS.get ("choice", "N/A") }}.

<a href="{{ was.urlfor ('checkout', context.choice) }}">Proceed</a>

Also ‘was.r’ is can be useful in case threr’re lots of render parameters.

app.r.product = "Apple"
app.r.howmany = 10

return was.render ("index.html")

And at jinja2 template,

Checkout for {{ app.r.howmany }} {{ app.r.product }}{{ app.r.howmany > 1 and "s" or ""}}

If you want modify Jinja2 envrionment, can through app.jinja_env object.

def generate_form_token ():
  ...

app.jinja_env.globals['form_token'] = generate_form_token

And this is same as,

@app.template_global ('form_token')
def generate_form_token ():
  ...

New in skitai version 0.15.16

Added new app.jinja_overlay () for easy calling app.jinja_env.overlay ().

Recently JS HTML renderers like Vue.js, React.js have confilicts with default jinja mustache variable. In this case you mightbe need change it.

app = Atila (__name__)
app.debug = True
app.use_reloader = True
app.jinja_overlay (
  variable_start_string = "{{",
  variable_end_string = "}}",
  block_start_string = "{%",
  block_end_string = "%}",
  comment_start_string = "{#",
  comment_end_string = "#}",
  line_statement_prefix = "%",
  line_comment_prefix = "%%",
  **kargs # Jinja2 Environment arguments
)

To add Jinja2 extensions,

app.add_jinja_ext ('jinja2.ext.i18n')

Currently, Atila use “jinja2.ext.do”, “jinja2.ext.loopcontrols” defaultly.

If you want remove extensions,

app.jinja_overlay (extensions = [])

Using Skitai Async Requests Services Working With Jinja2 Template

If you want to use Jinja2 template engine, install first.

pip3 install -U jinja2

Basic usage is here.

Async request’s benefit will be maximied at your view template rather than your controller. At controller, you just fire your requests and get responses at your template.

@app.route ("/")
@app.login_required
def intro (was):
  app.r.aa = was.get ("https://example.com/blur/blur")
  app.r.bb = was.get ("https://example.com/blur/blur/more-blur")
  return was.render ('template.html')

Your template,

{% set response = app.r.aa.dispatch () %}
{% if response.status == 3 %}
  {{ was.response.throw ("500 Internal Server Error") }}
{% endif %}

{% if response.status_code == 200 %}
  {% for each in response.data %}
    ...
  {% endfor %}
{% endif %}

Available only with Atila

Shorter version is for dispatch and throw HTTP error,

{% set response = app.r.aa.dispatch_or_throw ("500 Internal Server Error") %}

Registering Global Template Function

New in skitai version 0.26.16

template_global decorator makes a function possible to use in your template,

@app.template_global ("test_global")
def test (was):
  return ", ".join.(was.request.args.keys ())

At template,

{{ test_global () }}

Note that all template global function’s first parameter should be was. But when calling, you SHOULDN’t give was.

Registering Jinja2 Filter

New in skitai version 0.26.16

template_filter decorator makes a function possible to use in your template,

@app.template_filter ("reverse")
def reverse_filter (s):
  return s [::-1]

At template,

{{ "Hello" | reverse }}

Custom Error Template

New in skitai version 0.26.7

@app.default_error_handler
def default_error_handler (was, error):
  return was.render ('default.htm', error = error)

@app.error_handler (404)
def not_found (was, error):
  return was.render ('404.htm', error = error)

Template file 404.html is like this:

<h1>{{ error.code }} {{ error.message }}</h1>
<p>{{ error.detail }}</p>
<hr>
<div>URL: {{ error.url }}</div>
<div>Time: {{ error.time }}</div>

Note that custom error templates can not be used before routing to the app.

Working With Chameleon Template Engine

Chameleon is an beautiful HTML/XML template engine.

For using this engine you install first.

pip3 install -U chameleon

If you save Chameleon template with ‘.pt’ or ‘.ptal’ extensions at templates directory, Atila will render this template with Chameleon.

Working With Django

New in skitai version 0.26.15

I barely use Django, but recently I have opportunity using Django and it is very fantastic and especially impressive to Django Admin System.

Here are some examples collaborating with Djnago and Atila.

Before it begin, you should mount Django app,

# mount django admin
with skitai.preference () as pref:
  pref.use_reloader = True
  pref.use_debug = True
  # '/' mapped with django.admin in urls.py
  skitai.mount ("/admin", 'django/wsgi.py', 'application', pref)

# mount main app
with skitai.preference () as pref:
  pref.use_reloader = True
  pref.use_debug = True
  skitai.mount ('/', 'app.py', pref = pref)

skitai.run ()

When Django app is mounted, these will be processed.

  1. add django project root path will be added to sys.path
  2. app is mounted
  3. database alias (@mydjangoapp) will be created as base name of django project root

Using Django Models

You can use also Django models without mount app.

First of all, you should specify django setting with alias for django database engine.

skitai.alias ("@django", skitai.DJANGO, "myapp/settings.py")

Then call django.setup () and you can use your models,

import django
django.setup () # should call
from mydjangoapp.photos import models

@app,route ('/django/hello')
def django_hello (was):
  models.Photo.objects.create (user='Hans Roh', title = 'My Photo')
  result = models.Photo.filter (user='hansroh').order_by ('-create_at')

You can use Django Query Set as SQL generator for Skitai’s asynchronous query execution. But it has some limitations.

  • just vaild only select query and prefetch_related ()
  • effetive only to PostgreSQL and SQLite3 (but SQLite3 dose not support asynchronous execution, so it is practically meaningless)
from mydjangoapp.photos import models

@app,route ('/hello')
def django_hello (was):
  query = models.Photo.objects.filter (topic=1).order_by ('title')
  return was.jstream (was.sqlite3 ("@entity").execute (query).dispatch ().data, 'data')

How To

Response All Errors As JSON

@app.default_error_handler
def default_error_handler (was, error):
  code = error ["errno"] or str (error ["code"]) + '00'
  return was.Fault (
    error ["message"].lower (), code, None,
    error ["detail"], exc_info = error ["traceback"]
  )

Change Log

  • 0.9 (Apr, 2021)
    • deprecate app.test_client (), use skitai.test_client ()
    • add app.run ()
    • change app initializing hook name from __setup__ () to __config__ ()
    • add app.unroute ()
    • add app.extends ()
    • add app.overrides ()
    • multiple life-cycle hooks
  • 0.8 (Feb, 2020)
    • add request stream feature, @app.route (…, input_stream = True)
    • add was.Queue
    • add atila.service
    • fix was.pipe ()
    • add generator based coroutine and @app.coroutine
    • was.g and app.r has benn deprecated, use app.g and app.r
    • add app.r for current request context, was.g is changed to global app context with thread-safe
    • add was.pipe ()
    • @app.inspect can inspect JSON data
    • @app.maintain can have threading option
    • remove 400 Not My Fault with assert
    • rename from @app.require to @app.inspect but still valid
    • @app.maintain can have interval parameter
    • add was.Media () and was.Mounted ()
    • add was.render_or_Map ()
    • add was.Map () and was.ThreadPass ()
    • add was.static and was.media
    • both __setup__ and __mount__ can have mntopt argument optionally
    • add was.Static ()
    • add 400 Not My Fault with assert
    • add notags and safes arguments to @app.require
    • now, csrf token uses cookie not session and kept with browser
    • add remove_csrf ()
    • fix corequest cache sync
    • update, config.MINIFY_HTML = None (default) | ‘strip’ | ‘minify’
    • add @app.csrf_verification_required
    • add @app.clarify_permission’ and @app.clarify_login’ decorators
    • add __setup__ hook for service packages.__init__.py
  • 0.7 (Dec, 2019)
    • fix <path> type routing
    • change URL build alias from was.urlspec ()
    • change URL build alias from was.ab () to was.urlfor ()
    • add alias was.urlpatch () for was.partial () for clarity
    • add session.impending () and session.use_time ()
    • change default options for Jinja2
    • change session key name
    • fix session expireation
    • add extend param to session.mount ()
    • add was.render_or_API ()
    • add was.request.acceptables and was.request.acceptable (media)
    • fix @app.fix testpass_required when reloading
    • change session.mount spec
    • fix multiple mount bug related enable_namespace
    • fix websocket bug related enable_namespace
    • app.auto_mount was deprecated
    • default value of app.enable_namespace has been from False to True. ACTION REQUIRED, lower version incompatible
  • 0.6 (Oct, 2019)
    • fix query string exception handling
    • readd Chameleon template engine chapter to README
    • test on PyPy
  • 0.5 (Sep, 2019)
    • add app example
    • update requirements
  • 0.4 (Aug, 2019)
    • now, modules within __mount__ are reloadable
    • deprecated @app.test_params, use @app.require
    • deprecated was.Future and was.Futures, it doesn’t need.
  • 0.3 (Mar 13, 2019)
    • remove proxing django route
    • remove login service with django
    • remove django model signal redirecting
    • add @app.require
    • change mount handler: def mount (app) => def __mount__ (app) but lower version compatible
    • make available @app.route (“”)
    • add was.ProxyPass (alias, path, timeout = 3)
    • add special pre-defined URL parameter value: me, notme, new
    • add parameter validation, now response code 400, if validatiion if failed
    • fix implicit routing
    • add conditional permission control
  • 0.2 (Feb 18, 2019)
    • fix implicit routing for root
    • remove jinja2 from requirements
    • add app.websocket_send ()
    • fix Futures respinse bugs
    • add was.API (), was.Fault (), was.File and was.Futures ()
  • 0.1 (Jan 17, 2019)
    • was.promise () has been deprecated, use was.Futures ()
    • add interval based maintain jobs executor
    • change name from app.storage to app.store
    • add default_bearer_handler
    • fix routing bugs related fancy URL
    • add was.request.URL, DEFAULT, FORM (former was.request.form ()), JSON (former was.request.json ()), DATA (former was.request.data), ARGS (former was.request.args)
    • add @app.test_param (required = None, ints = None, floats = None)
    • project has been seperated from skitai and rename from saddle to atila, because saddle project is already exist on PYPI

Project details


Release history Release notifications | RSS feed

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Files for atila, version 0.10.1
Filename, size File type Python version Upload date Hashes
Filename, size atila-0.10.1-py3-none-any.whl (126.3 kB) File type Wheel Python version py3 Upload date Hashes View

Supported by

AWS AWS Cloud computing Datadog Datadog Monitoring DigiCert DigiCert EV certificate Facebook / Instagram Facebook / Instagram PSF Sponsor Fastly Fastly CDN Google Google Object Storage and Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Salesforce Salesforce PSF Sponsor Sentry Sentry Error logging StatusPage StatusPage Status page