├── .gitignore ├── .travis.yml ├── AUTHORS ├── CHANGES ├── MANIFEST.in ├── Makefile ├── README.rst ├── UNLICENSE ├── docs ├── Makefile ├── _templates │ ├── sidebarintro.html │ └── sidebarlogo.html ├── conf.py ├── developing.rst ├── foreword.rst ├── index.rst ├── installation.rst └── usage.rst ├── pycall ├── __init__.py ├── actions.py ├── call.py ├── callfile.py └── errors.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_actions.py ├── test_call.py └── test_callfile.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Temporary files. 2 | *.pyc 3 | *.swp 4 | *~ 5 | .cache 6 | .coverage 7 | 8 | # Build remnants. 9 | dist 10 | build 11 | _build 12 | pycall.egg-info 13 | 14 | # Auto-generated files. 15 | MANIFEST 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | install: pip install -e .[test] 9 | script: python setup.py test 10 | after_success: 11 | - coverage xml 12 | - coveralls 13 | - python-codacy-coverage -r coverage.xml 14 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | pycall is written and maintained by Randall Degges and various contributers: 2 | 3 | 4 | Development Lead 5 | ---------------- 6 | 7 | - Randall Degges 8 | 9 | 10 | Patches and Suggestions 11 | ----------------------- 12 | 13 | - Marcelo Araujo 14 | - EvilZluk 15 | - Guillermo Manzato 16 | - Kc57: https://github.com/Kc57 17 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | pycall Changelog 2 | ================ 3 | 4 | Here you can see the full list of changes between each pycall release. 5 | 6 | 7 | Version 2.3.2 8 | ------------- 9 | 10 | Released on September 28, 2017. 11 | 12 | - Fixing a file descriptor leak. 13 | 14 | 15 | Version 2.3.1 16 | ------------- 17 | 18 | Released February 17, 2017. 19 | 20 | - Upgrading path.py dependency support. 21 | - Cleaning up some deprecated code. 22 | - Fixing tests. 23 | 24 | 25 | Version 2.2 26 | ----------- 27 | 28 | Released on April 4, 2015. 29 | 30 | - Fixing issue where Contexts weren't considered valid actions for some reason. 31 | - PEP-8'ing source code. 32 | 33 | 34 | Version 2.1 35 | ----------- 36 | 37 | **Accidental release.** 38 | 39 | 40 | Version 2.0 41 | ----------- 42 | 43 | Released on May 1, 2011. 44 | 45 | - Complete re-write of codebase. 46 | - Added full unit-test suite. 47 | - 100% test coverage. 48 | - Full set of Sphinx documentation. 49 | - Numerous code improvements. 50 | - New website! 51 | 52 | 53 | Version 1.6 54 | ----------- 55 | 56 | Released on March 21, 2010. 57 | 58 | - Fixing major bug from previous release. `tmpdir` attribute now works. 59 | 60 | 61 | Version 1.5 62 | ----------- 63 | 64 | Released on March 21, 2010. 65 | 66 | - Adding a `tmpdir` option to :class:`callfile.CallFile` class. 67 | - Cleaning up syntax (PEP-8). 68 | 69 | 70 | Version 1.4 71 | ----------- 72 | 73 | Released on March 1, 2010. 74 | 75 | - Fixing an issue with cross-device call file creation. 76 | 77 | 78 | Version 1.3 79 | ----------- 80 | 81 | Released on February 23, 2010. 82 | 83 | - Adding support for 'Local' trunks. 84 | 85 | 86 | Version 1.2 87 | ----------- 88 | 89 | Released on November 11, 2009. 90 | 91 | - Various bugfixes. 92 | - Adding some simple demos. 93 | 94 | 95 | Version 1.1 96 | ----------- 97 | 98 | Released on October 21, 2009. 99 | 100 | - Various bugfixes. 101 | - Added exception handling. 102 | 103 | 104 | Version 1.0 105 | ----------- 106 | 107 | Released on October 20, 2009. 108 | 109 | First public release! 110 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include Makefile CHANGES UNLICENSE AUTHORS README.rst 2 | recursive-include docs * 3 | recursive-include tests * 4 | recursive-exclude docs *.pyc 5 | recursive-exclude tests *.pyc 6 | prune docs/_build 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ## Helpful make targets for coding. 2 | 3 | 4 | ## Clean up the project directory, and run tests. 5 | all: clean test 6 | 7 | 8 | ## Clean the project directory. 9 | clean: 10 | find . -name '*.pyc' -exec rm -f {} + 11 | find . -name '*.pyo' -exec rm -f {} + 12 | find . -name '*~' -exec rm -f {} + 13 | 14 | 15 | ## Run the test suite. 16 | test: 17 | python setup.py nosetests 18 | 19 | 20 | ## Distribute the latest release to PyPI. 21 | release: 22 | python setup.py release sdist upload 23 | 24 | 25 | ## Build the documentation with Sphinx. 26 | docs: 27 | $(MAKE) -C docs html dirhtml latex 28 | $(MAKE) -C docs/_build/latex all-pdf 29 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pycall 2 | ====== 3 | 4 | .. image:: https://img.shields.io/pypi/v/pycall.svg 5 | :alt: pycall Release 6 | :target: https://pypi.python.org/pypi/pycall 7 | 8 | .. image:: https://img.shields.io/pypi/dm/pycall.svg 9 | :alt: pycall Downloads 10 | :target: https://pypi.python.org/pypi/pycall 11 | 12 | .. image:: https://api.codacy.com/project/badge/grade/b5d09a0bb429481aa7c78c1df98628bf 13 | :alt: pycall Code Quality 14 | :target: https://www.codacy.com/app/r/pycall 15 | 16 | .. image:: https://img.shields.io/travis/rdegges/pycall.svg 17 | :alt: pycall Build 18 | :target: https://travis-ci.org/rdegges/pycall 19 | 20 | .. image:: https://coveralls.io/repos/github/rdegges/pycall/badge.svg?branch=master 21 | :alt: pycall Coverage 22 | :target: https://coveralls.io/github/rdegges/pycall?branch=master 23 | 24 | .. image:: https://img.shields.io/gratipay/user/rdegges.svg?maxAge=2592000 25 | :alt: Randall Degges on Gratipay 26 | :target: https://gratipay.com/~rdegges/ 27 | 28 | A flexible python library for creating and using Asterisk call files. 29 | 30 | Please visit our official website to get an overview, and see the docs: 31 | http://pycall.org/ 32 | 33 | 34 | Details 35 | ------- 36 | 37 | * author: Randall Degges 38 | * email: r@rdegges.com 39 | * license: UNLICENSE (see UNLICENSE for more information) 40 | * website: http://pycall.org/ 41 | -------------------------------------------------------------------------------- /UNLICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /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 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/pycall.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/pycall.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /docs/_templates/sidebarintro.html: -------------------------------------------------------------------------------- 1 |

About pycall

2 |

3 | pycall is a python library for Asterisk developers. pycall makes developing 4 | telephony applications fun and easy! You are currently looking at the 5 | documentation for the development version of pycall. pycall is stable, and 6 | deployed on multiple servers throughout the world. If you have any 7 | feedback, let me know. 8 |

9 |

Useful Links

10 | 16 | -------------------------------------------------------------------------------- /docs/_templates/sidebarlogo.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # pycall documentation build configuration file, created by 4 | # sphinx-quickstart on Sun May 16 00:39:15 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.append(os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be extensions 24 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 25 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx'] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ['_templates'] 29 | 30 | # The suffix of source filenames. 31 | source_suffix = '.rst' 32 | 33 | # The encoding of source files. 34 | #source_encoding = 'utf-8' 35 | 36 | # The master toctree document. 37 | master_doc = 'index' 38 | 39 | # General information about the project. 40 | project = u'pycall' 41 | copyright = u'2010, Randall Degges' 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | # The short X.Y version. 48 | version = '2.3.2' 49 | # The full version, including alpha/beta/rc tags. 50 | release = '2.3.2' 51 | 52 | # The language for content autogenerated by Sphinx. Refer to documentation 53 | # for a list of supported languages. 54 | #language = None 55 | 56 | # There are two options for replacing |today|: either, you set today to some 57 | # non-false value, then it is used: 58 | #today = '' 59 | # Else, today_fmt is used as the format for a strftime call. 60 | #today_fmt = '%B %d, %Y' 61 | 62 | # List of documents that shouldn't be included in the build. 63 | #unused_docs = [] 64 | 65 | # List of directories, relative to source directory, that shouldn't be searched 66 | # for source files. 67 | exclude_trees = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. Major themes that come with 93 | # Sphinx are currently 'default' and 'sphinxdoc'. 94 | html_theme = 'nature' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_use_modindex = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, an OpenSearch description file will be output, and all pages will 153 | # contain a tag referring to it. The value of this option must be the 154 | # base URL from which the finished HTML is served. 155 | #html_use_opensearch = '' 156 | 157 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 158 | #html_file_suffix = '' 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = 'pycalldoc' 162 | 163 | 164 | # -- Options for LaTeX output -------------------------------------------------- 165 | 166 | # The paper size ('letter' or 'a4'). 167 | #latex_paper_size = 'letter' 168 | 169 | # The font size ('10pt', '11pt' or '12pt'). 170 | #latex_font_size = '10pt' 171 | 172 | # Grouping the document tree into LaTeX files. List of tuples 173 | # (source start file, target name, title, author, documentclass [howto/manual]). 174 | latex_documents = [ 175 | ('index', 'pycall.tex', u'pycall Documentation', 176 | u'Randall Degges', 'manual'), 177 | ] 178 | 179 | # The name of an image file (relative to this directory) to place at the top of 180 | # the title page. 181 | #latex_logo = None 182 | 183 | # For "manual" documents, if this is true, then toplevel headings are parts, 184 | # not chapters. 185 | #latex_use_parts = False 186 | 187 | # Additional stuff for the LaTeX preamble. 188 | #latex_preamble = '' 189 | 190 | # Documents to append as an appendix to all manuals. 191 | #latex_appendices = [] 192 | 193 | # If false, no module index is generated. 194 | #latex_use_modindex = True 195 | 196 | 197 | # Example configuration for intersphinx: refer to the Python standard library. 198 | intersphinx_mapping = {'http://docs.python.org/': None} 199 | -------------------------------------------------------------------------------- /docs/developing.rst: -------------------------------------------------------------------------------- 1 | .. _developing: 2 | 3 | Developing 4 | ========== 5 | 6 | Contributing code to pycall is easy. The codebase is simple, clear, and tested. 7 | All ideas / patches / etc. are welcome. 8 | 9 | This section covers everything you need to know to contribute code, please give 10 | it a full read before submitting patches to the project. 11 | 12 | Project Goals 13 | ------------- 14 | 15 | The main goal of the pycall library is to provide a *simple* python wrapper for 16 | generating Asterisk call files. I'd like to keep the project and codebase small 17 | and concise. It would be easy to add features to pycall that fall outside of 18 | its humble goals--however, please resist that urge :) 19 | 20 | Before submitting patches that add functionality, please consider whether or 21 | not the patch contributes to the overall project goals, or takes away from 22 | them. 23 | 24 | Code Organization 25 | ----------------- 26 | 27 | pycall is broken up into several python modules to keep the code organized. To 28 | start reading code, check out the `callfile.py` module. This contains the main 29 | :class:`~callfile.CallFile` class that most users interact with. 30 | 31 | Each :class:`~callfile.CallFile` requires an :class:`~actions.Action` to be 32 | specified. The idea is that a :class:`~callfile.CallFile` should represent the 33 | physical call file, whereas an :class:`~actions.Action` should represent the 34 | behavior of the call file (what actions should our call file perform if the 35 | call is answered?). 36 | 37 | Actions can be either applications (:class:`~actions.Application`) or contexts 38 | (:class:`~actions.Context`). Both applicatoins and contexts correspond to their 39 | `Asterisk equivalents 40 | `_. 41 | 42 | Each :class:`~callfile.CallFile` must also specify a :class:`~call.Call` 43 | object. :class:`~call.Call` objects specify the actual call information-- what 44 | number to call, what callerid to use, etc. 45 | 46 | If there are errors, pycall will raise a custom :class:`~errors.PycallError` 47 | exception. The errors are very descriptive, and always point to a solution. 48 | 49 | Tests 50 | ----- 51 | 52 | pycall is fully tested. The project currently makes use of a full unit test 53 | suite to ensure that code works as advertised. In order to run the test suite 54 | for yourself, you need to install the `python-nose 55 | `_ library, then run `python setup.py 56 | nosetests`. If you'd like to see the coverage reports, you should also install 57 | the `coverage.py `_ library. 58 | 59 | All unit tests are broken up into python modules by topic. This is done to help 60 | keep separate test easy to work with. 61 | 62 | If you submit a patch, please ensure that it doesn't break any tests. If you 63 | submit tests with your patch, it makes it much easier for me to review patches 64 | and integrate them. 65 | 66 | Code Style 67 | ---------- 68 | 69 | When submitting patches, please make sure that your code follows pycall's style 70 | guidelines. The rules are pretty simple: just make your code fit in nicely with 71 | the current code! 72 | 73 | pycall uses tabs instead of spaces, and uses standard `PEP8 74 | `_ formatting for everything else. 75 | 76 | If in doubt, just look at pre-existing code. 77 | 78 | Documentation 79 | ------------- 80 | 81 | One of pycall's goals is to be well documented. Users should be able to quickly 82 | see how to use the library, and integrate it into their project within a few 83 | minutes. 84 | 85 | If you'd like to contribute documentation to the project, it is certainly 86 | welcome. All documentation is written using `Sphinx 87 | `_ and compiles to HTML and PDF formats. 88 | 89 | Development Tracker 90 | ------------------- 91 | 92 | pycall is proudly hosted at `Github `_. If 93 | you'd like to view the source code, contribute to the project, file bug reports 94 | or feature requests, please do so there. 95 | 96 | Submitting Code 97 | --------------- 98 | 99 | The best way to submit code is to `fork pycall on Github 100 | `_, make your changes, then submit a pull 101 | request. 102 | 103 | If you're unfamiliar with forking on Github, please read this `awesome article 104 | `_. 105 | -------------------------------------------------------------------------------- /docs/foreword.rst: -------------------------------------------------------------------------------- 1 | .. _foreword: 2 | 3 | Foreword 4 | ======== 5 | 6 | Thanks for checking out pycall. pycall is a simple python wrapper for creating 7 | `Asterisk call files 8 | `_. If you're 9 | familiar with the `Asterisk PBX system `_, and are 10 | building an application that needs to automatically place outbound calls, 11 | pycall may be just what you need! 12 | 13 | What is Asterisk? 14 | ----------------- 15 | 16 | Asterisk is a popular open source PBX (phone system) which many businesses and 17 | hobbyists use for placing and receiving phone calls. Asterisk is also used for 18 | a wide variety of other purposes, all relating to telephony and communications. 19 | 20 | If you've never used Asterisk, head over to `the Asterisk website 21 | `_ for more information. 22 | 23 | What Are Call Files? 24 | -------------------- 25 | 26 | Since pycall is a python library for "creating and using Asterisk call files", 27 | you may be wondering what a call file is. Call files are specially formatted 28 | text files that Asterisk uses to initiate outbound calls automtically. 29 | 30 | In a nutshell, Asterisk call files allow developers to automatically generate 31 | calls and launch programs through Asterisk, without any sort of complex network 32 | API. 33 | 34 | To learn more about call files, head over to the `VoIP Info call files page 35 | `_. 36 | 37 | Why Should I Use pycall? 38 | ------------------------ 39 | 40 | There are lots of reasons why you should use pycall. I could just be vague and 41 | leave it at that, but you want to *real* reasons right? 42 | 43 | * **Simple** 44 | 45 | pycall makes building telephony applications easy. 46 | 47 | * **Flexible** 48 | 49 | pycall gives you a lot of flexibility. If you're Asterisk environment is 50 | setup in a non-standard way, pycall won't mind. 51 | 52 | * **Well Documented** 53 | 54 | pycall has great documentation (but you already know that by now, right?) 55 | and active development. 56 | 57 | Target Audience 58 | --------------- 59 | 60 | pycall is intended for usage by telephony developers who are building Asterisk 61 | applications. It has sensible defaults, and is simple to implement into any 62 | application. 63 | 64 | If you're familiar with python programming, and want to write some telephony 65 | software, give pycall a try and you won't be disappointed! 66 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | pycall 2 | ====== 3 | 4 | Welcome to pycall's documentation. This documentation is divided into several 5 | different parts, and can be read through like a book. If you're relatively new 6 | to telephony, you may want to start at the :ref:`foreword`, and read through 7 | to the end; otherwise, you can jump right into the :ref:`usage` docs. 8 | 9 | If you want to contribute code to the project, please read the *entire* 10 | :ref:`developing` section before diving into the code. 11 | 12 | .. toctree:: 13 | 14 | foreword 15 | installation 16 | usage 17 | developing 18 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | .. _installation: 2 | 3 | Installation 4 | ============ 5 | 6 | pycall can be installed just like any other python program, through `pypi 7 | `_. To install it, simply run:: 8 | 9 | $ pip install pycall 10 | 11 | If you'd like to install the latest development version of pycall (not 12 | recommended), then `download the latest release 13 | `_ and run:: 14 | 15 | $ python setup.py install 16 | 17 | If you're totally unfamiliar with installing python packages, you should 18 | probably read the `official tutorial 19 | `_ before continuing. 20 | -------------------------------------------------------------------------------- /docs/usage.rst: -------------------------------------------------------------------------------- 1 | .. _usage: 2 | 3 | Usage 4 | ===== 5 | 6 | Integrating pycall into your project is quick and easy! After reading through 7 | the sections below, you should be able to integrate pycall into your project, 8 | and understand what it can and cannot be used for. 9 | 10 | 11 | Preparation 12 | ----------- 13 | 14 | The rest of this guide assumes you have the following: 15 | 16 | 1. A working Asterisk server. 17 | 18 | 2. Some sort of PSTN (public switch telephone network) connectivity. 19 | Regardless of what sort of PSTN connection you have (SIP / DAHDI / ZAPTEL / 20 | ISDN / etc.), as long as you can make calls, you're fine. 21 | 22 | For simplicity's sake, I'm going to assume for the rest of this guide that you 23 | have a SIP trunk named `flowroute` defined. 24 | 25 | 26 | Hello, World! 27 | ------------- 28 | 29 | pycall allows you to build applications that automate outbound calling. In the 30 | example below, we'll call a phone number specified on the command line, say 31 | "hello world", then hang up! :: 32 | 33 | import sys 34 | from pycall import CallFile, Call, Application 35 | 36 | def call(number): 37 | c = Call('SIP/flowroute/%s' % number) 38 | a = Application('Playback', 'hello-world') 39 | cf = CallFile(c, a) 40 | cf.spool() 41 | 42 | if __name__ == '__main__': 43 | call(sys.argv[1]) 44 | 45 | Just save the code above in a file named `call.py` and run it with python! :: 46 | 47 | $ python call.py 18002223333 48 | 49 | Assuming your Asterisk server is setup correctly, your program just placed a 50 | call to the phone number `18002223333`, and said "hello world" to the person 51 | who answered the phone! 52 | 53 | Code Breakdown 54 | ************** 55 | 56 | 1. First we imported the pycall classes. The :class:`~pycall.CallFile` class 57 | allows us to make call files. The :class:`~pycall.Call` class stores 58 | information about a specific call, and the :class:`~pycall.Application` 59 | class lets us specify an Asterisk application as our 60 | :class:`~pycall.Action`. Every call file requires some sort of action 61 | (what do you want to do when the caller answers?). 62 | 63 | 2. Next, we build a :class:`~pycall.Call` object, and specify the phone number 64 | to call in `standard Asterisk format 65 | `_. This tells 66 | Asterisk who to call. 67 | 68 | .. note:: 69 | 70 | In this example we made a call out of a SIP trunk named `flowroute`, but you 71 | can specify any sort of dial string in its place. You can even tell Asterisk 72 | to call multiple phone numbers at once by separating your dial strings with 73 | the & character (eg: `Local/1000@internal&SIP/flowroute/18002223333`). 74 | 75 | 3. Then we build an :class:`~pycall.Application` object that tells Asterisk 76 | what to do when the caller answers our call. In this case, we tell Asterisk 77 | to run the `Playback 78 | `_ command, and 79 | pass the argument 'hello-world' to it. 80 | 81 | .. note:: 82 | 83 | The name 'hello-world' here refers to one of the default Asterisk sound 84 | files that comes with all Asterisk installations. This file can be found in 85 | the directory `/var/lib/asterisk/sounds/en/` on most systems. 86 | 87 | 4. Finally, we create the actual :class:`~pycall.CallFile` object, and run 88 | its :meth:`~pycall.CallFile.spool` method to have Asterisk make the call. 89 | 90 | 91 | Scheduling a Call in the Future 92 | ------------------------------- 93 | 94 | Let's say you want to have Asterisk make a call at a certain time in the 95 | future--no problem. The :meth:`~pycall.CallFile.spool` method allows you to 96 | specify an optional datetime object to tell Asterisk when you want the magic to 97 | happen. 98 | 99 | In this example, we'll tell Asterisk to run the call in exactly 1 hour: :: 100 | 101 | import sys 102 | from datetime import datetime 103 | from datetime import timedelta 104 | from pycall import CallFile, Call, Application 105 | 106 | def call(number, time=None): 107 | c = Call('SIP/flowroute/%s' % number) 108 | a = Application('Playback', 'hello-world') 109 | cf = CallFile(c, a) 110 | cf.spool(time) 111 | 112 | if __name__ == '__main__': 113 | call(sys.argv[1], datetime.now()+timedelta(hours=1)) 114 | 115 | .. note:: 116 | 117 | If you specify a value of `None`, the call file will be ran immediately. 118 | 119 | Just for the heck of it, let's look at one more code snippet. This time we'll 120 | tell Asterisk to run the call file at exactly 1:00 AM on December 1, 2010. :: 121 | 122 | import sys 123 | from datetime import datetime 124 | from pycall.callfile import CallFile 125 | 126 | def call(number, time=None): 127 | c = Call('SIP/flowroute/%s' % number) 128 | a = Application('Playback', 'hello-world') 129 | cf = CallFile(c, a) 130 | cf.spool(time) 131 | 132 | if __name__ == '__main__': 133 | call(sys.argv[1], datetime(2010, 12, 1, 1, 0, 0)) 134 | 135 | 136 | Setting Call File Permissions 137 | ----------------------------- 138 | 139 | In most environments, Asterisk is installed and ran as the user / group 140 | 'asterisk', which often poses a problem if your application doesn't run under 141 | the 'asterisk' user account. 142 | 143 | pycall recognizes that this is a frustrating problem to deal with, and provides 144 | three mechanisms for helping make permissions as painless as possible: the 145 | :attr:`~pycall.CallFile.user` attribute, the 146 | :class:`~pycall.errors.NoUserError`, and the 147 | :class:`~pycall.errors.NoUserPermissionError`. 148 | 149 | * The :attr:`~pycall.CallFile.user` attribute lets you specify a system 150 | username that your call file should be ran as. For example, if your 151 | application is running as 'root', you could say:: 152 | 153 | cf = CallFile(c, a, user='asterisk') 154 | 155 | and pycall would chown the call file to the 'asterisk' user before 156 | spooling. 157 | 158 | * If you specify the :attr:`~pycall.CallFile.user` attribute, but the user 159 | doesn't exist, pycall will raise the :class:`~pycall.errors.NoUserError` so 160 | you know what's wrong. 161 | 162 | * Lastly, if your application doesn't have the proper permissions to change 163 | the ownership of your call file, pycall will raise the 164 | :class:`~pycall.errors.NoUserPermissionError`. 165 | 166 | As an example, here we'll change the call file permissions so that Asterisk can 167 | actually read our call file: :: 168 | 169 | import sys 170 | from pycall import CallFile, Call, Application 171 | 172 | def call(number): 173 | c = Call('SIP/flowroute/%s' % number) 174 | a = Application('Playback', 'hello-world') 175 | cf = CallFile(c, a, user='asterisk') 176 | cf.spool(time) 177 | 178 | if __name__ == '__main__': 179 | call(sys.argv[1]) 180 | 181 | .. note:: 182 | 183 | If you run this code on a system that doesn't have Asterisk installed, you 184 | will most likely get a :class:`~pycall.errors.NoUserError` since pycall 185 | won't be able to find the 'asterisk' user that it's trying to grant 186 | permissions to. 187 | 188 | 189 | Adding Complex Call Logic 190 | ------------------------- 191 | 192 | Most applications you write will probably be a bit more complex than "hello 193 | world". In the example below, we'll harness the power of the 194 | :class:`~pycall.Context` class to instruct Asterisk to run some custom 195 | `dial plan `_ 196 | code after the caller answers. :: 197 | 198 | from pycall import CallFile, Call, Context 199 | 200 | c = Call('SIP/flowroute/18002223333') 201 | con = Context('survey', 's', '1') 202 | cf = CallFile(c, con) 203 | cf.spool() 204 | 205 | For example purposes, let's assume that somewhere in your Asterisk 206 | `extensions.conf` file there exists some dial plan in a context labeled 207 | `survey`. 208 | 209 | After the caller answers our call, Asterisk will immediately jump to the dial 210 | plan code we've specified at `survey,s,1` and start executing as much logic as 211 | desired. 212 | 213 | 214 | Setting a CallerID 215 | ------------------ 216 | 217 | A lot of the time, you'll want to force Asterisk to assume a specific caller ID 218 | when making outbound calls. To do this, simply specify a value for the 219 | :attr:`~pycall.Call.callerid` attribute: :: 220 | 221 | c = Call('SIP/flowroute/18002223333', callerid="'Test User' <5555555555>'") 222 | 223 | Now, when Asterisk makes your call, the person receiving the call (depending on 224 | their phone and service type) should see a call coming from "Test User" who's 225 | phone number is 555-555-5555! 226 | 227 | 228 | Passing Variables to Your Dial Plan 229 | ----------------------------------- 230 | 231 | Often times, when building complex applications, you'll want to pass specific 232 | data from your application to Asterisk, so that you can read the information 233 | later. 234 | 235 | The example below will pass some information to our Asterisk dial plan code, so 236 | that it can use the information in our call. :: 237 | 238 | from pycall import CallFile, Call, Context 239 | 240 | vars = {'greeting': 'tt-monkeys'} 241 | 242 | c = Call('SIP/flowroute/18882223333', variables=vars) 243 | x = Context('survey', 's', '1') 244 | cf = CallFile(c, x) 245 | cf.spool() 246 | 247 | And somewhere in our `extensions.conf` file... :: 248 | 249 | [survey] 250 | exten => s,1,Playback(${greeting}) 251 | exten => s,n,Hangup() 252 | 253 | As you can see, our dial plan code can now access the variable 'greeting' and 254 | its value. 255 | 256 | 257 | Track Your Calls with Asterisk Account Codes 258 | -------------------------------------------- 259 | 260 | Asterisk call files allow you to specify that a certain call should be 261 | associated with a certain account. This is mainly useful for logging purposes. 262 | This example logs the call with the 'randall' account: :: 263 | 264 | c = Call('SIP/flowroute/18002223333', account='randall') 265 | 266 | .. note:: 267 | 268 | For more information on call logs, read the `CDR documentation 269 | `_. 270 | 271 | 272 | Specify Call Timing Values 273 | -------------------------- 274 | 275 | pycall provides several ways to control the timing of your calls. 276 | 277 | 1. :attr:`~pycall.Call.wait_time` lets you specify the amount of time to wait 278 | (in seconds) for the caller to answer before we consider our call attempt 279 | unsuccessful. 280 | 281 | 2. :attr:`~pycall.Call.retry_time` lets you specify the amount of time to wait 282 | (in seconds) between retries. Let's say you try to call the number 283 | 1-800-222-3333 but they don't answer, Asterisk will wait for 284 | :attr:`~pycall.Call.retry_time` seconds before calling the person again. 285 | 286 | 3. :attr:`~pycall.Call.max_retries` lets you specify the maximum amount of 287 | retry attempts (you don't want to call someone forever, do you?). 288 | 289 | Using these attributes is simple: :: 290 | 291 | c = Call('SIP/flowroute/18002223333', wait_time=10, retry_time=60, 292 | max_retries=2) 293 | 294 | 295 | Archiving Call Files 296 | -------------------- 297 | 298 | If, for some reason, you want to archive call files that have already been 299 | spooled with Asterisk, just set the :attr:`~pycall.CallFile.archive` attribute 300 | to `True`: :: 301 | 302 | cf = CallFile(..., archive=True) 303 | 304 | and Asterisk will copy the call file (with a status code) to the archive 305 | directory (typically `/var/spool/asterisk/outgoing_done`). 306 | 307 | 308 | Dealing with Non-Standard Asterisk Installs 309 | ------------------------------------------- 310 | 311 | If your Asterisk server isn't installed with the defaults, chances are you need 312 | to make some changes. pycall provides a ton of flexibility in this regard, so 313 | you should have no problems getting things running. 314 | 315 | Specifying a Specific Name for Call Files 316 | ***************************************** 317 | 318 | If you need to name your call file something special, just specify a value for 319 | both the :attr:`~pycall.CallFile.filename` and :attr:`~pycall.CallFile.tempdir` 320 | attributes: :: 321 | 322 | cf = CallFile(..., filename='test.call', tempdir='/tmp') 323 | 324 | .. note:: 325 | 326 | By default, pycall will randomly generate a call file name. 327 | 328 | Specifing a Custom Spooling Directory 329 | ************************************* 330 | 331 | If you're Asterisk install doesn't spool to the default 332 | `/var/spool/asterisk/outgoing` directory, you can override it with the 333 | :attr:`~pycall.CallFile.spool_dir` attribute: :: 334 | 335 | cf = CallFile(..., spool_dir='/tmp/outgoing') 336 | -------------------------------------------------------------------------------- /pycall/__init__.py: -------------------------------------------------------------------------------- 1 | """A flexible python library for creating and using Asterisk call files.""" 2 | 3 | 4 | from __future__ import absolute_import 5 | from .call import Call 6 | from .errors import PycallError, InvalidTimeError, NoSpoolPermissionError, NoUserError, NoUserPermissionError, UnknownError, ValidationError 7 | from .actions import Action, Application, Context 8 | from .callfile import CallFile 9 | -------------------------------------------------------------------------------- /pycall/actions.py: -------------------------------------------------------------------------------- 1 | """A simple wrapper for Asterisk call file actions.""" 2 | 3 | 4 | class Action(object): 5 | """A generic Asterisk action.""" 6 | pass 7 | 8 | 9 | class Application(Action): 10 | """Stores and manipulates Asterisk applications and data.""" 11 | 12 | def __init__(self, application, data): 13 | """Create a new `Application` object. 14 | 15 | :param str application: Asterisk application. 16 | :param str data: Asterisk application data. 17 | """ 18 | self.application = application 19 | self.data = data 20 | 21 | def render(self): 22 | """Render this action as call file directives. 23 | 24 | :rtype: Tuple of strings. 25 | """ 26 | return ('Application: ' + self.application, 'Data: ' + self.data) 27 | 28 | 29 | class Context(Action): 30 | """Stores and manipulates Asterisk contexts, extensions, and priorities.""" 31 | 32 | def __init__(self, context, extension, priority): 33 | """Create a new `Context` object. 34 | 35 | :param str context: Asterisk context to run. 36 | :param str extension: Asterisk extension to run. 37 | :param str priority: Asterisk priority to run. 38 | """ 39 | self.context = context 40 | self.extension = extension 41 | self.priority = priority 42 | 43 | def render(self): 44 | """Render this action as call file directives. 45 | 46 | :rtype: Tuple of strings. 47 | """ 48 | return ('Context: ' + self.context, 'Extension: ' + self.extension, 49 | 'Priority: ' + self.priority) 50 | -------------------------------------------------------------------------------- /pycall/call.py: -------------------------------------------------------------------------------- 1 | """A simple wrapper for Asterisk calls.""" 2 | 3 | 4 | class Call(object): 5 | """Stores and manipulates Asterisk calls.""" 6 | 7 | def __init__(self, channel, callerid=None, variables=None, account=None, 8 | wait_time=None, retry_time=None, max_retries=None): 9 | """Create a new `Call` object. 10 | 11 | :param str channel: The Asterisk channel to call. Should be in standard 12 | Asterisk format. 13 | :param str callerid: CallerID to use. 14 | :param dict variables: Variables to pass to Asterisk upon answer. 15 | :param str account: Account code to associate with this call. 16 | :param int wait_time: Amount of time to wait for answer (in seconds). 17 | :param int retry_time: Amount of time to wait (in seconds) between 18 | retry attempts. 19 | :param int max_retries: Maximum amount of retry attempts. 20 | """ 21 | self.channel = channel 22 | self.callerid = callerid 23 | self.variables = variables 24 | self.account = account 25 | self.wait_time = wait_time 26 | self.retry_time = retry_time 27 | self.max_retries = max_retries 28 | 29 | def is_valid(self): 30 | """Check to see if the `Call` attributes are valid. 31 | 32 | :returns: True if all attributes are valid, False otherwise. 33 | :rtype: Boolean. 34 | """ 35 | if self.variables and not isinstance(self.variables, dict): 36 | return False 37 | if self.wait_time != None and type(self.wait_time) != int: 38 | return False 39 | if self.retry_time != None and type(self.retry_time) != int: 40 | return False 41 | if self.max_retries != None and type(self.max_retries) != int: 42 | return False 43 | return True 44 | 45 | def render(self): 46 | """Render this call as call file directives. 47 | 48 | :returns: A list of call file directives. 49 | :rtype: List of strings. 50 | """ 51 | c = ['Channel: ' + self.channel] 52 | 53 | if self.callerid: 54 | c.append('Callerid: ' + self.callerid) 55 | if self.variables: 56 | for var, value in self.variables.items(): 57 | c.append('Set: %s=%s' % (var, value)) 58 | if self.account: 59 | c.append('Account: ' + self.account) 60 | if self.wait_time != None: 61 | c.append('WaitTime: %d' % self.wait_time) 62 | if self.retry_time != None: 63 | c.append('RetryTime: %d' % self.retry_time) 64 | if self.max_retries != None: 65 | c.append('Maxretries: %d' % self.max_retries) 66 | 67 | return c 68 | -------------------------------------------------------------------------------- /pycall/callfile.py: -------------------------------------------------------------------------------- 1 | """A simple wrapper for Asterisk call files.""" 2 | 3 | 4 | from __future__ import with_statement 5 | from shutil import move 6 | from time import mktime 7 | from pwd import getpwnam 8 | from tempfile import mkstemp 9 | from os import chown, error, utime 10 | import os 11 | 12 | from path import Path 13 | 14 | from .call import Call 15 | from .actions import Action, Context 16 | from .errors import InvalidTimeError, NoSpoolPermissionError, NoUserError, \ 17 | NoUserPermissionError, ValidationError 18 | 19 | 20 | class CallFile(object): 21 | """Stores and manipulates Asterisk call files.""" 22 | 23 | #: The default spooling directory (should be OK for most systems). 24 | DEFAULT_SPOOL_DIR = '/var/spool/asterisk/outgoing' 25 | 26 | def __init__(self, call, action, archive=None, filename=None, tempdir=None, 27 | user=None, spool_dir=None): 28 | """Create a new `CallFile` obeject. 29 | 30 | :param obj call: A `pycall.Call` instance. 31 | :param obj action: Either a `pycall.actions.Application` instance 32 | or a `pycall.actions.Context` instance. 33 | :param bool archive: Should Asterisk archive the call file? 34 | :param str filename: Filename of the call file. 35 | :param str tempdir: Temporary directory to store the call file before 36 | spooling. 37 | :param str user: Username to spool the call file as. 38 | :param str spool_dir: Directory to spool the call file to. 39 | :rtype: `CallFile` object. 40 | """ 41 | self.call = call 42 | self.action = action 43 | self.archive = archive 44 | self.user = user 45 | self.spool_dir = spool_dir or self.DEFAULT_SPOOL_DIR 46 | 47 | if filename and tempdir: 48 | self.filename = Path(filename) 49 | self.tempdir = Path(tempdir) 50 | else: 51 | tup = mkstemp(suffix='.call') 52 | f = Path(tup[1]) 53 | self.filename = f.name 54 | self.tempdir = f.parent 55 | os.close(tup[0]) 56 | 57 | def __str__(self): 58 | """Render this call file object for developers. 59 | 60 | :returns: String representation of this object. 61 | :rtype: String. 62 | """ 63 | return 'CallFile-> archive: %s, user: %s, spool_dir: %s' % ( 64 | self.archive, self.user, self.spool_dir) 65 | 66 | def is_valid(self): 67 | """Check to see if all attributes are valid. 68 | 69 | :returns: True if all attributes are valid, False otherwise. 70 | :rtype: Boolean. 71 | """ 72 | if not isinstance(self.call, Call): 73 | return False 74 | 75 | if not (isinstance(self.action, Action) or 76 | isinstance(self.action, Context)): 77 | return False 78 | 79 | if self.spool_dir and not Path(self.spool_dir).abspath().isdir(): 80 | return False 81 | 82 | if not self.call.is_valid(): 83 | return False 84 | 85 | return True 86 | 87 | def buildfile(self): 88 | """Build a call file in memory. 89 | 90 | :raises: `ValidationError` if this call file can not be validated. 91 | :returns: A list of call file directives as they will be written to the 92 | disk. 93 | :rtype: List of strings. 94 | """ 95 | if not self.is_valid(): 96 | raise ValidationError 97 | 98 | cf = [] 99 | cf += self.call.render() 100 | cf += self.action.render() 101 | 102 | if self.archive: 103 | cf.append('Archive: yes') 104 | 105 | return cf 106 | 107 | @property 108 | def contents(self): 109 | """Get the contents of this call file. 110 | 111 | :returns: Call file contents. 112 | :rtype: String. 113 | """ 114 | return '\n'.join(self.buildfile()) 115 | 116 | def writefile(self): 117 | """Write a temporary call file to disk.""" 118 | with open(Path(self.tempdir) / Path(self.filename), 'w') as f: 119 | f.write(self.contents) 120 | 121 | def spool(self, time=None): 122 | """Spool the call file with Asterisk. 123 | 124 | This will move the call file to the Asterisk spooling directory. If 125 | the `time` attribute is specified, then the call file will be spooled 126 | at the specified time instead of immediately. 127 | 128 | :param datetime time: The date and time to spool this call file (eg: 129 | Asterisk will run this call file at the specified time). 130 | """ 131 | self.writefile() 132 | 133 | if self.user: 134 | try: 135 | pwd = getpwnam(self.user) 136 | uid = pwd[2] 137 | gid = pwd[3] 138 | 139 | try: 140 | chown(Path(self.tempdir) / Path(self.filename), uid, gid) 141 | except error: 142 | raise NoUserPermissionError 143 | except KeyError: 144 | raise NoUserError 145 | 146 | if time: 147 | try: 148 | time = mktime(time.timetuple()) 149 | utime(Path(self.tempdir) / Path(self.filename), (time, time)) 150 | except (error, AttributeError, OverflowError, ValueError): 151 | raise InvalidTimeError 152 | 153 | try: 154 | move(Path(self.tempdir) / Path(self.filename), 155 | Path(self.spool_dir) / Path(self.filename)) 156 | except IOError: 157 | raise NoSpoolPermissionError 158 | -------------------------------------------------------------------------------- /pycall/errors.py: -------------------------------------------------------------------------------- 1 | """Custom error classes for signaling issues.""" 2 | 3 | 4 | from sys import version_info 5 | 6 | 7 | if version_info < (3, 0, 0): 8 | from exceptions import Exception 9 | 10 | 11 | class PycallError(Exception): 12 | pass 13 | 14 | 15 | class InvalidTimeError(PycallError): 16 | """You must specify a valid datetime object for the spool method's time 17 | parameter. 18 | """ 19 | 20 | 21 | class NoSpoolPermissionError(PycallError): 22 | """You do not have permission to spool this call file.""" 23 | 24 | 25 | class NoUserError(PycallError): 26 | """User does not exist.""" 27 | 28 | 29 | class NoUserPermissionError(PycallError): 30 | """You do not have permission to change the ownership of this call file.""" 31 | 32 | 33 | class UnknownError(PycallError): 34 | """Something must have gone horribly wrong.""" 35 | 36 | 37 | class ValidationError(PycallError): 38 | """CallFile could not be validated.""" 39 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | verbosity=3 3 | detailed-errors=1 4 | with-coverage=1 5 | cover-erase=1 6 | cover-package=pycall 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Handles packaging, distribution, and testing.""" 2 | 3 | 4 | from sys import exit 5 | from subprocess import call 6 | 7 | from setuptools import Command, find_packages, setup 8 | 9 | 10 | class BaseCommand(Command): 11 | user_options = [] 12 | 13 | def initialize_options(self): 14 | pass 15 | 16 | def finalize_options(self): 17 | pass 18 | 19 | 20 | class TestCommand(BaseCommand): 21 | description = 'run tests' 22 | 23 | def run(self): 24 | exit(call(['py.test', '--quiet', '--cov-report=term-missing', '--cov', 'pycall'])) 25 | 26 | 27 | class ReleaseCommand(BaseCommand): 28 | 29 | description = 'cut a new PyPI release' 30 | 31 | def run(self): 32 | call(['rm', '-rf', 'build', 'dist']) 33 | ret = call(['python', 'setup.py', 'sdist', 'bdist_wheel', '--universal', 'upload']) 34 | exit(ret) 35 | 36 | 37 | setup( 38 | 39 | # Basic package information. 40 | name = 'pycall', 41 | version = '2.3.2', 42 | packages = find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']), 43 | 44 | # Packaging options. 45 | zip_safe = False, 46 | include_package_data = True, 47 | 48 | # Package dependencies. 49 | install_requires = ['path.py>=6.2.0'], 50 | extras_require = { 51 | 'test': ['codacy-coverage', 'python-coveralls', 'pytest', 'pytest-cov', 'sphinx'], 52 | }, 53 | cmdclass = { 54 | 'test': TestCommand, 55 | 'release': ReleaseCommand, 56 | }, 57 | 58 | # Metadata for PyPI. 59 | author = 'Randall Degges', 60 | author_email = 'rdegges@gmail.com', 61 | license = 'UNLICENSE', 62 | url = 'http://pycall.org/', 63 | keywords = 'asterisk callfile call file telephony voip', 64 | description = 'A flexible python library for creating and using Asterisk call files.', 65 | long_description = open('README.rst').read() 66 | 67 | ) 68 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rdegges/pycall/abd60446be210d89a3c192993d4cda4bb98a4b64/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_actions.py: -------------------------------------------------------------------------------- 1 | """Unit tests for `pycall.actions`.""" 2 | 3 | 4 | from unittest import TestCase 5 | 6 | from pycall import Application, Context 7 | 8 | 9 | class TestApplication(TestCase): 10 | """Run tests on the `Application` class.""" 11 | 12 | def setUp(self): 13 | """Setup some default variables for test usage.""" 14 | self.a = Application('application', 'data') 15 | 16 | def test_attrs_stick(self): 17 | """Ensure attributes stick.""" 18 | self.assertEqual(self.a.application, 'application') 19 | self.assertEqual(self.a.data, 'data') 20 | 21 | def test_render_valid_application(self): 22 | """Ensure `render` works using a valid `application` attribute.""" 23 | self.assertTrue('application' in ''.join(self.a.render())) 24 | 25 | def test_str_valid_data(self): 26 | """Ensure `render` works using a valid `data` attribute.""" 27 | self.assertTrue('data' in ''.join(self.a.render())) 28 | 29 | 30 | class TestContext(TestCase): 31 | """Run tests on the `Context` class.""" 32 | 33 | def setUp(self): 34 | """Setup some default variables for test usage.""" 35 | self.c = Context('context', 'extension', 'priority') 36 | 37 | def test_attrs_stick(self): 38 | """Ensure attributes stick.""" 39 | self.assertEqual(self.c.context, 'context') 40 | self.assertEqual(self.c.extension, 'extension') 41 | self.assertEqual(self.c.priority, 'priority') 42 | 43 | def test_render_valid_context(self): 44 | """Ensure `render` works using a valid `context` attribute.""" 45 | self.assertTrue('context' in ''.join(self.c.render())) 46 | 47 | def test_render_valid_extension(self): 48 | """Ensure `render` works using a valid `extension` attribute.""" 49 | self.assertTrue('extension' in ''.join(self.c.render())) 50 | 51 | def test_render_valid_priority(self): 52 | """Ensure `render` works using a valid `priority` attribute.""" 53 | self.assertTrue('priority' in ''.join(self.c.render())) 54 | -------------------------------------------------------------------------------- /tests/test_call.py: -------------------------------------------------------------------------------- 1 | """Unit tests for `pycall.call`.""" 2 | 3 | 4 | from unittest import TestCase 5 | 6 | from pycall import Call 7 | 8 | 9 | class TestCall(TestCase): 10 | """Run tests on the `Call` class.""" 11 | 12 | def test_attrs_stick(self): 13 | """Ensure attributes stick.""" 14 | c = Call('channel', 'callerid', 'variables', 'account', 0, 1, 2) 15 | self.assertEqual(c.channel, 'channel') 16 | self.assertEqual(c.callerid, 'callerid') 17 | self.assertEqual(c.variables, 'variables') 18 | self.assertEqual(c.account, 'account') 19 | self.assertEqual(c.wait_time, 0) 20 | self.assertEqual(c.retry_time, 1) 21 | self.assertEqual(c.max_retries, 2) 22 | 23 | def test_is_valid_valid_variables(self): 24 | """Ensure `is_valid` works using a valid `variables` attribute.""" 25 | c = Call('channel', variables={'a': 'b'}) 26 | self.assertTrue(c.is_valid()) 27 | 28 | def test_is_valid_valid_wait_time(self): 29 | """Ensure `is_valid` works using a valid `wait_time` attribute.""" 30 | c = Call('channel', wait_time=0) 31 | self.assertTrue(c.is_valid()) 32 | 33 | def test_is_valid_valid_retry_time(self): 34 | """Ensure `is_valid` works using a valid `retry_time` attribute.""" 35 | c = Call('channel', retry_time=1) 36 | self.assertTrue(c.is_valid()) 37 | 38 | def test_is_valid_valid_max_retries(self): 39 | """Ensure `is_valid` works using a valid `max_retries` attribute.""" 40 | c = Call('channel', max_retries=2) 41 | self.assertTrue(c.is_valid()) 42 | 43 | def test_is_valid_invalid_variables(self): 44 | """Ensure `is_valid` fails given an invalid `variables` attribute.""" 45 | c = Call('channel', variables='ab') 46 | self.assertFalse(c.is_valid()) 47 | 48 | def test_is_valid_invalid_wait_time(self): 49 | """Ensure `is_valid` fails given an invalid `wait_time` attribute.""" 50 | c = Call('channel', wait_time='0') 51 | self.assertFalse(c.is_valid()) 52 | 53 | def test_is_valid_invalid_retry_time(self): 54 | """Ensure `is_valid` fails given an invalid `retry_time` attribute.""" 55 | c = Call('channel', retry_time='1') 56 | self.assertFalse(c.is_valid()) 57 | 58 | def test_is_valid_invalid_max_retries(self): 59 | """Ensure `is_valid` fails given an invalid `max_retries` attribute.""" 60 | c = Call('channel', max_retries='2') 61 | self.assertFalse(c.is_valid()) 62 | 63 | def test_render_valid_channel(self): 64 | """Ensure `render` works using a valid `channel` attribute.""" 65 | c = Call('channel') 66 | self.assertTrue('channel' in ''.join(c.render())) 67 | 68 | def test_render_valid_callerid(self): 69 | """Ensure `render` works using a valid `callerid` attribute.""" 70 | c = Call('channel', callerid='callerid') 71 | self.assertTrue('callerid' in ''.join(c.render())) 72 | 73 | def test_render_valid_variables(self): 74 | """Ensure `render` works using a valid `variables` attribute.""" 75 | c = Call('channel', variables={'a': 'b'}) 76 | self.assertTrue('a=b' in ''.join(c.render())) 77 | 78 | def test_render_valid_account(self): 79 | """Ensure `render` works using a valid `account` attribute.""" 80 | c = Call('channel', account='account') 81 | self.assertTrue('account' in ''.join(c.render())) 82 | 83 | def test_render_valid_wait_time(self): 84 | """Ensure `render` works using a valid `wait_time` attribute.""" 85 | c = Call('channel', wait_time=0) 86 | self.assertTrue('0' in ''.join(c.render())) 87 | 88 | def test_render_valid_retry_time(self): 89 | """Ensure `render` works using a valid `retry_time` attribute.""" 90 | c = Call('channel', retry_time=1) 91 | self.assertTrue('1' in ''.join(c.render())) 92 | 93 | def test_render_valid_max_retries(self): 94 | """Ensure `render` works using a valid `max_retries` attribute.""" 95 | c = Call('channel', max_retries=2) 96 | self.assertTrue('2' in ''.join(c.render())) 97 | 98 | def test_render_no_attrs(self): 99 | """Ensure `render` works with no optional attributes specified.""" 100 | c = Call('local/18882223333@outgoing') 101 | self.assertTrue('Channel: local/18882223333@outgoing' in ''.join(c.render())) 102 | -------------------------------------------------------------------------------- /tests/test_callfile.py: -------------------------------------------------------------------------------- 1 | """Unit tests for `pycall.callfile`.""" 2 | 3 | from time import mktime 4 | from getpass import getuser 5 | from datetime import datetime 6 | from unittest import TestCase 7 | 8 | from path import Path 9 | 10 | from pycall import Application, Call, CallFile, InvalidTimeError, \ 11 | NoSpoolPermissionError, NoUserError, NoUserPermissionError, \ 12 | ValidationError 13 | 14 | 15 | class TestCallFile(TestCase): 16 | """Run tests on the `CallFile` class.""" 17 | 18 | def setUp(self): 19 | """Setup some default variables for test usage.""" 20 | self.call = Call('channel') 21 | self.action = Application('application', 'data') 22 | self.spool_dir = '/tmp' 23 | 24 | def test_attrs_stick(self): 25 | """Ensure attributes stick.""" 26 | c = CallFile('call', 'action', 'archive', 'filename', 'tempdir', 27 | 'user', 'spool_dir') 28 | self.assertEqual(c.call, 'call') 29 | self.assertEqual(c.action, 'action') 30 | self.assertEqual(c.archive, 'archive') 31 | self.assertEqual(c.filename, 'filename') 32 | self.assertEqual(c.tempdir, 'tempdir') 33 | self.assertEqual(c.user, 'user') 34 | self.assertEqual(c.spool_dir, 'spool_dir') 35 | 36 | def test_attrs_default_spool_dir(self): 37 | """Ensure default `spool_dir` attribute works.""" 38 | c = CallFile(self.call, self.action) 39 | self.assertEqual(c.spool_dir, CallFile.DEFAULT_SPOOL_DIR) 40 | 41 | def test_attrs_default_filename(self): 42 | """Ensure default `filename` attribute works.""" 43 | c = CallFile(self.call, self.action) 44 | self.assertTrue(c.filename) 45 | 46 | def test_attrs_default_tempdir(self): 47 | """Ensure default `tempdir` attribute works.""" 48 | c = CallFile(self.call, self.action) 49 | self.assertTrue(c.tempdir) 50 | 51 | def test_str(self): 52 | """Ensure `__str__` works.""" 53 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 54 | self.assertTrue('archive' in c.__str__() and 'user' in c.__str__() and 55 | 'spool_dir' in c.__str__()) 56 | 57 | def test_is_valid_valid_call(self): 58 | """Ensure `is_valid` works using a valid `call` attribute.""" 59 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 60 | self.assertTrue(c.is_valid()) 61 | 62 | def test_is_valid_valid_action(self): 63 | """Ensure `is_valid` works using a valid `action` attribute.""" 64 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 65 | self.assertTrue(c.is_valid()) 66 | 67 | def test_is_valid_valid_spool_dir(self): 68 | """Ensure `is_valid` works using a valid `spool_dir` attribute.""" 69 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 70 | self.assertTrue(c.is_valid()) 71 | 72 | def test_is_valid_valid_call_is_valid(self): 73 | """Ensure `is_valid` works when `call.is_valid()` works.""" 74 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 75 | self.assertTrue(c.is_valid()) 76 | 77 | def test_is_valid_invalid_call(self): 78 | """Ensure `is_valid` fails given an invalid `call` attribute.""" 79 | c = CallFile('call', self.action, spool_dir=self.spool_dir) 80 | self.assertFalse(c.is_valid()) 81 | 82 | def test_is_valid_invalid_action(self): 83 | """Ensure `is_valid` fails given an invalid `action` attribute.""" 84 | c = CallFile(self.call, 'action', spool_dir=self.spool_dir) 85 | self.assertFalse(c.is_valid()) 86 | 87 | def test_is_valid_invalid_spool_dir(self): 88 | """Ensure `is_valid` fails given an invalid `spool_dir` attribute.""" 89 | c = CallFile(self.call, self.action, spool_dir='/woot') 90 | self.assertFalse(c.is_valid()) 91 | 92 | def test_is_valid_invalid_call_is_valid(self): 93 | """Ensure `is_valid` fails when `call.is_valid()` fails.""" 94 | c = CallFile(Call('channel', wait_time='10'), self.action, 95 | spool_dir=self.spool_dir) 96 | self.assertFalse(c.is_valid()) 97 | 98 | def test_buildfile_is_valid(self): 99 | """Ensure `buildfile` works with well-formed attributes.""" 100 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 101 | self.assertTrue(c.buildfile()) 102 | 103 | def test_buildfile_raises_validation_error(self): 104 | """Ensure `buildfile` raises `ValidationError` if the `CallFile` can't 105 | be validated. 106 | """ 107 | cf = CallFile(self.call, self.action, spool_dir='/woot') 108 | 109 | with self.assertRaises(ValidationError): 110 | cf.buildfile() 111 | 112 | def test_buildfile_valid_archive(self): 113 | """Ensure that `buildfile` works with a well-formed `archive` 114 | attribute. 115 | """ 116 | c = CallFile(self.call, self.action, archive=True, 117 | spool_dir=self.spool_dir) 118 | self.assertTrue('Archive: yes' in ''.join(c.buildfile())) 119 | 120 | def test_buildfile_invalid_archive(self): 121 | """Ensure `buildfile` works when `archive` is false.""" 122 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 123 | self.assertFalse('Archive:' in ''.join(c.buildfile())) 124 | 125 | def test_contents(self): 126 | """Ensure that the `contents` property works.""" 127 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 128 | self.assertTrue('channel' in c.contents and 129 | 'application' in c.contents and 'data' in c.contents) 130 | 131 | def test_writefile_creates_file(self): 132 | """Ensure that `writefile` actually generates a call file on the disk. 133 | """ 134 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 135 | c.writefile() 136 | self.assertTrue((Path(c.tempdir) / Path(c.filename)).abspath().exists()) 137 | 138 | def test_spool_no_time_no_user(self): 139 | """Ensure `spool` works when no `time` attribute is supplied, and no 140 | `user` attribute exists. 141 | """ 142 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 143 | c.spool() 144 | self.assertTrue((Path(c.spool_dir) / Path(c.filename)).abspath().exists()) 145 | 146 | def test_spool_no_time_no_spool_permission_error(self): 147 | """Ensure that `spool` raises `NoSpoolPermissionError` if the user 148 | doesn't have permissions to write to `spool_dir`. 149 | 150 | NOTE: This test WILL fail if the user account you run this test under 151 | has write access to the / directory on your local filesystem. 152 | """ 153 | c = CallFile(self.call, self.action, spool_dir='/') 154 | 155 | with self.assertRaises(NoSpoolPermissionError): 156 | c.spool() 157 | 158 | def test_spool_no_time_user(self): 159 | """Ensure that `spool` works when no `time` attribute is specified, and 160 | a valid `user` attribute exists. 161 | """ 162 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir, 163 | user=getuser()) 164 | c.spool() 165 | 166 | def test_spool_no_time_no_user_error(self): 167 | """Ensure that `spool` raises `NoUserError` if the user attribute is 168 | not a real system user. 169 | """ 170 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir, 171 | user='asjdfgkhkgaskqtjwhkjwetghqekjtbkwthbjkltwhwklt') 172 | 173 | with self.assertRaises(NoUserError): 174 | c.spool() 175 | 176 | def test_spool_no_time_no_user_permission_error(self): 177 | """Ensure that `spool` raises `NoUserPermissionError` if the user 178 | specified does not have permissions to write to the Asterisk spooling 179 | directory. 180 | """ 181 | c = CallFile(self.call, self.action, spool_dir='/', user='root') 182 | 183 | with self.assertRaises(NoUserPermissionError): 184 | c.spool() 185 | 186 | def test_spool_time_no_user_invalid_time_error(self): 187 | """Ensure that `spool` raises `InvalidTimeError` if the user doesn't 188 | specify a valid `time` parameter. 189 | """ 190 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 191 | 192 | with self.assertRaises(InvalidTimeError): 193 | c.spool(666) 194 | 195 | def test_spool_time_no_user(self): 196 | """Ensure that `spool` works when given a valid `time` parameter.""" 197 | c = CallFile(self.call, self.action, spool_dir=self.spool_dir) 198 | d = datetime.now() 199 | c.spool(d) 200 | self.assertEqual((Path(c.tempdir) / Path(c.filename)).abspath().atime, mktime(d.timetuple())) 201 | --------------------------------------------------------------------------------