encadre

Encadre, petit framework web Python (Flask) : contrôleurs en greffons, validation, tests
git clone https://git.schurger.org/encadre.git
Log | Files | Refs

commit 8c1af7cdbba2750cb2424c03318fef5251443568
Author: Jean Schurger <jean@schurger.org>
Date:   Tue, 17 Dec 2024 12:54:00 -0500

Imported.

Diffstat:
A.gitignore | 2++
AMakefile | 15+++++++++++++++
AREADME.org | 334+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A_org/body | 4++++
A_org/footer | 2++
A_org/header | 7+++++++
A_org/org.js | 1851+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A_org/org2html.js | 22++++++++++++++++++++++
A_org/style.css | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A_org/style.less | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acadre/__init__.py | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acadre/controllers.py | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acadre/framework.py | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acadre_flask/__init__.py | 166+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acadre_flask/test.py | 25+++++++++++++++++++++++++
Acadre_test_app.wsgi | 2++
Acadre_test_app/__init__.py | 25+++++++++++++++++++++++++
Acadre_test_controllers/__init__.py | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aconfig.ini | 48++++++++++++++++++++++++++++++++++++++++++++++++
Aroutes.html | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aroutes.org | 0
Asetup.cfg | 3+++
Asetup.py | 23+++++++++++++++++++++++
Atest/__init__.py | 6++++++
24 files changed, 3665 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +.coverage +.DS_Store diff --git a/Makefile b/Makefile @@ -0,0 +1,15 @@ +top: README.html routes.html + +%.html: %.org + cat _org/header >$@ + cat _org/style.css >>$@ + cat _org/body >>$@ + cat $< | ./_org/org2html.js >> $@ + cat _org/footer >>$@ + + +routes.org: cadre_test_controllers/*.py + cadre-test-app-dump-routes config.ini >routes.org + +tests: + nosetests --nocapture --with-coverage --cover-inclusive --cover-package=cadre,cadre_flask,cadre_test_controllers,cadre_test_app diff --git a/README.org b/README.org @@ -0,0 +1,334 @@ +#+TITLE: This is the 'cadre' framework ! + +This is the cadre framework engine. + +* Introduction + +It is a simple web framework, without hard dependencies to base web framework. +Right now, the only implementation of the underlying framework available is 'flask'. + +* Things you can do with it + +** Install it ! + +#+begin_example shell +python setup.py develop +#+end_example + +** Create an App + +Your app *MUST* be packaged ! (also your controllers). + +*** setup.py + +#+begin_example python +from setuptools import setup + +console_scripts = """ +cadre-test-app-serve = cadre_test_app:serve +cadre-test-app-dump-routes = cadre_test_app:dump_routes + +""" + +setup(name='cadre_test_app', + version='0.1.0', + description="Cadre_Test_App framework", + author="Ubity Awesome Dev Team", + author_email='dev@ubity.com', + packages=['cadre_test_app'], + install_requires=["Cadre"], + entry_points={'console_scripts': console_scripts, + 'cadre_test_app.controllers': [ + 'hello = cadre_test_app_hello']}, + license='GPLv3') +#+end_example python + +*** main (cadre_test_app/__init__.py) + +#+begin_example python +import sys +from cadre import Cadre + +class Cadre_Test_App(Cadre): + pass + +def serve(): + if len(sys.argv) != 2: + print("Usage: %s config.ini") + sys.exit(1) + Cadre_Test_App(sys.argv[1]).serve() + +# tool to dump routes +def dump_routes(): + if len(sys.argv) != 2: + print("Usage: %s config.ini") + sys.exit(1) + Cadre_Test_App(sys.argv[1]).dump_routes() +#+end_example + +*** config.ini + +Should contains at least a section telling that we are using /flask/ (as this is the only sub framework implemented. +The section name is you app name (ie: *cadre_test_app*). + +#+begin_example +[cadre_test_app] +framework = flask +#+end_example + +Optionnaly a *[flask]* section to give to 'flask'. + +#+begin_example +[flask] +PORT = 5001 +DEBUG = true +HOST = 0.0.0.0 +#+end_example + +Optionnaly the *loggers* section. +#+begin_example +[loggers] +keys = root, cadre, cadre_flask + +[logger_root] +handlers = + +[logger_cadre] +level = DEBUG +handlers = console +qualname = cadre + +[logger_cadre_flask] +level = DEBUG +handlers = console +qualname = cadre_flask + +[handlers] +keys = console + +[formatters] +keys = generic + +[formatter_generic] + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +#+end_example + +*** a controller (cadre_test_app_hello/__init__.py) + +#+begin_example python +from cadre import Controller + + +class Hello(Controller): + """ A very polite controller """ + + def index__(self): + """ Says hello ! """ + return "Hello world 0" + + +class Hello_2(Controller): + """ A very polite controller (version 2) """ + + def index__(self): + """ Says hello ! """ + return "Hello world" + + def to(self, who): + """ Says hello to whom ask for """ + return "Hello %s !" % who + + def foo(self): + return "Foo" + + def foo_(self): + return "Foo with an '_' at the end !" + + def bar_GET(self): + return "Bar" + + def baz_POST(self): + return "Baz" + + def qux_GET_POST(self): + return "Qux" +#+end_example + +** Run you app + +#+begin_example +python setup.py develop +cadre_test_app-serve config.ini +#+end_example + +** Query it + +*** Simple query + +#+begin_example +curl http://localhost:5001/hello/ +#+end_example + +*** Specific version of a controller + +**** Using headers +#+begin_example +curl http://localhost:5001/hello/ -H X-cadre_test_app-version:0 +#+end_example + +**** Using URL prefix + +You can set: + +#+begin_example +versioning = prefix +#+end_example + +In you're application section in the configuration file. +Doing so will prepend the version as prefix to each routes. + +**** First version + +By default, without specifying any version number, the first version of a controller is *0*. +To set a different version number, use the *first_version* keyword in your application's section configuration file. + +#+begin_example +first_version = 1 +#+end_example + +*** Parameters + +Routes are constructed using the controllers, method names and method args. + +In the following example, + +#+begin_example +class Foo(Controller): + + def bar(self, baz): + pass +#+end_example + +The matching route will be: */foo/bar/<baz>* + +For example querying */foo/bar/123* would call the *bar()* method with the value *123* for the *baz* parameter. + +*** Args + +Args are the url-encoded key=value style args after the ? in the URL. +They are available in requests.args. + +#+begin_example +class Foo(Controller): + + def bar(self): + from cadre import request + request.args.get('qwe') +#+end_example + + +*** Headers + +#+begin_example +class Foo(Controller): + + def bar(self): + from cadre import request + request.headers.get('X-Magic-Header') +#+end_example + +*** Cookies + +#+begin_example +class Foo(Controller): + + def bar_needs_cookie(self): + from cadre import request + request.cookies.get('my_great_cookie') + + def baz_set_cookie(self): + from cadre import request + request.cookies.set('my_great_cookie', 'my_great_value') +#+end_example + + +*** JSON + +#+begin_example +class Foo(Controller): + + def bar(self): + from cadre import request + request.json # containt an object, + # mostlikely a dict of the received JSON +#+end_example + +** Dump routes + +#+begin_example +python setup.py develop # if not done already +cadre_test_app-dump-routes +#+end_example + +** Run with UWSGI + +You can use UWSGI to run your app. +For that, you will need some UWSGI configuration and loader. + +*** configuration + +Can be added in /config.ini/ + +#+begin_example +[uwsgi] +http = localhost:5001 +file = cadre_test_app.wsgi +#+end_example + +*** loader cadre_test_app.wsgi +#+begin_example python +from cadre_test_app import Cadre_Test_App +application = Cadre_Test_App("config.ini").get_wsgi_app() +#+end_example + +*** start it + +#+begin_example +uwsgi config.ini +#+end_example + + +* Running 'cadre' tests + +#+begin_example shell +pip install nose coverage +make tests +#+end_example + +* Nice features (not yet documented) + +- Custom error handling +- *before* and *after* hooks +- Automatic detection of missing tests +- Validation of input JSON using decorator + +* Things to do as soon as possible + +- [X] Send a file +- [X] Handle query args +- [X] Return HTTP code on error +- [X] Get cookie +- [X] Set cookie +- [X] Add controllers test and runtime +- [X] walk sub __subclasses +- [X] Honor version in the flask routes +- [X] Raise a +meaningfull+ 405 if method is missing (route existing from another version) +- [X] Honor the verb +- [X] Make uwsgi friendly (returning an app) +- [X] Add a uwsgi example +- [X] Add __before__ __after__ +- [X] Add json +- [X] Get and Set cookies diff --git a/_org/body b/_org/body @@ -0,0 +1,3 @@ + </style> + </head> + <body> +\ No newline at end of file diff --git a/_org/footer b/_org/footer @@ -0,0 +1,2 @@ + </body> +</html> diff --git a/_org/header b/_org/header @@ -0,0 +1,6 @@ +<!doctype html> +<html lang="fr"> + <head> + <meta charset="utf-8" /> + <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=UTF-8" /> + <style> +\ No newline at end of file diff --git a/_org/org.js b/_org/org.js @@ -0,0 +1,1851 @@ +// Generated by export.rb at Fri Apr 26 15:29:06 UTC 2019 +/* + Copyright (c) 2014 Masafumi Oyamada + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +var Org = (function () { + var exports = {}; + + // ------------------------------------------------------------ + // Syntax + // ------------------------------------------------------------ + + var Syntax = { + rules: {}, + + define: function (name, syntax) { + this.rules[name] = syntax; + var methodName = "is" + name.substring(0, 1).toUpperCase() + name.substring(1); + this[methodName] = function (line) { + return this.rules[name].exec(line); + }; + } + }; + + Syntax.define("header", /^(\*+)\s+(.*)$/); // m[1] => level, m[2] => content + Syntax.define("preformatted", /^(\s*):(?: (.*)$|$)/); // m[1] => indentation, m[2] => content + Syntax.define("unorderedListElement", /^(\s*)(?:-|\+|\s+\*)\s+(.*)$/); // m[1] => indentation, m[2] => content + Syntax.define("orderedListElement", /^(\s*)(\d+)(?:\.|\))\s+(.*)$/); // m[1] => indentation, m[2] => number, m[3] => content + Syntax.define("tableSeparator", /^(\s*)\|((?:\+|-)*?)\|?$/); // m[1] => indentation, m[2] => content + Syntax.define("tableRow", /^(\s*)\|(.*?)\|?$/); // m[1] => indentation, m[2] => content + Syntax.define("blank", /^$/); + Syntax.define("horizontalRule", /^(\s*)-{5,}$/); // + Syntax.define("directive", /^(\s*)#\+(?:(begin|end)_)?(.*)$/i); // m[1] => indentation, m[2] => type, m[3] => content + Syntax.define("comment", /^(\s*)#(.*)$/); + Syntax.define("line", /^(\s*)(.*)$/); + Syntax.define("property", /^([A-Z]+):\s(.*)$/); // m[1] => keyword, m[2] => value + + // ------------------------------------------------------------ + // Token + // ------------------------------------------------------------ + + function Token() { + } + + Token.prototype = { + isListElement: function () { + return this.type === Lexer.tokens.orderedListElement || + this.type === Lexer.tokens.unorderedListElement; + }, + + isTableElement: function () { + return this.type === Lexer.tokens.tableSeparator || + this.type === Lexer.tokens.tableRow; + } + }; + + // ------------------------------------------------------------ + // Lexer + // ------------------------------------------------------------ + + function Lexer(stream) { + this.stream = stream; + this.tokenStack = []; + } + + Lexer.prototype = { + tokenize: function (line) { + var token = new Token(); + token.fromLineNumber = this.stream.lineNumber; + + if (Syntax.isHeader(line)) { + token.type = Lexer.tokens.header; + token.indentation = 0; + token.content = RegExp.$2; + // specific + token.level = RegExp.$1.length; + } else if(Syntax.isProperty(line)) { + token.type = Lexer.tokens.property; + token.indentation = 0; + token.content = RegExp.$2; + // specific + token.keyword = RegExp.$1; + } else if (Syntax.isPreformatted(line)) { + token.type = Lexer.tokens.preformatted; + token.indentation = RegExp.$1.length; + token.content = RegExp.$2; + } else if (Syntax.isUnorderedListElement(line)) { + token.type = Lexer.tokens.unorderedListElement; + token.indentation = RegExp.$1.length; + token.content = RegExp.$2; + } else if (Syntax.isOrderedListElement(line)) { + token.type = Lexer.tokens.orderedListElement; + token.indentation = RegExp.$1.length; + token.content = RegExp.$3; + // specific + token.number = RegExp.$2; + } else if (Syntax.isTableSeparator(line)) { + token.type = Lexer.tokens.tableSeparator; + token.indentation = RegExp.$1.length; + token.content = RegExp.$2; + } else if (Syntax.isTableRow(line)) { + token.type = Lexer.tokens.tableRow; + token.indentation = RegExp.$1.length; + token.content = RegExp.$2; + } else if (Syntax.isBlank(line)) { + token.type = Lexer.tokens.blank; + token.indentation = 0; + token.content = null; + } else if (Syntax.isHorizontalRule(line)) { + token.type = Lexer.tokens.horizontalRule; + token.indentation = RegExp.$1.length; + token.content = null; + } else if (Syntax.isDirective(line)) { + token.type = Lexer.tokens.directive; + token.indentation = RegExp.$1.length; + token.content = RegExp.$3; + // decide directive type (begin, end or oneshot) + var directiveTypeString = RegExp.$2; + if (/^begin/i.test(directiveTypeString)) + token.beginDirective = true; + else if (/^end/i.test(directiveTypeString)) + token.endDirective = true; + else + token.oneshotDirective = true; + } else if (Syntax.isComment(line)) { + token.type = Lexer.tokens.comment; + token.indentation = RegExp.$1.length; + token.content = RegExp.$2; + } else if (Syntax.isLine(line)) { + token.type = Lexer.tokens.line; + token.indentation = RegExp.$1.length; + token.content = RegExp.$2; + } else { + throw new Error("SyntaxError: Unknown line: " + line); + } + + return token; + }, + + pushToken: function (token) { + this.tokenStack.push(token); + }, + + pushDummyTokenByType: function (type) { + var token = new Token(); + token.type = type; + this.tokenStack.push(token); + }, + + peekStackedToken: function () { + return this.tokenStack.length > 0 ? + this.tokenStack[this.tokenStack.length - 1] : null; + }, + + getStackedToken: function () { + return this.tokenStack.length > 0 ? + this.tokenStack.pop() : null; + }, + + peekNextToken: function () { + return this.peekStackedToken() || + this.tokenize(this.stream.peekNextLine()); + }, + + getNextToken: function () { + return this.getStackedToken() || + this.tokenize(this.stream.getNextLine()); + }, + + hasNext: function () { + return this.stream.hasNext(); + }, + + getLineNumber: function () { + return this.stream.lineNumber; + } + }; + + Lexer.tokens = {}; + [ + "header", + "orderedListElement", + "unorderedListElement", + "tableRow", + "tableSeparator", + "preformatted", + "line", + "horizontalRule", + "blank", + "directive", + "comment", + "property" + ].forEach(function (tokenName, i) { + Lexer.tokens[tokenName] = i; + }); + + // ------------------------------------------------------------ + // Exports + // ------------------------------------------------------------ + + if (typeof exports !== "undefined") + exports.Lexer = Lexer; + + function PrototypeNode(type, children) { + this.type = type; + this.children = []; + + if (children) { + for (var i = 0, len = children.length; i < len; ++i) { + this.appendChild(children[i]); + } + } + } + PrototypeNode.prototype = { + previousSibling: null, + parent: null, + get firstChild() { + return this.children.length < 1 ? + null : this.children[0]; + }, + get lastChild() { + return this.children.length < 1 ? + null : this.children[this.children.length - 1]; + }, + appendChild: function (newChild) { + var previousSibling = this.children.length < 1 ? + null : this.lastChild; + this.children.push(newChild); + newChild.previousSibling = previousSibling; + newChild.parent = this; + }, + toString: function () { + var string = "<" + this.type + ">"; + + if (typeof this.value !== "undefined") { + string += " " + this.value; + } else if (this.children) { + string += "\n" + this.children.map(function (child, idx) { + return "#" + idx + " " + child.toString(); + }).join("\n").split("\n").map(function (line) { + return " " + line; + }).join("\n"); + } + + return string; + } + }; + + var Node = { + types: {}, + + define: function (name, postProcess) { + this.types[name] = name; + + var methodName = "create" + name.substring(0, 1).toUpperCase() + name.substring(1); + var postProcessGiven = typeof postProcess === "function"; + + this[methodName] = function (children, options) { + var node = new PrototypeNode(name, children); + + if (postProcessGiven) + postProcess(node, options || {}); + + return node; + }; + } + }; + + Node.define("text", function (node, options) { + node.value = options.value; + }); + Node.define("header", function (node, options) { + node.level = options.level; + }); + Node.define("property"); + Node.define("orderedList"); + Node.define("unorderedList"); + Node.define("definitionList"); + Node.define("listElement"); + Node.define("paragraph"); + Node.define("preformatted"); + Node.define("table"); + Node.define("tableRow"); + Node.define("tableCell"); + Node.define("horizontalRule"); + Node.define("directive"); + + // Inline + Node.define("inlineContainer"); + + Node.define("bold"); + Node.define("italic"); + Node.define("underline"); + Node.define("code"); + Node.define("verbatim"); + Node.define("dashed"); + Node.define("link", function (node, options) { + node.src = options.src; + }); + + if (typeof exports !== "undefined") + exports.Node = Node; + + function Stream(sequence) { + this.sequences = sequence.split(/\r?\n/); + this.totalLines = this.sequences.length; + this.lineNumber = 0; + } + + Stream.prototype.peekNextLine = function () { + return this.hasNext() ? this.sequences[this.lineNumber] : null; + }; + + Stream.prototype.getNextLine = function () { + return this.hasNext() ? this.sequences[this.lineNumber++] : null; + }; + + Stream.prototype.hasNext = function () { + return this.lineNumber < this.totalLines; + }; + + if (typeof exports !== "undefined") { + exports.Stream = Stream; + } + + // var Stream = require("./stream.js").Stream; + // var Lexer = require("./lexer.js").Lexer; + // var Node = require("./node.js").Node; + + function Parser() { + this.inlineParser = new InlineParser(); + } + + Parser.parseStream = function (stream, options) { + var parser = new Parser(); + parser.initStatus(stream, options); + parser.parseNodes(); + return parser.nodes; + }; + + Parser.prototype = { + initStatus: function (stream, options) { + if (typeof stream === "string") + stream = new Stream(stream); + this.lexer = new Lexer(stream); + this.nodes = []; + this.options = { + toc: true, + num: true, + "^": "{}", + multilineCell: false + }; + // Override option values + if (options && typeof options === "object") { + for (var key in options) { + this.options[key] = options[key]; + } + } + this.document = { + options: this.options, + directiveValues: {}, + convert: function (ConverterClass, exportOptions) { + var converter = new ConverterClass(this, exportOptions); + return converter.result; + } + }; + }, + + parse: function (stream, options) { + this.initStatus(stream, options); + this.parseDocument(); + this.document.nodes = this.nodes; + return this.document; + }, + + createErrorReport: function (message) { + return new Error(message + " at line " + this.lexer.getLineNumber()); + }, + + skipBlank: function () { + var blankToken = null; + while (this.lexer.peekNextToken().type === Lexer.tokens.blank) + blankToken = this.lexer.getNextToken(); + return blankToken; + }, + + setNodeOriginFromToken: function (node, token) { + node.fromLineNumber = token.fromLineNumber; + return node; + }, + + appendNode: function (newNode) { + var previousSibling = this.nodes.length > 0 ? this.nodes[this.nodes.length - 1] : null; + this.nodes.push(newNode); + newNode.previousSibling = previousSibling; + }, + + // ------------------------------------------------------------ + // <Document> ::= <Element>* + // ------------------------------------------------------------ + + parseDocument: function () { + this.parseTitle(); + this.parseNodes(); + }, + + parseNodes: function () { + while (this.lexer.hasNext()) { + var element = this.parseElement(); + if (element) this.appendNode(element); + } + }, + + parseTitle: function () { + this.skipBlank(); + + if (this.lexer.hasNext() && + this.lexer.peekNextToken().type === Lexer.tokens.line) + this.document.title = this.createTextNode(this.lexer.getNextToken().content); + else + this.document.title = null; + + this.lexer.pushDummyTokenByType(Lexer.tokens.blank); + }, + + // ------------------------------------------------------------ + // <Element> ::= (<Header> | <List> + // | <Preformatted> | <Paragraph> + // | <Table>)* + // ------------------------------------------------------------ + + parseElement: function () { + var element = null; + + switch (this.lexer.peekNextToken().type) { + case Lexer.tokens.header: + element = this.parseHeader(); + break; + case Lexer.tokens.property: + element = this.parseProperty(); + break; + case Lexer.tokens.preformatted: + element = this.parsePreformatted(); + break; + case Lexer.tokens.orderedListElement: + case Lexer.tokens.unorderedListElement: + element = this.parseList(); + break; + case Lexer.tokens.line: + element = this.parseText(); + break; + case Lexer.tokens.tableRow: + case Lexer.tokens.tableSeparator: + element = this.parseTable(); + break; + case Lexer.tokens.blank: + this.skipBlank(); + if (this.lexer.hasNext()) { + if (this.lexer.peekNextToken().type === Lexer.tokens.line) + element = this.parseParagraph(); + else + element = this.parseElement(); + } + break; + case Lexer.tokens.horizontalRule: + this.lexer.getNextToken(); + element = Node.createHorizontalRule(); + break; + case Lexer.tokens.directive: + element = this.parseDirective(); + break; + case Lexer.tokens.comment: + // Skip + this.lexer.getNextToken(); + break; + default: + throw this.createErrorReport("Unhandled token: " + this.lexer.peekNextToken().type); + } + + return element; + }, + + parseElementBesidesDirectiveEnd: function () { + try { + // Temporary, override the definition of `parseElement` + this.parseElement = this.parseElementBesidesDirectiveEndBody; + return this.parseElement(); + } finally { + this.parseElement = this.originalParseElement; + } + }, + + parseElementBesidesDirectiveEndBody: function () { + if (this.lexer.peekNextToken().type === Lexer.tokens.directive && + this.lexer.peekNextToken().endDirective) { + return null; + } + + return this.originalParseElement(); + }, + + // ------------------------------------------------------------ + // <Header> + // + // : preformatted + // : block + // ------------------------------------------------------------ + + parseHeader: function () { + var headerToken = this.lexer.getNextToken(); + var header = Node.createHeader([ + this.createTextNode(headerToken.content) // TODO: Parse inline markups + ], { level: headerToken.level }); + this.setNodeOriginFromToken(header, headerToken); + + while(this.lexer.hasNext() && this.lexer.peekNextToken().type === Lexer.tokens.property){ + var property = this.parseProperty(); + header.appendChild(property); + } + + return header; + }, + + parseProperty: function(rootToken){ + var propertyToken = this.lexer.getNextToken(); + var property = Node.createProperty(null); + this.setNodeOriginFromToken(property, propertyToken); + property.keyword = propertyToken.keyword; + property.content = propertyToken.content; + return property; + }, + + // ------------------------------------------------------------ + // <Preformatted> + // + // : preformatted + // : block + // ------------------------------------------------------------ + + parsePreformatted: function () { + var preformattedFirstToken = this.lexer.peekNextToken(); + var preformatted = Node.createPreformatted([]); + this.setNodeOriginFromToken(preformatted, preformattedFirstToken); + + var textContents = []; + + while (this.lexer.hasNext()) { + var token = this.lexer.peekNextToken(); + if (token.type !== Lexer.tokens.preformatted || + token.indentation < preformattedFirstToken.indentation) + break; + this.lexer.getNextToken(); + textContents.push(token.content); + } + + preformatted.appendChild(this.createTextNode(textContents.join("\n"), true /* no emphasis */)); + + return preformatted; + }, + + // ------------------------------------------------------------ + // <List> + // + // - foo + // 1. bar + // 2. baz + // ------------------------------------------------------------ + + // XXX: not consider codes (e.g., =Foo::Bar=) + definitionPattern: /^(.*?) :: *(.*)$/, + + parseList: function () { + var rootToken = this.lexer.peekNextToken(); + var list; + var isDefinitionList = false; + + if (this.definitionPattern.test(rootToken.content)) { + list = Node.createDefinitionList([]); + isDefinitionList = true; + } else { + list = rootToken.type === Lexer.tokens.unorderedListElement ? + Node.createUnorderedList([]) : Node.createOrderedList([]); + } + this.setNodeOriginFromToken(list, rootToken); + + while (this.lexer.hasNext()) { + var nextToken = this.lexer.peekNextToken(); + if (!nextToken.isListElement() || nextToken.indentation !== rootToken.indentation) + break; + list.appendChild(this.parseListElement(rootToken.indentation, isDefinitionList)); + } + + return list; + }, + + unknownDefinitionTerm: "???", + + parseListElement: function (rootIndentation, isDefinitionList) { + var listElementToken = this.lexer.getNextToken(); + var listElement = Node.createListElement([]); + this.setNodeOriginFromToken(listElement, listElementToken); + + listElement.isDefinitionList = isDefinitionList; + + if (isDefinitionList) { + var match = this.definitionPattern.exec(listElementToken.content); + listElement.term = [ + this.createTextNode(match && match[1] ? match[1] : this.unknownDefinitionTerm) + ]; + listElement.appendChild(this.createTextNode(match ? match[2] : listElementToken.content)); + } else { + listElement.appendChild(this.createTextNode(listElementToken.content)); + } + + while (this.lexer.hasNext()) { + var blankToken = this.skipBlank(); + if (!this.lexer.hasNext()) + break; + + var notBlankNextToken = this.lexer.peekNextToken(); + if (blankToken && !notBlankNextToken.isListElement()) + this.lexer.pushToken(blankToken); // Recover blank token only when next line is not listElement. + if (notBlankNextToken.indentation <= rootIndentation) + break; // end of the list + + var element = this.parseElement(); // recursive + if (element) + listElement.appendChild(element); + } + + return listElement; + }, + + // ------------------------------------------------------------ + // <Table> ::= <TableRow>+ + // ------------------------------------------------------------ + + parseTable: function () { + var nextToken = this.lexer.peekNextToken(); + var table = Node.createTable([]); + this.setNodeOriginFromToken(table, nextToken); + var sawSeparator = false; + + var allowMultilineCell = nextToken.type === Lexer.tokens.tableSeparator && this.options.multilineCell; + + while (this.lexer.hasNext() && + (nextToken = this.lexer.peekNextToken()).isTableElement()) { + if (nextToken.type === Lexer.tokens.tableRow) { + var tableRow = this.parseTableRow(allowMultilineCell); + table.appendChild(tableRow); + } else { + // Lexer.tokens.tableSeparator + sawSeparator = true; + this.lexer.getNextToken(); + } + } + + if (sawSeparator && table.children.length) { + table.children[0].children.forEach(function (cell) { + cell.isHeader = true; + }); + } + + return table; + }, + + // ------------------------------------------------------------ + // <TableRow> ::= <TableCell>+ + // ------------------------------------------------------------ + + parseTableRow: function (allowMultilineCell) { + var tableRowTokens = []; + + while (this.lexer.peekNextToken().type === Lexer.tokens.tableRow) { + tableRowTokens.push(this.lexer.getNextToken()); + if (!allowMultilineCell) { + break; + } + } + + if (!tableRowTokens.length) { + throw this.createErrorReport("Expected table row"); + } + + var firstTableRowToken = tableRowTokens.shift(); + var tableCellTexts = firstTableRowToken.content.split("|"); + + tableRowTokens.forEach(function (rowToken) { + rowToken.content.split("|").forEach(function (cellText, cellIdx) { + tableCellTexts[cellIdx] = (tableCellTexts[cellIdx] || "") + "\n" + cellText; + }); + }); + + // TODO: Prepare two pathes: (1) + var tableCells = tableCellTexts.map( + // TODO: consider '|' escape? + function (text) { + return Node.createTableCell(Parser.parseStream(text)); + }, this); + + return this.setNodeOriginFromToken(Node.createTableRow(tableCells), firstTableRowToken); + }, + + // ------------------------------------------------------------ + // <Directive> ::= "#+.*" + // ------------------------------------------------------------ + + parseDirective: function () { + var directiveToken = this.lexer.getNextToken(); + var directiveNode = this.createDirectiveNodeFromToken(directiveToken); + + if (directiveToken.endDirective) + throw this.createErrorReport("Unmatched 'end' directive for " + directiveNode.directiveName); + + if (directiveToken.oneshotDirective) { + this.interpretDirective(directiveNode); + return directiveNode; + } + + if (!directiveToken.beginDirective) + throw this.createErrorReport("Invalid directive " + directiveNode.directiveName); + + // Parse begin ~ end + directiveNode.children = []; + if (this.isVerbatimDirective(directiveNode)) + return this.parseDirectiveBlockVerbatim(directiveNode); + else + return this.parseDirectiveBlock(directiveNode); + }, + + createDirectiveNodeFromToken: function (directiveToken) { + var matched = /^[ ]*([^ ]*)[ ]*(.*)[ ]*$/.exec(directiveToken.content); + + var directiveNode = Node.createDirective(null); + this.setNodeOriginFromToken(directiveNode, directiveToken); + directiveNode.directiveName = matched[1].toLowerCase(); + directiveNode.directiveArguments = this.parseDirectiveArguments(matched[2]); + directiveNode.directiveOptions = this.parseDirectiveOptions(matched[2]); + directiveNode.directiveRawValue = matched[2]; + + return directiveNode; + }, + + isVerbatimDirective: function (directiveNode) { + var directiveName = directiveNode.directiveName; + return directiveName === "src" || directiveName === "example" || directiveName === "html"; + }, + + parseDirectiveBlock: function (directiveNode, verbatim) { + this.lexer.pushDummyTokenByType(Lexer.tokens.blank); + + while (this.lexer.hasNext()) { + var nextToken = this.lexer.peekNextToken(); + if (nextToken.type === Lexer.tokens.directive && + nextToken.endDirective && + this.createDirectiveNodeFromToken(nextToken).directiveName === directiveNode.directiveName) { + // Close directive + this.lexer.getNextToken(); + return directiveNode; + } + var element = this.parseElementBesidesDirectiveEnd(); + if (element) + directiveNode.appendChild(element); + } + + throw this.createErrorReport("Unclosed directive " + directiveNode.directiveName); + }, + + parseDirectiveBlockVerbatim: function (directiveNode) { + var textContent = []; + + while (this.lexer.hasNext()) { + var nextToken = this.lexer.peekNextToken(); + if (nextToken.type === Lexer.tokens.directive && + nextToken.endDirective && + this.createDirectiveNodeFromToken(nextToken).directiveName === directiveNode.directiveName) { + this.lexer.getNextToken(); + directiveNode.appendChild(this.createTextNode(textContent.join("\n"), true)); + return directiveNode; + } + textContent.push(this.lexer.stream.getNextLine()); + } + + throw this.createErrorReport("Unclosed directive " + directiveNode.directiveName); + }, + + parseDirectiveArguments: function (parameters) { + return parameters.split(/[ ]+/).filter(function (param) { + return param.length && param[0] !== "-"; + }); + }, + + parseDirectiveOptions: function (parameters) { + return parameters.split(/[ ]+/).filter(function (param) { + return param.length && param[0] === "-"; + }); + }, + + interpretDirective: function (directiveNode) { + // http://orgmode.org/manual/Export-options.html + switch (directiveNode.directiveName) { + case "options:": + this.interpretOptionDirective(directiveNode); + break; + case "title:": + this.document.title = directiveNode.directiveRawValue; + break; + case "author:": + this.document.author = directiveNode.directiveRawValue; + break; + case "email:": + this.document.email = directiveNode.directiveRawValue; + break; + default: + this.document.directiveValues[directiveNode.directiveName] = directiveNode.directiveRawValue; + break; + } + }, + + interpretOptionDirective: function (optionDirectiveNode) { + optionDirectiveNode.directiveArguments.forEach(function (pairString) { + var pair = pairString.split(":"); + this.options[pair[0]] = this.convertLispyValue(pair[1]); + }, this); + }, + + convertLispyValue: function (lispyValue) { + switch (lispyValue) { + case "t": + return true; + case "nil": + return false; + default: + if (/^[0-9]+$/.test(lispyValue)) + return parseInt(lispyValue); + return lispyValue; + } + }, + + // ------------------------------------------------------------ + // <Paragraph> ::= <Blank> <Line>* + // ------------------------------------------------------------ + + parseParagraph: function () { + var paragraphFisrtToken = this.lexer.peekNextToken(); + var paragraph = Node.createParagraph([]); + this.setNodeOriginFromToken(paragraph, paragraphFisrtToken); + + var textContents = []; + + while (this.lexer.hasNext()) { + var nextToken = this.lexer.peekNextToken(); + if (nextToken.type !== Lexer.tokens.line + || nextToken.indentation < paragraphFisrtToken.indentation) + break; + this.lexer.getNextToken(); + textContents.push(nextToken.content); + } + + paragraph.appendChild(this.createTextNode(textContents.join("\n"))); + + return paragraph; + }, + + parseText: function (noEmphasis) { + var lineToken = this.lexer.getNextToken(); + return this.createTextNode(lineToken.content, noEmphasis); + }, + + // ------------------------------------------------------------ + // <Text> (DOM Like) + // ------------------------------------------------------------ + + createTextNode: function (text, noEmphasis) { + return noEmphasis ? Node.createText(null, { value: text }) + : this.inlineParser.parseEmphasis(text); + } + }; + Parser.prototype.originalParseElement = Parser.prototype.parseElement; + + // ------------------------------------------------------------ + // Parser for Inline Elements + // + // @refs org-emphasis-regexp-components + // ------------------------------------------------------------ + + function InlineParser() { + this.preEmphasis = " \t\\('\""; + this.postEmphasis = "- \t.,:!?;'\"\\)"; + this.borderForbidden = " \t\r\n,\"'"; + this.bodyRegexp = "[\\s\\S]*?"; + this.markers = "*/_=~+"; + + this.emphasisPattern = this.buildEmphasisPattern(); + this.linkPattern = /\[\[([^\]]*)\](?:\[([^\]]*)\])?\]/g; // \1 => link, \2 => text + } + + InlineParser.prototype = { + parseEmphasis: function (text) { + var emphasisPattern = this.emphasisPattern; + emphasisPattern.lastIndex = 0; + + var result = [], + match, + previousLast = 0, + savedLastIndex; + + while ((match = emphasisPattern.exec(text))) { + var whole = match[0]; + var pre = match[1]; + var marker = match[2]; + var body = match[3]; + var post = match[4]; + + { + // parse links + var matchBegin = emphasisPattern.lastIndex - whole.length; + var beforeContent = text.substring(previousLast, matchBegin + pre.length); + savedLastIndex = emphasisPattern.lastIndex; + result.push(this.parseLink(beforeContent)); + emphasisPattern.lastIndex = savedLastIndex; + } + + var bodyNode = [Node.createText(null, { value: body })]; + var bodyContainer = this.emphasizeElementByMarker(bodyNode, marker); + result.push(bodyContainer); + + previousLast = emphasisPattern.lastIndex - post.length; + } + + if (emphasisPattern.lastIndex === 0 || + emphasisPattern.lastIndex !== text.length - 1) + result.push(this.parseLink(text.substring(previousLast))); + + if (result.length === 1) { + // Avoid duplicated inline container wrapping + return result[0]; + } else { + return Node.createInlineContainer(result); + } + }, + + depth: 0, + parseLink: function (text) { + var linkPattern = this.linkPattern; + linkPattern.lastIndex = 0; + + var match, + result = [], + previousLast = 0, + savedLastIndex; + + while ((match = linkPattern.exec(text))) { + var whole = match[0]; + var src = match[1]; + var title = match[2]; + + // parse before content + var matchBegin = linkPattern.lastIndex - whole.length; + var beforeContent = text.substring(previousLast, matchBegin); + result.push(Node.createText(null, { value: beforeContent })); + + // parse link + var link = Node.createLink([]); + link.src = src; + if (title) { + savedLastIndex = linkPattern.lastIndex; + link.appendChild(this.parseEmphasis(title)); + linkPattern.lastIndex = savedLastIndex; + } else { + link.appendChild(Node.createText(null, { value: src })); + } + result.push(link); + + previousLast = linkPattern.lastIndex; + } + + if (linkPattern.lastIndex === 0 || + linkPattern.lastIndex !== text.length - 1) + result.push(Node.createText(null, { value: text.substring(previousLast) })); + + return Node.createInlineContainer(result); + }, + + emphasizeElementByMarker: function (element, marker) { + switch (marker) { + case "*": + return Node.createBold(element); + case "/": + return Node.createItalic(element); + case "_": + return Node.createUnderline(element); + case "=": + case "~": + return Node.createCode(element); + case "+": + return Node.createDashed(element); + } + }, + + buildEmphasisPattern: function () { + return new RegExp( + "([" + this.preEmphasis + "]|^|\r?\n)" + // \1 => pre + "([" + this.markers + "])" + // \2 => marker + "([^" + this.borderForbidden + "]|" + // \3 => body + "[^" + this.borderForbidden + "]" + + this.bodyRegexp + + "[^" + this.borderForbidden + "])" + + "\\2" + + "([" + this.postEmphasis +"]|$|\r?\n)", // \4 => post + // flags + "g" + ); + } + }; + + if (typeof exports !== "undefined") { + exports.Parser = Parser; + exports.InlineParser = InlineParser; + } + + // var Node = require("../node.js").Node; + + function Converter() { + } + + Converter.prototype = { + exportOptions: { + headerOffset: 1, + exportFromLineNumber: false, + suppressSubScriptHandling: false, + suppressAutoLink: false, + // HTML + translateSymbolArrow: false, + suppressCheckboxHandling: false, + // { "directive:": function (node, childText, auxData) {} } + customDirectiveHandler: null, + // e.g., "org-js-" + htmlClassPrefix: null, + htmlIdPrefix: null + }, + + untitled: "Untitled", + result: null, + + // TODO: Manage TODO lists + + initialize: function (orgDocument, exportOptions) { + this.orgDocument = orgDocument; + this.documentOptions = orgDocument.options || {}; + this.exportOptions = exportOptions || {}; + + this.headers = []; + this.headerOffset = + typeof this.exportOptions.headerOffset === "number" ? this.exportOptions.headerOffset : 1; + this.sectionNumbers = [0]; + }, + + createTocItem: function (headerNode, parentTocs) { + var childTocs = []; + childTocs.parent = parentTocs; + var tocItem = { headerNode: headerNode, childTocs: childTocs }; + return tocItem; + }, + + computeToc: function (exportTocLevel) { + if (typeof exportTocLevel !== "number") + exportTocLevel = Infinity; + + var toc = []; + toc.parent = null; + + var previousLevel = 1; + var currentTocs = toc; // first + + for (var i = 0; i < this.headers.length; ++i) { + var headerNode = this.headers[i]; + + if (headerNode.level > exportTocLevel) + continue; + + var levelDiff = headerNode.level - previousLevel; + if (levelDiff > 0) { + for (var j = 0; j < levelDiff; ++j) { + if (currentTocs.length === 0) { + // Create a dummy tocItem + var dummyHeader = Node.createHeader([], { + level: previousLevel + j + }); + dummyHeader.sectionNumberText = ""; + currentTocs.push(this.createTocItem(dummyHeader, currentTocs)); + } + currentTocs = currentTocs[currentTocs.length - 1].childTocs; + } + } else if (levelDiff < 0) { + levelDiff = -levelDiff; + for (var k = 0; k < levelDiff; ++k) { + currentTocs = currentTocs.parent; + } + } + + currentTocs.push(this.createTocItem(headerNode, currentTocs)); + + previousLevel = headerNode.level; + } + + return toc; + }, + + convertNode: function (node, recordHeader, insideCodeElement) { + if (!insideCodeElement) { + if (node.type === Node.types.directive) { + if (node.directiveName === "example" || + node.directiveName === "src") { + insideCodeElement = true; + } + } else if (node.type === Node.types.preformatted) { + insideCodeElement = true; + } + } + + if (typeof node === "string") { + node = Node.createText(null, { value: node }); + } + + var childText = node.children ? this.convertNodesInternal(node.children, recordHeader, insideCodeElement) : ""; + var text; + + var auxData = this.computeAuxDataForNode(node); + + switch (node.type) { + case Node.types.header: + // Parse task status + var taskStatus = null; + var _match = childText.match(/^[A-Z]{3,}\s+/); + if(_match && _match[0]){ + taskStatus = _match[0].trim().toLowerCase(); + } + + // Compute section number + var sectionNumberText = null; + if (recordHeader) { + var thisHeaderLevel = node.level; + var previousHeaderLevel = this.sectionNumbers.length; + if (thisHeaderLevel > previousHeaderLevel) { + // Fill missing section number + var levelDiff = thisHeaderLevel - previousHeaderLevel; + for (var j = 0; j < levelDiff; ++j) { + this.sectionNumbers[thisHeaderLevel - 1 - j] = 0; // Extend + } + } else if (thisHeaderLevel < previousHeaderLevel) { + this.sectionNumbers.length = thisHeaderLevel; // Collapse + } + this.sectionNumbers[thisHeaderLevel - 1]++; + if (this.documentOptions.num) + sectionNumberText = this.sectionNumbers.join("."); + node.sectionNumberText = sectionNumberText; // Can be used in ToC + } + + text = this.convertHeader(node, childText, auxData, + taskStatus, sectionNumberText); + + if (recordHeader) + this.headers.push(node); + break; + case Node.types.orderedList: + text = this.convertOrderedList(node, childText, auxData); + break; + case Node.types.unorderedList: + text = this.convertUnorderedList(node, childText, auxData); + break; + case Node.types.definitionList: + text = this.convertDefinitionList(node, childText, auxData); + break; + case Node.types.listElement: + if (node.isDefinitionList) { + var termText = this.convertNodesInternal(node.term, recordHeader, insideCodeElement); + text = this.convertDefinitionItem(node, childText, auxData, + termText, childText); + } else { + text = this.convertListItem(node, childText, auxData); + } + break; + case Node.types.paragraph: + text = this.convertParagraph(node, childText, auxData); + break; + case Node.types.preformatted: + text = this.convertPreformatted(node, childText, auxData); + break; + case Node.types.table: + text = this.convertTable(node, childText, auxData); + break; + case Node.types.tableRow: + text = this.convertTableRow(node, childText, auxData); + break; + case Node.types.tableCell: + if (node.isHeader) { + if (childText) + text = this.convertTableHeader(node, childText, auxData); + } else + text = this.convertTableCell(node, childText, auxData); + break; + case Node.types.horizontalRule: + text = this.convertHorizontalRule(node, childText, auxData); + break; + // ============================================================ // + // Inline + // ============================================================ // + case Node.types.inlineContainer: + text = this.convertInlineContainer(node, childText, auxData); + break; + case Node.types.bold: + text = this.convertBold(node, childText, auxData); + break; + case Node.types.italic: + text = this.convertItalic(node, childText, auxData); + break; + case Node.types.underline: + text = this.convertUnderline(node, childText, auxData); + break; + case Node.types.code: + text = this.convertCode(node, childText, auxData); + break; + case Node.types.dashed: + text = this.convertDashed(node, childText, auxData); + break; + case Node.types.link: + text = this.convertLink(node, childText, auxData); + break; + case Node.types.property: + text = this.convertProperty(node, childText, auxData); + break; + case Node.types.directive: + switch (node.directiveName) { + case "quote": + text = this.convertQuote(node, childText, auxData); + break; + case "example": + text = this.convertExample(node, childText, auxData); + break; + case "src": + text = this.convertSrc(node, childText, auxData); + break; + case "html": + case "html:": + text = this.convertHTML(node, childText, auxData); + break; + default: + if (this.exportOptions.customDirectiveHandler && + this.exportOptions.customDirectiveHandler[node.directiveName]) { + text = this.exportOptions.customDirectiveHandler[node.directiveName]( + node, childText, auxData + ); + } else { + text = childText; + } + } + break; + case Node.types.text: + text = this.convertText(node.value, insideCodeElement); + break; + default: + throw Error("Unknown node type: " + node.type); + } + + if (typeof this.postProcess === "function") { + text = this.postProcess(node, text, insideCodeElement); + } + + return text; + }, + + convertText: function (text, insideCodeElement) { + var escapedText = this.escapeSpecialChars(text, insideCodeElement); + + if (!this.exportOptions.suppressSubScriptHandling && !insideCodeElement) { + escapedText = this.makeSubscripts(escapedText, insideCodeElement); + } + if (!this.exportOptions.suppressAutoLink) { + escapedText = this.linkURL(escapedText); + } + + return escapedText; + }, + + // By default, ignore html + convertHTML: function (node, childText, auxData) { + return childText; + }, + + convertNodesInternal: function (nodes, recordHeader, insideCodeElement) { + var nodesTexts = []; + for (var i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + var nodeText = this.convertNode(node, recordHeader, insideCodeElement); + nodesTexts.push(nodeText); + } + return this.combineNodesTexts(nodesTexts); + }, + + convertHeaderBlock: function (headerBlock, recordHeader) { + throw Error("convertHeaderBlock is not implemented"); + }, + + convertHeaderTree: function (headerTree, recordHeader) { + return this.convertHeaderBlock(headerTree, recordHeader); + }, + + convertNodesToHeaderTree: function (nodes, nextBlockBegin, blockHeader) { + var childBlocks = []; + var childNodes = []; + + if (typeof nextBlockBegin === "undefined") { + nextBlockBegin = 0; + } + if (typeof blockHeader === "undefined") { + blockHeader = null; + } + + for (var i = nextBlockBegin; i < nodes.length;) { + var node = nodes[i]; + + var isHeader = node.type === Node.types.header; + + if (!isHeader) { + childNodes.push(node); + i = i + 1; + continue; + } + + // Header + if (blockHeader && node.level <= blockHeader.level) { + // Finish Block + break; + } else { + // blockHeader.level < node.level + // Begin child block + var childBlock = this.convertNodesToHeaderTree(nodes, i + 1, node); + childBlocks.push(childBlock); + i = childBlock.nextIndex; + } + } + + // Finish block + return { + header: blockHeader, + childNodes: childNodes, + nextIndex: i, + childBlocks: childBlocks + }; + }, + + convertNodes: function (nodes, recordHeader, insideCodeElement) { + return this.convertNodesInternal(nodes, recordHeader, insideCodeElement); + }, + + combineNodesTexts: function (nodesTexts) { + return nodesTexts.join(""); + }, + + getNodeTextContent: function (node) { + if (node.type === Node.types.text) + return this.escapeSpecialChars(node.value); + else + return node.children ? node.children.map(this.getNodeTextContent, this).join("") : ""; + }, + + // @Override + escapeSpecialChars: function (text) { + throw Error("Implement escapeSpecialChars"); + }, + + // http://daringfireball.net/2010/07/improved_regex_for_matching_urls + urlPattern: /\b(?:https?:\/\/|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}\/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'".,<>?«»“”‘’])/ig, + + // @Override + linkURL: function (text) { + var self = this; + return text.replace(this.urlPattern, function (matched) { + if (matched.indexOf("://") < 0) + matched = "http://" + matched; + return self.makeLink(matched); + }); + }, + + makeLink: function (url) { + throw Error("Implement makeLink"); + }, + + makeSubscripts: function (text) { + if (this.documentOptions["^"] === "{}") + return text.replace(/\b([^_ \t]*)_{([^}]*)}/g, + this.makeSubscript); + else if (this.documentOptions["^"]) + return text.replace(/\b([^_ \t]*)_([^_]*)\b/g, + this.makeSubscript); + else + return text; + }, + + makeSubscript: function (match, body, subscript) { + throw Error("Implement makeSubscript"); + }, + + stripParametersFromURL: function (url) { + return url.replace(/\?.*$/, ""); + }, + + imageExtensionPattern: new RegExp("(" + [ + "bmp", "png", "jpeg", "jpg", "gif", "tiff", + "tif", "xbm", "xpm", "pbm", "pgm", "ppm", "svg" + ].join("|") + ")$", "i") + }; + + if (typeof exports !== "undefined") + exports.Converter = Converter; + + // var Converter = require("./converter.js").Converter; + // var Node = require("../node.js").Node; + + function ConverterHTML(orgDocument, exportOptions) { + this.initialize(orgDocument, exportOptions); + this.result = this.convert(); + } + + ConverterHTML.prototype = { + __proto__: Converter.prototype, + + convert: function () { + var title = this.orgDocument.title ? this.convertNode(this.orgDocument.title) : this.untitled; + var titleHTML = this.tag("h" + Math.max(Number(this.headerOffset), 1), title); + var contentHTML = this.convertNodes(this.orgDocument.nodes, true /* record headers */); + var toc = ''; + var tocHTML = ''; + if (this.documentOptions.toc) { + toc = this.computeToc(this.documentOptions["toc"]); + tocHTML = this.tocToHTML(toc); + } + return { + title: title, + titleHTML: titleHTML, + contentHTML: contentHTML, + tocHTML: tocHTML, + toc: toc, + toString: function () { + return titleHTML + tocHTML + "\n" + contentHTML; + } + }; + }, + + tocToHTML: function (toc) { + function tocToHTMLFunction(tocList) { + var html = ""; + for (var i = 0; i < tocList.length; ++i) { + var tocItem = tocList[i]; + var sectionNumberText = tocItem.headerNode.sectionNumberText; + var sectionNumber = this.documentOptions.num ? + this.inlineTag("span", sectionNumberText, { + "class": "section-number" + }) : ""; + var header = this.getNodeTextContent(tocItem.headerNode); + var headerLink = this.inlineTag("a", sectionNumber + header, { + href: "#header-" + sectionNumberText.replace(/\./g, "-") + }); + var subList = tocItem.childTocs.length ? tocToHTMLFunction.call(this, tocItem.childTocs) : ""; + html += this.tag("li", headerLink + subList); + } + return this.tag("ul", html); + } + + return tocToHTMLFunction.call(this, toc); + }, + + computeAuxDataForNode: function (node) { + while (node.parent && + node.parent.type === Node.types.inlineContainer) { + node = node.parent; + } + var attributesNode = node.previousSibling; + var attributesText = ""; + while (attributesNode && + attributesNode.type === Node.types.directive && + attributesNode.directiveName === "attr_html:") { + attributesText += attributesNode.directiveRawValue + " "; + attributesNode = attributesNode.previousSibling; + } + return attributesText; + }, + + // Method to construct org-js generated class + orgClassName: function (className) { + return this.exportOptions.htmlClassPrefix ? + this.exportOptions.htmlClassPrefix + className + : className; + }, + + // Method to construct org-js generated id + orgId: function (id) { + return this.exportOptions.htmlIdPrefix ? + this.exportOptions.htmlIdPrefix + id + : id; + }, + + // ---------------------------------------------------- + // Node conversion + // ---------------------------------------------------- + + convertHeader: function (node, childText, auxData, + taskStatus, sectionNumberText) { + var headerAttributes = {}; + + if (taskStatus) { + childText = this.inlineTag("span", childText.substring(0, 4), { + "class": "task-status " + taskStatus + }) + childText.substring(5); + } + + if (sectionNumberText) { + childText = this.inlineTag("span", sectionNumberText, { + "class": "section-number" + }) + childText; + headerAttributes["id"] = "header-" + sectionNumberText.replace(/\./g, "-"); + } + + if (taskStatus) + headerAttributes["class"] = "task-status " + taskStatus; + + return this.tag("h" + (this.headerOffset + node.level), + childText, headerAttributes, auxData); + }, + + convertOrderedList: function (node, childText, auxData) { + return this.tag("ol", childText, null, auxData); + }, + + convertUnorderedList: function (node, childText, auxData) { + return this.tag("ul", childText, null, auxData); + }, + + convertDefinitionList: function (node, childText, auxData) { + return this.tag("dl", childText, null, auxData); + }, + + convertDefinitionItem: function (node, childText, auxData, + term, definition) { + return this.tag("dt", term) + this.tag("dd", definition); + }, + + convertListItem: function (node, childText, auxData) { + if (this.exportOptions.suppressCheckboxHandling) { + return this.tag("li", childText, null, auxData); + } else { + var listItemAttributes = {}; + var listItemText = childText; + // Embed checkbox + if (/^\s*\[(X| |-)\]([\s\S]*)/.exec(listItemText)) { + listItemText = RegExp.$2 ; + var checkboxIndicator = RegExp.$1; + + var checkboxAttributes = { type: "checkbox" }; + switch (checkboxIndicator) { + case "X": + checkboxAttributes["checked"] = "true"; + listItemAttributes["data-checkbox-status"] = "done"; + break; + case "-": + listItemAttributes["data-checkbox-status"] = "intermediate"; + break; + default: + listItemAttributes["data-checkbox-status"] = "undone"; + break; + } + + listItemText = this.inlineTag("input", null, checkboxAttributes) + listItemText; + } + + return this.tag("li", listItemText, listItemAttributes, auxData); + } + }, + + convertParagraph: function (node, childText, auxData) { + return this.tag("p", childText, null, auxData); + }, + + convertPreformatted: function (node, childText, auxData) { + return this.tag("pre", childText, null, auxData); + }, + + convertTable: function (node, childText, auxData) { + return this.tag("table", this.tag("tbody", childText), null, auxData); + }, + + convertTableRow: function (node, childText, auxData) { + return this.tag("tr", childText); + }, + + convertTableHeader: function (node, childText, auxData) { + return this.tag("th", childText); + }, + + convertTableCell: function (node, childText, auxData) { + return this.tag("td", childText); + }, + + convertHorizontalRule: function (node, childText, auxData) { + return this.tag("hr", null, null, auxData); + }, + + convertInlineContainer: function (node, childText, auxData) { + return childText; + }, + + convertBold: function (node, childText, auxData) { + return this.inlineTag("b", childText); + }, + + convertItalic: function (node, childText, auxData) { + return this.inlineTag("i", childText); + }, + + convertUnderline: function (node, childText, auxData) { + return this.inlineTag("span", childText, { + style: "text-decoration:underline;" + }); + }, + + convertCode: function (node, childText, auxData) { + return this.inlineTag("code", childText); + }, + + convertDashed: function (node, childText, auxData) { + return this.inlineTag("del", childText); + }, + + convertLink: function (node, childText, auxData) { + var srcParameterStripped = this.stripParametersFromURL(node.src); + if (this.imageExtensionPattern.exec(srcParameterStripped)) { + var imgText = this.getNodeTextContent(node); + return this.inlineTag("img", null, { + src: node.src, + alt: imgText, + title: imgText + }, auxData); + } else { + return this.inlineTag("a", childText, { href: node.src }); + } + }, + + convertQuote: function (node, childText, auxData) { + return this.tag("blockquote", childText, null, auxData); + }, + + convertExample: function (node, childText, auxData) { + return this.tag("pre", childText, null, auxData); + }, + + convertSrc: function (node, childText, auxData) { + var codeLanguage = node.directiveArguments.length + ? node.directiveArguments[0] + : "unknown"; + childText = this.tag("code", childText, { + "class": "language-" + codeLanguage + }, auxData); + return this.tag("pre", childText, { + "class": "prettyprint" + }); + }, + + // @override + convertHTML: function (node, childText, auxData) { + if (node.directiveName === "html:") { + return node.directiveRawValue; + } else if (node.directiveName === "html") { + return node.children.map(function (textNode) { + return textNode.value; + }).join("\n"); + } else { + return childText; + } + }, + + // @implement + convertHeaderBlock: function (headerBlock, level, index) { + level = level || 0; + index = index || 0; + + var contents = []; + + var headerNode = headerBlock.header; + if (headerNode) { + contents.push(this.convertNode(headerNode)); + } + + var blockContent = this.convertNodes(headerBlock.childNodes); + contents.push(blockContent); + + var childBlockContent = headerBlock.childBlocks + .map(function (block, idx) { + return this.convertHeaderBlock(block, level + 1, idx); + }, this) + .join("\n"); + contents.push(childBlockContent); + + var contentsText = contents.join("\n"); + + if (headerNode) { + return this.tag("section", "\n" + contents.join("\n"), { + "class": "block block-level-" + level + }); + } else { + return contentsText; + } + }, + + // ---------------------------------------------------- + // Supplemental methods + // ---------------------------------------------------- + + replaceMap: { + // [replacing pattern, predicate] + "&": ["&#38;", null], + "<": ["&#60;", null], + ">": ["&#62;", null], + '"': ["&#34;", null], + "'": ["&#39;", null], + "->": ["&#10132;", function (text, insideCodeElement) { + return this.exportOptions.translateSymbolArrow && !insideCodeElement; + }] + }, + + replaceRegexp: null, + + // @implement @override + escapeSpecialChars: function (text, insideCodeElement) { + if (!this.replaceRegexp) { + this.replaceRegexp = new RegExp(Object.keys(this.replaceMap).join("|"), "g"); + } + + var replaceMap = this.replaceMap; + var self = this; + return text.replace(this.replaceRegexp, function (matched) { + if (!replaceMap[matched]) { + throw Error("escapeSpecialChars: Invalid match"); + } + + var predicate = replaceMap[matched][1]; + if (typeof predicate === "function" && + !predicate.call(self, text, insideCodeElement)) { + // Not fullfill the predicate + return matched; + } + + return replaceMap[matched][0]; + }); + }, + + // @implement + postProcess: function (node, currentText, insideCodeElement) { + if (this.exportOptions.exportFromLineNumber && + typeof node.fromLineNumber === "number") { + // Wrap with line number information + currentText = this.inlineTag("div", currentText, { + "data-line-number": node.fromLineNumber + }); + } + return currentText; + }, + + // @implement + makeLink: function (url) { + return "<a href=\"" + url + "\">" + decodeURIComponent(url) + "</a>"; + }, + + // @implement + makeSubscript: function (match, body, subscript) { + return "<span class=\"org-subscript-parent\">" + + body + + "</span><span class=\"org-subscript-child\">" + + subscript + + "</span>"; + }, + + // ---------------------------------------------------- + // Specific methods + // ---------------------------------------------------- + + attributesObjectToString: function (attributesObject) { + var attributesString = ""; + for (var attributeName in attributesObject) { + if (attributesObject.hasOwnProperty(attributeName)) { + var attributeValue = attributesObject[attributeName]; + // To avoid id/class name conflicts with other frameworks, + // users can add arbitrary prefix to org-js generated + // ids/classes via exportOptions. + if (attributeName === "class") { + attributeValue = this.orgClassName(attributeValue); + } else if (attributeName === "id") { + attributeValue = this.orgId(attributeValue); + } + attributesString += " " + attributeName + "=\"" + attributeValue + "\""; + } + } + return attributesString; + }, + + inlineTag: function (name, innerText, attributesObject, auxAttributesText) { + attributesObject = attributesObject || {}; + + var htmlString = "<" + name; + // TODO: check duplicated attributes + if (auxAttributesText) + htmlString += " " + auxAttributesText; + htmlString += this.attributesObjectToString(attributesObject); + + if (innerText === null) + return htmlString + "/>"; + + htmlString += ">" + innerText + "</" + name + ">"; + + return htmlString; + }, + + tag: function (name, innerText, attributesObject, auxAttributesText) { + return this.inlineTag(name, innerText, attributesObject, auxAttributesText) + "\n"; + } + }; + + if (typeof exports !== "undefined") + exports.ConverterHTML = ConverterHTML; + + return exports; +})(); + +Object.assign(exports, Org) diff --git a/_org/org2html.js b/_org/org2html.js @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +var org = require("./org"); +var parser = new org.Parser(); + +process.stdin.resume(); +process.stdin.setEncoding('utf8'); + +var orgCode = ""; +process.stdin.on('data', function (chunk) { + orgCode += chunk; +}); + +process.stdin.on('end', function () { + parseAndOutputHTML(); +}); + +function parseAndOutputHTML() { + var orgDocument = parser.parse(orgCode); + var orgHTMLDocument = orgDocument.convert(org.ConverterHTML, {}); + console.log(orgHTMLDocument.toString()); +} diff --git a/_org/style.css b/_org/style.css @@ -0,0 +1,158 @@ +@media print { + .noprint { + display: none; + } +} +@media screen { + .noscreen { + display: none; + } +} +html { + font-family: arial; + font-size: 12pt; +} +#body > h1 { + margin-top: 64px; + text-align: center; + margin-bottom: 64px; +} +.logo { + position: absolute; + right: 32px; + top: 8px; + margin-bottom: 100px; +} +li { + list-style-type: none; +} +.section-number { + color: #9d80b3; + padding-right: 10pt; +} +h1 { + color: #2c0449; + font-size: 24pt; +} +h2 { + color: #2c0449; + font-size: 20pt; +} +h3 { + color: #2c0449; + font-size: 16pt; +} +.side-title { + color: #2c0449; + font-size: 16pt; + font-weight: bold; +} +:link { + border-bottom: 1px dotted #65427e; + color: #2c0449; + text-decoration: none; +} +:visited { + border-bottom: 1px dotted #65427e; + color: #9d80b3; + text-decoration: none; +} +:link:hover, +:visited:hover { + color: #65427e; +} +b { + color: #2c0449; +} +code { + background-color: #f4e7fd; + border: 1px solid #6a4d7f; + border-radius: 1pt; + font-size: 90%; +} +pre code { + background-color: none; + border: none; +} +pre { + background-color: #f4e7fd; + font-family: courier, monospace; + font-size: 90%; + overflow: auto; + border: 1px solid #6a4d7f; + border-radius: 4pt; + box-shadow: 2px 2px 3px #9d80b3; + margin-left: auto; + margin-right: auto; + margin-bottom: 10pt; + margin-top: 15pt; + padding: 5pt; +} +textarea { + overflow-x: auto; +} +img { + border: 1px solid #6a4d7f; + border-radius: 4pt; + box-shadow: 2px 2px 3px #9d80b3; + margin-left: auto; + margin-right: auto; + margin-bottom: 10pt; + margin-top: 15pt; + padding: 5pt; +} +.noshadow { + border: none; + box-shadow: none; +} +dt { + font-weight: bold; +} +table { + background: #ebfaf9; + border: 1px solid #6a4d7f; + border-radius: 4pt; + box-shadow: 2px 2px 3px #9d80b3; + margin-left: auto; + margin-right: auto; + margin-bottom: 10pt; + margin-top: 15pt; + padding: 5pt; +} +.root { + border-bottom: none; +} +th { + background: #d1faf7; + border: 1px solid #75f0e8; + border-radius: 1pt; + padding: 5px; + vertical-align: top; +} +td { + background: #ebfaf9; + border: 1px solid #99e5e0; + border-radius: 1pt; + font-family: courier, monospace; + padding: 2px; + vertical-align: top; +} +#src_file { + padding: 8px; + float: right; + font-size: 10px; + color: #ccc; +} +.task-status { + margin-right: 4px; + padding-left: 4px; + padding-right: 4px; +} +.task-status .todo { + background: red; + color: white; +} +.task-status .done { + background: green; + color: white; +} diff --git a/_org/style.less b/_org/style.less @@ -0,0 +1,208 @@ +// Paramètres principaux +// --------------------- + +@main-font-size: 12pt; +@main-hue: 275; +@table-hue: 176; + +.border(@color, @radius: 1pt) { + border: 1px solid darken(@color, 20%); + border-radius: @radius; +} + +.header(@level) { + color: @dark-color; + font-size: @main-font-size + (4 - @level) * 4pt; +} + +.spacing() { + margin-left: auto; + margin-right: auto; + margin-bottom: 10pt; + margin-top: 15pt; + padding: 5pt; +} + +.shadow() { + .border(@light-color, 4pt); + box-shadow: 2px 2px 3px @light-color; + .spacing(); +} + +// Paramètres dérivés +// ------------------ + +@dark-color: hsl(@main-hue, 90%, 15%); +@light-color: hsl(@main-hue, 25%, 60%); +@mixed-color: mix(@dark-color, @light-color); +@background-color: hsl(@main-hue, 85%, 95%); + +@table-color: hsl(@table-hue, 60%, 95%); +@title-color: hsl(@table-hue, 80%, 90%); + +// Disposition générale +// -------------------- + +@media print { .noprint { display: none; } } +@media screen { .noscreen { display: none; } } + +html { + font-family: arial; + font-size: @main-font-size; +} + + +// Title +#body > h1 { + margin-top: 64px; + text-align: center; + margin-bottom: 64px; +} + +.logo { + position: absolute; + right: 32px; + top: 8px; + margin-bottom: 100px; +} + +li { list-style-type: none; } +// Titres +// ------ + +.section-number { + color: @light-color; + padding-right: 10pt; +} + +h1 { + .header(1); +} + +h2 { + .header(2); +} + +h3 { + .header(3); +} + +.side-title { + .header(3); + font-weight: bold; +} + +// Liens +// ----- + +:link { + border-bottom: 1px dotted @mixed-color; + color: @dark-color; + text-decoration: none; +} + +:visited { + border-bottom: 1px dotted @mixed-color; + color: @light-color; + text-decoration: none; +} + +:link:hover, :visited:hover { + color: @mixed-color; + } + +// Exemples +// -------- + +b { + color: @dark-color; +} + +code { + background-color: @background-color; + .border(@light-color); + font-size: 90%; +} + +pre code { + background-color: none; + border: none; +} + +pre { + background-color: @background-color; + font-family: courier, monospace; + font-size: 90%; + overflow: auto; + .shadow(); +} + +textarea { + overflow-x: auto; +} + +// Images +// ------ + +img { + .shadow(); +} + +.noshadow { + border: none; + box-shadow: none; +} + +// Enumérations +// ------------ + +dt { + font-weight: bold; +} + +// Tables +// ------ + +table { + background: @table-color; + .shadow(); +} +.root { + border-bottom: none; +} +th { + background: @title-color; + .border(@title-color); + padding: 5px; + vertical-align: top; +} + +td { + background: @table-color; + .border(@table-color); + font-family: courier, monospace; + padding: 2px; + vertical-align: top; +} + + +#src_file { + padding: 8px; + float: right; + font-size:10px; + color: #ccc; +} + +.task-status { + margin-right: 4px; + padding-left: 4px; + padding-right: 4px; + .todo { + background: red; + color: white; + } + .done { + background: green; + color: white; + } +} diff --git a/cadre/__init__.py b/cadre/__init__.py @@ -0,0 +1,80 @@ +import sys +import configparser +import traceback +import logging +import logging.config +from cadre.controllers import Controller, load_controllers +from cadre.framework import Framework, DumpRoutes +from cadre.framework import framework_from_config, validate + +logger = logging.getLogger(__name__) + + +class NotReady: + def __init__(self, diagnostic): + self.diagnostic = diagnostic + + def __getattr__(self, attribute): + raise AttributeError("Cannot access %r: %s" % + (attribute, self.diagnostic)) + + def __getitem__(self, item): + raise AttributeError("Cannot find %r: %s" % (item, self.diagnostic)) + + def __bool__(self): + return False + + +request = cookies = NotReady("Cadre is not setup yet.") + + +class Cadre(): + + config = {} + + def __init__(self, config_filename): + self.application = getattr(self, 'application', + self.__class__.__name__.lower()) + logger.debug("Starting cadre application '%s'." % self.application) + self.config_filename = config_filename + if self.config_filename: + self.config.update(self.read_config(self.config_filename)) + try: + logging.config.fileConfig(self.config_filename) + except Exception: + print("You'd better configure logging in '%s':\n%s" % + (self.config_filename, traceback.format_exc())) + setattr(sys.modules['cadre'], 'config', self.config) + + def read_config(self, filename): + config = {self.__class__.__name__.lower(): {}} + p = configparser.ConfigParser() + p.read(filename) + for n, s in p.items(): + if s: + config[n] = dict(s) + return config + + def serve(self): + assert self.config, "Missing config." + f = framework_from_config(self.__class__.__name__.lower(), self.config) + f.setup_application(self) + load_controllers(f, self.application) + f.serve() + + def dump_routes(self): + assert self.config, "Missing config." + f = DumpRoutes(self.__class__.__name__.lower(), self.config) + load_controllers(f, self.application) + print(f) + + def get_wsgi_app(self): + config = self.read_config(self.config_filename) + assert config, "Missing config." + f = framework_from_config(self.__class__.__name__.lower(), config) + f.setup_application(self) + load_controllers(f, self.application) + return f.get_wsgi_app() + + +__all__ = ['Cadre', 'Framework', 'Controller', 'validate'] diff --git a/cadre/controllers.py b/cadre/controllers.py @@ -0,0 +1,135 @@ +import traceback +import types +import logging +import unittest +import pkg_resources +from cadre.framework import Framework + +logger = logging.getLogger(__name__) + + +class Controller(): + + version = 0 + + @classmethod + def setup(cls, framework, root): + methods = [] + cls.framework = framework + ctrl_name = cls.__name__.lower() + version = ctrl_name.split('_')[-1] + if version.isdigit(): + ctrl_name = ctrl_name[:-(len(version) + 1)] + version = int(version) + else: + version = framework.first_version + + for name in [a for a in dir(cls) if a[0] != '_']: + attr = getattr(cls, name) + if isinstance(attr, types.FunctionType) and \ + not name.startswith('test_'): + verbs = ''.join([c for c in name if c.isupper() or c == '_' + ]).lstrip('_').split('_') + if verbs != ['']: + name = name[:name.find("_%s" % verbs[0])] + if name == 'index__': + if root: + if name == 'index__': + framework.root_id = id(attr) + else: + root_id = getattr(framework, 'root_id', None) + # Prevent inheritance of index__ + if root_id == id(attr): + continue + methods.append( + (name, attr, + attr.__code__.co_varnames[1:attr.__code__.co_argcount], + verbs if verbs != [''] else ['GET'])) + framework.add_controller((cls, '__root__' if root else ctrl_name, + version), methods) + + +def setup_controllers(cls, f, level=0): + if cls.__name__ != 'Controller': # exclude abstract + cls.setup(f, root=level < 2) + for sub in cls.__subclasses__(): + setup_controllers(sub, f, level + 1) + + +def load_controllers(f, application): + for ep in pkg_resources.iter_entry_points('%s.controllers' % application): + try: + ep.load() + except ModuleNotFoundError: + logger.error("Error importing '%s': %s" % ( + ep, traceback.format_exc())) + for ep in pkg_resources.iter_entry_points('cadre.controllers'): + try: + ep.load() + except ModuleNotFoundError: + logger.error("Error importing '%s': %s" % ( + ep, traceback.format_exc())) + setup_controllers(Controller, f) + + +class CadreTestCase(unittest.TestCase): + def setUp(self): + self.framework._json = {} + self.framework._environ = {} + self.framework._headers = {} + self.framework._args = {} + self.framework._cookies = {} + + def tearDown(self): + pass + + +class TestFramework(Framework): + def request_json(self): + return self._json + + def request_environ(self): + + return self._environ + + def request_headers(self): + return self._headers + + def request_args(self): + return self._args + + def get_cookie(self, key): + return self._cookies.get(key) + + def set_cookie(self, key, value): # pylint: disable=W0221 + self._cookies[key] = value + + def send_bytes(self, bytes_like, **kwargs): # pylint: disable=W0613 + return bytes_like.read() + + +def get_controllers_tsts(app): + f = TestFramework(app.application, None) + load_controllers(f, app.application) + + def missing_test(self): + raise Exception("Missing test") + + for c in f.controllers: + for v in f.controllers[c]: + cls = f.controllers[c][v]['cls'] + attributes = {} + for name in [a for a in dir(cls) if a[0] != '_']: + attr = getattr(cls, name) + if not name.startswith('test_'): + attributes[name] = attr + if isinstance(attr, types.FunctionType) and \ + not name.startswith('test_'): + test_name = 'test_%s' % name + test_long_name = 'test_%s_%s' % (cls.__name__, name) + test_fct = getattr(cls, test_name, missing_test) + attributes[test_long_name] = test_fct + test_cls = type("Test%s" % cls.__name__, + (getattr(cls, 'testcase_cls', CadreTestCase), ), + attributes) + yield (test_cls.__name__, test_cls) diff --git a/cadre/framework.py b/cadre/framework.py @@ -0,0 +1,187 @@ +import sys +import logging +import pkg_resources +import traceback +from decorator import decorator + +logger = logging.getLogger(__name__) +ENI = Exception("Not implemented.") + + +class Cookies(): + def __init__(self, framework): + self.f = framework + + def get(self, key): + return self.f.get_cookie(key) + + def set(self, *args, **kwargs): + return self.f.set_cookie(*args, **kwargs) + + +class Request(): + def __init__(self, framework): + self.f = framework + self._json = None + self._environ = None + self._headers = None + self._args = None + + def _get_json(self): + return self._json or self.f.request_json() + + # Let unit tests set some json + def _set_json(self, json): + self._json = json + + json = property(_get_json, _set_json) + + def _get_environ(self): + return self._environ or self.f.request_environ() + + # Let unit tests set environ + def _set_environ(self, environ): + self._environ = environ + + environ = property(_get_environ, _set_environ) + + def _get_headers(self): + return self._headers or self.f.request_headers() + + # Let unit tests set environ + def _set_headers(self, headers): + self._headers = headers + + headers = property(_get_headers, _set_headers) + + def _get_args(self): + return self._args or self.f.request_args() + + # Let unit tests set environ + def _set_args(self, args): + self._args = args + + args = property(_get_args, _set_args) + + +def validate(vargs): + def helper(func, *args, **kwargs): + if hasattr(args[0].framework, 'validate_query'): + args[0].framework.validate_query(vargs) + return func(*args, **kwargs) + + return decorator(helper) + + +class Framework(): + + request_class = Request + cookies_class = Cookies + + def __init__(self, application, config): + self.application = application + self.config = config + self.first_version = int((self.config or {}).get(application, {}).get( + 'first_version', '0')) + if self.first_version: + logger.debug("Config set 'first_version' to %s." % + self.first_version) + self.versioning_use_prefix = (self.config or {}).get( + application, {}).get('versioning', 'headers').lower() == 'prefix' + if self.versioning_use_prefix: + logger.debug("Config set versioning to 'prefix' mode.") + self.routes = {} + self.controllers = {} + self.request = self.request_class(self) + setattr(sys.modules['cadre'], 'request', self.request) + self.cookies = self.cookies_class(self) + setattr(sys.modules['cadre'], 'cookies', self.cookies) + + def add_controller(self, ctrl, methods): + ctrl_cls, ctrl_name, ctrl_version = ctrl + self.controllers.setdefault(ctrl_name, {}) + self.controllers[ctrl_name][ctrl_version] = { + 'doc': (ctrl_cls.__doc__ or '').strip(), + 'cls': ctrl_cls, + 'methods': {}} + for method_name, method, args, verbs in methods: + self.controllers[ctrl_name][ctrl_version]['methods'].setdefault( + method_name, {}) + for verb in verbs: + self.controllers[ctrl_name][ctrl_version]['methods'][ + method_name][verb] = { + 'fct': method, + 'doc': (method.__doc__ or '').strip(), + 'args': args} + + def setup_application(self, instance): + self.instance = instance + self.before_call_chain = [] + if hasattr(instance, 'before_call'): + self.before_call_chain.append(self.instance_before_call) + self.after_call_chain = [] + if hasattr(instance, 'after_call'): + self.after_call_chain.append(self.instance_after_call) + if hasattr(instance, 'validate_query'): + self.validate_query = instance.validate_query + + def instance_before_call(self): + getattr(self.instance, 'before_call')() + + def instance_after_call(self, response): + getattr(self.instance, 'after_call')() + return response + + def not_implemented(self): + raise ENI + + request_json = request_environ = request_headers = \ + request_args = get_cookie = set_cookie = send_bytes = not_implemented + + def format_exception(self, e): + ret = None + code = 500 + logger.exception(traceback.format_exc()) + if hasattr(self.instance, "format_exception"): + ret, code = self.instance.format_exception(e) + else: + ha = (self.config or {}).get(self.application, {}).get( + 'on_exception', '').lower() + if ha == 'traceback': + ret = traceback.format_exc() + if ha == 'str': + ret = str(e) + if ret is None: + raise e + return ret, code + + +class DumpRoutes(Framework): + + def __repr__(self): + ret = [] + for c in sorted(self.controllers): + ret.append("* Controller: %s" % (c)) + for v in self.controllers[c]: + ret.append("** Version: %s: %s" % + (v, self.controllers[c][v]['doc'])) + for m in self.controllers[c][v]['methods']: + for _v in self.controllers[c][v]['methods'][m]: + ret.append( + "- [%s] %s%s: %s" % + (_v, m, + self.controllers[c][v]['methods'][m][_v]['args'], + self.controllers[c][v]['methods'][m][_v]['doc'])) + return "\n".join(ret) + + +def framework_from_config(application, config): + fw_name = (config.get(application) or {}).get('framework') + assert fw_name, "No framework configured." + for ep in pkg_resources.iter_entry_points('%s.frameworks' % application): + if ep.name == fw_name: + return ep.load()(application, config) + for ep in pkg_resources.iter_entry_points('cadre.frameworks'): + if ep.name == fw_name: + return ep.load()(application, config) + raise Exception("No framework available") diff --git a/cadre_flask/__init__.py b/cadre_flask/__init__.py @@ -0,0 +1,166 @@ +import sys +import re +import logging +from flask import Flask, request, send_file, abort +from flask_cors import CORS + +from cadre import Framework + +logger = logging.getLogger(__name__) + + +class FlaskFramework(Framework): + def __init__(self, application, config): + Framework.__init__(self, application, config) + self.versions = {} + self._endpoints = set() + self.application = application + self.flask_app = Flask(application) + self.flask_app.url_map.strict_slashes = False + self.flask_app.register_error_handler(404, self.handle_exception) + for k, v in (self.config.get('flask') or {}).items(): + _v = v + if v.lower() == 'false': + _v = False + if v.lower() == 'true': + _v = True + if v.isdigit(): + _v = int(v) + self.flask_app.config[k.upper()] = _v + if not (self.config.get('flask') or {}).get('external_cors'): + logger.debug("We handle CORS.") + CORS(self.flask_app) + else: + logger.debug("CORS must be handheld upstream.") + + def setup_application(self, instance): + Framework.setup_application(self, instance) + for fct in self.before_call_chain: + self.flask_app.before_request(fct) + for fct in self.after_call_chain: + self.flask_app.after_request(fct) + self.flask_app.after_request(self._set_cookies) + + def _add_method(self, ctrl_name, method_name): + def accessor(**args): + import flask + v = None + if self.versioning_use_prefix: + v = flask.request.environ.get('PATH_INFO').lstrip('/').split( + '/')[0] + logger.debug("Got version '%s' from URL." % v) + else: + # flask handle case and underscores + h = 'X-%s-Version' % self.application + v = flask.request.headers.get(h) + logger.debug("Got version '%s' from header '%s'." % (v, h)) + if v and not v.isdigit(): + flask.abort(405) + v = int(v) if v else sys.maxsize + v = v if v in self.controllers[ctrl_name] else max( + self.controllers[ctrl_name].keys()) + verb = flask.request.environ['REQUEST_METHOD'] + cls = self.controllers[ctrl_name][v]['cls'] + try: + m = self.controllers[ctrl_name][v]['methods'][method_name][ + verb]['fct'].__name__ + except KeyError: + logger.error("Cannot found method for: controller: '%s', " + "method: '%s', version: '%s', verb: '%s'" % + (ctrl_name, method_name, v, verb)) + flask.abort(405) + self._cookies = [] + try: + ret = getattr(cls(), m)(**args) + except Exception as e: + ret = self.handle_exception(e) + return ret + + setattr(self, '_m_%s_%s' % (ctrl_name, method_name), accessor) + + def handle_exception(self, e): + message, code = self.format_exception(e) + if not message: + abort(code) + return message, code + + def _mk_endpoint(self, ctrl_name, method_name, args, version): + ep = "/%s/%s%s" % (ctrl_name.lower(), + method_name if method_name != 'index__' else '', + ('/' + '/'.join(['<%s>' % a + for a in args]) if args else '')) + return '/%s%s' % (version, ep) if version else ep + + def _setup_routes(self): + """ Add routes to flask """ + endpoints = {} + methods = {} + for c in sorted(self.controllers): + for v in self.controllers[c]: + for m in self.controllers[c][v]['methods']: + for _v in self.controllers[c][v]['methods'][m]: + fct = self.controllers[c][v]['methods'][m][_v]['fct'] + args = self.controllers[c][v]['methods'][m][_v]['args'] + e = self._mk_endpoint( + c, + m, + args, + version=v if self.versioning_use_prefix else None) + endpoints.setdefault(e, set()) + if isinstance(fct.__defaults__, tuple): + url = re.search(r'^(.*[\\\/])', e).group() + endpoints.setdefault(url, set()) + endpoints[url].add(_v) + methods[url] = (c, m) + endpoints[e].add(_v) + methods[e] = (c, m) + for e, verbs in endpoints.items(): + self._add_method(*methods[e]) + logger.debug("Adding endpoint: '%s' allowing with verbs: '%s'." % + (e, verbs)) + route = e + if route.startswith('/__root__'): + route = e.replace('/__root__', '', 1) + self.flask_app.add_url_rule(route, + route, + getattr(self, '_m_%s_%s' % methods[e]), + methods=verbs) + + def serve(self): + self._setup_routes() + self.flask_app.run( + host=self.flask_app.config.get('HOST', '0.0.0.0'), + port=self.flask_app.config.get('PORT', 5001), + debug=self.flask_app.config.get('DEBUG', True), + threaded=self.flask_app.config.get('THREADED', False), + ) + + def get_wsgi_app(self): + self._setup_routes() + return self.flask_app + + def _set_cookies(self, response): + for args, kwargs in getattr(self, '_cookies', {}): + response.set_cookie(*args, **kwargs) + return response + + def request_json(self): + return request.json + + def request_environ(self): + return request.environ + + def request_headers(self): + return request.headers + + def request_args(self): + return request.args + + def get_cookie(self, key): + return request.cookies.get(key) + + def set_cookie(self, *args, **kwargs): + self._cookies.append((args, kwargs)) + + def send_bytes(self, bytes_like, **kwargs): + return send_file(bytes_like, **kwargs) diff --git a/cadre_flask/test.py b/cadre_flask/test.py @@ -0,0 +1,25 @@ +import unittest +from cadre import Cadre +from cadre.framework import framework_from_config +from cadre.controllers import load_controllers + + +class CadreFlask(Cadre): + + application = 'cadre_test_app' + + def __init__(self): + Cadre.__init__(self, None) + self.framework = framework_from_config(self.application, + {self.application: + {'framework': 'flask'}}) + load_controllers(self.framework, self.application) + + +class TestCadreFlask(unittest.TestCase): + + def setUp(self): + self.cadre = CadreFlask() + + def test_setup_routes(self): + self.cadre.framework._setup_routes() diff --git a/cadre_test_app.wsgi b/cadre_test_app.wsgi @@ -0,0 +1,2 @@ +from cadre_test_app import Cadre_Test_App +application = Cadre_Test_App("config.ini").get_wsgi_app() diff --git a/cadre_test_app/__init__.py b/cadre_test_app/__init__.py @@ -0,0 +1,25 @@ +import sys + +import fastjsonschema +from cadre import Cadre + + +class Cadre_Test_App(Cadre): + + def validate_query(self, schema): + from cadre import request + fastjsonschema.compile(schema)(request.json) + + +def serve(): + if len(sys.argv) != 2: + print("Usage: %s config.ini") + sys.exit(1) + Cadre_Test_App(sys.argv[1]).serve() + + +def dump_routes(): + if len(sys.argv) != 2: + print("Usage: %s config.ini") + sys.exit(1) + Cadre_Test_App(sys.argv[1]).dump_routes() diff --git a/cadre_test_controllers/__init__.py b/cadre_test_controllers/__init__.py @@ -0,0 +1,191 @@ +import unittest +import random +from cadre import Controller, validate + + +class Hello(Controller): + """ A very polite controller """ + + def index__(self): + """ Says hello ! """ + return "Hello world" + + def test_index__(self): + self.assertTrue(self.index__() == "Hello world") + + def config(self): + """ Dump the config """ + from cadre import config + return config + + def test_config(self): + from cadre import config + k = 'test_%s' % random.randint(0, 1000) + v = random.randint(0, 1000) + config.update({k: v}) + self.assertTrue(self.config()[k] == v) + + @validate({ + 'title': 'Dummy test', + 'type': 'object', + 'properties': { + 'numbers': { + 'description': "We want an array of numbers", + 'type': 'array', + 'items': { + 'type': 'integer' + }, + 'minItems': 1 + } + }, + 'required': ['numbers'] + }) + def i_am_validated_POST(self): + from cadre import request + return request.json + + def test_i_am_validated_POST(self): + from cadre import request + request.json = {'numbers': [1, 2]} + self.assertTrue(self.i_am_validated_POST() == { + 'numbers': [1, 2]}) + + @validate({ + 'title': 'Dummy test', + 'type': 'object', + 'properties': { + 'numbers': { + 'description': "We want an array of numbers", + 'type': 'array', + 'items': { + 'type': 'integer' + }, + 'minItems': 1 + } + }, + 'required': ['numbers'] + }) + def i_will_not_pass_validation_POST(self): + from cadre import request + return request.json + + def test_i_will_not_pass_validation_POST(self): + from cadre import request + request.json = {'xnumbers': [1, 2]} + self.i_am_validated_POST() + self.assertRaises(Exception) + + +class Hello_2(Controller): + """ A very polite controller (version 2) """ + + def index__(self): + """ Says hello ! """ + return "Hello world (2)" + + def test_index__(self): + self.assertTrue(self.index__() == "Hello world (2)") + + def to(self, who): + """ Says hello to whom ask for """ + return "Hello %s !" % who + + def test_to(self): + assert True + + def foo(self): + return "Foo" + + def test_foo(self): + assert True + + def foo_(self): + return "Foo with an '_' at the end !" + + def test_foo_(self): + assert True + + def bar_GET(self): + return "Bar" + + def test_bar_GET(self): + assert True + + def baz_POST(self): + from cadre import request + return request.json + + def test_baz_POST(self): + from cadre import request + k = 'test_%s' % random.randint(0, 1000) + v = random.randint(0, 1000) + request.json = {k: v} + self.assertTrue(self.baz_POST()[k] == v) + + def qux_GET_POST(self): + return "Qux" + + def test_qux_GET_POST(self): + assert True + + def args(self): + from cadre import request + return request.args.get('qwe', "no 'qwe' arg") + + def test_args(self): + from cadre import request + v = 'test_%s' % random.randint(0, 1000) + request.args['qwe'] = v + self.assertTrue(self.args() == v) + + +class Hello_3(Hello_2): + """ A very polite controller """ + + def index__(self): + """ Says hello ! """ + return "Hello world (3)" + + def test_index__(self): + self.assertTrue(self.index__() == "Hello world (3)") + + def bar_GET(self): + """ Better 'bar' """ + return "Better Bar" + + def test_bar_GET(self): + assert True + + def bar_POST(self): + """ That one handle POST too ! """ + return "Handle POST too." + + def test_bar_POST(self): + assert True + + def send_bytes(self): + return self.framework.send_bytes( + open("/etc/passwd", "rb"), + as_attachment=True, + attachment_filename='etc-passwd.txt', + mimetype='text/plain') + + def test_send_bytes(self): + self.assertTrue(b'root' in self.send_bytes()) + + def dividebyzero(self): + return 1 / 0 + + +class StrangeTestCase(unittest.TestCase): + + def setUp(self): + pass + + def tearDown(self): + pass + + +class Hello_4(Hello_3): + + testcase_cls = StrangeTestCase diff --git a/config.ini b/config.ini @@ -0,0 +1,48 @@ +[cadre_test_app] +framework = flask +# versioning = headers # can be set to prefix +# first_version = 0 + +# Choose what to do with an exception +# either throw the 'traceback' or just the message 'string' +# and return it along with a 500 +# or let reraise by default +on_exception = traceback + + +[flask] +PORT = 5001 +DEBUG = true +HOST = 0.0.0.0 + +[uwsgi] +http = localhost:5001 +file = cadre_test_app.wsgi + +[loggers] +keys = root, cadre, cadre_flask + +[logger_root] +handlers = + +[logger_cadre] +level = DEBUG +handlers = console +qualname = cadre + +[logger_cadre_flask] +level = DEBUG +handlers = console +qualname = cadre_flask + +[handlers] +keys = console + +[formatters] +keys = generic + +[formatter_generic] + +[handler_console] +class = StreamHandler +args = (sys.stderr,) diff --git a/routes.html b/routes.html @@ -0,0 +1,171 @@ +<!doctype html> +<html lang="fr"> + <head> + <meta charset="utf-8" /> + <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=UTF-8" /> + <style>@media print { + .noprint { + display: none; + } +} +@media screen { + .noscreen { + display: none; + } +} +html { + font-family: arial; + font-size: 12pt; +} +#body > h1 { + margin-top: 64px; + text-align: center; + margin-bottom: 64px; +} +.logo { + position: absolute; + right: 32px; + top: 8px; + margin-bottom: 100px; +} +li { + list-style-type: none; +} +.section-number { + color: #9d80b3; + padding-right: 10pt; +} +h1 { + color: #2c0449; + font-size: 24pt; +} +h2 { + color: #2c0449; + font-size: 20pt; +} +h3 { + color: #2c0449; + font-size: 16pt; +} +.side-title { + color: #2c0449; + font-size: 16pt; + font-weight: bold; +} +:link { + border-bottom: 1px dotted #65427e; + color: #2c0449; + text-decoration: none; +} +:visited { + border-bottom: 1px dotted #65427e; + color: #9d80b3; + text-decoration: none; +} +:link:hover, +:visited:hover { + color: #65427e; +} +b { + color: #2c0449; +} +code { + background-color: #f4e7fd; + border: 1px solid #6a4d7f; + border-radius: 1pt; + font-size: 90%; +} +pre code { + background-color: none; + border: none; +} +pre { + background-color: #f4e7fd; + font-family: courier, monospace; + font-size: 90%; + overflow: auto; + border: 1px solid #6a4d7f; + border-radius: 4pt; + box-shadow: 2px 2px 3px #9d80b3; + margin-left: auto; + margin-right: auto; + margin-bottom: 10pt; + margin-top: 15pt; + padding: 5pt; +} +textarea { + overflow-x: auto; +} +img { + border: 1px solid #6a4d7f; + border-radius: 4pt; + box-shadow: 2px 2px 3px #9d80b3; + margin-left: auto; + margin-right: auto; + margin-bottom: 10pt; + margin-top: 15pt; + padding: 5pt; +} +.noshadow { + border: none; + box-shadow: none; +} +dt { + font-weight: bold; +} +table { + background: #ebfaf9; + border: 1px solid #6a4d7f; + border-radius: 4pt; + box-shadow: 2px 2px 3px #9d80b3; + margin-left: auto; + margin-right: auto; + margin-bottom: 10pt; + margin-top: 15pt; + padding: 5pt; +} +.root { + border-bottom: none; +} +th { + background: #d1faf7; + border: 1px solid #75f0e8; + border-radius: 1pt; + padding: 5px; + vertical-align: top; +} +td { + background: #ebfaf9; + border: 1px solid #99e5e0; + border-radius: 1pt; + font-family: courier, monospace; + padding: 2px; + vertical-align: top; +} +#src_file { + padding: 8px; + float: right; + font-size: 10px; + color: #ccc; +} +.task-status { + margin-right: 4px; + padding-left: 4px; + padding-right: 4px; +} +.task-status .todo { + background: red; + color: white; +} +.task-status .done { + background: green; + color: white; +} + </style> + </head> + <body><h1>Untitled</h1> +<ul></ul> + + + </body> +</html> diff --git a/routes.org b/routes.org diff --git a/setup.cfg b/setup.cfg @@ -0,0 +1,3 @@ +[nosetests] +verbosity=3 +with-doctest=1 diff --git a/setup.py b/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup + +console_scripts = """ +cadre-test-app-serve = cadre_test_app:serve +cadre-test-app-dump-routes = cadre_test_app:dump_routes +""" + +setup(name='cadre', + version='0.1.0', + description="Cadre framework", + author="Jean Schurger", + author_email='jean@schurger.org', + packages=['cadre', 'cadre_flask'], + install_requires=['Flask', 'fastjsonschema', 'flask-cors', + 'decorator', 'nose', 'coverage'], + entry_points={ + 'console_scripts': console_scripts, + 'cadre.frameworks': + ['flask = cadre_flask:FlaskFramework'], + 'cadre_test_app.controllers': + ['test_controllers = cadre_test_controllers'] + }, + license='GPLv3') diff --git a/test/__init__.py b/test/__init__.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from cadre_test_app import Cadre_Test_App +from cadre.controllers import get_controllers_tsts + +locals().update(get_controllers_tsts(Cadre_Test_App(None)))