├── .gitignore ├── AUTHORS ├── Changelog ├── INSTALL ├── MANIFEST.in ├── Makefile ├── README ├── README.rst ├── TODO ├── contrib └── doc2ghpages ├── docs ├── Makefile ├── _ext │ ├── applyxrefs.py │ └── literals_to_xrefs.py ├── _theme │ └── nature │ │ ├── static │ │ ├── nature.css_t │ │ └── pygments.css │ │ └── theme.conf ├── changelog.rst ├── conf.py ├── index.rst ├── introduction.rst └── reference │ ├── durian.event.rst │ ├── durian.forms.rst │ ├── durian.match.able.rst │ ├── durian.match.rst │ ├── durian.match.strategy.rst │ ├── durian.models.rst │ ├── durian.tasks.rst │ ├── durian.views.rst │ └── index.rst ├── durian ├── __init__.py ├── admin.py ├── event.py ├── forms.py ├── match │ ├── __init__.py │ ├── able.py │ └── strategy.py ├── models.py ├── registry.py ├── tasks.py ├── templates │ └── durian │ │ ├── base.html │ │ ├── base_site.html │ │ ├── create_hook.html │ │ └── select_hook.html ├── tests │ ├── __init__.py │ ├── test_hooks.py │ └── test_match.py ├── urls.py └── views.py ├── examples ├── durianproject │ ├── __init__.py │ ├── durianapp │ │ ├── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ └── views.py │ ├── manage.py │ ├── settings.py │ └── urls.py └── hook.py ├── setup.cfg ├── setup.py └── testproj ├── __init__.py ├── manage.py ├── settings.py └── urls.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *~ 4 | *.sqlite 5 | *.sqlite-journal 6 | settings_local.py 7 | local_settings.py 8 | .*.sw[po] 9 | dist/ 10 | *.egg-info 11 | doc/__build/* 12 | build/ 13 | locale/ 14 | pip-log.txt 15 | devdatabase.db 16 | .directory 17 | bundle_version.gen 18 | celeryd.log 19 | celeryd.pid 20 | docs/.build 21 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Ask Solem 2 | 3 | Inspired by John Boxall's django-webhooks 4 | http://github.com/johnboxall/django_webhooks/tree/master 5 | -------------------------------------------------------------------------------- /Changelog: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ask/durian/bd7a773ce3470884fd74e97f0bba4b351545df56/Changelog -------------------------------------------------------------------------------- /INSTALL: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | You can install ``durian`` either via the Python Package Index (PyPI) 5 | or from source. 6 | 7 | To install using ``pip``,:: 8 | 9 | $ pip install durian 10 | 11 | 12 | To install using ``easy_install``,:: 13 | 14 | $ easy_install durian 15 | 16 | 17 | If you have downloaded a source tarball you can install it 18 | by doing the following,:: 19 | 20 | $ python setup.py build 21 | # python setup.py install # as root 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include Changelog 3 | include INSTALL 4 | include MANIFEST.in 5 | include README 6 | include TODO 7 | recursive-include docs * 8 | recursive-include durian * 9 | recursive-include testproj * 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PEP8=pep8 2 | 3 | pep8: 4 | (find . -name "*.py" | xargs pep8 | perl -nle'\ 5 | print; $$a=1 if $$_}{exit($$a)') 6 | 7 | cycomplex: 8 | find celery -type f -name "*.py" | xargs pygenie.py complexity 9 | 10 | ghdocs: 11 | contrib/doc2ghpages 12 | 13 | autodoc: 14 | contrib/doc4allmods celery 15 | 16 | bump: 17 | contrib/bump -c celery 18 | 19 | coverage2: 20 | [ -d testproj/temp ] || mkdir -p testproj/temp 21 | (cd testproj; python manage.py test --figleaf) 22 | 23 | coverage: 24 | [ -d testproj/temp ] || mkdir -p testproj/temp 25 | (cd testproj; python manage.py test --coverage) 26 | 27 | test: 28 | (cd testproj; python manage.py test) 29 | 30 | testverbose: 31 | (cd testproj; python manage.py test --verbosity=2) 32 | 33 | releaseok: pep8 autodoc test 34 | 35 | removepyc: 36 | find . -name "*.pyc" | xargs rm 37 | 38 | release: releaseok ghdocs removepyc 39 | 40 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | README.rst -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================================================================ 2 | durian - Web Hooks for Django 3 | ============================================================================ 4 | 5 | :Version: 0.1.0 6 | 7 | .. image:: http://cloud.github.com/downloads/ask/durian/webhooks-logo.png 8 | 9 | Introduction 10 | ============ 11 | 12 | We want the web sites we create to communicate with other sites. To enable 13 | this we give the clients an URL they can connect to. This is fine for most 14 | requests, but let's take a look at RSS. 15 | 16 | RSS publishes your articles for others to subscribe to. Whenever you have a 17 | new article to publish you add it to the RSS document available at an URL 18 | like:: 19 | 20 | http://example.com/articles.rss 21 | 22 | The client connects to this URL, say, every 20 minutes to check if there's 23 | something new. And if there is something new, it has to re-download the entire 24 | content, even if it already has some of the articles from before. 25 | We call this communication method `pulling`_. 26 | 27 | This is where web hooks (or HTTP callbacks) comes in, instead of giving the 28 | clients an URL they can connect to, the clients *give you an URL* you connect 29 | to every time there is something to update. 30 | 31 | By `pushing`_ instead of pulling the updates, both you 32 | and your clients saves bandwidth, sometimes by a lot. 33 | 34 | .. image:: http://cloud.github.com/downloads/ask/durian/webhook-callback2.png 35 | 36 | You can read more about web hooks at the `Web Hooks Blog`_. 37 | These slides by Jeff Lindsay is a good introduction to the subject: 38 | `Using Web Hooks`_. 39 | 40 | .. _`Web Hooks Blog`: http://blog.webhooks.org 41 | .. _`Using Web Hooks`: 42 | http://www.slideshare.net/progrium/using-web-hooks 43 | .. _`pushing`: http://en.wikipedia.org/wiki/Push_technology 44 | .. _`pulling`: http://en.wikipedia.org/wiki/Pull_technology 45 | 46 | **NOTE** This software is just in the planning stage and is going to 47 | change drastically. You can follow what is happening here, and is welcome to 48 | help out making it happen, but you should probably not use it for anything 49 | until it has reached an alpha version. 50 | 51 | 52 | Examples 53 | ======== 54 | 55 | Creating an event with a model and a signal 56 | ------------------------------------------- 57 | 58 | In this example we'll be creating a ModelHook. 59 | 60 | A ModelHook is a hook which takes a Django model and signal. 61 | So whenever that signal is fired, the hook is also triggered. 62 | 63 | You can specify which of the model fields you want to pass on to the listeners 64 | via the ``provides_args`` attribute. 65 | 66 | 67 | First let's create a simple model of a person storing the persons 68 | name, address and a secret field we don't want to pass on to listeners: 69 | 70 | >>> from django.db import models 71 | >>> from django.utils.translation import ugettext_lazy as _ 72 | 73 | >>> class Person(models.Model): 74 | ... name = models.CharField(_(u"name"), blank=False, max_length=200) 75 | ... address = models.CharField(_(u"address"), max_length=200) 76 | ... secret = models.CharField(_(u"secret"), max_length=200) 77 | 78 | 79 | Now to the hook itself. We subclass the ModelHook class and register it in 80 | the global webhook registry. For now we'll set ``async`` to False, this means 81 | the tasks won't be sent to ``celeryd`` but executed locally instead. In 82 | production you would certainly want the dispatch to be asynchronous. 83 | 84 | >>> from durian.event import ModelHook 85 | >>> from durian.registry import hooks 86 | >>> from django.db.models import signals 87 | 88 | 89 | >>> class PersonHook(ModelHook): 90 | ... name = "person" 91 | ... model = Person 92 | ... signal = signals.post_save 93 | ... provides_args = ["name", "address"] 94 | ... async = False 95 | >>> hooks.register(PersonHook) 96 | 97 | Now we can create ourselves some listeners. They can be created manually 98 | or by using the web-interface. A listener must have a URL, which is the 99 | destination callback the signal is sent to, and you can optionally filter 100 | events so you only get the events you care about. 101 | 102 | >>> # send event when person with name Joe is changed/added. 103 | >>> PersonHook().listener( 104 | ... url="http://where.joe/is/listening").match( 105 | ... name="Joe").save() 106 | 107 | >>> # send event whenever a person with a name that starts with the 108 | >>> # letter "J" is changed/added: 109 | >>> from durian.match import Startswith 110 | >>> PersonHook().listener( 111 | ... url="http://where.joe/is/listening").match( 112 | ... name=Startswith("J").save() 113 | 114 | >>> # send event when any Person is changed/added. 115 | >>> PersonHook().listener(url="http://where.joe/is/listening").save() 116 | 117 | The filters can use special matching classes, as you see with the 118 | ``Startswith`` above. See `Matching classes`_ for a list of these. 119 | 120 | In this screenshot you can see the view for selecting the person event: 121 | 122 | .. image:: 123 | http://cloud.github.com/downloads/ask/durian/durian-shot-select_event.png 124 | 125 | and then creating a listener for that event: 126 | 127 | .. image:: 128 | http://cloud.github.com/downloads/ask/durian/durian-shot-create_listenerv2.png 129 | 130 | 131 | Creating custom hooks 132 | --------------------- 133 | 134 | Sometimes you'd like to create hooks for something else than a model. 135 | If there's already a Django signal you want to bind to there is the 136 | ``SignalHook``. Otherwise you can send your own signal by creating a custom 137 | ``Hook``. 138 | 139 | The only required attribute of a hook is the name, so it can be uniquely 140 | identified in the hook registry. 141 | 142 | There are two ways of defining a hook, either by instantiation a Hook 143 | class, or by subclassing one. You can register a hook instance, or a hook 144 | class, it doesn't matter as long as the name is different: 145 | 146 | >>> from durian.registry import hooks 147 | 148 | >>> # Defining a hook by instantiating a hook class: 149 | >>> myhook = Hook(name="myhook") 150 | >>> hooks.register(myhook) 151 | 152 | >>> # Defining a hook by subclassing a hook class: 153 | >>> class MyHook(Hook): 154 | ... name = "myhook" 155 | >>> hooks.register(MyHook) 156 | 157 | 158 | These also supports the ``provides_args`` attribute which can automatically 159 | generate event filter forms. 160 | 161 | See the API reference for a complete list of ``Hook`` arguments and 162 | attributes. 163 | 164 | Triggering a hook is simple by using the ``send`` method:: 165 | 166 | >>> class MyHook(Hook): 167 | ... name = "myhook" 168 | ... provides_args = ["name", "address"] 169 | ... async = False 170 | >>> hooks.register(MyHook) 171 | 172 | >>> MyHook().send(sender=None, 173 | ... name="George Constanza", address="New York City") 174 | 175 | 176 | View for listening URL 177 | ---------------------- 178 | 179 | >>> from django.http import HttpResponse 180 | >>> from anyjson import deserialize 181 | 182 | >>> def listens(request): 183 | ... payload = deserialize(request.raw_post_data) 184 | ... print(payload["name"]) 185 | ... return HttpResponse("thanks!") 186 | 187 | 188 | Matching classes 189 | ---------------- 190 | 191 | 192 | * Any() 193 | Matches anything. Even if the field is not sent at all. 194 | 195 | * Is(pattern) 196 | Strict equality. The values must match precisely. 197 | 198 | * Startswith(pattern) 199 | Matches if the string starts with the given pattern. 200 | 201 | * Endswith(pattern) 202 | Matches if the string ends with the given pattern 203 | 204 | * Contains(pattern) 205 | Matches if the string contains the given pattern. 206 | 207 | * Like(regex) 208 | Match by a regular expression. 209 | 210 | 211 | 212 | Installation 213 | ============ 214 | 215 | You can install ``durian`` either via the Python Package Index (PyPI) 216 | or from source. 217 | 218 | To install using ``pip``,:: 219 | 220 | $ pip install durian 221 | 222 | 223 | To install using ``easy_install``,:: 224 | 225 | $ easy_install durian 226 | 227 | 228 | If you have downloaded a source tarball you can install it 229 | by doing the following,:: 230 | 231 | $ python setup.py build 232 | # python setup.py install # as root 233 | 234 | Examples 235 | ======== 236 | 237 | .. Please write some examples using your package here. 238 | 239 | 240 | License 241 | ======= 242 | 243 | BSD License 244 | 245 | 246 | Contact 247 | ======= 248 | 249 | Ask Solem 250 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | See http://bugs.opera.com/browse/OPAL 2 | -------------------------------------------------------------------------------- /contrib/doc2ghpages: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | git checkout master 4 | (cd docs; 5 | rm -rf .build; 6 | make html; 7 | (cd .build/html; 8 | sphinx-to-github;)) 9 | git checkout gh-pages 10 | cp -r docs/.build/html/* . 11 | git commit . -m "Autogenerated documentation for github." 12 | git push --all 13 | git checkout master 14 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | 9 | # Internal variables. 10 | PAPEROPT_a4 = -D latex_paper_size=a4 11 | PAPEROPT_letter = -D latex_paper_size=letter 12 | ALLSPHINXOPTS = -d .build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 13 | 14 | .PHONY: help clean html web pickle htmlhelp latex changes linkcheck 15 | 16 | help: 17 | @echo "Please use \`make ' where is one of" 18 | @echo " html to make standalone HTML files" 19 | @echo " pickle to make pickle files" 20 | @echo " json to make JSON files" 21 | @echo " htmlhelp to make HTML files and a HTML help project" 22 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 23 | @echo " changes to make an overview over all changed/added/deprecated items" 24 | @echo " linkcheck to check all external links for integrity" 25 | 26 | clean: 27 | -rm -rf .build/* 28 | 29 | html: 30 | mkdir -p .build/html .build/doctrees 31 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) .build/html 32 | @echo 33 | @echo "Build finished. The HTML pages are in .build/html." 34 | 35 | pickle: 36 | mkdir -p .build/pickle .build/doctrees 37 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) .build/pickle 38 | @echo 39 | @echo "Build finished; now you can process the pickle files." 40 | 41 | web: pickle 42 | 43 | json: 44 | mkdir -p .build/json .build/doctrees 45 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) .build/json 46 | @echo 47 | @echo "Build finished; now you can process the JSON files." 48 | 49 | htmlhelp: 50 | mkdir -p .build/htmlhelp .build/doctrees 51 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) .build/htmlhelp 52 | @echo 53 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 54 | ".hhp project file in .build/htmlhelp." 55 | 56 | latex: 57 | mkdir -p .build/latex .build/doctrees 58 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) .build/latex 59 | @echo 60 | @echo "Build finished; the LaTeX files are in .build/latex." 61 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 62 | "run these through (pdf)latex." 63 | 64 | changes: 65 | mkdir -p .build/changes .build/doctrees 66 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) .build/changes 67 | @echo 68 | @echo "The overview file is in .build/changes." 69 | 70 | linkcheck: 71 | mkdir -p .build/linkcheck .build/doctrees 72 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) .build/linkcheck 73 | @echo 74 | @echo "Link check complete; look for any errors in the above output " \ 75 | "or in .build/linkcheck/output.txt." 76 | -------------------------------------------------------------------------------- /docs/_ext/applyxrefs.py: -------------------------------------------------------------------------------- 1 | """Adds xref targets to the top of files.""" 2 | 3 | import sys 4 | import os 5 | 6 | testing = False 7 | 8 | DONT_TOUCH = ( 9 | './index.txt', 10 | ) 11 | 12 | 13 | def target_name(fn): 14 | if fn.endswith('.txt'): 15 | fn = fn[:-4] 16 | return '_' + fn.lstrip('./').replace('/', '-') 17 | 18 | 19 | def process_file(fn, lines): 20 | lines.insert(0, '\n') 21 | lines.insert(0, '.. %s:\n' % target_name(fn)) 22 | try: 23 | f = open(fn, 'w') 24 | except IOError: 25 | print("Can't open %s for writing. Not touching it." % fn) 26 | return 27 | try: 28 | f.writelines(lines) 29 | except IOError: 30 | print("Can't write to %s. Not touching it." % fn) 31 | finally: 32 | f.close() 33 | 34 | 35 | def has_target(fn): 36 | try: 37 | f = open(fn, 'r') 38 | except IOError: 39 | print("Can't open %s. Not touching it." % fn) 40 | return (True, None) 41 | readok = True 42 | try: 43 | lines = f.readlines() 44 | except IOError: 45 | print("Can't read %s. Not touching it." % fn) 46 | readok = False 47 | finally: 48 | f.close() 49 | if not readok: 50 | return (True, None) 51 | 52 | #print fn, len(lines) 53 | if len(lines) < 1: 54 | print("Not touching empty file %s." % fn) 55 | return (True, None) 56 | if lines[0].startswith('.. _'): 57 | return (True, None) 58 | return (False, lines) 59 | 60 | 61 | def main(argv=None): 62 | if argv is None: 63 | argv = sys.argv 64 | 65 | if len(argv) == 1: 66 | argv.extend('.') 67 | 68 | files = [] 69 | for root in argv[1:]: 70 | for (dirpath, dirnames, filenames) in os.walk(root): 71 | files.extend([(dirpath, f) for f in filenames]) 72 | files.sort() 73 | files = [os.path.join(p, fn) for p, fn in files if fn.endswith('.txt')] 74 | #print files 75 | 76 | for fn in files: 77 | if fn in DONT_TOUCH: 78 | print("Skipping blacklisted file %s." % fn) 79 | continue 80 | 81 | target_found, lines = has_target(fn) 82 | if not target_found: 83 | if testing: 84 | print '%s: %s' % (fn, lines[0]), 85 | else: 86 | print "Adding xref to %s" % fn 87 | process_file(fn, lines) 88 | else: 89 | print "Skipping %s: already has a xref" % fn 90 | 91 | if __name__ == '__main__': 92 | sys.exit(main()) 93 | -------------------------------------------------------------------------------- /docs/_ext/literals_to_xrefs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Runs through a reST file looking for old-style literals, and helps replace them 3 | with new-style references. 4 | """ 5 | 6 | import re 7 | import sys 8 | import shelve 9 | 10 | refre = re.compile(r'``([^`\s]+?)``') 11 | 12 | ROLES = ( 13 | 'attr', 14 | 'class', 15 | "djadmin", 16 | 'data', 17 | 'exc', 18 | 'file', 19 | 'func', 20 | 'lookup', 21 | 'meth', 22 | 'mod', 23 | "djadminopt", 24 | "ref", 25 | "setting", 26 | "term", 27 | "tfilter", 28 | "ttag", 29 | 30 | # special 31 | "skip", 32 | ) 33 | 34 | ALWAYS_SKIP = [ 35 | "NULL", 36 | "True", 37 | "False", 38 | ] 39 | 40 | 41 | def fixliterals(fname): 42 | data = open(fname).read() 43 | 44 | last = 0 45 | new = [] 46 | storage = shelve.open("/tmp/literals_to_xref.shelve") 47 | lastvalues = storage.get("lastvalues", {}) 48 | 49 | for m in refre.finditer(data): 50 | 51 | new.append(data[last:m.start()]) 52 | last = m.end() 53 | 54 | line_start = data.rfind("\n", 0, m.start()) 55 | line_end = data.find("\n", m.end()) 56 | prev_start = data.rfind("\n", 0, line_start) 57 | next_end = data.find("\n", line_end + 1) 58 | 59 | # Skip always-skip stuff 60 | if m.group(1) in ALWAYS_SKIP: 61 | new.append(m.group(0)) 62 | continue 63 | 64 | # skip when the next line is a title 65 | next_line = data[m.end():next_end].strip() 66 | if next_line[0] in "!-/:-@[-`{-~" and \ 67 | all(c == next_line[0] for c in next_line): 68 | new.append(m.group(0)) 69 | continue 70 | 71 | sys.stdout.write("\n"+"-"*80+"\n") 72 | sys.stdout.write(data[prev_start+1:m.start()]) 73 | sys.stdout.write(colorize(m.group(0), fg="red")) 74 | sys.stdout.write(data[m.end():next_end]) 75 | sys.stdout.write("\n\n") 76 | 77 | replace_type = None 78 | while replace_type is None: 79 | replace_type = raw_input( 80 | colorize("Replace role: ", fg="yellow")).strip().lower() 81 | if replace_type and replace_type not in ROLES: 82 | replace_type = None 83 | 84 | if replace_type == "": 85 | new.append(m.group(0)) 86 | continue 87 | 88 | if replace_type == "skip": 89 | new.append(m.group(0)) 90 | ALWAYS_SKIP.append(m.group(1)) 91 | continue 92 | 93 | default = lastvalues.get(m.group(1), m.group(1)) 94 | if default.endswith("()") and \ 95 | replace_type in ("class", "func", "meth"): 96 | default = default[:-2] 97 | replace_value = raw_input( 98 | colorize("Text [", fg="yellow") + default + \ 99 | colorize("]: ", fg="yellow")).strip() 100 | if not replace_value: 101 | replace_value = default 102 | new.append(":%s:`%s`" % (replace_type, replace_value)) 103 | lastvalues[m.group(1)] = replace_value 104 | 105 | new.append(data[last:]) 106 | open(fname, "w").write("".join(new)) 107 | 108 | storage["lastvalues"] = lastvalues 109 | storage.close() 110 | 111 | 112 | def colorize(text='', opts=(), **kwargs): 113 | """ 114 | Returns your text, enclosed in ANSI graphics codes. 115 | 116 | Depends on the keyword arguments 'fg' and 'bg', and the contents of 117 | the opts tuple/list. 118 | 119 | Returns the RESET code if no parameters are given. 120 | 121 | Valid colors: 122 | 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white' 123 | 124 | Valid options: 125 | 'bold' 126 | 'underscore' 127 | 'blink' 128 | 'reverse' 129 | 'conceal' 130 | 'noreset' - string will not be auto-terminated with the RESET code 131 | 132 | Examples: 133 | colorize('hello', fg='red', bg='blue', opts=('blink',)) 134 | colorize() 135 | colorize('goodbye', opts=('underscore',)) 136 | print colorize('first line', fg='red', opts=('noreset',)) 137 | print 'this should be red too' 138 | print colorize('and so should this') 139 | print 'this should not be red' 140 | """ 141 | color_names = ('black', 'red', 'green', 'yellow', 142 | 'blue', 'magenta', 'cyan', 'white') 143 | foreground = dict([(color_names[x], '3%s' % x) for x in range(8)]) 144 | background = dict([(color_names[x], '4%s' % x) for x in range(8)]) 145 | 146 | RESET = '0' 147 | opt_dict = {'bold': '1', 148 | 'underscore': '4', 149 | 'blink': '5', 150 | 'reverse': '7', 151 | 'conceal': '8'} 152 | 153 | text = str(text) 154 | code_list = [] 155 | if text == '' and len(opts) == 1 and opts[0] == 'reset': 156 | return '\x1b[%sm' % RESET 157 | for k, v in kwargs.iteritems(): 158 | if k == 'fg': 159 | code_list.append(foreground[v]) 160 | elif k == 'bg': 161 | code_list.append(background[v]) 162 | for o in opts: 163 | if o in opt_dict: 164 | code_list.append(opt_dict[o]) 165 | if 'noreset' not in opts: 166 | text = text + '\x1b[%sm' % RESET 167 | return ('\x1b[%sm' % ';'.join(code_list)) + text 168 | 169 | if __name__ == '__main__': 170 | try: 171 | fixliterals(sys.argv[1]) 172 | except (KeyboardInterrupt, SystemExit): 173 | print 174 | -------------------------------------------------------------------------------- /docs/_theme/nature/static/nature.css_t: -------------------------------------------------------------------------------- 1 | /** 2 | * Sphinx stylesheet -- default theme 3 | * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 4 | */ 5 | 6 | @import url("basic.css"); 7 | 8 | /* -- page layout ----------------------------------------------------------- */ 9 | 10 | body { 11 | font-family: Arial, sans-serif; 12 | font-size: 100%; 13 | background-color: #111; 14 | color: #555; 15 | margin: 0; 16 | padding: 0; 17 | } 18 | 19 | hr{ 20 | border: 1px solid #B1B4B6; 21 | } 22 | 23 | div.document { 24 | background-color: #eee; 25 | } 26 | 27 | div.body { 28 | background-color: #ffffff; 29 | color: #3E4349; 30 | padding: 0 30px 30px 30px; 31 | font-size: 0.8em; 32 | } 33 | 34 | div.footer { 35 | color: #555; 36 | width: 100%; 37 | padding: 13px 0; 38 | text-align: center; 39 | font-size: 75%; 40 | } 41 | 42 | div.footer a { 43 | color: #444; 44 | text-decoration: underline; 45 | } 46 | 47 | div.related { 48 | background-color: #6BA81E; 49 | line-height: 32px; 50 | color: #fff; 51 | text-shadow: 0px 1px 0 #444; 52 | font-size: 0.80em; 53 | } 54 | 55 | div.related a { 56 | color: #E2F3CC; 57 | } 58 | 59 | div.sphinxsidebar { 60 | font-size: 0.75em; 61 | line-height: 1.5em; 62 | } 63 | 64 | div.sphinxsidebarwrapper{ 65 | padding: 20px 0; 66 | } 67 | 68 | div.sphinxsidebar h3, 69 | div.sphinxsidebar h4 { 70 | font-family: Arial, sans-serif; 71 | color: #222; 72 | font-size: 1.2em; 73 | font-weight: normal; 74 | margin: 0; 75 | padding: 5px 10px; 76 | background-color: #ddd; 77 | text-shadow: 1px 1px 0 white 78 | } 79 | 80 | div.sphinxsidebar h4{ 81 | font-size: 1.1em; 82 | } 83 | 84 | div.sphinxsidebar h3 a { 85 | color: #444; 86 | } 87 | 88 | 89 | div.sphinxsidebar p { 90 | color: #888; 91 | padding: 5px 20px; 92 | } 93 | 94 | div.sphinxsidebar p.topless { 95 | } 96 | 97 | div.sphinxsidebar ul { 98 | margin: 10px 20px; 99 | padding: 0; 100 | color: #000; 101 | } 102 | 103 | div.sphinxsidebar a { 104 | color: #444; 105 | } 106 | 107 | div.sphinxsidebar input { 108 | border: 1px solid #ccc; 109 | font-family: sans-serif; 110 | font-size: 1em; 111 | } 112 | 113 | div.sphinxsidebar input[type=text]{ 114 | margin-left: 20px; 115 | } 116 | 117 | /* -- body styles ----------------------------------------------------------- */ 118 | 119 | a { 120 | color: #005B81; 121 | text-decoration: none; 122 | } 123 | 124 | a:hover { 125 | color: #E32E00; 126 | text-decoration: underline; 127 | } 128 | 129 | div.body h1, 130 | div.body h2, 131 | div.body h3, 132 | div.body h4, 133 | div.body h5, 134 | div.body h6 { 135 | font-family: Arial, sans-serif; 136 | background-color: #BED4EB; 137 | font-weight: normal; 138 | color: #212224; 139 | margin: 30px 0px 10px 0px; 140 | padding: 5px 0 5px 10px; 141 | text-shadow: 0px 1px 0 white 142 | } 143 | 144 | div.body h1 { border-top: 20px solid white; margin-top: 0; font-size: 200%; } 145 | div.body h2 { font-size: 150%; background-color: #C8D5E3; } 146 | div.body h3 { font-size: 120%; background-color: #D8DEE3; } 147 | div.body h4 { font-size: 110%; background-color: #D8DEE3; } 148 | div.body h5 { font-size: 100%; background-color: #D8DEE3; } 149 | div.body h6 { font-size: 100%; background-color: #D8DEE3; } 150 | 151 | a.headerlink { 152 | color: #c60f0f; 153 | font-size: 0.8em; 154 | padding: 0 4px 0 4px; 155 | text-decoration: none; 156 | } 157 | 158 | a.headerlink:hover { 159 | background-color: #c60f0f; 160 | color: white; 161 | } 162 | 163 | div.body p, div.body dd, div.body li { 164 | text-align: justify; 165 | line-height: 1.5em; 166 | } 167 | 168 | div.admonition p.admonition-title + p { 169 | display: inline; 170 | } 171 | 172 | div.highlight{ 173 | background-color: white; 174 | } 175 | 176 | div.note { 177 | background-color: #eee; 178 | border: 1px solid #ccc; 179 | } 180 | 181 | div.seealso { 182 | background-color: #ffc; 183 | border: 1px solid #ff6; 184 | } 185 | 186 | div.topic { 187 | background-color: #eee; 188 | } 189 | 190 | div.warning { 191 | background-color: #ffe4e4; 192 | border: 1px solid #f66; 193 | } 194 | 195 | p.admonition-title { 196 | display: inline; 197 | } 198 | 199 | p.admonition-title:after { 200 | content: ":"; 201 | } 202 | 203 | pre { 204 | padding: 10px; 205 | background-color: White; 206 | color: #222; 207 | line-height: 1.2em; 208 | border: 1px solid #C6C9CB; 209 | font-size: 1.2em; 210 | margin: 1.5em 0 1.5em 0; 211 | -webkit-box-shadow: 1px 1px 1px #d8d8d8; 212 | -moz-box-shadow: 1px 1px 1px #d8d8d8; 213 | } 214 | 215 | tt { 216 | background-color: #ecf0f3; 217 | color: #222; 218 | padding: 1px 2px; 219 | font-size: 1.2em; 220 | font-family: monospace; 221 | } -------------------------------------------------------------------------------- /docs/_theme/nature/static/pygments.css: -------------------------------------------------------------------------------- 1 | .c { color: #999988; font-style: italic } /* Comment */ 2 | .k { font-weight: bold } /* Keyword */ 3 | .o { font-weight: bold } /* Operator */ 4 | .cm { color: #999988; font-style: italic } /* Comment.Multiline */ 5 | .cp { color: #999999; font-weight: bold } /* Comment.preproc */ 6 | .c1 { color: #999988; font-style: italic } /* Comment.Single */ 7 | .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ 8 | .ge { font-style: italic } /* Generic.Emph */ 9 | .gr { color: #aa0000 } /* Generic.Error */ 10 | .gh { color: #999999 } /* Generic.Heading */ 11 | .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ 12 | .go { color: #111 } /* Generic.Output */ 13 | .gp { color: #555555 } /* Generic.Prompt */ 14 | .gs { font-weight: bold } /* Generic.Strong */ 15 | .gu { color: #aaaaaa } /* Generic.Subheading */ 16 | .gt { color: #aa0000 } /* Generic.Traceback */ 17 | .kc { font-weight: bold } /* Keyword.Constant */ 18 | .kd { font-weight: bold } /* Keyword.Declaration */ 19 | .kp { font-weight: bold } /* Keyword.Pseudo */ 20 | .kr { font-weight: bold } /* Keyword.Reserved */ 21 | .kt { color: #445588; font-weight: bold } /* Keyword.Type */ 22 | .m { color: #009999 } /* Literal.Number */ 23 | .s { color: #bb8844 } /* Literal.String */ 24 | .na { color: #008080 } /* Name.Attribute */ 25 | .nb { color: #999999 } /* Name.Builtin */ 26 | .nc { color: #445588; font-weight: bold } /* Name.Class */ 27 | .no { color: #ff99ff } /* Name.Constant */ 28 | .ni { color: #800080 } /* Name.Entity */ 29 | .ne { color: #990000; font-weight: bold } /* Name.Exception */ 30 | .nf { color: #990000; font-weight: bold } /* Name.Function */ 31 | .nn { color: #555555 } /* Name.Namespace */ 32 | .nt { color: #000080 } /* Name.Tag */ 33 | .nv { color: purple } /* Name.Variable */ 34 | .ow { font-weight: bold } /* Operator.Word */ 35 | .mf { color: #009999 } /* Literal.Number.Float */ 36 | .mh { color: #009999 } /* Literal.Number.Hex */ 37 | .mi { color: #009999 } /* Literal.Number.Integer */ 38 | .mo { color: #009999 } /* Literal.Number.Oct */ 39 | .sb { color: #bb8844 } /* Literal.String.Backtick */ 40 | .sc { color: #bb8844 } /* Literal.String.Char */ 41 | .sd { color: #bb8844 } /* Literal.String.Doc */ 42 | .s2 { color: #bb8844 } /* Literal.String.Double */ 43 | .se { color: #bb8844 } /* Literal.String.Escape */ 44 | .sh { color: #bb8844 } /* Literal.String.Heredoc */ 45 | .si { color: #bb8844 } /* Literal.String.Interpol */ 46 | .sx { color: #bb8844 } /* Literal.String.Other */ 47 | .sr { color: #808000 } /* Literal.String.Regex */ 48 | .s1 { color: #bb8844 } /* Literal.String.Single */ 49 | .ss { color: #bb8844 } /* Literal.String.Symbol */ 50 | .bp { color: #999999 } /* Name.Builtin.Pseudo */ 51 | .vc { color: #ff99ff } /* Name.Variable.Class */ 52 | .vg { color: #ff99ff } /* Name.Variable.Global */ 53 | .vi { color: #ff99ff } /* Name.Variable.Instance */ 54 | .il { color: #009999 } /* Literal.Number.Integer.Long */ -------------------------------------------------------------------------------- /docs/_theme/nature/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = nature.css 4 | pygments_style = tango 5 | 6 | [options] 7 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ../Changelog -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | import sys 4 | import os 5 | 6 | # If your extensions are in another directory, add it here. If the directory 7 | # is relative to the documentation root, use os.path.abspath to make it 8 | # absolute, like shown here. 9 | sys.path.insert(0, "../") 10 | import durian 11 | 12 | from django.conf import settings 13 | if not settings.configured: 14 | settings.configure() 15 | 16 | # General configuration 17 | # --------------------- 18 | 19 | # Add any Sphinx extension module names here, as strings. 20 | # They can be extensions 21 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 22 | extensions = ['sphinx.ext.autodoc'] 23 | 24 | # Add any paths that contain templates here, relative to this directory. 25 | templates_path = ['.templates'] 26 | 27 | # The suffix of source filenames. 28 | source_suffix = '.rst' 29 | 30 | # The encoding of source files. 31 | #source_encoding = 'utf-8' 32 | 33 | # The master toctree document. 34 | master_doc = 'index' 35 | 36 | # General information about the project. 37 | project = u'durian' 38 | copyright = u'2009, Opera Softare (WebTeam)' 39 | 40 | # The version info for the project you're documenting, acts as replacement for 41 | # |version| and |release|, also used in various other places throughout the 42 | # built documents. 43 | # 44 | # The short X.Y version. 45 | version = ".".join(map(str, durian.VERSION[0:2])) 46 | # The full version, including alpha/beta/rc tags. 47 | release = durian.__version__ 48 | 49 | # The language for content autogenerated by Sphinx. Refer to documentation 50 | # for a list of supported languages. 51 | #language = None 52 | 53 | # There are two options for replacing |today|: either, you set today to some 54 | # non-false value, then it is used: 55 | #today = '' 56 | # Else, today_fmt is used as the format for a strftime call. 57 | #today_fmt = '%B %d, %Y' 58 | 59 | # List of documents that shouldn't be included in the build. 60 | #unused_docs = [] 61 | 62 | # List of directories, relative to source directory, that shouldn't be searched 63 | # for source files. 64 | exclude_trees = ['.build'] 65 | 66 | # The reST default role (used for this markup: `text`) to use for all 67 | # documents. 68 | #default_role = None 69 | 70 | # If true, '()' will be appended to :func: etc. cross-reference text. 71 | add_function_parentheses = True 72 | 73 | # If true, the current module name will be prepended to all description 74 | # unit titles (such as .. function::). 75 | #add_module_names = True 76 | 77 | # If true, sectionauthor and moduleauthor directives will be shown in the 78 | # output. They are ignored by default. 79 | #show_authors = False 80 | 81 | # The name of the Pygments (syntax highlighting) style to use. 82 | pygments_style = 'trac' 83 | 84 | #html_translator_class = "djangodocs.DjangoHTMLTranslator" 85 | 86 | 87 | # Options for HTML output 88 | # ----------------------- 89 | 90 | # The style sheet to use for HTML and HTML Help pages. A file of that name 91 | # must exist either in Sphinx' static/ path, or in one of the custom paths 92 | # given in html_static_path. 93 | #html_style = 'agogo.css' 94 | 95 | # The name for this set of Sphinx documents. If None, it defaults to 96 | # " v documentation". 97 | #html_title = None 98 | 99 | # A shorter title for the navigation bar. Default is the same as html_title. 100 | #html_short_title = None 101 | 102 | # The name of an image file (relative to this directory) to place at the top 103 | # of the sidebar. 104 | #html_logo = None 105 | 106 | # The name of an image file (within the static path) to use as favicon of the 107 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 108 | # pixels large. 109 | #html_favicon = None 110 | 111 | # Add any paths that contain custom static files (such as style sheets) here, 112 | # relative to this directory. They are copied after the builtin static files, 113 | # so a file named "default.css" will overwrite the builtin "default.css". 114 | html_static_path = ['.static'] 115 | 116 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 117 | # using the given strftime format. 118 | #html_last_updated_fmt = '%b %d, %Y' 119 | 120 | # If true, SmartyPants will be used to convert quotes and dashes to 121 | # typographically correct entities. 122 | html_use_smartypants = True 123 | 124 | # Custom sidebar templates, maps document names to template names. 125 | #html_sidebars = {} 126 | 127 | # Additional templates that should be rendered to pages, maps page names to 128 | # template names. 129 | #html_additional_pages = {} 130 | 131 | # If false, no module index is generated. 132 | html_use_modindex = True 133 | 134 | # If false, no index is generated. 135 | html_use_index = True 136 | 137 | # If true, the index is split into individual pages for each letter. 138 | #html_split_index = False 139 | 140 | # If true, the reST sources are included in the HTML build as _sources/. 141 | #html_copy_source = True 142 | 143 | # If true, an OpenSearch description file will be output, and all pages will 144 | # contain a tag referring to it. The value of this option must be the 145 | # base URL from which the finished HTML is served. 146 | #html_use_opensearch = '' 147 | 148 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 149 | #html_file_suffix = '' 150 | 151 | # Output file base name for HTML help builder. 152 | htmlhelp_basename = 'duriandoc' 153 | 154 | 155 | # Options for LaTeX output 156 | # ------------------------ 157 | 158 | # The paper size ('letter' or 'a4'). 159 | #latex_paper_size = 'letter' 160 | 161 | # The font size ('10pt', '11pt' or '12pt'). 162 | #latex_font_size = '10pt' 163 | 164 | # Grouping the document tree into LaTeX files. List of tuples 165 | # (source start file, target name, title, author, document class 166 | # [howto/manual]). 167 | latex_documents = [ 168 | ('index', 'durian.tex', ur'durian Documentation', 169 | ur'Ask Solem', 'manual'), 170 | ] 171 | 172 | # The name of an image file (relative to this directory) to place at the top of 173 | # the title page. 174 | #latex_logo = None 175 | 176 | # For "manual" documents, if this is true, then toplevel headings are parts, 177 | # not chapters. 178 | #latex_use_parts = False 179 | 180 | # Additional stuff for the LaTeX preamble. 181 | #latex_preamble = '' 182 | 183 | # Documents to append as an appendix to all manuals. 184 | #latex_appendices = [] 185 | 186 | # If false, no module index is generated. 187 | #latex_use_modindex = True 188 | 189 | html_theme = "nature" 190 | html_theme_path = ["_theme"] 191 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Durian Documentation 2 | ================================== 3 | 4 | Contents: 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | introduction 10 | reference/index 11 | changelog 12 | 13 | 14 | Indices and tables 15 | ================== 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | 21 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | ../README -------------------------------------------------------------------------------- /docs/reference/durian.event.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Defining Hooks - durian.event 3 | ==================================== 4 | 5 | .. currentmodule:: durian.event 6 | 7 | .. automodule:: durian.event 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/durian.forms.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Django Forms - durian.forms 3 | ================================== 4 | 5 | .. currentmodule:: durian.forms 6 | 7 | .. automodule:: durian.forms 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/durian.match.able.rst: -------------------------------------------------------------------------------- 1 | ============================================== 2 | Matching Conditions - durian.match.able 3 | ============================================== 4 | 5 | .. currentmodule:: durian.match.able 6 | 7 | .. automodule:: durian.match.able 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/durian.match.rst: -------------------------------------------------------------------------------- 1 | ================================================== 2 | Matching/Filtering Utilities - durian.match 3 | ================================================== 4 | 5 | .. currentmodule:: durian.match 6 | 7 | .. automodule:: durian.match 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/durian.match.strategy.rst: -------------------------------------------------------------------------------- 1 | ================================================== 2 | Matching Strategies - durian.match.strategy 3 | ================================================== 4 | 5 | .. currentmodule:: durian.match.strategy 6 | 7 | .. automodule:: durian.match.strategy 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/durian.models.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | Django Models - durian.models 3 | ==================================== 4 | 5 | .. currentmodule:: durian.models 6 | 7 | .. automodule:: durian.models 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/durian.tasks.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Celery Tasks - durian.tasks 3 | ================================== 4 | 5 | .. currentmodule:: durian.tasks 6 | 7 | .. automodule:: durian.tasks 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/durian.views.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | Django Views - durian.views 3 | ================================== 4 | 5 | .. currentmodule:: durian.views 6 | 7 | .. automodule:: durian.views 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/reference/index.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | Module API Reference 3 | =========================== 4 | 5 | :Release: |version| 6 | :Date: |today| 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | 11 | durian.event 12 | durian.tasks 13 | durian.views 14 | durian.forms 15 | durian.models 16 | durian.match 17 | durian.match.able 18 | durian.match.strategy 19 | -------------------------------------------------------------------------------- /durian/__init__.py: -------------------------------------------------------------------------------- 1 | """Webhooks for Django **EXPERIMENTAL**""" 2 | VERSION = (0, 1, 0) 3 | __version__ = ".".join(map(str, VERSION)) 4 | __author__ = "Ask Solem" 5 | __contact__ = "askh@opera.com" 6 | __homepage__ = "http://github.com/ask/durian/" 7 | __docformat__ = "restructuredtext" 8 | -------------------------------------------------------------------------------- /durian/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from durian.models import Listener 3 | 4 | admin.site.register(Listener) 5 | -------------------------------------------------------------------------------- /durian/event.py: -------------------------------------------------------------------------------- 1 | from durian.models import Listener 2 | from celery.utils import get_full_cls_name, gen_unique_id 3 | from durian.tasks import WebhookSignal 4 | from durian.forms import HookConfigForm, create_match_forms 5 | from durian.match.strategy import deepmatch 6 | from durian.match import mtuplelist_to_matchdict 7 | from functools import partial as curry 8 | 9 | 10 | class Hook(object): 11 | """A Web Hook Event. 12 | 13 | :keyword name: See :attr:`name`. 14 | :keyword provides_args: See :attr:`provides_args`. 15 | :keyword config_form: See :attr:`config_form`. 16 | :keyword timeout: See :attr:`timeout`. 17 | :keyword async: See :attr:`async`. 18 | :keyword retry: See :attr:`retry`. 19 | :keyword max_retries: See :attr:`max_retries`. 20 | :keyword fail_silently: See :attr:`fail_silently`. 21 | :keyword task_cls: See :attr:`task_cls`. 22 | :keyword match_forms: See :attr:`match_forms` 23 | 24 | .. attribute:: name 25 | 26 | The name of the hook. 27 | 28 | If not provided this will be automatically generated using the class 29 | module and name, if you want to use this feature you can't use 30 | relative imports. 31 | 32 | .. attribute:: provides_args 33 | 34 | The list of arguments the event provides. This is the standard 35 | list of arguments you are going to pass on to :meth:`send`, used to 36 | generate the filter events form (:attr:`match_forms`). 37 | 38 | .. attribute:: config_form 39 | 40 | A Django form to save configuration for listeners attaching to this 41 | form. The default form is :class:`durian.forms.HookConfigForm`, which 42 | has the URL field. 43 | 44 | .. attribute:: timeout 45 | 46 | The timeout in seconds before we give up trying to dispatch the 47 | event to a listener URL. 48 | 49 | .. attribute:: async 50 | 51 | If ``True``, signals are dispatched to celery workers via a mesage. 52 | Otherwise dispatch happens locally (not a good idea in production). 53 | 54 | .. attribute:: retry 55 | 56 | Retry the task if it fails. 57 | 58 | .. attribute:: max_retries 59 | 60 | Maximum number of retries before we give up. 61 | 62 | .. attribute:: fail_silently 63 | 64 | Fail silently if the dispatch gives an HTTP error. 65 | 66 | .. attribute:: task_cls 67 | 68 | The :class:`celery.task.base.Task` class to use for dispatching 69 | the event. 70 | 71 | .. attribute:: match_forms 72 | 73 | A list of forms to create an event filter. This is automatically 74 | generated based on the :attr:`provides_args` attribute. 75 | 76 | """ 77 | 78 | name = None 79 | verbose_name = None 80 | task_cls = WebhookSignal 81 | timeout = 4 82 | async = True 83 | retry = False 84 | max_retries = 3 85 | fail_silently = False 86 | config_form = HookConfigForm 87 | provides_args = set() 88 | match_forms = None 89 | 90 | def __init__(self, name=None, verbose_name=None, task_cls=None, 91 | timeout=None, async=None, retry=None, max_retries=None, 92 | fail_silently=False, config_form=None, provides_args=None, 93 | match_forms=None, **kwargs): 94 | self.name = name or self.name or get_full_cls_name(self.__class__) 95 | self.verbose_name = verbose_name or self.verbose_name or self.name 96 | self.task_cls = task_cls or self.task_cls 97 | if timeout is not None: 98 | self.timeout = timeout 99 | if async is not None: 100 | self.async = async 101 | if retry is not None: 102 | self.retry = retry 103 | if max_retries is not None: 104 | self.max_retries = max_retries 105 | if fail_silently is not None: 106 | self.fail_silently = fail_silently 107 | self.provides_args = set(provides_args) or self.provides_args 108 | self.config_form = config_form or self.config_form 109 | form_name = "%sConfigForm" % self.name.capitalize() 110 | self.match_forms = match_forms or self.match_forms or \ 111 | create_match_forms(form_name, self.provides_args) 112 | 113 | def send(self, sender, **payload): 114 | """Send signal and dispatch to all listeners. 115 | 116 | :param sender: The sender of the signal. Either a specific object 117 | or ``None``. 118 | 119 | :param payload: The data to pass on to listeners. Usually the keys 120 | described in :attr:`provides_args` and any additional keys you'd 121 | want to provide. 122 | 123 | """ 124 | payload = self.prepare_payload(sender, payload) 125 | apply_ = curry(self._send_signal, sender, payload) 126 | return map(apply_, self.get_listeners(sender, payload)) 127 | 128 | def _send_signal(self, sender, payload, target): 129 | applier = self.get_applier() 130 | return applier(args=[target.url, payload], kwargs=self.task_keywords) 131 | 132 | def event_filter(self, sender, payload, match): 133 | """How we filter events. 134 | 135 | :param sender: The sender of the signal. 136 | 137 | :param payload: The signal data. 138 | 139 | :param match: The match dictionary, or ``None``. 140 | 141 | """ 142 | if not match: 143 | return True 144 | return deepmatch(match, payload) 145 | 146 | def get_match_forms(self, **kwargs): 147 | """Initialize the match forms with data recived by a request. 148 | 149 | :returns: A list of instantiated match forms. 150 | 151 | """ 152 | return [match_form(**kwargs) 153 | for match_form in self.match_forms.values()] 154 | 155 | def apply_match_forms(self, data): 156 | """With data recieved by request, convert to a list of match 157 | tuples.""" 158 | mtuplelist = [match_form(data).field_to_mtuple() 159 | for match_form in self.match_forms.values()] 160 | return mtuplelist_to_matchdict(mtuplelist) 161 | 162 | def get_listeners(self, sender, payload): 163 | """Get a list of all the listeners who wants this signal.""" 164 | possible_targets = Listener.objects.filter(hook=self.name) 165 | return [target for target in possible_targets 166 | if self.event_filter(sender, payload, target.match)] 167 | 168 | def get_applier(self, async=None): 169 | """Get the current apply method. Asynchronous or synchronous.""" 170 | async = async or self.async 171 | method = "apply_async" if async else "apply" 172 | sender = getattr(self.task_cls, method) 173 | return sender 174 | 175 | def prepare_payload(self, sender, payload): 176 | """Prepare the payload for dispatching. 177 | 178 | You can add any additional formatting of the payload here. 179 | 180 | """ 181 | return payload 182 | 183 | def add_listener_by_form(self, form, match=None): 184 | """Add listener with an instantiated :attr:`config_form`. 185 | 186 | :param form: An instance of :attr:`config_form`. 187 | :param match: Optional event filter match dict. 188 | 189 | """ 190 | if not hasattr(form, "cleaned_data"): 191 | form.is_valid() 192 | config = dict(form.cleaned_data) 193 | url = config.pop("url") 194 | return Listener.objects.create(hook=self.name, url=url, 195 | match=match, config=config) 196 | 197 | def add_listener(self, url, match={}, **config): 198 | """Add listener for this signal. 199 | 200 | :param url: The url the listener is listening on. 201 | :keyword match: The even filter match dict. 202 | :keyword \*\*config: Hook specific listener configuration. 203 | 204 | """ 205 | return Listener.objects.create(hook=self, url=url, match=match, 206 | **dict(config)) 207 | 208 | def listener(self, form): 209 | """Create a new listener.""" 210 | return IntermediateListener(self, form) 211 | 212 | @property 213 | def task_keywords(self): 214 | """The keyword arguments sent to the celery task.""" 215 | return {"retry": self.retry, 216 | "max_retries": self.max_retries, 217 | "fail_silently": self.fail_silently, 218 | "timeout": self.timeout} 219 | 220 | 221 | class SignalHook(Hook): 222 | """Hook attached to a Django signal.""" 223 | signal = None 224 | _dispatch_uid = None 225 | 226 | def __init__(self, signal=None, **kwargs): 227 | self.signal = signal 228 | 229 | # Signal receivers must have a unique id, by default 230 | # they're generated by the reciver name and the sender, 231 | # but since it's possible to have different recieves for the 232 | # same instance, we need to generate our own unique id. 233 | if not self.__class__._dispatch_uid: 234 | self.__class__._dispatch_uid = gen_unique_id() 235 | 236 | super(SignalHook, self).__init__(**kwargs) 237 | 238 | def connect(self, sender): 239 | self.signal.connect(self.send, sender=sender, 240 | dispatch_uid=self.__class__._dispatch_uid) 241 | 242 | def disconnect(self, sender): 243 | self.signal.disconnect(self.send, sender=sender, 244 | dispatch_uid=self.__class__._dispatch_uid) 245 | 246 | 247 | class ModelHook(SignalHook): 248 | """ 249 | >>> from django.db import signals 250 | >>> from django.contrib.auth.models import User 251 | 252 | >>> hook = ModelHook(User, signals.post_save, 253 | ... name="user-post-save", 254 | ... provides_args=["username", "is_admin"]) 255 | >>> joe = User.objects.get(username="joe") 256 | >>> joe.is_admin = True 257 | >>> joe.save() 258 | 259 | """ 260 | model = None 261 | 262 | def __init__(self, model=None, **kwargs): 263 | super(ModelHook, self).__init__(**kwargs) 264 | self.model = model or self.model 265 | if not self.model: 266 | raise NotImplementedError("ModelHook requires a model.") 267 | if self.signal: 268 | self.connect() 269 | if not self.provides_args: 270 | self.provides_args = self.get_model_default_fields() 271 | 272 | def get_model_default_fields(self): 273 | return [field.name 274 | for field in self.model._meta.fields 275 | if field.name != self.model._meta.pk.name] 276 | 277 | def prepare_payload(self, sender, payload): 278 | instance = payload.pop("instance") 279 | payload.pop("signal", None) 280 | model_data = dict((field_name, getattr(instance, field_name, None)) 281 | for field_name in self.provides_args) 282 | model_data.update(payload) 283 | return model_data 284 | 285 | def connect(self): 286 | super(ModelHook, self).connect(self.model) 287 | 288 | def disconnect(self): 289 | super(ModelHook, self).disconnect(self.model) 290 | 291 | 292 | class IntermediateListener(object): 293 | 294 | def __init__(self, hook, form): 295 | self.hook = hook 296 | self.form = form 297 | self.conditions = None 298 | 299 | def match(self, **match): 300 | self.conditions = match 301 | return self 302 | 303 | def save(self): 304 | return self.hook.add_listener_by_form(self.form, self.conditions) 305 | -------------------------------------------------------------------------------- /durian/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import ugettext_lazy as _ 3 | from durian.match import mtuplelist_to_matchdict, MATCHABLE_CHOICES 4 | from durian.registry import hooks 5 | 6 | 7 | class HookConfigForm(forms.Form): 8 | """Form for the default hook config. 9 | 10 | By default listeners only needs an URL, if you want custom configuration 11 | keys you can subclass this form and use that as the hooks 12 | :attr:`durian.event.Hook.config_form` attribute. 13 | 14 | """ 15 | url = forms.URLField(label=_(u"Listener URL"), required=True) 16 | 17 | def save(self): 18 | return dict(self.cleaned_data) 19 | 20 | 21 | def create_select_hook_form(*args, **kwargs): 22 | """Dynamically create a form that has a ``ChoiceField`` listing all the 23 | hook types registered in the hook registry.""" 24 | 25 | class SelectHookForm(forms.Form): 26 | type = forms.ChoiceField(choices=hooks.as_choices()) 27 | 28 | return SelectHookForm(*args, **kwargs) 29 | 30 | 31 | class BaseMatchForm(forms.Form): 32 | """Base class for match forms. 33 | 34 | Supports converting the form to a match tuple. 35 | 36 | """ 37 | _condition_for = None 38 | 39 | def __init__(self, *args, **kwargs): 40 | self._condition_for = kwargs.pop("condition_for", self._condition_for) 41 | super(BaseMatchForm, self).__init__(*args, **kwargs) 42 | 43 | def field_to_mtuple(self): 44 | """Convert the form to a match tuple.""" 45 | if not hasattr(self, "cleaned_data"): 46 | if not self.is_valid(): 47 | # FIXME 48 | raise Exception("FORM IS NOT VALID: %s" % self.errors) 49 | 50 | field = self._condition_for 51 | return (field, 52 | self.cleaned_data["%s_cond" % field], 53 | self.cleaned_data["%s_query" % field]) 54 | 55 | def save(self): 56 | return self.field_to_mtuple() 57 | 58 | 59 | def create_match_forms(name, provides_args): 60 | """With a list of supported arguments, generate a list of match 61 | forms for these. 62 | 63 | E.g. if the supported arguments is ``["name", "address"]``, it will 64 | generate forms like these:: 65 | 66 | Name: SELECT:[any|exact|starts with|ends with|contains] [ query ] 67 | Address: SELECT:[any|exact|starts with|ends with|contains] [ query ] 68 | 69 | When these form are feeded with data they can be converted to a match 70 | dict like: 71 | 72 | >>> {"name": Startswith("foo"), "address": Endswith("New York")} 73 | 74 | """ 75 | 76 | def gen_field_for_name(name): 77 | return {"%s_cond" % name: forms.ChoiceField(label=name, 78 | choices=MATCHABLE_CHOICES, 79 | widget=forms.Select()), 80 | "%s_query" % name: forms.CharField(label="", 81 | required=False, 82 | initial=""), 83 | } 84 | 85 | def gen_form_for_field(field): 86 | dict_ = gen_field_for_name(field) 87 | dict_["_condition_for"] = field 88 | return type(name + field, (BaseMatchForm, ), dict_) 89 | 90 | return dict((field, gen_form_for_field(field)) 91 | for field in provides_args) 92 | -------------------------------------------------------------------------------- /durian/match/__init__.py: -------------------------------------------------------------------------------- 1 | from durian.match.able import Any, Is, Startswith, Endswith, Contains, Like 2 | from durian.match.strategy import deepmatch 3 | from django.utils.translation import ugettext_lazy as _ 4 | 5 | CONDITION_PASS = 0x0 6 | CONDITION_EXACT = 0x1 7 | CONDITION_STARTSWITH = 0x2 8 | CONDITION_ENDSWITH = 0x3 9 | CONDITION_CONTAINS = 0x4 10 | 11 | MATCHABLE_CHOICES = ( 12 | (CONDITION_PASS, (_("anything"))), 13 | (CONDITION_EXACT, (_("exact"))), 14 | (CONDITION_STARTSWITH, (_("starts with"))), 15 | (CONDITION_ENDSWITH, (_("ends with"))), 16 | (CONDITION_CONTAINS, (_("contains"))), 17 | ) 18 | 19 | CONST_TO_MATCHABLE = { 20 | CONDITION_PASS: Any, 21 | CONDITION_EXACT: Is, 22 | CONDITION_STARTSWITH: Startswith, 23 | CONDITION_ENDSWITH: Endswith, 24 | CONDITION_CONTAINS: Contains, 25 | } 26 | 27 | 28 | def const_to_matchable(const_kind, what): 29 | return CONST_TO_MATCHABLE[int(const_kind)](what) 30 | 31 | 32 | def mtuplelist_to_matchdict(mtuplelist): 33 | """Converts a list of ``(name, kind, what)`` tuples to a match dict. 34 | Where name is the field to match, kind is a matchable number mapping to 35 | ``CONST_TO_MATCHABLE``. 36 | 37 | Possible types are: ``CONDITION_EXACT``, ``CONDITION_STARTSWITH``, 38 | ``CONDITION_ENDSWITH, ``CONDITION_CONTAINS``. 39 | 40 | 41 | 42 | Probably best explained by an example: 43 | 44 | >>> mtuplelist = [("name", CONDITION_ENDSWITH, "Constanza"), 45 | ... ("zipcode", CONDITION_STARTSWITH, "70")] 46 | >>> mtuplelist_to_matchdict(mtuplelist) 47 | {"name": Endswith("Constanza"), "zipcode": Startswith("70")} 48 | 49 | """ 50 | return dict((name, const_to_matchable(kind, what) or what) 51 | for name, kind, what in mtuplelist 52 | if int(kind) != CONDITION_PASS) 53 | -------------------------------------------------------------------------------- /durian/match/able.py: -------------------------------------------------------------------------------- 1 | import re 2 | import operator 3 | 4 | 5 | class Matchable(object): 6 | """Base matchable class. 7 | 8 | :param value: See :attr:`value`. 9 | 10 | Matchables are used to modify the way a value is tested for equality. 11 | 12 | Subclasses of :class:`Matchable` must implement the :meth:`__eq__` method. 13 | 14 | .. attribute:: value 15 | 16 | The value to match against. 17 | 18 | """ 19 | 20 | def __init__(self, value): 21 | self.value = value 22 | 23 | def __ne__(self, other): 24 | return not self.__eq__(other) 25 | 26 | def __eq__(self, other): 27 | raise NotImplementedError("Matchable objects must define __eq__") 28 | 29 | def __repr__(self): 30 | return '%s("%s")' % (self.__class__.__name__, self.value) 31 | 32 | 33 | class Any(Matchable): 34 | """Matchable always matching anything.""" 35 | 36 | def __init__(self, value): 37 | value = value or "" 38 | super(Any, self).__init__(value) 39 | 40 | def __eq__(self, other): 41 | return True 42 | 43 | 44 | class Is(Matchable): 45 | """Matchable checking for strict equality. 46 | 47 | That is, the values must be identical. 48 | (same as the regular ``==`` operator.) 49 | 50 | """ 51 | 52 | def __eq__(self, other): 53 | return operator.eq(self.value, other) 54 | 55 | 56 | class Startswith(Matchable): 57 | """Matchable checking if the matched string starts with the matchee. 58 | 59 | Same as ``other.startswith(value)``. 60 | 61 | """ 62 | 63 | def __eq__(self, other): 64 | return other.startswith(self.value) 65 | 66 | 67 | class Endswith(Matchable): 68 | """Matchable checking if the matched string ends with the matchee. 69 | 70 | Same as ``other.endswith(value)``. 71 | 72 | """ 73 | 74 | def __eq__(self, other): 75 | return other.endswith(self.value) 76 | 77 | 78 | class Contains(Matchable): 79 | """Matchable checking if the matched string contains the matchee. 80 | 81 | Same as ``other.find(value)``. 82 | 83 | """ 84 | 85 | def __eq__(self, other): 86 | return other.find(self.value) != -1 87 | 88 | 89 | class Like(Matchable): 90 | """Matchable checking if the matched string matches a regular 91 | expression.""" 92 | 93 | def __init__(self, value): 94 | super(Like, self).__init__(value) 95 | self.pattern = re.compile(self.value) 96 | 97 | def __eq__(self, other): 98 | return bool(self.pattern.search(other)) 99 | -------------------------------------------------------------------------------- /durian/match/strategy.py: -------------------------------------------------------------------------------- 1 | from collections import deque 2 | 3 | 4 | def deepmatch(needle, haystack): 5 | """With a needle dictionary, recursively match all its keys to 6 | the haystack dictionary.""" 7 | stream = deque([(needle, haystack)]) 8 | 9 | while True: 10 | atom_left, atom_right = stream.pop() 11 | for key, value in atom_left.items(): 12 | if isinstance(value, dict): 13 | if key not in atom_right: 14 | return False 15 | stream.append((value, atom_right[key])) 16 | else: 17 | if atom_right.get(key) != value: 18 | return False 19 | if not stream: 20 | break 21 | return True 22 | -------------------------------------------------------------------------------- /durian/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import ugettext_lazy as _ 3 | from celery.fields import PickledObjectField 4 | from celery.serialization import pickle 5 | 6 | 7 | class Listener(models.Model): 8 | hook = models.CharField(_("hook"), max_length=255, 9 | help_text=_("Connects to hook")) 10 | url = models.URLField(verify_exists=False, 11 | help_text=_("The URL I'm listening at.")) 12 | created_at = models.DateTimeField(auto_now_add=True) 13 | updated_at = models.DateTimeField(auto_now=True) 14 | config = PickledObjectField(_("configuration"), default=pickle.dumps({}), 15 | help_text=_("Hook specific configuration.")) 16 | match = PickledObjectField(_(u"conditions"), default=pickle.dumps({}), 17 | help_text=_("Hook specific event filter")) 18 | 19 | class Meta: 20 | verbose_name = _("listener") 21 | verbose_name_plural = _("listeners") 22 | 23 | def __unicode__(self): 24 | return "%s match:%s config:%s" % ( 25 | self.url, self.match, self.config) 26 | -------------------------------------------------------------------------------- /durian/registry.py: -------------------------------------------------------------------------------- 1 | """durian.registry""" 2 | from celery.exceptions import NotRegistered, AlreadyRegistered 3 | from UserDict import UserDict 4 | from inspect import isclass 5 | 6 | 7 | class HookRegistry(UserDict): 8 | """Global hook registry.""" 9 | 10 | AlreadyRegistered = AlreadyRegistered 11 | NotRegistered = NotRegistered 12 | 13 | def __init__(self): 14 | self.data = {} 15 | 16 | def register(self, hook): 17 | """Register a hook in the hook registry. 18 | 19 | :param hook: The hook to register. 20 | 21 | :raises AlreadyRegistered: if the task is already registered. 22 | 23 | """ 24 | # instantiate class if not already. 25 | hook = hook() if isclass(hook) else hook 26 | 27 | name = hook.name 28 | if name in self.data: 29 | raise self.AlreadyRegistered( 30 | "Hook with name %s is already registered." % name) 31 | 32 | self.data[name] = hook 33 | 34 | def unregister(self, name): 35 | """Unregister hook by name. 36 | 37 | :param name: name of the hook to unregister, or a 38 | :class:`durian.event.Hook` class with a valid ``name`` attribute. 39 | 40 | :raises celery.exceptions.NotRegistered: if the hook has not 41 | been registered. 42 | 43 | """ 44 | if hasattr(name, "name"): 45 | name = name.name 46 | if name not in self.data: 47 | raise self.NotRegistered( 48 | "Hook with name %s is not registered." % name) 49 | del self.data[name] 50 | 51 | def get_all(self): 52 | """Get all hooks.""" 53 | return self.data 54 | 55 | def get_hook(self, name): 56 | """Get hook by name.""" 57 | return self.data[name] 58 | 59 | def as_choices(self): 60 | """Return the hook registry as a choices tuple for use 61 | within Django models and forms.""" 62 | dict_types = dict((type.name, type) 63 | for type in self.data.values()) 64 | sorted_names = sorted(dict_types.keys()) 65 | return [(type.name, type.verbose_name.capitalize()) 66 | for name, type in dict_types.items()] 67 | 68 | """ 69 | .. data:: hooks 70 | 71 | The global hook registry. 72 | 73 | """ 74 | hooks = HookRegistry() 75 | -------------------------------------------------------------------------------- /durian/tasks.py: -------------------------------------------------------------------------------- 1 | from celery.task.base import Task 2 | from celery.registry import tasks 3 | from celery.exceptions import MaxRetriesExceededError 4 | from anyjson import serialize 5 | 6 | 7 | class WebhookSignal(Task): 8 | """The default web hook action. Simply sends the payload to the 9 | listener URL as POST data. 10 | 11 | Task arguments 12 | 13 | * url 14 | The listener destination URL to send payload to. 15 | 16 | * payload 17 | The payload to send to the listener. 18 | 19 | """ 20 | name = "durian.tasks.WebhookSignal" 21 | ignore_result = True 22 | 23 | def run(self, url, payload, **kwargs): 24 | import urllib2 25 | import socket 26 | 27 | orig_timeout = socket.getdefaulttimeout() 28 | retry = kwargs.get("retry", False) 29 | fail_silently = kwargs.get("fail_silently", False) 30 | self.max_retries = kwargs.get("max_retries", self.max_retries) 31 | timeout = kwargs.get("timeout", orig_timeout) 32 | 33 | socket.setdefaulttimeout(timeout) 34 | try: 35 | urllib2.urlopen(url, serialize(payload)) 36 | except urllib2.URLError, exc: 37 | if self.retry: 38 | try: 39 | self.retry(args=[url, payload], kwargs=kwargs, exc=exc) 40 | except MaxRetriesExceededError: 41 | if self.fail_silently: 42 | return 43 | raise 44 | else: 45 | if fail_silently: 46 | return 47 | raise 48 | finally: 49 | socket.setdefaulttimeout(orig_timeout) 50 | -------------------------------------------------------------------------------- /durian/templates/durian/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block extrastyle %}{% endblock %} 6 | 7 | {% block title %}{% endblock %} 8 | 9 | {% block site_extrahead %}{% endblock %} 10 | {% block extrahead %}{% endblock %} 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 24 | 25 | 26 | 27 |
28 | {% block pretitle %}{% endblock %} 29 |

{% block content_title %}{% endblock %}

30 |
31 | {% block objecttools %}{% endblock %} 32 | {% block sidebar %}{% endblock %} 33 | {% block content %}{{ content }}{% endblock %} 34 |
35 |
36 |
37 | 38 | 39 | 40 | 44 | 45 |
46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /durian/templates/durian/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "durian/base.html" %} 2 | 3 | {% block title %}Durian | {{ title }}{% endblock %} 4 | 5 | {% block site_name_header %} 6 | Durian 7 | {% endblock %} 8 | 9 | {% block content_title %}{{ title }}{% endblock %} 10 | 11 | {% block nav-global %}{% endblock %} 12 | 13 | -------------------------------------------------------------------------------- /durian/templates/durian/create_hook.html: -------------------------------------------------------------------------------- 1 | {% extends "durian/base_site.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |
6 | 7 |

{% trans "Listener configuration" %}

8 | {{ config_form.as_p }} 9 | 10 |

{% trans "Filter events" %}

11 | {% for match_form in match_forms %} 12 |

13 | {{ match_form }} 14 |

15 | {% endfor %} 16 | 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /durian/templates/durian/select_hook.html: -------------------------------------------------------------------------------- 1 | {% extends "durian/base_site.html" %} 2 | 3 | {% block content %} 4 |

5 | Please select the event you want to attach to.

6 |
7 | 8 |
9 | {{ select_hook_form.as_p }} 10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /durian/tests/__init__.py: -------------------------------------------------------------------------------- 1 | from djangox.test.depth import alltests 2 | 3 | 4 | def suite(): 5 | return alltests(__file__, __name__) 6 | -------------------------------------------------------------------------------- /durian/tests/test_hooks.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from durian.event import Hook, ModelHook, IntermediateListener 3 | from durian.registry import hooks 4 | from durian.forms import BaseMatchForm 5 | from durian.models import Listener 6 | from celery.registry import tasks 7 | from durian.tasks import WebhookSignal 8 | from durian import match 9 | from django.contrib.auth.models import User 10 | from django.db.models import signals 11 | from django.dispatch import Signal 12 | 13 | 14 | class TestWebhookSignal(WebhookSignal): 15 | name = "__durian__.unittest.TestWebhookSignal" 16 | ignore_result = True 17 | scratchpad = {} 18 | 19 | def run(self, url, payload, **kwargs): 20 | self.__class__.scratchpad[url] = payload 21 | 22 | 23 | testhook = Hook(name="__durian__.unittest.testhook", 24 | provides_args=["name", "address", "phone", "email"], 25 | task_cls=TestWebhookSignal, 26 | async=False) 27 | hooks.register(testhook) 28 | 29 | testmhook = ModelHook(model=User, signal=signals.post_save, 30 | name="__durian__.unittest.testmhook", 31 | provides_args=["username", "email", "first_name", 32 | "last_name"], 33 | task_cls=TestWebhookSignal, 34 | async=False) 35 | hooks.register(testmhook) 36 | 37 | 38 | class TestHook(unittest.TestCase): 39 | 40 | def test_in_registry(self): 41 | in_reg = hooks.get("__durian__.unittest.testhook") 42 | self.assertTrue(in_reg) 43 | self.assertTrue(isinstance(in_reg, Hook)) 44 | 45 | def test_match_forms(self): 46 | mforms = testhook.match_forms 47 | for field in testhook.provides_args: 48 | self.assertTrue(field in mforms) 49 | mform = mforms[field]() 50 | self.assertTrue(isinstance(mform, BaseMatchForm)) 51 | self.assertTrue("%s_cond" % field in mform.base_fields) 52 | self.assertTrue("%s_query" % field in mform.base_fields) 53 | 54 | matchdict = testhook.apply_match_forms({ 55 | "name_cond": match.CONDITION_EXACT, 56 | "name_query": "George Constanza", 57 | "address_cond": match.CONDITION_ENDSWITH, 58 | "address_query": "New York City", 59 | "phone_cond": match.CONDITION_STARTSWITH, 60 | "phone_query": "212", 61 | "email_cond": match.CONDITION_CONTAINS, 62 | "email_query": "@vandelay"}) 63 | 64 | self.assertTrue(isinstance(matchdict.get("name"), match.Is)) 65 | self.assertTrue(isinstance(matchdict.get("address"), match.Endswith)) 66 | self.assertTrue(isinstance(matchdict.get("phone"), match.Startswith)) 67 | self.assertTrue(isinstance(matchdict.get("email"), match.Contains)) 68 | 69 | self.assertTrue(match.deepmatch(matchdict, { 70 | "name": "George Constanza", 71 | "address": "Border Lane, New York City", 72 | "phone": "212 555 88 23", 73 | "email": "george@vandelay.com", 74 | })) 75 | 76 | def test_trigger_event(self): 77 | url = "http://where.joe/listens" 78 | form = testhook.config_form({"url": url}) 79 | listener = testhook.listener(form).match(name="Joe").save() 80 | self.assertTrue(isinstance(listener, Listener)) 81 | 82 | lis = Listener.objects.filter(url=url) 83 | self.assertTrue(lis.count()) 84 | self.assertTrue(lis[0].url == url) 85 | 86 | testhook.send(sender=self, 87 | name="Joe", address="foo", phone="123", 88 | email="joe@example.com") 89 | 90 | self.assertTrue(TestWebhookSignal.scratchpad.get(url)) 91 | del(TestWebhookSignal.scratchpad[url]) 92 | 93 | testhook.send(sender=self, 94 | name="Simon", address="bar", phone="456", 95 | email="simon@example.com") 96 | self.assertFalse(TestWebhookSignal.scratchpad.get(url)) 97 | 98 | 99 | class TestModelHook(unittest.TestCase): 100 | 101 | def test_trigger_event(self): 102 | url = "http://where.joe/mlistens" 103 | form = testmhook.config_form({"url": url}) 104 | self.assertTrue(testmhook) 105 | a = testmhook.listener(form) 106 | self.assertTrue(isinstance(a, IntermediateListener)) 107 | self.assertTrue(callable(a.match)) 108 | self.assertTrue(testmhook.listener(form).match(username="joe")) 109 | listener = testmhook.listener(form).match(username="joe").save() 110 | self.assertTrue(isinstance(listener, Listener)) 111 | 112 | lis = Listener.objects.filter(url=url) 113 | self.assertTrue(lis.count()) 114 | self.assertTrue(lis[0].url == url) 115 | 116 | u = User.objects.create_user(username="joe", 117 | email="joe@example.com", password="joe") 118 | 119 | scratch = TestWebhookSignal.scratchpad.get(url) 120 | self.assertTrue(scratch) 121 | self.assertTrue(scratch["created"]) 122 | self.assertEquals(scratch["username"], "joe") 123 | self.assertEquals(scratch["email"], "joe@example.com") 124 | self.assertFalse(scratch.get("password")) 125 | del(TestWebhookSignal.scratchpad[url]) 126 | 127 | u.last_name = "Example" 128 | u.save() 129 | 130 | scratch = TestWebhookSignal.scratchpad.get(url) 131 | self.assertTrue(scratch) 132 | self.assertEquals(scratch["created"], False) 133 | self.assertEquals(scratch["username"], "joe") 134 | self.assertEquals(scratch["email"], "joe@example.com") 135 | self.assertEquals(scratch["last_name"], "Example") 136 | self.assertFalse(scratch.get("password")) 137 | del(TestWebhookSignal.scratchpad[url]) 138 | -------------------------------------------------------------------------------- /durian/tests/test_match.py: -------------------------------------------------------------------------------- 1 | from durian.match.strategy import deepmatch 2 | from durian.match.able import Is, Like, Startswith, Endswith, Contains 3 | from durian.match import mtuplelist_to_matchdict 4 | from durian import match 5 | import unittest 6 | 7 | 8 | class TestMtuplelist(unittest.TestCase): 9 | 10 | def test_conversion(self): 11 | mtuplelist = [("name", match.CONDITION_ENDSWITH, "Constanza"), 12 | ("zipcode", match.CONDITION_STARTSWITH, "70"), 13 | ("address", match.CONDITION_EXACT, "Milkyway"), 14 | ("work", match.CONDITION_CONTAINS, "andeley"), 15 | ("zoo", match.CONDITION_PASS, "zoo")] 16 | matchdict = mtuplelist_to_matchdict(mtuplelist) 17 | self.assertFalse(matchdict.get("zoo")) 18 | self.assertTrue(isinstance(matchdict.get("name"), Endswith)) 19 | self.assertEquals(matchdict["name"].value, "Constanza") 20 | self.assertTrue(isinstance(matchdict.get("zipcode"), Startswith)) 21 | self.assertEquals(matchdict["zipcode"].value, "70") 22 | self.assertTrue(isinstance(matchdict.get("address"), Is)) 23 | self.assertEquals(matchdict["address"].value, "Milkyway") 24 | self.assertTrue(isinstance(matchdict.get("work"), Contains)) 25 | self.assertEquals(matchdict["work"].value, "andeley") 26 | 27 | matches = {"name": "George Constanza", 28 | "zipcode": "70312", 29 | "address": "Milkyway", 30 | "work": "Vandeley Industries"} 31 | 32 | notmatches = {"name": "Jerry Seinfeld", 33 | "zipcode": "70314", 34 | "address": "Milkyway", 35 | "work": "Comedian"} 36 | 37 | self.assertTrue(deepmatch(matches, matchdict)) 38 | self.assertFalse(deepmatch(notmatches, matchdict)) 39 | 40 | 41 | class TestStrategy(unittest.TestCase): 42 | 43 | def test_deepmatch(self): 44 | self.assertTrue(deepmatch({"name": Like("Constanza")}, 45 | {"name": "George Constanza"})) 46 | self.assertFalse(deepmatch({"name": Like("Seinfeld")}, 47 | {"name": "George Constanza"})) 48 | self.assertTrue(deepmatch({"name": Startswith("George")}, 49 | {"name": "George Constanza"})) 50 | self.assertFalse(deepmatch({"name": Startswith("Cosmo")}, 51 | {"name": "George Constanza"})) 52 | self.assertTrue(deepmatch({"name": { 53 | "first_name": Startswith("El"), 54 | "last_name": Endswith("es"), 55 | }}, 56 | {"name": { 57 | "first_name": "Elaine", 58 | "last_name": "Benes", 59 | }})) 60 | x = { 61 | "foo": "xuzzy", 62 | "baz": "xxx", 63 | "mooze": { 64 | "a": "b", 65 | "c": { 66 | "d": "e", 67 | } 68 | } 69 | } 70 | self.assertTrue(deepmatch(x, x)) 71 | self.assertFalse(deepmatch(x, {"foo": "xuzzy", 72 | "baz": "xxx", 73 | "mooze": { 74 | "a": "b", 75 | "c": { 76 | "x": "y", 77 | } 78 | } 79 | })) 80 | self.assertTrue(deepmatch({"foo": "bar"}, {"foo": "bar"})) 81 | self.assertFalse(deepmatch({"foo": 1}, {"foo": "1"})) 82 | self.assertFalse(deepmatch({"foo": "bar", "baz": {"x": "x"}}, 83 | {"foo": "bar"})) 84 | -------------------------------------------------------------------------------- /durian/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import patterns, url, include 2 | from durian import views 3 | 4 | 5 | class DurianSite(object): 6 | namespace = "durian" 7 | app_name = "durian" 8 | 9 | def __init__(self, namespace=None, app_name=None): 10 | self.namespace = namespace or self.namespace 11 | self.app_name = app_name or self.app_name 12 | 13 | @property 14 | def urlpatterns(self): 15 | return patterns("", 16 | url(r'^select/', views.select, name="select"), 17 | url(r'^create/', views.create, name="create"), 18 | url(r'^debug/', views.debug, name="debug"), 19 | ) 20 | 21 | @property 22 | def urls(self): 23 | return (self.urlpatterns, "durian", "durian") 24 | 25 | 26 | durian = DurianSite() 27 | urlpatterns = durian.urlpatterns 28 | -------------------------------------------------------------------------------- /durian/views.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from django.shortcuts import render_to_response 3 | from django.template import RequestContext 4 | from django.http import HttpResponse, HttpResponseRedirect 5 | from django.http import HttpResponseNotAllowed 6 | from django.utils.translation import ugettext as _ 7 | from anyjson import deserialize 8 | from durian.forms import create_select_hook_form 9 | from durian.registry import hooks 10 | 11 | 12 | def send(request, hook_type): 13 | """Trigger hook by sending payload as POST data.""" 14 | 15 | if request.method != "POST": 16 | return HttpResponseNotAllowed(_("Method not allowed: %s") % ( 17 | request.method)) 18 | 19 | payload = dict(request.POST.copy()) 20 | sender = request.META 21 | 22 | hook = get_hook_or_404(hook_type) 23 | hook.send(sender=sender, **payload) 24 | 25 | 26 | def select(request, template_name="durian/select_hook.html"): 27 | """Select hook to create.""" 28 | context = RequestContext(request) 29 | context["title"] = _("Select event") 30 | context["select_hook_form"] = create_select_hook_form() 31 | return render_to_response(template_name, context_instance=context) 32 | 33 | 34 | def get_hook_or_404(hook_type): 35 | """Get hook by type in the registry or raise HTTP 404.""" 36 | if hook_type not in hooks: 37 | raise Http404(_("Unknown hook type: %s" % hook_type)) 38 | return hooks[hook_type] 39 | 40 | 41 | def create(request, template_name="durian/create_hook.html"): 42 | """View to create a new hook.""" 43 | context = RequestContext(request) 44 | if request.method == "POST": 45 | hook = get_hook_or_404(request.POST["type"]) 46 | config_form = hook.config_form(request.POST) 47 | if config_form.is_valid(): 48 | matchdict = hook.apply_match_forms(request.POST) 49 | hook.add_listener_by_form(config_form, match=matchdict) 50 | return HttpResponse("Listener Created!") 51 | else: 52 | hook = get_hook_or_404(request.GET["type"]) 53 | config_form = hook.config_form() 54 | 55 | match_forms = hook.get_match_forms() 56 | context["title"] = _("Create %s Listener" % ( 57 | hook.verbose_name.capitalize())) 58 | context["hook_type"] = hook.name 59 | context["hook_name"] = hook.verbose_name 60 | context["match_forms"] = match_forms 61 | context["config_form"] = config_form 62 | return render_to_response(template_name, context_instance=context) 63 | 64 | 65 | def debug(request): 66 | """Simple listener destination URL to dump out the payload and 67 | request to stderr.""" 68 | sys.stderr.write(str(request.get_full_path()) + "\n") 69 | sys.stderr.write(str(request.raw_post_data) + "\n") 70 | sys.stderr.write(str(request.POST) + "\n") 71 | sys.stderr.write(str(deserialize(request.raw_post_data)) + "\n") 72 | return HttpResponse("Thanks!") 73 | -------------------------------------------------------------------------------- /examples/durianproject/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ask/durian/bd7a773ce3470884fd74e97f0bba4b351545df56/examples/durianproject/__init__.py -------------------------------------------------------------------------------- /examples/durianproject/durianapp/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ask/durian/bd7a773ce3470884fd74e97f0bba4b351545df56/examples/durianproject/durianapp/__init__.py -------------------------------------------------------------------------------- /examples/durianproject/durianapp/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.db.models import signals 3 | from django.utils.translation import ugettext_lazy as _ 4 | from durian.event import ModelHook 5 | from durian.registry import hooks 6 | 7 | 8 | class Person(models.Model): 9 | name = models.CharField(_(u"name"), blank=False, max_length=200) 10 | address = models.CharField(_(u"address"), max_length=200) 11 | secret = models.CharField(_(u"secret"), max_length=200) 12 | 13 | 14 | class PersonHook(ModelHook): 15 | name = "person" 16 | model = Person 17 | signal = signals.post_save 18 | provides_args = ["name", "address"] 19 | async = False 20 | hooks.register(PersonHook) 21 | -------------------------------------------------------------------------------- /examples/durianproject/durianapp/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates two different styles of tests (one doctest and one 3 | unittest). These will both pass when you run "manage.py test". 4 | 5 | Replace these with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | 13 | def test_basic_addition(self): 14 | """Tests that 1 + 1 always equals 2.""" 15 | self.failUnlessEqual(1 + 1, 2) 16 | 17 | __test__ = {"doctest": """ 18 | Another way to test that 1 + 1 is equal to 2. 19 | 20 | >>> 1 + 1 == 2 21 | True 22 | """} 23 | -------------------------------------------------------------------------------- /examples/durianproject/durianapp/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /examples/durianproject/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write( 8 | "Error: Can't find the file 'settings.py' in the " + \ 9 | "directory containing %r. It appears you've" % __file__ + \ 10 | "customized things.\nYou'll have to run " + \ 11 | "django-admin.py, passing it your settings module.\n" + \ 12 | "(If the file settings.py does indeed exist, it's " + \ 13 | "causing an ImportError somehow.)\n") 14 | sys.exit(1) 15 | 16 | if __name__ == "__main__": 17 | execute_manager(settings) 18 | -------------------------------------------------------------------------------- /examples/durianproject/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for durianproject project. 2 | 3 | DEBUG = True 4 | TEMPLATE_DEBUG = DEBUG 5 | 6 | ADMINS = ( 7 | # ('Your Name', 'your_email@domain.com'), 8 | ) 9 | 10 | MANAGERS = ADMINS 11 | 12 | DATABASE_ENGINE = 'sqlite3' 13 | DATABASE_NAME = 'durianproject.sqlite' 14 | DATABASE_USER = '' 15 | DATABASE_PASSWORD = '' 16 | DATABASE_HOST = '' 17 | DATABASE_PORT = '' 18 | 19 | # Local time zone for this installation. Choices can be found here: 20 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 21 | # although not all choices may be available on all operating systems. 22 | # If running in a Windows environment this must be set to the same as your 23 | # system time zone. 24 | TIME_ZONE = 'America/Chicago' 25 | 26 | # Language code for this installation. All choices can be found here: 27 | # http://www.i18nguy.com/unicode/language-identifiers.html 28 | LANGUAGE_CODE = 'en-us' 29 | 30 | SITE_ID = 1 31 | 32 | # If you set this to False, Django will make some optimizations so as not 33 | # to load the internationalization machinery. 34 | USE_I18N = True 35 | 36 | # Absolute path to the directory that holds media. 37 | # Example: "/home/media/media.lawrence.com/" 38 | MEDIA_ROOT = '' 39 | 40 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 41 | # trailing slash if there is a path component (optional in other cases). 42 | # Examples: "http://media.lawrence.com", "http://example.com/media/" 43 | MEDIA_URL = '' 44 | 45 | # URL prefix for admin media -- CSS, JavaScript and images. 46 | # Make sure to use a trailing slash. 47 | # Examples: "http://foo.com/media/", "/media/". 48 | ADMIN_MEDIA_PREFIX = '/media/' 49 | 50 | # Make this unique, and don't share it with anybody. 51 | SECRET_KEY = '+s)c0psuin+78$f2(k7)bxglr-i+_wy*lz)))48m@_1(zgogho' 52 | 53 | # List of callables that know how to import templates from various sources. 54 | TEMPLATE_LOADERS = ( 55 | 'django.template.loaders.filesystem.load_template_source', 56 | 'django.template.loaders.app_directories.load_template_source', 57 | ) 58 | 59 | MIDDLEWARE_CLASSES = ( 60 | 'django.middleware.common.CommonMiddleware', 61 | 'django.contrib.sessions.middleware.SessionMiddleware', 62 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 63 | ) 64 | 65 | ROOT_URLCONF = 'durianproject.urls' 66 | 67 | INSTALLED_APPS = ( 68 | 'django.contrib.auth', 69 | 'django.contrib.contenttypes', 70 | 'django.contrib.sessions', 71 | 'django.contrib.sites', 72 | 'django.contrib.admin', 73 | 'celery', 74 | 'durian', 75 | 'durianapp', 76 | ) 77 | -------------------------------------------------------------------------------- /examples/durianproject/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import patterns, url, include 2 | from durian.urls import durian 3 | from django.contrib import admin 4 | from django.conf import settings 5 | admin.autodiscover() 6 | 7 | # Uncomment the next two lines to enable the admin: 8 | # from django.contrib import admin 9 | # admin.autodiscover() 10 | 11 | urlpatterns = patterns('', 12 | url(r'^durian/', durian.urls), 13 | url(r'^admin/', admin.site.urls), 14 | url(r"%s(?P.*)$" % settings.MEDIA_URL[1:], 15 | "django.views.static.serve", { 16 | "document_root": settings.MEDIA_ROOT}), 17 | ) 18 | -------------------------------------------------------------------------------- /examples/hook.py: -------------------------------------------------------------------------------- 1 | from durian.models import Listener 2 | from django import forms 3 | from durian.forms import HookConfigForm 4 | from durian.event import Hook 5 | from durian.registry import hooks 6 | 7 | 8 | # SIMPLE HOOK 9 | 10 | class MyHook(Hook): 11 | name = "myhook" 12 | hooks.register(MyHook()) 13 | 14 | 15 | def install_listener(): 16 | l = Listener(url="http://localhost:8000/durian/debug/", 17 | hook=MyHook.name) 18 | l.save() 19 | 20 | # HOOK SENT EVERY TIME USER "ask" COMMITS A CHANGE 21 | 22 | # This form is needed to add a listener, so the correct username/password 23 | # is sent when sending updates to twitter. 24 | 25 | class TwitterCommitHookConfigForm(HookConfigForm): 26 | username = forms.CharField(label=_(u"username")) 27 | password = forms.CharField(label=_(u"password"), required=True, 28 | widget=forms.PasswordInput()) 29 | digest = forms.BooleanField(widget=forms.CheckboxInput()) 30 | active = forms.BooleanField(widget=forms.CheckboxInput()) 31 | 32 | 33 | # This is the hook itself. 34 | class TwitterCommitHook(Hook): 35 | name = "myuserhook" 36 | config_form = TwitterCommitHookConfigForm 37 | providing_args = ["username", "password", "digest", "active"] 38 | hooks.register(TwitterCommitHook) 39 | 40 | 41 | # This is the function triggering the hook 42 | def commit_change(self, commit_msg, user, revision): 43 | 44 | # ...Do what happens regularly at a commit... 45 | 46 | TwitterCommitHook().send(sender=commit_change, user=user, revision=revision, 47 | commit_msg=commit_msg) 48 | 49 | 50 | 51 | # Now let's register a listener. 52 | 53 | from celeryhook.match import Startswith 54 | hook = TwitterCommitHook() 55 | form = hook.config_form({"username": "ask", "password": "foo"}) 56 | hook.listener(form).match(commit_msg=Startswith("Important change"), 57 | username="ask").save() 58 | 59 | 60 | 61 | 62 | 63 | 64 | # A Django view registering a listener. 65 | def add_twitter_hook(request, template_name="myapp/twitterhook.html"): 66 | hook = TwitterCommitHook() 67 | context = RequestContext() 68 | if request.method == "POST": 69 | form = hook.config_form(request.POST) 70 | if form.is_valid(): 71 | hook.add_listener_by_form(form) 72 | else: 73 | form = hook.config_form() 74 | 75 | context["form"] = form 76 | 77 | return render_to_response(template_name, context_instance=context) 78 | 79 | 80 | # ### MODEL HOOK 81 | 82 | 83 | from django.db import signals 84 | from django.contrib.auth.models import User 85 | from durian.event import ModelHook 86 | 87 | 88 | userhook = ModelHook(name="user-post-save", 89 | model=User, 90 | signal=signals.post_save, 91 | provides_args=["username", "is_admin"]) 92 | 93 | # send event when Joe is changed 94 | userhook.listener( 95 | url="http://where.joe/is/listening").match( 96 | username="joe").save() 97 | 98 | # send event when any user is changed. 99 | userhook.listener(url="http://where.joe/is/listening").save() 100 | 101 | # Send event when Joe is admin 102 | userhook.listener( 103 | url="http://where.joe/is/listening").match( 104 | username="joe", is_admin=True).save() 105 | 106 | 107 | 108 | 109 | joe = User.objects.get(username="joe") 110 | joe.is_admin = True 111 | joe.save() 112 | 113 | 114 | A hook that sends events to twitter 115 | ----------------------------------- 116 | 117 | 118 | In ``myapp/tasks.py``: 119 | 120 | >>> from celery.task import Task 121 | >>> from celery.registry import tasks 122 | 123 | >>> class TwitterUpdateTask(WebhookSignal): 124 | ... name = "myapp.tasks.TwitterWebhookSignal" 125 | ... 126 | ... def run(self, username, password, message, \*\*kwargs): 127 | ... import twitter 128 | ... api = twitter.Api(username=username, password=password) 129 | ... api.PostUpdate(message) 130 | >>> tasks.register(TwitterUpdateTask) 131 | 132 | 133 | In ``myapp/hooks.py``: 134 | 135 | >>> from durian.event import Hook 136 | >>> from durian.registry import hooks 137 | >>> from durian.forms import BaseHookConfigForm 138 | >>> from django.utils.translation import _ 139 | >>> from django import forms 140 | 141 | >>> class TwitterHookConfigForm(HookConfigForm): 142 | ... username = forms.CharField(label=_("twitter username"), 143 | ... required=True) 144 | ... password = forms.CharField(label=_("twitter password"), 145 | ... widget=forms.PasswordInput()) 146 | 147 | 148 | >>> class TwitterHook(Hook): 149 | ... name = "Twitter" 150 | ... task_cls = TwitterUpdateTask 151 | ... config_form = TwitterHookConfigForm 152 | ... 153 | ... def 154 | 155 | 156 | 157 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | verbosity = 1 3 | detailed-errors = 1 4 | with-coverage = 1 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import codecs 4 | import sys 5 | import os 6 | 7 | try: 8 | from setuptools import setup, find_packages, Command 9 | except ImportError: 10 | from ez_setup import use_setuptools 11 | use_setuptools() 12 | from setuptools import setup, find_packages, Command 13 | 14 | import durian 15 | 16 | 17 | class RunTests(Command): 18 | description = "Run the django test suite from the testproj dir." 19 | 20 | user_options = [] 21 | 22 | def initialize_options(self): 23 | pass 24 | 25 | def finalize_options(self): 26 | pass 27 | 28 | def run(self): 29 | this_dir = os.getcwd() 30 | testproj_dir = os.path.join(this_dir, "testproj") 31 | os.chdir(testproj_dir) 32 | sys.path.append(testproj_dir) 33 | from django.core.management import execute_manager 34 | os.environ["DJANGO_SETTINGS_MODULE"] = os.environ.get( 35 | "DJANGO_SETTINGS_MODULE", "settings") 36 | settings_file = os.environ["DJANGO_SETTINGS_MODULE"] 37 | settings_mod = __import__(settings_file, {}, {}, ['']) 38 | execute_manager(settings_mod, argv=[ 39 | __file__, "test"]) 40 | os.chdir(this_dir) 41 | 42 | 43 | install_requires = ["django-unittest-depth", 44 | "celery"] 45 | 46 | if os.path.exists("README.rst"): 47 | long_description = codecs.open("README.rst", "r", "utf-8").read() 48 | else: 49 | long_description = "See http://pypi.python.org/pypi/celery" 50 | 51 | 52 | setup( 53 | name='durian', 54 | version=durian.__version__, 55 | description=durian.__doc__, 56 | author=durian.__author__, 57 | author_email=durian.__contact__, 58 | url=durian.__homepage__, 59 | platforms=["any"], 60 | packages=find_packages(exclude=['ez_setup']), 61 | zip_safe=False, 62 | install_requires=install_requires, 63 | cmdclass = {"test": RunTests}, 64 | classifiers=[ 65 | "Development Status :: 4 - Beta", 66 | "Framework :: Django", 67 | "Environment :: Web Environment", 68 | "Topic :: Internet :: WWW/HTTP", 69 | "Topic :: Communications", 70 | "Intended Audience :: Developers", 71 | "Programming Language :: Python", 72 | "Operating System :: OS Independent", 73 | "License :: OSI Approved :: BSD License", 74 | ], 75 | long_description=long_description, 76 | ) 77 | -------------------------------------------------------------------------------- /testproj/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ask/durian/bd7a773ce3470884fd74e97f0bba4b351545df56/testproj/__init__.py -------------------------------------------------------------------------------- /testproj/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from django.core.management import execute_manager 3 | try: 4 | import settings # Assumed to be in the same directory. 5 | except ImportError: 6 | import sys 7 | sys.stderr.write( 8 | "Error: Can't find the file 'settings.py' in the directory \ 9 | containing %r. It appears you've customized things.\n\ 10 | You'll have to run django-admin.py, passing it your settings\ 11 | module.\n(If the file settings.py does indeed exist, it's\ 12 | causing an ImportError somehow.)\n" % __file__) 13 | sys.exit(1) 14 | 15 | if __name__ == "__main__": 16 | execute_manager(settings) 17 | -------------------------------------------------------------------------------- /testproj/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for testproj project. 2 | 3 | import os 4 | import sys 5 | # import source code dir 6 | sys.path.insert(0, os.path.join(os.getcwd(), os.pardir)) 7 | 8 | SITE_ID = 301 9 | 10 | DEBUG = True 11 | TEMPLATE_DEBUG = DEBUG 12 | 13 | ROOT_URLCONF = "urls" 14 | 15 | ADMINS = ( 16 | # ('Your Name', 'your_email@domain.com'), 17 | ) 18 | 19 | TEST_RUNNER = "celery.tests.runners.run_tests" 20 | TEST_APPS = ( 21 | "durian", 22 | ) 23 | 24 | AMQP_SERVER = "localhost" 25 | AMQP_PORT = 5672 26 | AMQP_VHOST = "/" 27 | AMQP_USER = "guest" 28 | AMQP_PASSWORD = "guest" 29 | 30 | TT_HOST = "localhost" 31 | TT_PORT = 1978 32 | 33 | CELERY_AMQP_EXCHANGE = "testdurian" 34 | CELERY_AMQP_ROUTING_KEY = "testdurian" 35 | CELERY_AMQP_CONSUMER_QUEUE = "testdurian" 36 | 37 | MANAGERS = ADMINS 38 | 39 | DATABASE_ENGINE = 'sqlite3' 40 | DATABASE_NAME = 'testdb.sqlite' 41 | DATABASE_USER = '' 42 | DATABASE_PASSWORD = '' 43 | DATABASE_HOST = '' 44 | DATABASE_PORT = '' 45 | 46 | INSTALLED_APPS = ( 47 | 'django.contrib.auth', 48 | 'django.contrib.contenttypes', 49 | 'django.contrib.sessions', 50 | 'django.contrib.sites', 51 | 'celery', 52 | 'durian', 53 | ) 54 | 55 | try: 56 | import test_extensions 57 | except ImportError: 58 | pass 59 | else: 60 | pass 61 | #INSTALLED_APPS += ("test_extensions", ) 62 | 63 | SEND_CELERY_TASK_ERROR_EMAILS = False 64 | -------------------------------------------------------------------------------- /testproj/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls.defaults import * 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | # from django.contrib import admin 5 | # admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | # Example: 9 | # (r'^testproj/', include('testproj.foo.urls')), 10 | 11 | # Uncomment the admin/doc line below and add 'django.contrib.admindocs' 12 | # to INSTALLED_APPS to enable admin documentation: 13 | # (r'^admin/doc/', include('django.contrib.admindocs.urls')), 14 | 15 | # Uncomment the next line to enable the admin: 16 | # (r'^admin/(.*)', admin.site.root), 17 | ) 18 | --------------------------------------------------------------------------------