├── pysipp ├── cli │ ├── __init__.py │ ├── minidom.py │ └── sippfmt.py ├── plugin.py ├── netplug.py ├── load.py ├── hookspec.py ├── report.py ├── utils.py ├── launch.py ├── command.py ├── __init__.py └── agent.py ├── MANIFEST.in ├── tests ├── scens │ ├── just_confpy │ │ └── pysipp_conf.py │ ├── default_with_confpy │ │ ├── pysipp_conf.py │ │ ├── uas.xml │ │ └── uac.xml │ └── default │ │ ├── uas.xml │ │ └── uac.xml ├── test_utils.py ├── test_loader.py ├── conftest.py ├── test_launcher.py ├── test_commands.py ├── test_stack.py └── test_agent.py ├── pyproject.toml ├── .github └── workflows │ ├── pre-commit.yml │ └── tox.yml ├── tox.ini ├── .travis.yml ├── .gitignore ├── .pre-commit-config.yaml ├── setup.py ├── README.md └── LICENSE /pysipp/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /tests/scens/just_confpy/pysipp_conf.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | 4 | [tool.flake8] 5 | max-line-length = 79 6 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | pre-commit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-python@v3 17 | - uses: pre-commit/action@v3.0.0 18 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import pytest 4 | 5 | from pysipp import utils 6 | 7 | 8 | def test_load_mod(scendir): 9 | confpy = os.path.join(scendir, "default_with_confpy", "pysipp_conf.py") 10 | assert utils.load_mod(confpy) 11 | 12 | 13 | def test_load_mod_ko(): 14 | with pytest.raises(FileNotFoundError): 15 | utils.load_mod("not_here.py") 16 | -------------------------------------------------------------------------------- /tests/scens/default_with_confpy/pysipp_conf.py: -------------------------------------------------------------------------------- 1 | import pluggy 2 | 3 | hookimpl = pluggy.HookimplMarker("pysipp") 4 | 5 | 6 | @hookimpl 7 | def pysipp_conf_scen(agents, scen): 8 | scen.uri_username = "doggy" 9 | agents["uac"].srcaddr = "127.0.0.1", 5070 10 | 11 | 12 | @hookimpl 13 | def pysipp_order_agents(agents, clients, servers): 14 | # should still work due to re-transmissions 15 | return reversed(list(agents.values())) 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (https://testrun.org/tox/latest/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py37, py38, py39, py310, py311, py312, py313 8 | 9 | [gh-actions] 10 | python = 11 | 3.7: py37 12 | 3.8: py38 13 | 3.9: py39 14 | 3.10: py310 15 | 3.11: py311 16 | 3.12: py312 17 | 3.13: py313 18 | 19 | [testenv] 20 | deps = 21 | pytest 22 | pdbpp 23 | commands = 24 | pytest tests/ {posargs} 25 | -------------------------------------------------------------------------------- /pysipp/plugin.py: -------------------------------------------------------------------------------- 1 | """ 2 | `pluggy` plugin and hook management 3 | """ 4 | import contextlib 5 | 6 | import pluggy 7 | 8 | from . import hookspec 9 | 10 | hookimpl = pluggy.HookimplMarker("pysipp") 11 | mng = pluggy.PluginManager("pysipp") 12 | mng.add_hookspecs(hookspec) 13 | 14 | 15 | @contextlib.contextmanager 16 | def register(plugins): 17 | """Temporarily register plugins""" 18 | try: 19 | if any(plugins): 20 | for p in plugins: 21 | mng.register(p) 22 | yield 23 | finally: 24 | if any(plugins): 25 | for p in plugins: 26 | mng.unregister(p) 27 | -------------------------------------------------------------------------------- /tests/test_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Scen dir loading 3 | """ 4 | import os 5 | 6 | from pysipp.load import iter_scen_dirs 7 | 8 | 9 | def test_scendir_loading(scendir): 10 | dir_list = list(iter_scen_dirs(scendir)) 11 | assert len(dir_list) == 2 # only dirs with xmls 12 | 13 | 14 | def test_iter_dirs(scendir): 15 | paths = { 16 | "default": [True, False], 17 | "default_with_confpy": [True, True], 18 | "just_confpy": [False, True], 19 | } 20 | for path, xmls, confpy in iter_scen_dirs(scendir): 21 | expect = paths.get(os.path.basename(path), None) 22 | if expect: 23 | assert [bool(xmls), bool(confpy)] == expect 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | cache: 4 | - bundler 5 | - pip 6 | 7 | python: 8 | - 2.7 9 | - 3.5 10 | - 3.6 11 | # - 3.7 12 | - nightly 13 | # - pypy 14 | # - pypy3 15 | 16 | addons: 17 | apt: 18 | packages: 19 | - libpcap-dev 20 | - libsctp-dev 21 | - libncurses5-dev 22 | - libssl-dev 23 | - libgsl0-dev 24 | 25 | install: 26 | - pip install -e . 27 | - pip install --upgrade pytest 28 | - pip list 29 | 30 | before_script: 31 | - wget https://github.com/SIPp/sipp/releases/download/v3.6.0/sipp-3.6.0.tar.gz 32 | - tar -xvzf sipp-3.6.0.tar.gz 33 | - cd sipp-3.6.0 34 | - ./configure 35 | - make 36 | - export PATH="$PWD:$PATH" 37 | - cd .. 38 | 39 | script: 40 | - pytest tests/ 41 | -------------------------------------------------------------------------------- /.github/workflows/tox.yml: -------------------------------------------------------------------------------- 1 | name: tox 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 19 | steps: 20 | - uses: actions/checkout@v3 21 | - name: Install sip-tester 22 | uses: awalsh128/cache-apt-pkgs-action@latest 23 | with: 24 | packages: sip-tester 25 | version: 1.0 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Run tox with tox-gh-actions 31 | uses: ymyzk/run-tox-gh-actions@main 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | unit testing 3 | """ 4 | import os 5 | 6 | import pytest 7 | 8 | from pysipp import agent 9 | from pysipp import scenario 10 | from pysipp import utils 11 | 12 | 13 | def pytest_configure(config): 14 | # configure log level based on `-v` flags to pytest 15 | utils.log_to_stderr( 16 | level=max(40 - config.option.verbose * 10, 10), 17 | ) 18 | 19 | 20 | @pytest.fixture 21 | def scendir(): 22 | path = "{}/scens/".format(os.path.dirname(__file__)) 23 | assert os.path.isdir(path) 24 | return path 25 | 26 | 27 | @pytest.fixture 28 | def default_agents(): 29 | uas = agent.server(local_host="127.0.0.1", local_port=5060, call_count=1) 30 | uac = agent.client(call_count=1, destaddr=(uas.local_host, uas.local_port)) 31 | return uas, uac 32 | 33 | 34 | @pytest.fixture(params=[True, False], ids="autolocalsocks={}".format) 35 | def basic_scen(request): 36 | """The most basic scenario instance""" 37 | return scenario(autolocalsocks=request.param) 38 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | # force all unspecified python hooks to run python3 3 | python: python3 4 | repos: 5 | - repo: https://github.com/asottile/reorder_python_imports 6 | rev: v3.9.0 7 | hooks: 8 | - id: reorder-python-imports 9 | - repo: https://github.com/psf/black 10 | rev: 22.10.0 11 | hooks: 12 | - id: black 13 | - repo: https://github.com/PyCQA/flake8 14 | rev: 6.0.0 15 | hooks: 16 | - id: flake8 17 | exclude: '^(testing|test_voip)/' 18 | - repo: https://github.com/pre-commit/pre-commit-hooks 19 | rev: v4.4.0 20 | hooks: 21 | - id: trailing-whitespace 22 | args: [--markdown-linebreak-ext=md] 23 | - id: check-merge-conflict 24 | - id: detect-private-key 25 | - id: end-of-file-fixer 26 | - id: mixed-line-ending 27 | - id: check-added-large-files 28 | args: ["--maxkb=2000"] 29 | - repo: https://github.com/Lucas-C/pre-commit-hooks 30 | rev: v1.3.1 31 | hooks: 32 | - id: remove-crlf 33 | - id: remove-tabs 34 | -------------------------------------------------------------------------------- /tests/test_launcher.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic agent/scenario launching 3 | """ 4 | from pysipp.agent import client 5 | from pysipp.agent import server 6 | from pysipp.launch import PopenRunner 7 | 8 | 9 | def run_blocking(*agents): 10 | runner = PopenRunner() 11 | assert not runner.is_alive() 12 | runner(ua.render() for ua in agents) 13 | assert not runner.is_alive() 14 | return runner 15 | 16 | 17 | def test_agent_fails(): 18 | uas = server(call_count=1) 19 | # apply bogus ip which can't be bound 20 | uas.local_host, uas.local_port = "99.99.99.99", 5060 21 | # client calls server at bogus addr 22 | uac = client(destaddr=(uas.local_host, uas.local_port)) 23 | uac.recv_timeout = 1 # avoids SIPp issue #176 24 | uac.call_count = 1 # avoids SIPp issue #176 25 | 26 | runner = run_blocking(uas, uac) 27 | 28 | # fails due to invalid ip 29 | uasproc = runner.get(timeout=0)[uas.render()] 30 | assert uasproc.streams.stderr 31 | assert uasproc.returncode == 255, uasproc.streams.stderr 32 | 33 | # killed by signal 34 | uacproc = runner.get(timeout=0)[uac.render()] 35 | # assert not uacproc.streams.stderr # sometimes this has a log msg? 36 | ret = uacproc.returncode 37 | # killed by SIGUSR1 or terminates before it starts (racy) 38 | assert ret == -10 or ret == 0 39 | 40 | 41 | def test_default_scen(default_agents): 42 | runner = run_blocking(*default_agents) 43 | 44 | # both agents should be successful 45 | for cmd, proc in runner.get(timeout=0).items(): 46 | assert not proc.returncode 47 | -------------------------------------------------------------------------------- /pysipp/netplug.py: -------------------------------------------------------------------------------- 1 | """ 2 | auto-networking plugin 3 | """ 4 | import socket 5 | 6 | from pysipp import plugin 7 | 8 | 9 | def getsockaddr(host, family=socket.AF_INET, port=0, sockmod=socket): 10 | """Retrieve a random socket address from the local OS by 11 | binding to an ip, acquiring a random port and then 12 | closing the socket and returning that address. 13 | 14 | ..warning:: Obviously this is not guarateed to be an unused address 15 | since we don't actually keep it bound, so there may be a race with 16 | other processes acquiring the addr before our SIPp process re-binds. 17 | """ 18 | for fam, stype, proto, _, sa in socket.getaddrinfo( 19 | host, 20 | port, 21 | family, 22 | socket.SOCK_DGRAM, 23 | 0, 24 | socket.AI_PASSIVE, 25 | ): 26 | s = socket.socket(family, stype, proto) 27 | s.bind(sa) 28 | sockaddr = s.getsockname()[:2] 29 | s.close() 30 | return sockaddr 31 | 32 | raise socket.error("getaddrinfo returned empty sequence") 33 | 34 | 35 | @plugin.hookimpl 36 | def pysipp_conf_scen(agents, scen): 37 | """Automatically allocate random socket addresses from the local OS for 38 | each agent in the scenario if not previously set by the user. 39 | """ 40 | host = scen.defaults.local_host or socket.getfqdn() 41 | for ua in scen.agents.values(): 42 | copy = scen.prepare_agent(ua) 43 | 44 | ip, port = getsockaddr(ua.local_host or host) 45 | 46 | if not copy.local_host: 47 | ua.local_host = ip 48 | 49 | if not copy.local_port: 50 | ua.local_port = port 51 | 52 | if not copy.media_addr: 53 | ua.media_addr = ua.local_host 54 | 55 | if not copy.media_port: 56 | ua.media_port = getsockaddr(ua.media_addr or host)[1] 57 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Copyright (C) 2015 Tyler Goodlet 4 | # 5 | # This program is free software; you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation; either version 2 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program; if not, write to the Free Software 17 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 18 | # 19 | # Authors : Tyler Goodlet 20 | from setuptools import setup 21 | 22 | 23 | with open("README.md") as f: 24 | readme = f.read() 25 | 26 | 27 | setup( 28 | name="pysipp", 29 | version="0.2.0", 30 | description="A SIPp scenario launcher", 31 | long_description=readme, 32 | long_description_content_type="text/markdown", 33 | license="GPLv2", 34 | author="Tyler Goodlet", 35 | author_email="jgbt@protonmail.com", 36 | url="https://github.com/SIPp/pysipp", 37 | platforms=["linux"], 38 | packages=["pysipp", "pysipp.cli"], 39 | install_requires=["pluggy>=1.0.0"], 40 | tests_require=["pytest"], 41 | entry_points={ 42 | "console_scripts": ["sippfmt=pysipp.cli.sippfmt:main"], 43 | }, 44 | classifiers=[ 45 | "Development Status :: 3 - Alpha", 46 | "Intended Audience :: Developers", 47 | "License :: OSI Approved :: GNU General Public License v2 (GPLv2)", 48 | "Operating System :: POSIX :: Linux", 49 | "Programming Language :: Python :: >=3.7", 50 | "Topic :: Software Development", 51 | "Topic :: Software Development :: Testing", 52 | "Topic :: Software Development :: Quality Assurance", 53 | "Topic :: Utilities", 54 | ], 55 | ) 56 | -------------------------------------------------------------------------------- /pysipp/load.py: -------------------------------------------------------------------------------- 1 | """ 2 | Load files from scenario directories 3 | """ 4 | import glob 5 | import os 6 | 7 | from . import utils 8 | 9 | log = utils.get_logger() 10 | 11 | 12 | class CollectionError(Exception): 13 | """Scenario dir collection error""" 14 | 15 | 16 | def glob_for_scripts(directory): 17 | """Find scenario xml scripts and conf.py in script dir""" 18 | xmls = glob.iglob(directory + "/*.xml") 19 | # double check 20 | xmls = [f for f in xmls if "xml" in os.path.splitext(f)[1]] 21 | confpy = glob.glob(directory + "/pysipp_conf.py") 22 | if len(confpy) > 1: 23 | raise ValueError( 24 | "can only be at most one pysipp_conf.py in scen directory!" 25 | ) 26 | log.debug("discovered xmls:\n{}".format("\n".join(xmls))) 27 | return xmls, confpy[0] if confpy else None 28 | 29 | 30 | def iter_scen_dirs(rootdir, dir_filter=lambda dir_name: dir_name): 31 | """Build a map of SIPp scripts by searching the filesystem for .xml files 32 | 33 | :param str rootdir: dir in the filesystem to start scanning for xml files 34 | :return: an iterator over all scenario dirs yielding tuples of the form 35 | (, , ) 36 | """ 37 | mod_space = set() 38 | for path, dirnames, filenames in os.walk(rootdir): 39 | 40 | # filter the path dirs to traverse as we recurse the file system 41 | # (only use if you know what you're doing) 42 | dirnames[:] = filter(dir_filter, dirnames) 43 | 44 | # scan for files 45 | path = os.path.abspath(path) 46 | xmls, confpy = glob_for_scripts(path) 47 | 48 | if not len(xmls): 49 | log.debug("No SIPp xml scripts found under '{}'".format(path)) 50 | continue 51 | if not confpy: 52 | log.debug("No pysipp_conf.py found under '{}'".format(path)) 53 | 54 | # load module sources 55 | mod = ( 56 | utils.load_mod( 57 | confpy, 58 | # use unique names (as far as scendirs go) 59 | # to avoid module caching 60 | name="pysipp_confpy_{}".format(os.path.dirname(confpy)), 61 | ) 62 | if confpy 63 | else None 64 | ) 65 | 66 | # verify confpy mods should be unique 67 | if mod: 68 | assert mod not in mod_space 69 | mod_space.add(mod) 70 | 71 | yield path, xmls, mod 72 | -------------------------------------------------------------------------------- /pysipp/hookspec.py: -------------------------------------------------------------------------------- 1 | """ 2 | hookspec defs 3 | """ 4 | import pluggy 5 | 6 | hookspec = pluggy.HookspecMarker("pysipp") 7 | 8 | 9 | # UA factory hooks 10 | @hookspec 11 | def pysipp_pre_ua_defaults(ua): 12 | """Called prior to default ua cmd line arguments being assigned. 13 | Only a subset of `pysipp.UserAgent` attributes can be assigned. 14 | """ 15 | 16 | 17 | @hookspec 18 | def pysipp_post_ua_defaults(ua): 19 | """Called just after all default ua cmdline args have been assigned. 20 | Any attribute can be overridden on `ua`. 21 | """ 22 | 23 | 24 | # Scenario hooks 25 | @hookspec 26 | def pysipp_load_scendir(path, xmls, confpy): 27 | """Called once for every scenario directory that is scanned and loaded by 28 | `pysipp.load.iter_scen_dirs`. The `xmls` arg is a list of path strings and 29 | `confpy` is the imported conf.py module if one exists or None. 30 | 31 | A single implementation of this hook must return `True` to include the 32 | scanned dir as a collected scenario and all must return `False` if the 33 | scenario should be skipped entirely. 34 | """ 35 | 36 | 37 | @hookspec(firstresult=True) 38 | def pysipp_conf_scen_protocol(agents, confpy, scenkwargs): 39 | """Performs scenario configuration by making multiple hook calls with 40 | surrounding logic for determining the sub-registration of of pysipp_conf.py 41 | modules. A scenario object must be returned. 42 | """ 43 | 44 | 45 | @hookspec(firstresult=True) 46 | def pysipp_order_agents(agents, clients, servers): 47 | """Return ua iterator which delivers agents in launch order""" 48 | 49 | 50 | @hookspec(firstresult=True) 51 | def pysipp_new_scen(agents, confpy, scenkwargs): 52 | """Instantiate a scenario object. 53 | A scenario must adhere to a simple protocol: 54 | - support a `name` attribute which uniquely identifies the scenario 55 | - support 'agents', 'clients', 'servers' attributes 56 | - be callable (usually launching the underlying agents when 57 | called by in turn calling the `pysipp_run_protocol` hook) 58 | """ 59 | 60 | 61 | @hookspec 62 | def pysipp_conf_scen(agents, scen): 63 | """Called once by each pysipp.Scenario instance just after instantiation. 64 | Normally this hook is used to configure "call routing" by setting agent 65 | socket arguments. It it the recommended hook for applying a default 66 | scenario configuration. 67 | """ 68 | 69 | 70 | @hookspec(firstresult=True) 71 | def pysipp_new_runner(): 72 | """Create and return a runner instance to be used for invoking 73 | multiple SIPp commands. The runner must be callable and support both a 74 | `block` and `timeout` kwarg. 75 | """ 76 | 77 | 78 | @hookspec(firstresult=True) 79 | def pysipp_run_protocol(scen, runner, block, timeout, raise_exc): 80 | """Perform steps to execute all SIPp commands usually by calling a 81 | preconfigured command launcher/runner. 82 | """ 83 | -------------------------------------------------------------------------------- /pysipp/report.py: -------------------------------------------------------------------------------- 1 | """ 2 | reporting for writing SIPp log files to the console 3 | """ 4 | import time 5 | from collections import OrderedDict 6 | from os import path 7 | 8 | from . import utils 9 | 10 | log = utils.get_logger() 11 | 12 | EXITCODES = { 13 | 0: "All calls were successful", 14 | 1: "At least one call failed", 15 | 15: "Process was terminated", 16 | 97: "Exit on internal command. Calls may have been processed", 17 | 99: "Normal exit without calls processed", 18 | -1: "Fatal error", 19 | -2: "Fatal error binding a socket", 20 | -10: "Signalled to stop with SIGUSR1", 21 | 254: "Connection Error: socket already in use", 22 | 255: "Command or syntax error: check stderr output", 23 | } 24 | 25 | 26 | def err_summary(agents2procs): 27 | """Return an error message detailing SIPp cmd exit codes 28 | if any of the commands exitted with a non-zero status 29 | """ 30 | name2ec = OrderedDict() 31 | # gather all exit codes 32 | for ua, proc in agents2procs: 33 | name2ec[ua.name] = proc.returncode 34 | 35 | if any(name2ec.values()): 36 | # raise a detailed error 37 | msg = "Some agents failed\n" 38 | msg += "\n".join( 39 | "'{}' with exit code {} -> {}".format( 40 | name, rc, EXITCODES.get(rc, "unknown exit code") 41 | ) 42 | for name, rc in name2ec.items() 43 | ) 44 | return msg 45 | 46 | 47 | def emit_logfiles(agents2procs, level="warning", max_lines=100): 48 | """Log all available SIPp log-file contents""" 49 | emit = getattr(log, level) 50 | for ua, proc in agents2procs: 51 | 52 | # print stderr 53 | emit( 54 | "stderr for '{}' @ {}\n{}\n".format( 55 | ua.name, ua.srcaddr, proc.streams.stderr 56 | ) 57 | ) 58 | # FIXME: no idea, but some logs are not being printed without this 59 | # logging mod bug? 60 | time.sleep(0.01) 61 | 62 | # print log file contents 63 | for name, fpath in ua.iter_toconsole_items(): 64 | if fpath and path.isfile(fpath): 65 | with open(fpath, "r") as lf: 66 | lines = lf.readlines() 67 | llen = len(lines) 68 | 69 | # truncate long log files 70 | if llen > max_lines: 71 | toolong = ( 72 | "...\nOutput has been truncated to {} lines - " 73 | "see '{}' for full details\n" 74 | ).format(max_lines, fpath) 75 | output = "".join(lines[:max_lines]) + toolong 76 | else: 77 | output = "".join(lines) 78 | # log it 79 | emit( 80 | "'{}' contents for '{}' @ {}:\n{}".format( 81 | name, ua.name, ua.srcaddr, output 82 | ) 83 | ) 84 | # FIXME: same as above 85 | time.sleep(0.01) 86 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command generation 3 | """ 4 | import pytest 5 | 6 | from pysipp import utils 7 | from pysipp.command import SippCmd 8 | 9 | log = utils.get_logger() 10 | 11 | 12 | def test_bool_field(): 13 | cmd = SippCmd() 14 | with pytest.raises(ValueError): 15 | cmd.rtp_echo = "doggy" 16 | 17 | assert "-rtp_echo" not in cmd.render() 18 | cmd.rtp_echo = True 19 | assert "-rtp_echo" in cmd.render() 20 | assert type(cmd).rtp_echo is not cmd.rtp_echo 21 | 22 | 23 | def test_dict_field(): 24 | cmd = SippCmd() 25 | assert isinstance(cmd.key_vals, dict) 26 | 27 | # one entry 28 | cmd.key_vals["doggy"] = 100 29 | assert "-key doggy '100'" in cmd.render() 30 | 31 | # two entries 32 | cmd.key_vals["kitty"] = 200 33 | assert "-key kitty '200'" in cmd.render() 34 | assert ( 35 | "-key kitty '200'" in cmd.render() 36 | and "-key doggy '100'" in cmd.render() 37 | ) 38 | 39 | # three entries 40 | cmd.key_vals["mousey"] = 300 41 | assert ( 42 | "-key kitty '200'" in cmd.render() 43 | and "-key doggy '100'" in cmd.render() 44 | and "-key mousey '300'" in cmd.render() 45 | ) 46 | 47 | log.debug("cmd is '{}'".format(cmd.render())) 48 | 49 | # override entire dict 50 | cmd.key_vals = { 51 | "mousey": 300, 52 | "doggy": 100, 53 | } 54 | assert "-key kitty '200'" not in cmd.render() 55 | assert ( 56 | "-key doggy '100'" in cmd.render() 57 | and "-key mousey '300'" in cmd.render() 58 | ) 59 | 60 | # clear all 61 | cmd.key_vals.clear() 62 | assert "-key" not in cmd.render() 63 | 64 | 65 | def test_list_field(): 66 | cmd = SippCmd() 67 | assert cmd.info_files is None 68 | 69 | # one entry 70 | cmd.info_files = ["100"] 71 | assert "-inf '100'" in cmd.render() 72 | 73 | # two entries 74 | cmd.info_files = ["100", "200"] 75 | assert "-inf '100' -inf '200'" in cmd.render() 76 | 77 | # clear all 78 | del cmd.info_files[:] 79 | assert "-inf" not in cmd.render() 80 | 81 | # three entries - two via 'info_files' and one via 'info_file' 82 | cmd.info_files = ["100", "200"] 83 | cmd.info_file = "300" 84 | assert "-inf '300' -inf '100' -inf '200'" in cmd.render() 85 | 86 | # clear all 87 | cmd.info_file = "" 88 | cmd.info_files[:] = [] 89 | assert "-inf" not in cmd.render() 90 | 91 | 92 | def test_prefix(): 93 | cmd = SippCmd() 94 | pre = "doggy bath" 95 | cmd.prefix = pre 96 | # single quotes are added 97 | assert cmd.render() == "'{}'".format(pre) + " " 98 | 99 | 100 | def test_addr_field(): 101 | cmd = SippCmd() 102 | cmd.proxy_host = None 103 | assert not cmd.render() 104 | 105 | cmd.proxy_host = "127.0.0.1" 106 | cmd.proxy_port = 5060 107 | assert cmd.render() == "-rsa '127.0.0.1':'5060' " 108 | 109 | cmd.proxy_host = "::1" 110 | assert cmd.render() == "-rsa '[::1]':'5060' " 111 | 112 | cmd.proxy_host = "example.com" 113 | assert cmd.render() == "-rsa 'example.com':'5060' " 114 | -------------------------------------------------------------------------------- /pysipp/utils.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import inspect 3 | import logging 4 | import os 5 | import tempfile 6 | import types 7 | 8 | LOG_FORMAT = ( 9 | "%(asctime)s %(threadName)s [%(levelname)s] %(name)s " 10 | "%(filename)s:%(lineno)d : %(message)s" 11 | ) 12 | 13 | DATE_FORMAT = "%b %d %H:%M:%S" 14 | 15 | 16 | def load_source(name: str, path: str) -> types.ModuleType: 17 | """ 18 | Replacement for deprecated imp.load_source() 19 | Thanks to: 20 | https://github.com/epfl-scitas/spack for pointing out the 21 | important missing "spec.loader.exec_module(module)" line. 22 | """ 23 | spec = importlib.util.spec_from_file_location(name, path) 24 | module = importlib.util.module_from_spec(spec) 25 | spec.loader.exec_module(module) 26 | return module 27 | 28 | 29 | def get_logger(): 30 | """Get the project logger instance""" 31 | return logging.getLogger("pysipp") 32 | 33 | 34 | def log_to_stderr(level="INFO", **kwargs): 35 | defaults = {"format": LOG_FORMAT, "level": level} 36 | defaults.update(kwargs) 37 | logging.basicConfig(**defaults) 38 | 39 | 40 | def get_tmpdir(): 41 | """Return a random temp dir""" 42 | return tempfile.mkdtemp(prefix="pysipp_") 43 | 44 | 45 | def load_mod(path, name=None): 46 | """Load a source file as a module""" 47 | name = name or os.path.splitext(os.path.basename(path))[0] 48 | # load module sources 49 | return load_source(name, path) 50 | 51 | 52 | def iter_data_descrs(cls): 53 | """Deliver all public data-descriptors (for properties only if `fset` is 54 | defined) as `name`, `attr`, pairs 55 | """ 56 | for name in dir(cls): 57 | attr = getattr(cls, name) 58 | if inspect.isdatadescriptor(attr): 59 | if (hasattr(attr, "fset") and not attr.fset) or "_" in name[0]: 60 | continue 61 | yield name, attr 62 | 63 | 64 | def DictProxy(d, keys, cls=None): 65 | """A dictionary proxy object which provides attribute access to the 66 | elements of the provided dictionary `d` 67 | """ 68 | 69 | class DictProxyAttr(object): 70 | """An attribute which when modified proxies to an instance dictionary 71 | named `dictname`. 72 | """ 73 | 74 | def __init__(self, key): 75 | self.key = key 76 | 77 | def __get__(self, obj, cls): 78 | if obj is None: 79 | return self 80 | return d.get(self.key) 81 | 82 | def __set__(self, obj, value): 83 | d[self.key] = value 84 | 85 | # provide attribute access for all named keys 86 | attrs = {key: DictProxyAttr(key) for key in keys} 87 | 88 | if cls is not None: 89 | # apply all attributes on provided type 90 | for name, attr in attrs.items(): 91 | setattr(cls, name, attr) 92 | else: 93 | # delegate some methods to the original dict 94 | proxied_attrs = [ 95 | "__repr__", 96 | "__getitem__", 97 | "__setitem__", 98 | "__contains__", 99 | "__len__", 100 | "get", 101 | "update", 102 | "setdefault", 103 | ] 104 | attrs.update({attr: getattr(d, attr) for attr in proxied_attrs}) 105 | 106 | # construct required default methods 107 | def init(self): 108 | self.__dict__ = d 109 | 110 | attrs.update({"__init__": init}) 111 | 112 | # render a new type 113 | return type("DictProxy", (), attrs) 114 | -------------------------------------------------------------------------------- /pysipp/cli/minidom.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from xml.dom import minidom 3 | from xml.dom.minidom import Node 4 | 5 | 6 | @functools.total_ordering 7 | class AttributeSorter(object): 8 | """Special attribute sorter. 9 | 10 | Sort elements in alphabetical order, but let special important 11 | attributes bubble to the front. 12 | """ 13 | 14 | __special__ = ("request", "response") 15 | 16 | def __init__(self, obj): 17 | self.obj = obj 18 | 19 | def __lt__(self, other): 20 | if self.obj in self.__special__: 21 | return True 22 | elif other.obj in self.__special__: 23 | return False 24 | return self.obj < other.obj 25 | 26 | 27 | class Newline(minidom.CharacterData): 28 | """Minidom node which represents a newline.""" 29 | 30 | __slots__ = () 31 | 32 | nodeType = Node.TEXT_NODE 33 | nodeName = "#text" 34 | attributes = None 35 | 36 | def writexml(self, writer, indent="", addindent="", newl=""): 37 | """Emit a newline.""" 38 | writer.write(newl) 39 | 40 | 41 | def createSeparator(self): 42 | """Create a document element which represents a empty line.""" 43 | c = Newline() 44 | c.ownerDocument = self 45 | return c 46 | 47 | 48 | minidom.Document.createSeparator = createSeparator 49 | 50 | 51 | def monkeypatch_scenario_xml(self, writer, indent="", addindent="", newl=""): 52 | """Ensure there's a newline before the scenario tag. 53 | 54 | It needs to be there or SIPp otherwise seems to have trouble 55 | parsing the document. 56 | """ 57 | writer.write("\n") 58 | minidom.Element.writexml(self, writer, indent, addindent, newl) 59 | 60 | 61 | def monkeypatch_element_xml(self, writer, indent="", addindent="", newl=""): 62 | """Format scenario step elements. 63 | 64 | Ensures a stable and predictable order to the attributes of these 65 | elements with the most important information always coming first, 66 | then let all other elements follow in alphabetical order. 67 | """ 68 | writer.write("{}<{}".format(indent, self.tagName)) 69 | 70 | attrs = self._get_attributes() 71 | a_names = sorted(attrs.keys(), key=AttributeSorter) 72 | 73 | for a_name in a_names: 74 | writer.write(' {}="'.format(a_name)) 75 | minidom._write_data(writer, attrs[a_name].value) 76 | writer.write('"') 77 | if self.childNodes: 78 | writer.write(">") 79 | if ( 80 | len(self.childNodes) == 1 81 | and self.childNodes[0].nodeType == Node.TEXT_NODE 82 | ): 83 | self.childNodes[0].writexml(writer, "", "", "") 84 | else: 85 | writer.write(newl) 86 | for node in self.childNodes: 87 | node.writexml(writer, indent + addindent, addindent, newl) 88 | writer.write(indent) 89 | writer.write("{}".format(self.tagName, newl)) 90 | else: 91 | writer.write("/>{}".format(newl)) 92 | 93 | 94 | def monkeypatch_sipp_cdata_xml(self, writer, indent="", addindent="", newl=""): 95 | """Format CDATA. 96 | 97 | Ensure that CDATA blocks are indented as expected, for visual 98 | clarity. 99 | """ 100 | if self.data.find("]]>") >= 0: 101 | raise ValueError("']]>' not allowed in a CDATA section") 102 | 103 | writer.write("{}{}".format(newl, indent, newl)) 110 | -------------------------------------------------------------------------------- /pysipp/cli/sippfmt.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import argparse 4 | import types 5 | 6 | from . import minidom 7 | 8 | 9 | def copy_tree(doc, node): 10 | """Duplicate a minidom element.""" 11 | new_node = doc.createElement(node.tagName) 12 | for k, v in node.attributes.items(): 13 | new_node.setAttribute(k, v) 14 | 15 | for child in node.childNodes: 16 | if child.nodeType == minidom.Node.COMMENT_NODE: 17 | new_node.appendChild(child) 18 | elif child.nodeType == minidom.Node.ELEMENT_NODE: 19 | new_node.appendChild(copy_tree(doc, child)) 20 | 21 | return new_node 22 | 23 | 24 | def monkeypatch_element(node): 25 | """Alter the representation of scenario elements. 26 | 27 | Monkey patch the `writexml` so that when pretty print these 28 | elements, we always put the response/request attributes first, and 29 | then all remaining attributes in alphabetical order. 30 | """ 31 | node.writexml = types.MethodType(minidom.monkeypatch_element_xml, node) 32 | 33 | 34 | def monkeypatch_cdata(node): 35 | """Alter the representation of CDATA elements. 36 | 37 | Monkey patch the `writexml` so that when pretty print these 38 | elements, the appropriate amount of whitespace is embedded into 39 | the CDATA block for visual consistency. 40 | """ 41 | node.writexml = types.MethodType(minidom.monkeypatch_sipp_cdata_xml, node) 42 | 43 | 44 | def process_element(doc, elem): 45 | """Process individual sections of a sipp scenario. 46 | 47 | Copy and format the various steps inside the sipp script, such as 48 | the recv and send elements. Make sure that CDATA chunks are 49 | preserved and well formatted. 50 | """ 51 | new_node = doc.createElement(elem.tagName) 52 | monkeypatch_element(new_node) 53 | 54 | # copy attributes 55 | for k, v in elem.attributes.items(): 56 | new_node.setAttribute(k, v) 57 | 58 | for child in elem.childNodes: 59 | if child.nodeType == minidom.Node.CDATA_SECTION_NODE: 60 | data = doc.createCDATASection(child.data.strip()) 61 | monkeypatch_cdata(data) 62 | new_node.appendChild(data) 63 | elif child.nodeType == minidom.Node.COMMENT_NODE: 64 | new_node.appendChild(child) 65 | elif child.nodeType == minidom.Node.ELEMENT_NODE: 66 | new_node.appendChild(copy_tree(doc, child)) 67 | 68 | return new_node 69 | 70 | 71 | def process_document(filepath): 72 | """Process an XML document. 73 | 74 | Process the document with minidom, process it for consistency, and 75 | emit a new document. Minidom is used since we need to preserve the 76 | structure of the XML document rather than its content. 77 | """ 78 | dom = minidom.parse(filepath) 79 | scenario = next( 80 | elem 81 | for elem in dom.childNodes 82 | if getattr(elem, "tagName", None) == "scenario" 83 | ) 84 | 85 | imp = minidom.getDOMImplementation("") 86 | dt = imp.createDocumentType("scenario", None, "sipp.dtd") 87 | doc = imp.createDocument(None, "scenario", dt) 88 | 89 | new_scen = doc.childNodes[-1] 90 | new_scen.writexml = types.MethodType( 91 | minidom.monkeypatch_scenario_xml, new_scen 92 | ) 93 | 94 | for elem in scenario.childNodes: 95 | if elem.nodeType == minidom.Node.TEXT_NODE: 96 | continue 97 | elif elem.nodeType == minidom.Node.CDATA_SECTION_NODE: 98 | continue 99 | elif elem.nodeType == minidom.Node.ELEMENT_NODE: 100 | new_node = process_element(doc, elem) 101 | if new_node: 102 | new_scen.appendChild(new_node) 103 | new_scen.appendChild(doc.createSeparator()) 104 | else: 105 | new_scen.appendChild(elem) 106 | 107 | # delete the last separator 108 | if new_scen.childNodes and isinstance( 109 | new_scen.childNodes[-1], minidom.Newline 110 | ): 111 | del new_scen.childNodes[-1] 112 | 113 | doc.appendChild(new_scen) 114 | return doc 115 | 116 | 117 | def main(): 118 | """Format sipp scripts.""" 119 | parser = argparse.ArgumentParser(description="Format sipp scripts") 120 | parser.add_argument("filename") 121 | args = parser.parse_args() 122 | 123 | doc = process_document(args.filename) 124 | xml = doc.toprettyxml(indent=" ", encoding="ISO-8859-1") 125 | print(xml, end="") 126 | 127 | 128 | if __name__ == "__main__": 129 | main() 130 | -------------------------------------------------------------------------------- /tests/scens/default/uas.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 50 | Content-Length: 0 51 | 52 | ]]> 53 | 54 | 55 | 56 | 65 | Content-Type: application/sdp 66 | Content-Length: [len] 67 | 68 | v=0 69 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 70 | s=- 71 | c=IN IP[media_ip_type] [media_ip] 72 | t=0 0 73 | m=audio [media_port] RTP/AVP 0 74 | a=rtpmap:0 PCMU/8000 75 | 76 | ]]> 77 | 78 | 79 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 98 | Content-Length: 0 99 | 100 | ]]> 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /tests/scens/default_with_confpy/uas.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 50 | Content-Length: 0 51 | 52 | ]]> 53 | 54 | 55 | 56 | 65 | Content-Type: application/sdp 66 | Content-Length: [len] 67 | 68 | v=0 69 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 70 | s=- 71 | c=IN IP[media_ip_type] [media_ip] 72 | t=0 0 73 | m=audio [media_port] RTP/AVP 0 74 | a=rtpmap:0 PCMU/8000 75 | 76 | ]]> 77 | 78 | 79 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 98 | Content-Length: 0 99 | 100 | ]]> 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /tests/scens/default/uac.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ;tag=[pid]SIPpTag00[call_number] 31 | To: [service] 32 | Call-ID: [call_id] 33 | CSeq: 1 INVITE 34 | Contact: sip:sipp@[local_ip]:[local_port] 35 | Max-Forwards: 70 36 | Subject: Performance Test 37 | Content-Type: application/sdp 38 | Content-Length: [len] 39 | 40 | v=0 41 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 42 | s=- 43 | c=IN IP[media_ip_type] [media_ip] 44 | t=0 0 45 | m=audio [media_port] RTP/AVP 0 46 | a=rtpmap:0 PCMU/8000 47 | 48 | ]]> 49 | 50 | 51 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ;tag=[pid]SIPpTag00[call_number] 75 | To: [service] [peer_tag_param] 76 | Call-ID: [call_id] 77 | CSeq: 1 ACK 78 | Contact: sip:sipp@[local_ip]:[local_port] 79 | Max-Forwards: 70 80 | Subject: Performance Test 81 | Content-Length: 0 82 | 83 | ]]> 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ;tag=[pid]SIPpTag00[call_number] 97 | To: [service] [peer_tag_param] 98 | Call-ID: [call_id] 99 | CSeq: 2 BYE 100 | Contact: sip:sipp@[local_ip]:[local_port] 101 | Max-Forwards: 70 102 | Subject: Performance Test 103 | Content-Length: 0 104 | 105 | ]]> 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /tests/scens/default_with_confpy/uac.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ;tag=[pid]SIPpTag00[call_number] 31 | To: [service] 32 | Call-ID: [call_id] 33 | CSeq: 1 INVITE 34 | Contact: sip:sipp@[local_ip]:[local_port] 35 | Max-Forwards: 70 36 | Subject: Performance Test 37 | Content-Type: application/sdp 38 | Content-Length: [len] 39 | 40 | v=0 41 | o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip] 42 | s=- 43 | c=IN IP[media_ip_type] [media_ip] 44 | t=0 0 45 | m=audio [media_port] RTP/AVP 0 46 | a=rtpmap:0 PCMU/8000 47 | 48 | ]]> 49 | 50 | 51 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ;tag=[pid]SIPpTag00[call_number] 75 | To: [service] [peer_tag_param] 76 | Call-ID: [call_id] 77 | CSeq: 1 ACK 78 | Contact: sip:sipp@[local_ip]:[local_port] 79 | Max-Forwards: 70 80 | Subject: Performance Test 81 | Content-Length: 0 82 | 83 | ]]> 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | ;tag=[pid]SIPpTag00[call_number] 97 | To: [service] [peer_tag_param] 98 | Call-ID: [call_id] 99 | CSeq: 2 BYE 100 | Contact: sip:sipp@[local_ip]:[local_port] 101 | Max-Forwards: 70 102 | Subject: Performance Test 103 | Content-Length: 0 104 | 105 | ]]> 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/SIPp/pysipp.svg?branch=master)](https://travis-ci.org/SIPp/pysipp) 2 | # `pysipp` is for people who hate SIPp 3 | but (want to) use it for automated testing because it gets the job done... 4 | 5 | 6 | ## What is it? 7 | Python configuring and launching the infamous 8 | [SIPp](http://sipp.sourceforge.net/) using an api inspired by 9 | [requests](http://docs.python-requests.org/) 10 | 11 | ## It definitely lets you 12 | 13 | - Launch multi-UA scenarios (aka SIPp subprocesses) sanely 14 | * avoids nightmarish shell command concoctions from multiple terminals 15 | * allows for complex functional or end-to-end SIP testing 16 | - Reuse your existing SIPp XML scripts 17 | - Integrate nicely with [pytest](http://pytest.org/) 18 | 19 | 20 | ## It doesn't try to 21 | 22 | - Auto-generate SIPp XML scripts like [sippy_cup](https://github.com/mojolingo/sippy_cup) 23 | * `pysipp` in no way tries to work around the problem of SIPp's awful 24 | XML control language; your current scenario scripts are compatible! 25 | 26 | 27 | ## Basic Usage 28 | Launching the default UAC scenario is a short line: 29 | 30 | ```python 31 | import pysipp 32 | pysipp.client(destaddr=('10.10.8.88', 5060))() 33 | ``` 34 | 35 | Manually running the default `uac` --calls--> `uas` scenario is also simple: 36 | 37 | ```python 38 | uas = pysipp.server(srcaddr=('10.10.8.88', 5060)) 39 | uac = pysipp.client(destaddr=uas.srcaddr) 40 | # run server async 41 | uas(block=False) # returns a `pysipp.launch.PopenRunner` instance by default 42 | uac() # run client synchronously 43 | ``` 44 | 45 | ## Authentication 46 | When using the `[authentication]` [sipp keyword](https://sipp.readthedocs.io/en/latest/scenarios/keywords.html#authentication) 47 | in scenarios, providing the credentials can be done with the 48 | `auth_username` and `auth_password` arguments, for example: 49 | 50 | ```python 51 | pysipp.client(auth_username='sipp', auth_password='sipp-pass') 52 | ``` 53 | 54 | ## Multiple Agents 55 | For multi-UA orchestrations we can use a `pysipp.scenario`. 56 | The scenario from above is the default agent configuration: 57 | 58 | ```python 59 | scen = pysipp.scenario() 60 | scen() # run uac and uas synchronously to completion 61 | ``` 62 | 63 | Say you have a couple SIPp xml scrips and a device you're looking to 64 | test using them (eg. a B2BUA or SIP proxy). Assuming you've organized 65 | the scripts nicely in a directory like so: 66 | 67 | ``` 68 | test_scenario/ 69 | uac.xml 70 | referer_uas.xml 71 | referee_uas.xml 72 | ``` 73 | 74 | If you've configured your DUT to listen for for SIP on `10.10.8.1:5060` 75 | and route traffic to the destination specified in the SIP `Request-URI` header 76 | and your local ip address is `10.10.8.8`: 77 | 78 | ```python 79 | scen = pysipp.scenario(dirpath='path/to/test_scenario/', 80 | proxyaddr=('10.10.8.1', 5060)) 81 | 82 | # run all agents in sequence starting with servers 83 | scen() 84 | ``` 85 | 86 | **pysipp** by default uses `-screen_file` SIPp argument to redirect output, 87 | but this argument is only available in SIPp version >= [3.5.0](https://sourceforge.net/p/sipp/mailman/message/34041962/), 88 | for lower versions to run properly, this argument must be 89 | disable setting `enable_screen_file` to `False`: 90 | 91 | ```python 92 | scen = pysipp.scenario(enable_screen_file=False) 93 | ``` 94 | 95 | If you've got multiple such scenario directories you can iterate over 96 | them: 97 | 98 | ```python 99 | for path, scen in pysipp.walk('path/to/scendirs/root/'): 100 | print("Running scenario collected from {}".format(path)) 101 | scen() 102 | ``` 103 | 104 | ## Async Scenario Launching 105 | You can also launch multiple multi-UA scenarios concurrently using 106 | non-blocking mode: 107 | 108 | ```python 109 | scens = [] 110 | for path, scen in pysipp.walk('path/to/scendirs/root/', proxyaddr=('10.10.8.1', 5060)): 111 | print("Running scenario collected from {}".format(path)) 112 | scen(block=False) 113 | scens.append(scen) 114 | 115 | # All scenarios should now be running concurrently so we can continue 116 | # program execution... 117 | print("Continuing program execution...") 118 | 119 | # Wait to collect all the results 120 | print("Finalizing all scenarios and collecting results") 121 | for scen in scens: 122 | scen.finalize() 123 | ``` 124 | 125 | `scen.finalize()` actually calls a special cleanup function defined in the 126 | [`pysipp_run_protocol`](https://github.com/SIPp/pysipp/blob/master/pysipp/__init__.py#L207) 127 | hook which invokes the internal reporting functions and returns a `dict` of cmd -> process 128 | items. 129 | 130 | ## API 131 | To see the mapping of SIPp command line args to `pysipp.agent.UserAgent` 132 | attributes, take a look at `pysipp.command.sipp_spec`. 133 | This should give you an idea of what can be set on each agent. 134 | 135 | 136 | ## Features 137 | - (a)synchronous multi-scenario invocation 138 | - fully plugin-able thanks to [pluggy](https://github.com/hpk42/pluggy) 139 | - detailed console reporting 140 | 141 | ... more to come! 142 | 143 | 144 | ## Dependencies 145 | SIPp duh...Get the latest version on [github](https://github.com/SIPp/sipp) 146 | 147 | 148 | ## Install 149 | from git 150 | ``` 151 | pip install git+git://github.com/SIPp/pysipp.git 152 | ``` 153 | 154 | 155 | ## Hopes and dreams 156 | I'd love to see `pysipp` become a standard end-to-end unit testing 157 | tool for SIPp itself (particularly if paired with `pytest`). 158 | 159 | Other thoughts are that someone might one day write actual 160 | Python bindings to the internals of SIPp such that a pure Python DSL 161 | can be used instead of the silly default xml SIP-flow mini-language. 162 | If/when that happens, pysipp can serve as a front end interface. 163 | 164 | 165 | ## Advanced Usage 166 | `pysipp` comes packed with some nifty features for customizing 167 | SIPp default command configuration and launching as well as detailed 168 | console reporting. There is even support for remote execution of SIPp 169 | over the network using [rpyc](https://rpyc.readthedocs.org/en/latest/) 170 | 171 | ### Enable detailed console reporting 172 | ```python 173 | pysipp.utils.log_to_stderr("DEBUG") 174 | ``` 175 | 176 | ### Applying default settings 177 | For now see [#4](https://github.com/SIPp/pysipp/issues/4) 178 | 179 | ## More to come? 180 | - document attributes / flags 181 | - writing plugins 182 | - using a `pysipp_conf.py` 183 | - remote execution 184 | - async mult-scenario load testing 185 | -------------------------------------------------------------------------------- /pysipp/launch.py: -------------------------------------------------------------------------------- 1 | """ 2 | Launchers for invoking SIPp user agents 3 | """ 4 | import os 5 | import select 6 | import shlex 7 | import signal 8 | import subprocess 9 | import threading 10 | import time 11 | from collections import namedtuple 12 | from collections import OrderedDict 13 | from pprint import pformat 14 | 15 | from . import utils 16 | 17 | log = utils.get_logger() 18 | 19 | Streams = namedtuple("Streams", "stdout stderr") 20 | 21 | 22 | class TimeoutError(Exception): 23 | "SIPp process timeout exception" 24 | 25 | 26 | class PopenRunner(object): 27 | """Run a sequence of SIPp agents asynchronously. If any process terminates 28 | with a non-zero exit code, immediately kill all remaining processes and 29 | collect std streams. 30 | 31 | Adheres to an interface similar to `multiprocessing.pool.AsyncResult`. 32 | """ 33 | 34 | def __init__( 35 | self, 36 | subprocmod=subprocess, 37 | osmod=os, 38 | poller=select.epoll, 39 | ): 40 | # these could optionally be rpyc proxy objs 41 | self.spm = subprocmod 42 | self.osm = osmod 43 | self.poller = poller() 44 | # collector thread placeholder 45 | self._waiter = None 46 | # store proc results 47 | self._procs = OrderedDict() 48 | 49 | def __call__(self, cmds, block=True, rate=300, **kwargs): 50 | if self._waiter and self._waiter.is_alive(): 51 | raise RuntimeError( 52 | "Not all processes from a prior run have completed" 53 | ) 54 | if self._procs: 55 | raise RuntimeError( 56 | "Process results have not been cleared from previous run" 57 | ) 58 | sp = self.spm 59 | os = self.osm 60 | DEVNULL = open(os.devnull, "wb") 61 | fds2procs = OrderedDict() 62 | 63 | # run agent commands in sequence 64 | for cmd in cmds: 65 | log.debug('launching cmd:\n"{}"\n'.format(cmd)) 66 | proc = sp.Popen(shlex.split(cmd), stdout=DEVNULL, stderr=sp.PIPE) 67 | fd = proc.stderr.fileno() 68 | log.debug("registering fd '{}' for pid '{}'".format(fd, proc.pid)) 69 | fds2procs[fd] = self._procs[cmd] = proc 70 | # register for stderr hangup events 71 | self.poller.register(proc.stderr.fileno(), select.EPOLLHUP) 72 | # limit launch rate 73 | time.sleep(1.0 / rate) 74 | 75 | # launch waiter 76 | self._waiter = threading.Thread(target=self._wait, args=(fds2procs,)) 77 | self._waiter.daemon = True 78 | self._waiter.start() 79 | 80 | return self.get(**kwargs) if block else self._procs 81 | 82 | def _wait(self, fds2procs): 83 | log.debug("started waiter for procs {}".format(fds2procs)) 84 | signalled = None 85 | left = len(fds2procs) 86 | collected = 0 87 | while collected < left: 88 | pairs = self.poller.poll() # wait on hangup events 89 | log.debug("received hangup for pairs '{}'".format(pairs)) 90 | for fd, status in pairs: 91 | collected += 1 92 | proc = fds2procs[fd] 93 | # attach streams so they can be read more then once 94 | log.debug("collecting streams for {}".format(proc)) 95 | proc.streams = Streams(*proc.communicate()) # timeout=2)) 96 | if proc.returncode != 0 and not signalled: 97 | # stop all other agents if there is a failure 98 | signalled = self.stop() 99 | 100 | log.debug("terminating waiter thread") 101 | 102 | def get(self, timeout=180): 103 | """Block up to `timeout` seconds for all agents to complete. 104 | Either return (cmd, proc) pairs or raise `TimeoutError` on timeout 105 | """ 106 | if self._waiter.is_alive(): 107 | self._waiter.join(timeout=timeout) 108 | 109 | if self._waiter.is_alive(): 110 | # kill them mfin SIPps 111 | signalled = self.stop() 112 | self._waiter.join(timeout=10) 113 | 114 | if self._waiter.is_alive(): 115 | # try to stop a few more times 116 | for _ in range(3): 117 | signalled = self.stop() 118 | self._waiter.join(timeout=1) 119 | 120 | if self._waiter.is_alive(): 121 | # some procs failed to terminate via signalling 122 | raise RuntimeError("Unable to kill all agents!?") 123 | 124 | # all procs were killed by SIGUSR1 125 | raise TimeoutError( 126 | "pids '{}' failed to complete after '{}' seconds".format( 127 | pformat([p.pid for p in signalled.values()]), timeout 128 | ) 129 | ) 130 | 131 | return self._procs 132 | 133 | def stop(self): 134 | """Stop all agents with SIGUSR1 as per SIPp's signal handling""" 135 | return self._signalall(signal.SIGUSR1) 136 | 137 | def terminate(self): 138 | """Kill all agents with SIGTERM""" 139 | return self._signalall(signal.SIGTERM) 140 | 141 | def _signalall(self, signum): 142 | signalled = OrderedDict() 143 | for cmd, proc in self.iterprocs(): 144 | proc.send_signal(signum) 145 | log.warning( 146 | "sent signal '{}' to cmd '{}' with pid '{}'".format( 147 | signum, cmd, proc.pid 148 | ) 149 | ) 150 | signalled[cmd] = proc 151 | return signalled 152 | 153 | def iterprocs(self): 154 | """Iterate all processes which are still alive yielding 155 | (cmd, proc) pairs 156 | """ 157 | return ( 158 | (cmd, proc) 159 | for cmd, proc in self._procs.items() 160 | if proc and proc.poll() is None 161 | ) 162 | 163 | def is_alive(self): 164 | """Return bool indicating whether some agents are still alive""" 165 | return any(self.iterprocs()) 166 | 167 | def ready(self): 168 | """Return bool indicating whether all agents have completed""" 169 | return not self.is_alive() 170 | 171 | def clear(self): 172 | """Clear all processes from the last run""" 173 | assert self.ready(), "Not all processes have completed" 174 | self._procs.clear() 175 | -------------------------------------------------------------------------------- /tests/test_stack.py: -------------------------------------------------------------------------------- 1 | """ 2 | End to end tests with plugin support 3 | """ 4 | import functools 5 | import os 6 | 7 | import pytest 8 | 9 | import pysipp 10 | 11 | 12 | @pytest.fixture 13 | def scenwalk(scendir): 14 | return functools.partial(pysipp.walk, scendir) 15 | 16 | 17 | def test_collect(scenwalk): 18 | """Verify the scendir filtering hook""" 19 | assert len(list(scenwalk())) == 2 20 | 21 | # test filtering hook 22 | class blockall(object): 23 | @pysipp.plugin.hookimpl 24 | def pysipp_load_scendir(self, path, xmls, confpy): 25 | return False 26 | 27 | with pysipp.plugin.register([blockall()]): 28 | assert not len(list(scenwalk())) 29 | 30 | @pysipp.plugin.hookimpl 31 | def confpy_included(self, path, xmls, confpy): 32 | return bool(confpy) 33 | 34 | blockall.pysipp_load_scendir = confpy_included 35 | 36 | pysipp.plugin.mng.register(blockall()) 37 | assert len(list(scenwalk())) == 1 38 | 39 | 40 | def test_confpy_hooks(scendir): 41 | """Test that hooks included in a confpy file work 42 | 43 | Assertions here are based on predefined hooks 44 | """ 45 | path, scen = list(pysipp.walk(scendir + "/default_with_confpy"))[0] 46 | assert scen.mod 47 | # ordering hook should reversed agents 48 | agents = list(scen.agents.values()) 49 | assert agents[0].is_client() 50 | assert agents[1].is_server() 51 | # check that `scen.remote_host = 'doggy'` was applied 52 | assert scen.defaults.uri_username == "doggy" 53 | for agent in scen.prepare(): 54 | assert agent.uri_username == "doggy" 55 | 56 | 57 | def test_proxyaddr_with_scendir(scendir): 58 | """When building a scenario from a xml file directory the 59 | `proxyaddr` kwarg should be assigned. 60 | """ 61 | remoteaddr = ("9.9.9.9", 80) 62 | scen = pysipp.scenario( 63 | dirpath=scendir + "/default_with_confpy", proxyaddr=remoteaddr 64 | ) 65 | 66 | assert scen.clientdefaults.proxyaddr == remoteaddr 67 | for name, cmd in scen.cmditems(): 68 | if name == "uac": 69 | assert "-rsa '{}':'{}'".format(*remoteaddr) in cmd 70 | assert "'{}':'{}'".format(*scen.clientdefaults.destaddr) in cmd 71 | elif name == "uas": 72 | assert "-rsa '{}':'{}'".format(*remoteaddr) not in cmd 73 | 74 | 75 | def test_sync_run(scenwalk): 76 | """Ensure all scenarios in the test run to completion in 77 | synchronous mode""" 78 | for path, scen in scenwalk(): 79 | runner = scen(timeout=6) 80 | for cmd, proc in runner.get(timeout=0).items(): 81 | assert proc.returncode == 0 82 | 83 | 84 | def test_async_run(scenwalk): 85 | """Ensure multiple scenarios run to completion in asynchronous mode.""" 86 | finalizers = [] 87 | for path, scen in scenwalk(): 88 | finalizers.append((scen, scen(block=False))) 89 | 90 | # collect all results synchronously 91 | for scen, finalizer in finalizers: 92 | for cmd, proc in scen.finalize(timeout=6).items(): 93 | assert proc.returncode == 0 94 | 95 | 96 | def test_basic(basic_scen): 97 | """Test the most basic uac <-> uas call flow""" 98 | assert len(basic_scen.agents) == 2 99 | # ensure sync run works 100 | runner = basic_scen() 101 | assert not runner.is_alive() 102 | 103 | 104 | def test_unreachable_uas(basic_scen): 105 | """Test the basic scenario but have the uas bind to a different port thus 106 | causing the uac to timeout on request responses. Ensure that an error is 107 | raised and that the appropriate log files are generated per agent. 108 | """ 109 | uas = basic_scen.agents["uas"] 110 | uas.proxyaddr = uas.local_host, 9999 111 | with pytest.raises(RuntimeError): 112 | basic_scen() 113 | 114 | # verify log file generation for each agent 115 | for ua in basic_scen.prepare(): 116 | for name, path in ua.iter_logfile_items(): 117 | # randomly the -logfile stopped being generated? 118 | if "log" not in name: 119 | assert os.path.isfile(path) 120 | os.remove(path) 121 | 122 | 123 | def test_hook_overrides(basic_scen): 124 | """Ensure that composite agent attributes (such as socket addresses) do 125 | not override individual agent argument attrs that were set explicitly 126 | elsewhere (eg. in a hook). 127 | """ 128 | 129 | class Router(object): 130 | @pysipp.plugin.hookimpl 131 | def pysipp_conf_scen(self, agents, scen): 132 | # no explicit port is set on agents by default 133 | agents["uas"].local_port = 5090 134 | agents["uac"].remote_port = agents["uas"].local_port 135 | 136 | with pysipp.plugin.register([Router()]): 137 | pysipp.plugin.mng.hook.pysipp_conf_scen( 138 | agents=basic_scen.agents, scen=basic_scen 139 | ) 140 | 141 | # apply a composite socket addr attr 142 | basic_scen.clientdefaults["destaddr"] = "10.10.99.99", "doggy" 143 | 144 | # destaddr set in clientdefaults should not override agent values 145 | agents = basic_scen.prepare() 146 | # ensure uac still points to uas port 147 | assert agents[1].remote_port == agents[0].local_port 148 | 149 | 150 | @pytest.mark.parametrize( 151 | "dictname", 152 | ["defaults", "clientdefaults", "serverdefaults"], 153 | ids=str, 154 | ) 155 | @pytest.mark.parametrize( 156 | "data", 157 | [ 158 | {"local_host": "127.0.0.1"}, 159 | {"local_port": 5080}, 160 | {"local_port": 5080, "local_host": "127.0.0.1"}, 161 | {"srcaddr": ("127.0.0.1", 5080)}, 162 | {"media_addr": "127.0.0.1"}, 163 | {"media_port": 5080}, 164 | {"media_port": 5080, "media_addr": "127.0.0.1"}, 165 | {"mediaaddr": ("127.0.0.1", 5080)}, 166 | ], 167 | ids=lambda d: str(d), 168 | ) 169 | def test_autonet_overrides(dictname, data): 170 | """Ensure the auto-networking plugin doesn't override default or agent 171 | settings applied by client code. 172 | """ 173 | scen = pysipp.scenario(**{dictname: data}) 174 | scen = scen.from_agents() 175 | # netplug.py hooks shouldn't override the uac srcaddr 176 | if "client" in dictname: 177 | agents = scen.clients 178 | elif "server" in dictname: 179 | agents = scen.servers 180 | else: 181 | agents = scen.agents 182 | 183 | for key, val in data.items(): 184 | for ua in agents.values(): 185 | assert getattr(ua, key) == val 186 | -------------------------------------------------------------------------------- /tests/test_agent.py: -------------------------------------------------------------------------------- 1 | """ 2 | pysipp.agent module tests 3 | """ 4 | import tempfile 5 | 6 | import pytest 7 | 8 | import pysipp 9 | from pysipp import agent 10 | from pysipp import launch 11 | from pysipp import plugin 12 | 13 | 14 | @pytest.fixture 15 | def ua(): 16 | return agent.Scenario([agent.ua()]).prepare()[0] 17 | 18 | 19 | def test_ua(ua): 20 | """Set up a typeless agent and perform basic attr checks""" 21 | sock = ("10.10.9.9", 5060) 22 | ua.proxyaddr = sock 23 | assert ua.name == str(None) 24 | assert "'{}':'{}'".format(*sock) in ua.render() 25 | 26 | 27 | def check_log_files(ua, logdir=None): 28 | logdir = logdir or ua.logdir 29 | assert logdir 30 | 31 | # check attr values contain logdir and agent name 32 | for name, path in ua.iter_logfile_items(): 33 | assert logdir in path 34 | assert ua.name in path 35 | 36 | cmd = ua.render() 37 | assert logdir in cmd 38 | logs = [token for token in cmd.split() if logdir in token] 39 | # check num of args with logdir in the value 40 | assert len(logs) == len(ua._log_types) 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "funcname", 45 | ["ua", "client", "server"], 46 | ) 47 | def test_logdir(funcname): 48 | """Verify that a default logdir is set and filenames are 49 | based on the agent's name. 50 | """ 51 | func = getattr(agent, funcname) 52 | # enables SIPp logging by default 53 | ua = agent.Scenario([func()]).prepare()[0] 54 | check_log_files(ua, tempfile.gettempdir()) 55 | 56 | 57 | def test_scen_assign_logdir(): 58 | """Verify log file arguments when logdir is set using Scenario.defaults""" 59 | scen = pysipp.scenario() 60 | logdir = tempfile.mkdtemp(suffix="_pysipp") 61 | scen.defaults.logdir = logdir 62 | for ua in scen.prepare(): 63 | check_log_files(ua, logdir) 64 | 65 | 66 | def test_scen_pass_logdir(): 67 | """Verify log file arguments when logdir is set using Scenario.defaults""" 68 | logdir = tempfile.mkdtemp(suffix="_pysipp") 69 | scen = pysipp.scenario(logdir=logdir) 70 | assert scen.defaults.logdir == logdir 71 | 72 | # logdir isn't set until the scenario is "prepared" 73 | assert scen.agents["uac"].logdir is None 74 | 75 | # logdir is set once scenario is "rendered" 76 | for ua in scen.prepare(): 77 | check_log_files(ua, logdir) 78 | 79 | 80 | def test_walk_pass_logdir(): 81 | logdir = tempfile.mkdtemp(suffix="_pysipp") 82 | scen = next(pysipp.walk("./tests/scens/default/", logdir=logdir))[1] 83 | assert scen.logdir == logdir 84 | 85 | # logdir is set once scenario is "rendered" 86 | for ua in scen.prepare(): 87 | check_log_files(ua, logdir) 88 | 89 | 90 | def test_client(): 91 | # check the built-in uac xml scenario 92 | remote_sock = ("192.168.1.1", 5060) 93 | uac = agent.client(destaddr=remote_sock) 94 | cmdstr = uac.render() 95 | assert "-sn 'uac'" in cmdstr 96 | assert "'{}':'{}'".format(*remote_sock) in cmdstr 97 | 98 | # pretend script file 99 | script = "/home/sipp_scen/uac.xml" 100 | uac2 = agent.client(destaddr=remote_sock, scen_file=script) 101 | cmdstr = uac2.render() 102 | assert "-sn 'uac'" not in cmdstr 103 | assert "-sf '{}'".format(script) in cmdstr 104 | 105 | 106 | def test_server(): 107 | ua = agent.server() 108 | cmdstr = ua.render() 109 | assert "-sn 'uas'" in cmdstr 110 | assert not (ua.remote_host and ua.remote_port) 111 | 112 | 113 | @pytest.mark.parametrize( 114 | "ua, retcode, kwargs, exc", 115 | [ 116 | # test unspecialized ua failure 117 | (agent.ua(), 255, {}, RuntimeError), 118 | # test client failure on bad remote destination 119 | (agent.client(destaddr=("99.99.99.99", 5060)), 1, {}, RuntimeError), 120 | # test if server times out it is signalled 121 | (agent.server(), 0, {"timeout": 1}, launch.TimeoutError), 122 | ], 123 | ids=["ua", "uac", "uas"], 124 | ) 125 | def test_failures(ua, retcode, kwargs, exc): 126 | """Test failure cases for all types of agents""" 127 | # run it without raising 128 | runner = ua(raise_exc=False, **kwargs) 129 | cmds2procs = runner.get(timeout=0) 130 | assert not runner.is_alive() 131 | assert len(list(runner.iterprocs())) == 0 132 | # tests transparency of the defaults config pipeline 133 | scen = plugin.mng.hook.pysipp_conf_scen_protocol( 134 | agents=[ua], confpy=None, scenkwargs={} 135 | ) 136 | cmd = scen.prepare_agent(ua).render() 137 | assert cmd in cmds2procs 138 | assert len(cmds2procs) == 1 139 | proc = cmds2procs[cmd] 140 | assert proc.returncode == retcode 141 | 142 | # rerun it with raising 143 | if not exc: 144 | with pytest.raises(RuntimeError): 145 | ua(**kwargs) 146 | 147 | 148 | def test_scenario(): 149 | uas, uac = agent.server(), agent.client() 150 | agents = [uas, uac] 151 | scen = agent.Scenario(agents) 152 | scen2 = agent.Scenario(agents) 153 | 154 | # verify contained agents 155 | assert list(scen.agents.values()) == agents == scen._agents 156 | assert scen.prepare() != agents # new copies 157 | 158 | # verify order 159 | agents = list(scen.agents.values()) 160 | assert uas is agents[0] 161 | assert uac is agents[1] 162 | # verify servers 163 | assert uas is list(scen.servers.values())[0] 164 | # verify clients 165 | assert uac is list(scen.clients.values())[0] 166 | 167 | # ensure defaults attr setting works 168 | doggy = "doggy" 169 | scen.local_host = doggy 170 | uas, uac = scen.prepare() 171 | assert uac.local_host == uas.local_host == doggy 172 | 173 | # should be no shared state between instances 174 | assert scen2.local_host != doggy 175 | scen2.local_host = 10 176 | assert scen.local_host == doggy 177 | 178 | # same error for any non-spec defined agent attr 179 | with pytest.raises(AttributeError): 180 | scen.agentdefaults.local_man = "flasher" 181 | scen.prepare() 182 | 183 | # defaults on servers only 184 | scen.serverdefaults.uri_username = doggy 185 | uas, uac = scen.prepare() 186 | assert uas.uri_username == doggy 187 | assert uac.uri_username != doggy 188 | 189 | assert scen.name == "uas_uac" 190 | 191 | 192 | def test_pass_bad_socket_addr(): 193 | with pytest.raises(ValueError): 194 | pysipp.client(proxyaddr="10.10.8.88") 195 | 196 | 197 | def test_authentication_arguments(): 198 | client = agent.client(auth_username="username", auth_password="passw0rd") 199 | 200 | cmd = client.render() 201 | 202 | assert "-au 'username'" in cmd 203 | assert "-ap 'passw0rd'" in cmd 204 | -------------------------------------------------------------------------------- /pysipp/command.py: -------------------------------------------------------------------------------- 1 | """ 2 | Command string rendering 3 | """ 4 | import socket 5 | import string 6 | from collections import OrderedDict 7 | 8 | from . import utils 9 | 10 | log = utils.get_logger() 11 | 12 | 13 | def iter_format(item): 14 | return string.Formatter().parse(item) 15 | 16 | 17 | class Field(object): 18 | _default = None 19 | 20 | def __init__(self, name, fmtstr): 21 | self.name = name 22 | self.fmtstr = fmtstr 23 | 24 | def __get__(self, obj, cls): 25 | if obj is None: 26 | return self 27 | return obj._values.setdefault( 28 | self.name, 29 | self._default() if self._default else None, 30 | ) 31 | 32 | def __set__(self, obj, value): 33 | self.render(value) # value checking 34 | obj._values[self.name] = value 35 | 36 | def render(self, value): 37 | return ( 38 | self.fmtstr.format(**{self.name: "'{}'".format(value)}) 39 | if value 40 | else "" 41 | ) 42 | 43 | 44 | class AddrField(Field): 45 | def render(self, value): 46 | if not value: 47 | return 48 | 49 | try: 50 | socket.inet_pton(socket.AF_INET6, value) 51 | name = "'[{}]'".format(value) 52 | except socket.error: 53 | name = "'{}'".format(value) 54 | 55 | return self.fmtstr.format(**{self.name: name}) 56 | 57 | 58 | class BoolField(Field): 59 | def __set__(self, obj, value): 60 | if not isinstance(value, bool): 61 | raise ValueError("{} must be a boolean type".format(self.name)) 62 | super(type(self), self).__set__(obj, value) 63 | 64 | def render(self, value): 65 | # return the fmt string with a null string replacement 66 | return self.fmtstr.format(**{self.name: ""}) if value else "" 67 | 68 | 69 | class DictField(Field): 70 | _default = OrderedDict 71 | 72 | def render(self, value): 73 | return "".join( 74 | self.fmtstr.format(**{self.name: "{} '{}'".format(key, val)}) 75 | for key, val in value.items() 76 | ) 77 | 78 | 79 | class ListField(Field): 80 | _default = [] 81 | 82 | def render(self, value): 83 | return "".join( 84 | self.fmtstr.format(**{self.name: "'{}'".format(val)}) 85 | for val in value 86 | ) 87 | 88 | 89 | def cmdstrtype(spec): 90 | """Build a command str renderer from an iterable of format string tokens. 91 | 92 | Given a `spec` (i.e. an iterable of format string specifiers), this 93 | function returns a command string renderer type which allows for 94 | `str.format` "replacement fields" to be assigned using attribute access. 95 | 96 | Ex. 97 | >>> cmd = cmdstrtype([ 98 | '{bin_path} ' 99 | '{remote_host} ', 100 | ':{remote_port} ', 101 | '-i {local_host} ', 102 | '-p {local_port} ', 103 | '-recv_timeout {msg_timeout} ', 104 | '-i {local_host} ', 105 | ])() 106 | >>> cmd.bin_path = '/usr/bin/sipp/' 107 | >>> str(cmd) 108 | '/usr/bin/sipp' 109 | >>> str(cmd) == cmd.render() 110 | True 111 | >>> cmd.remote_host = 'doggy.com' 112 | >>> cmd.local_host = '192.168.0.1' 113 | >>> cmd.render() 114 | '/usr/bin/sipp doggy.com -i 192.168.0.1' 115 | >>> cmd.remote_port = 5060 116 | >>> cmd.render() 117 | '/usr/bin/sipp doggy.com:5060 -i 192.168.0.1' 118 | """ 119 | 120 | class Renderer(object): 121 | _specparams = OrderedDict() 122 | 123 | def __init__(self, defaults=None): 124 | self._values = {} 125 | if defaults: 126 | self.applydict(defaults) 127 | self._init = True # lock attribute creation 128 | 129 | def __str__(self): 130 | return self.render() 131 | 132 | def render(self): 133 | tokens = [] 134 | for key, descr in self._specparams.items(): 135 | # trigger descriptor protocol `__get__` 136 | value = getattr(self, key) 137 | if value is not None: 138 | tokens.append(descr.render(value)) 139 | 140 | return "".join(tokens) 141 | 142 | def __setattr__(self, key, value): 143 | # immutable after instantiation 144 | if ( 145 | getattr(self, "_init", False) 146 | and (key not in self.__class__.__dict__) 147 | and (key not in self._specparams) 148 | and key[0] != "_" 149 | ): 150 | raise AttributeError( 151 | "no settable public attribute '{}' defined".format(key) 152 | ) 153 | object.__setattr__(self, key, value) 154 | 155 | @classmethod 156 | def descriptoritems(cls): 157 | return utils.iter_data_descrs(cls) 158 | 159 | @classmethod 160 | def keys(cls): 161 | return [key for key, descr in cls.descriptoritems()] 162 | 163 | def applydict(self, d): 164 | """Apply contents of dict `d` onto local instance variables.""" 165 | for name, value in d.items(): 166 | setattr(self, name, value) 167 | 168 | def todict(self): 169 | """Serialze all descriptor defined attributes into a dictionary""" 170 | contents = {} 171 | for key in self.keys(): 172 | val = getattr(self, key) 173 | if val: 174 | contents[key] = val 175 | return contents 176 | 177 | # build renderer type with custom descriptors 178 | for item in spec: 179 | if isinstance(item, tuple): 180 | fmtstr, descrtype = item 181 | else: 182 | fmtstr, descrtype = item, Field 183 | fieldname = list(iter_format(fmtstr))[0][1] 184 | descr = descrtype(fieldname, fmtstr) 185 | Renderer._specparams[fieldname] = descr 186 | setattr(Renderer, fieldname, descr) 187 | 188 | return Renderer 189 | 190 | 191 | sipp_spec = [ 192 | # contact info 193 | "{prefix} ", 194 | "{bin_path} ", 195 | ("-i {local_host} ", AddrField), 196 | "-p {local_port} ", 197 | "-s {uri_username} ", 198 | ("-rsa {proxy_host}", AddrField), # NOTE: no space 199 | ":{proxy_port} ", 200 | "-auth_uri {auth_uri} ", 201 | # sockets and protocols 202 | "-bind_local {bind_local} ", 203 | ("-mi {media_addr} ", AddrField), 204 | "-mp {media_port} ", 205 | "-t {transport} ", 206 | "-tls_cert {tls_cert} ", 207 | "-tls_key {tls_key} ", 208 | "-tls_crl {tls_crl} ", 209 | # scenario config/ctl 210 | "-sn {scen_name} ", 211 | "-sf {scen_file} ", 212 | "-oocsf {ooc_scen_file} ", 213 | "-recv_timeout {recv_timeout} ", 214 | "-timeout {timeout} ", 215 | "-d {pause_duration} ", 216 | "-default_behaviors {default_behaviors} ", 217 | ("-3pcc {ipc_host}", AddrField), # NOTE: no space 218 | ":{ipc_port} ", 219 | # SIP vars 220 | "-cid_str {cid_str} ", 221 | "-base_cseq {base_cseq} ", 222 | "-au {auth_username} ", 223 | "-ap {auth_password} ", 224 | # load settings 225 | "-r {rate} ", 226 | "-l {limit} ", 227 | "-m {call_count} ", 228 | "-rp {rate_period} ", 229 | "-users {users} ", 230 | "-deadcall_wait {deadcall_wait} ", 231 | # data insertion 232 | ("-key {key_vals} ", DictField), 233 | ("-set {global_vars} ", DictField), 234 | # files 235 | "-error_file {error_file} ", 236 | "-calldebug_file {calldebug_file} ", 237 | "-message_file {message_file} ", 238 | "-log_file {log_file} ", 239 | "-inf {info_file} ", 240 | ("-inf {info_files} ", ListField), 241 | "-screen_file {screen_file} ", 242 | # bool flags 243 | ("-rtp_echo {rtp_echo}", BoolField), 244 | ("-timeout_error {timeout_error}", BoolField), 245 | ("-aa {auto_answer}", BoolField), 246 | ("-trace_err {trace_error}", BoolField), 247 | ("-trace_calldebug {trace_calldebug}", BoolField), 248 | ("-trace_error_codes {trace_error_codes}", BoolField), 249 | ("-trace_msg {trace_message}", BoolField), 250 | ("-trace_logs {trace_log}", BoolField), 251 | ("-trace_screen {trace_screen}", BoolField), 252 | ("-error_overwrite {error_overwrite}", BoolField), 253 | ("{remote_host}", AddrField), # NOTE: no space 254 | ":{remote_port}", 255 | ] 256 | 257 | 258 | # a SIPp cmd renderer 259 | SippCmd = cmdstrtype(sipp_spec) 260 | -------------------------------------------------------------------------------- /pysipp/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2015 Tyler Goodlet 2 | # 3 | # This program is free software; you can redistribute it and/or modify 4 | # it under the terms of the GNU General Public License as published by 5 | # the Free Software Foundation; either version 2 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software 15 | # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 16 | # Authors : Tyler Goodlet 17 | """ 18 | pysipp - a python wrapper for launching SIPp 19 | """ 20 | import sys 21 | from os.path import dirname 22 | 23 | from . import agent 24 | from . import launch 25 | from . import netplug 26 | from . import plugin 27 | from . import report 28 | from .agent import client 29 | from .agent import server 30 | from .load import iter_scen_dirs 31 | 32 | 33 | class SIPpFailure(RuntimeError): 34 | """SIPp commands failed""" 35 | 36 | 37 | __package__ = "pysipp" 38 | __author__ = "Tyler Goodlet (tgoodlet@gmail.com)" 39 | 40 | __all__ = ["walk", "client", "server", "plugin"] 41 | 42 | 43 | def walk(rootpath, delay_conf_scen=False, autolocalsocks=True, **scenkwargs): 44 | """SIPp scenario generator. 45 | 46 | Build and return scenario objects for each scenario directory. 47 | Most hook calls are described here. 48 | """ 49 | with plugin.register([netplug] if autolocalsocks else []): 50 | hooks = plugin.mng.hook 51 | for path, xmls, confpy in iter_scen_dirs(rootpath): 52 | # sanity checks 53 | for xml in xmls: 54 | assert dirname(xml) == path 55 | if confpy: 56 | assert dirname(confpy.__file__) == path 57 | 58 | # predicate hook based filtering 59 | res = hooks.pysipp_load_scendir( 60 | path=path, xmls=xmls, confpy=confpy 61 | ) 62 | if res and not all(res): 63 | continue 64 | 65 | agents = [] 66 | for xml in xmls: 67 | if "uac" in xml.lower(): 68 | ua = agent.client(scen_file=xml) 69 | agents.append(ua) 70 | elif "uas" in xml.lower(): 71 | ua = agent.server(scen_file=xml) 72 | agents.insert(0, ua) # servers are always launched first 73 | else: 74 | raise ValueError( 75 | "xml script must contain one of 'uac' or 'uas':\n" 76 | f"{xml}" 77 | ) 78 | 79 | if delay_conf_scen: 80 | # default scen impl 81 | scen = agent.Scenario(agents, confpy=confpy) 82 | 83 | else: 84 | scen = hooks.pysipp_conf_scen_protocol( 85 | agents=agents, 86 | confpy=confpy, 87 | scenkwargs=scenkwargs, 88 | ) 89 | 90 | yield path, scen 91 | 92 | 93 | def scenario(dirpath=None, proxyaddr=None, autolocalsocks=True, **scenkwargs): 94 | """Return a single Scenario loaded from `dirpath` if provided else the 95 | basic default call flow. 96 | """ 97 | if dirpath: 98 | # deliver single scenario from dir 99 | path, scen = next( 100 | walk(dirpath, autolocalsocks=autolocalsocks, **scenkwargs) 101 | ) 102 | else: 103 | with plugin.register([netplug] if autolocalsocks else []): 104 | # deliver the default scenario bound to loopback sockets 105 | uas = agent.server() 106 | uac = agent.client() 107 | 108 | # same as above 109 | scen = plugin.mng.hook.pysipp_conf_scen_protocol( 110 | agents=[uas, uac], confpy=None, scenkwargs=scenkwargs 111 | ) 112 | 113 | if proxyaddr: 114 | assert isinstance( 115 | proxyaddr, tuple 116 | ), "proxyaddr must be a (addr, port) tuple" 117 | scen.clientdefaults.proxyaddr = proxyaddr 118 | 119 | return scen 120 | 121 | 122 | # Default hook implementations 123 | @plugin.hookimpl 124 | def pysipp_load_scendir(path, xmls, confpy): 125 | """If there are no SIPp scripts at the current path then skip this 126 | directory during collection. 127 | """ 128 | return bool(xmls) 129 | 130 | 131 | @plugin.hookimpl 132 | def pysipp_conf_scen_protocol(agents, confpy, scenkwargs): 133 | """Perform default configuration rule set""" 134 | # more sanity 135 | if confpy: 136 | ua = agents[0] 137 | assert dirname(confpy.__file__) == dirname(ua.scen_file) 138 | 139 | hooks = plugin.mng.hook 140 | # register pysipp_conf.py module temporarily so that each scenario only 141 | # hooks a single pysipp_conf.py 142 | with plugin.register([confpy]): 143 | # default scen impl 144 | scen = agent.Scenario(agents, confpy=confpy) 145 | 146 | # order the agents for launch 147 | agents = ( 148 | list( 149 | hooks.pysipp_order_agents( 150 | agents=scen.agents, 151 | clients=scen.clients, 152 | servers=scen.servers, 153 | ) 154 | ) 155 | or agents 156 | ) 157 | 158 | # create scenario wrapper 159 | scen = hooks.pysipp_new_scen( 160 | agents=agents, confpy=confpy, scenkwargs=scenkwargs 161 | ) 162 | 163 | # configure scenario 164 | hooks.pysipp_conf_scen(agents=scen.agents, scen=scen) 165 | 166 | # XXX patch pluggy to support direct method parsing allowing to 167 | # remove ^ 168 | # hooks.pysipp_conf_scen.call_extra(scen=scen) 169 | 170 | return scen 171 | 172 | 173 | @plugin.hookimpl 174 | def pysipp_order_agents(agents, clients, servers): 175 | """Lexicographically sort agents by name and always start servers first""" 176 | return (agents[name] for name in sorted(servers) + sorted(clients)) 177 | 178 | 179 | @plugin.hookimpl 180 | def pysipp_new_scen(agents, confpy, scenkwargs): 181 | return agent.Scenario(agents, confpy=confpy, **scenkwargs) 182 | 183 | 184 | @plugin.hookimpl(trylast=True) 185 | def pysipp_conf_scen(agents, scen): 186 | """Default validation logic and routing with media""" 187 | if scen.servers: 188 | # point all clients to send requests to 'primary' server agent 189 | # if they aren't already 190 | servers_addr = scen.serverdefaults.get("srcaddr", ("127.0.0.1", 5060)) 191 | uas = scen.prepare_agent(list(scen.servers.values())[0]) 192 | scen.clientdefaults.setdefault("destaddr", uas.srcaddr or servers_addr) 193 | 194 | elif not scen.clientdefaults.proxyaddr: 195 | # no servers in scenario so point proxy addr to remote socket addr 196 | scen.clientdefaults.proxyaddr = scen.clientdefaults.destaddr 197 | 198 | # make the non-players echo media 199 | if scen.has_media and len(scen.agents) == 2: 200 | for ua in scen.agents.values(): 201 | if not ua.plays_media: 202 | ua.rtp_echo = True 203 | 204 | 205 | @plugin.hookimpl 206 | def pysipp_new_runner(): 207 | """Provision and assign a default cmd runner""" 208 | return launch.PopenRunner() 209 | 210 | 211 | @plugin.hookimpl 212 | def pysipp_run_protocol(scen, runner, block, timeout, raise_exc): 213 | """ "Run all rendered commands with the provided runner or the built-in 214 | PopenRunner which runs commands locally. 215 | """ 216 | # use provided runner or default provided by hook 217 | runner = runner or plugin.mng.hook.pysipp_new_runner() 218 | agents = scen.prepare() 219 | 220 | def finalize(cmds2procs=None, timeout=180, raise_exc=True): 221 | """Wait for all remaining agents in the scenario to finish executing 222 | and perform error and logfile reporting. 223 | """ 224 | cmds2procs = cmds2procs or runner.get(timeout=timeout) 225 | agents2procs = list(zip(agents, cmds2procs.values())) 226 | msg = report.err_summary(agents2procs) 227 | if msg: 228 | # report logs and stderr 229 | report.emit_logfiles(agents2procs) 230 | if raise_exc: 231 | # raise RuntimeError on agent failure(s) 232 | # (HINT: to rerun type `scen()` from the debugger) 233 | raise SIPpFailure(msg) 234 | 235 | return cmds2procs 236 | 237 | try: 238 | # run all agents (raises RuntimeError on timeout) 239 | cmds2procs = runner( 240 | (ua.render() for ua in agents), block=block, timeout=timeout 241 | ) 242 | except launch.TimeoutError: # sucessful timeout 243 | cmds2procs = finalize(timeout=0, raise_exc=False) 244 | if raise_exc: 245 | raise 246 | else: 247 | # async 248 | if not block: 249 | # XXX async run must bundle up results for later processing 250 | scen.finalize = finalize 251 | return finalize 252 | 253 | # sync 254 | finalize(cmds2procs, raise_exc=raise_exc) 255 | 256 | return runner 257 | 258 | 259 | # register the default hook set 260 | plugin.mng.register(sys.modules[__name__]) 261 | -------------------------------------------------------------------------------- /pysipp/agent.py: -------------------------------------------------------------------------------- 1 | """ 2 | Wrappers for user agents which apply sensible cmdline arg defaults 3 | """ 4 | import itertools 5 | import re 6 | import tempfile 7 | from collections import namedtuple 8 | from collections import OrderedDict 9 | from copy import deepcopy 10 | from os import path 11 | 12 | from shutil import which 13 | 14 | from . import command 15 | from . import plugin 16 | from . import utils 17 | 18 | log = utils.get_logger() 19 | 20 | SocketAddr = namedtuple("SocketAddr", "ip port") 21 | 22 | 23 | def tuple_property(attrs): 24 | def getter(self): 25 | tup = tuple(getattr(self, attr) for attr in attrs) 26 | if all(tup): 27 | return tup 28 | return None 29 | 30 | def setter(self, pair): 31 | if not isinstance(pair, tuple): 32 | if pair is None: 33 | pair = (None, None) 34 | else: 35 | raise ValueError("{} must be a tuple".format(pair)) 36 | for attr, val in zip(attrs, pair): 37 | setattr(self, attr, val) 38 | 39 | doc = "{} parameters composed as a tuple".format(", ".join(attrs)) 40 | 41 | return property(getter, setter, doc=doc) 42 | 43 | 44 | class UserAgent(command.SippCmd): 45 | """An extension of a SIPp command string which provides more pythonic 46 | higher level attributes for assigning input arguments similar to 47 | configuration options for a SIP UA. 48 | """ 49 | 50 | # we skip `error` since we can get it from stderr 51 | _log_types = "screen log".split() 52 | _debug_log_types = "calldebug message".split() 53 | _to_console = "screen" 54 | 55 | @property 56 | def name(self): 57 | """Compute the name identifier for this agent based the scenario script 58 | or scenario name 59 | """ 60 | return self.scen_name or path2namext(self.scen_file) or str(None) 61 | 62 | srcaddr = tuple_property(("local_host", "local_port")) 63 | destaddr = tuple_property(("remote_host", "remote_port")) 64 | mediaaddr = tuple_property(("media_addr", "media_port")) 65 | proxyaddr = tuple_property(("proxy_host", "proxy_port")) 66 | ipcaddr = tuple_property(("ipc_host", "ipc_port")) 67 | call_load = tuple_property(("rate", "limit", "call_count")) 68 | 69 | def __call__( 70 | self, block=True, timeout=180, runner=None, raise_exc=True, **kwargs 71 | ): 72 | 73 | # create and configure a temp scenario 74 | scen = plugin.mng.hook.pysipp_conf_scen_protocol( 75 | agents=[self], 76 | confpy=None, 77 | scenkwargs={}, 78 | ) 79 | # run the standard protocol 80 | # (attach allocted runner for reuse/post-portem) 81 | return plugin.mng.hook.pysipp_run_protocol( 82 | scen=scen, 83 | block=block, 84 | timeout=timeout, 85 | runner=runner, 86 | raise_exc=raise_exc, 87 | **kwargs 88 | ) 89 | 90 | def is_client(self): 91 | return "uac" in self.name.lower() 92 | 93 | def is_server(self): 94 | return "uas" in self.name.lower() 95 | 96 | def iter_logfile_items( 97 | self, types_attr="_log_types", enable_screen_file=True 98 | ): 99 | for name in getattr(self, types_attr): 100 | if name != "screen" or enable_screen_file: 101 | attr_name = name + "_file" 102 | yield attr_name, getattr(self, attr_name) 103 | 104 | def iter_toconsole_items(self): 105 | yield "screen_file", self.screen_file 106 | 107 | @property 108 | def cmd(self): 109 | """Rendered SIPp command string""" 110 | return self.render() 111 | 112 | @property 113 | def logdir(self): 114 | return getattr(self, "_logdir", None) 115 | 116 | @logdir.setter 117 | def logdir(self, dirpath): 118 | assert path.isdir(dirpath), "{} is an invalid path".format(dirpath) 119 | self._logdir = dirpath 120 | 121 | @property 122 | def plays_media(self, patt="play_pcap_audio"): 123 | """Bool determining whether script plays media""" 124 | # TODO: should be able to parse using -sd 125 | if not self.scen_file: 126 | return False 127 | 128 | with open(self.scen_file, "r") as sf: 129 | return bool(re.search(patt, sf.read())) 130 | 131 | def enable_tracing(self): 132 | """Enable trace flags for this command""" 133 | for name in self._log_types: 134 | attr_name = "trace_" + name 135 | setattr(self, attr_name, True) 136 | 137 | def enable_logging( 138 | self, logdir=None, debug=False, enable_screen_file=True 139 | ): 140 | """Enable agent logging by appending appropriately named log file 141 | arguments to the underlying command. 142 | """ 143 | logattrs = self.iter_logfile_items( 144 | enable_screen_file=enable_screen_file 145 | ) 146 | if debug: 147 | logattrs = itertools.chain( 148 | logattrs, 149 | self.iter_logfile_items("_debug_log_types"), 150 | ) 151 | # prefix all log file paths 152 | for name, attr in logattrs: 153 | setattr( 154 | self, 155 | name, 156 | attr 157 | or path.join( 158 | logdir or self.logdir or tempfile.gettempdir(), 159 | "{}_{}".format(self.name, name), 160 | ), 161 | ) 162 | 163 | self.enable_tracing() 164 | 165 | 166 | def path2namext(filepath): 167 | if not filepath: 168 | return None 169 | name, ext = path.splitext(path.basename(filepath)) 170 | return name 171 | 172 | 173 | def ua(logdir=None, **kwargs): 174 | """Default user agent factory. 175 | Returns a command string instance with sensible default arguments. 176 | """ 177 | defaults = { 178 | "bin_path": which("sipp"), 179 | } 180 | # drop any built-in scen if a script file is provided 181 | if "scen_file" in kwargs: 182 | kwargs.pop("scen_name", None) 183 | 184 | # override with user settings 185 | defaults.update(kwargs) 186 | ua = UserAgent(defaults) 187 | 188 | # assign output file paths 189 | if logdir: 190 | ua.logdir = logdir 191 | 192 | return ua 193 | 194 | 195 | def server(**kwargs): 196 | """A SIPp user agent server 197 | (i.e. recieves a SIP message as it's first action) 198 | """ 199 | defaults = { 200 | "scen_name": "uas", 201 | } 202 | if "dstaddr" in kwargs: 203 | raise ValueError( 204 | "User agent server does not accept a destination address" 205 | ) 206 | # override with user settings 207 | defaults.update(kwargs) 208 | return ua(**defaults) 209 | 210 | 211 | def client(**kwargs): 212 | """A SIPp user agent client 213 | (i.e. sends a SIP message as it's first action) 214 | """ 215 | defaults = { 216 | "scen_name": "uac", 217 | } 218 | # override with user settings 219 | defaults.update(kwargs) 220 | return ua(**defaults) 221 | 222 | 223 | # default values every scenario should define at a minimum 224 | _minimum_defaults_template = { 225 | "key_vals": {}, 226 | "global_vars": {}, 227 | } 228 | _scen_defaults_template = { 229 | "recv_timeout": 5000, 230 | "call_count": 1, 231 | "rate": 1, 232 | "limit": 1, 233 | "logdir": tempfile.gettempdir(), 234 | } 235 | _scen_defaults_template.update(deepcopy(_minimum_defaults_template)) 236 | 237 | 238 | def Scenario(agents, **kwargs): 239 | """Wraps (subsets of) user agents in global state pertaining to 240 | configuration, routing, and default arguments. 241 | 242 | If called it will invoke the standard run hooks. 243 | """ 244 | scentype = type("Scenario", (ScenarioType,), {}) 245 | 246 | _defs = OrderedDict(deepcopy(_scen_defaults_template)) 247 | # for any passed kwargs that have keys in ``_defaults_template``, set them 248 | # as the new defaults for the scenario 249 | for key, val in kwargs.copy().items(): 250 | if key in _defs: 251 | _defs[key] = kwargs.pop(key) 252 | 253 | # if a `defaults` kwarg is passed in by the user override template 254 | # values with values from that as well 255 | user_defaults = kwargs.pop("defaults", None) 256 | if user_defaults: 257 | _defs.update(user_defaults) 258 | 259 | # this gives us scen. attribute access to scen.defaults 260 | utils.DictProxy(_defs, UserAgent.keys(), cls=scentype) 261 | return scentype(agents, _defs, **kwargs) 262 | 263 | 264 | class ScenarioType(object): 265 | """Wraps (subsets of) user agents in global state pertaining to 266 | configuration, routing, and default arguments. 267 | 268 | If called it will invoke the standard run hooks. 269 | """ 270 | 271 | def __init__( 272 | self, 273 | agents, 274 | defaults, 275 | clientdefaults=None, 276 | serverdefaults=None, 277 | confpy=None, 278 | enable_screen_file=True, 279 | ): 280 | # agents iterable in launch-order 281 | self._agents = agents 282 | ua_attrs = UserAgent.keys() 283 | 284 | # default settings 285 | self._defaults = defaults 286 | self.defaults = utils.DictProxy(self._defaults, ua_attrs)() 287 | 288 | # client settings 289 | self._clientdefaults = OrderedDict( 290 | clientdefaults or deepcopy(_minimum_defaults_template) 291 | ) 292 | self.clientdefaults = utils.DictProxy(self._clientdefaults, ua_attrs)() 293 | 294 | # server settings 295 | self._serverdefaults = OrderedDict( 296 | serverdefaults or deepcopy(_minimum_defaults_template) 297 | ) 298 | self.serverdefaults = utils.DictProxy(self._serverdefaults, ua_attrs)() 299 | 300 | # hook module 301 | self.mod = confpy 302 | self.enable_screen_file = enable_screen_file 303 | 304 | @property 305 | def agents(self): 306 | return OrderedDict((ua.name, ua) for ua in self._agents) 307 | 308 | @property 309 | def clients(self): 310 | return OrderedDict( 311 | (ua.name, ua) for ua in self._agents if ua.is_client() 312 | ) 313 | 314 | @property 315 | def servers(self): 316 | return OrderedDict( 317 | (ua.name, ua) for ua in self._agents if ua.is_server() 318 | ) 319 | 320 | @property 321 | def name(self): 322 | """Attempt to extract a name from a combination of scenario directory 323 | and script names 324 | """ 325 | dirnames = [] 326 | for agent in self._agents: 327 | if agent.scen_file: 328 | dirnames.append(path.basename(path.dirname(agent.scen_file))) 329 | else: 330 | dirnames.append(agent.scen_name) 331 | 332 | # concat dirnames if scripts come from separate dir locations 333 | if len(set(dirnames)) > 1: 334 | return "_".join(dirnames) 335 | 336 | return dirnames[0] 337 | 338 | def findbyaddr(self, socket, bytype=""): 339 | """Lookup an agent by socket address. `bytype` is a keyword which 340 | determines which socket to use at the key and can be one of 341 | {'media', 'dest', 'src'} 342 | """ 343 | for agent in self.prepare(): 344 | val = getattr(agent, "{}sockaddr".format(bytype), False) 345 | if val: 346 | return agent 347 | 348 | @property 349 | def has_media(self): 350 | """Bool dermining whether this scen is a media player""" 351 | if any(agent.plays_media for agent in self._agents): 352 | return True 353 | return False 354 | 355 | @property 356 | def dirpath(self): 357 | """Scenario directory path in the file system where all xml scripts 358 | and pysipp_conf.py should reside. 359 | """ 360 | scenfile = self.prepare()[0].scen_file 361 | return path.dirname(scenfile) if scenfile else None 362 | 363 | def cmditems(self): 364 | """Agent names to cmd strings items""" 365 | return [(agent.name, agent.cmd) for agent in self.prepare()] 366 | 367 | def pformat_cmds(self): 368 | """Pretty format string for printing agent commands""" 369 | return "\n\n".join( 370 | ["{}:\n{}".format(name, cmd) for name, cmd in self.cmditems()] 371 | ) 372 | 373 | def prepare_agent(self, agent): 374 | """Return a new agent with all default settings applied from this 375 | scenario 376 | """ 377 | 378 | def merge(dicts): 379 | """Merge dicts without clobbering up to 1 level deep's worth of 380 | sub-dicts 381 | """ 382 | merged = deepcopy(dicts[0]) 383 | for key, val in itertools.chain(*[d.items() for d in dicts[1:]]): 384 | if isinstance(val, dict): 385 | merged.setdefault(key, val).update(val) 386 | else: 387 | merged[key] = val 388 | 389 | return merged 390 | 391 | if agent.is_client(): 392 | secondary = self._clientdefaults 393 | dname = "clientdefaults" 394 | elif agent.is_server(): 395 | secondary = self._serverdefaults 396 | dname = "serverdefaults" 397 | else: 398 | secondary = {} 399 | dname = "unspecialized ua" 400 | 401 | # call pre defaults hook 402 | plugin.mng.hook.pysipp_pre_ua_defaults(ua=agent) 403 | 404 | # apply defaults 405 | ordered = [self._defaults, secondary, agent.todict()] 406 | for name, defs in zip(["defaults", dname, "agent.todict()"], ordered): 407 | log.debug("{} '{}' contents:\n{}".format(agent.name, name, defs)) 408 | 409 | params = merge(ordered) 410 | log.debug("{} merged contents:\n{}".format(agent.name, params)) 411 | ua = UserAgent(defaults=params) 412 | 413 | ua.enable_logging(enable_screen_file=self.enable_screen_file) 414 | 415 | # call post defaults hook 416 | plugin.mng.hook.pysipp_post_ua_defaults(ua=ua) 417 | 418 | return ua 419 | 420 | def prepare(self, agents=None): 421 | """Prepare (provided) agents according to the default configuration 422 | setttings in `defaults`, `clients`, and `servers` and return copies 423 | in a list. 424 | """ 425 | copies = [] 426 | agents = agents or self._agents 427 | for agent in agents: 428 | copies.append(self.prepare_agent(agent)) 429 | return copies 430 | 431 | def from_settings(self, **kwargs): 432 | """Create a new scenario from scratch using current settings calling 433 | all normal plugin hooks. 434 | """ 435 | from . import scenario 436 | 437 | scenkwargs = { 438 | "dirpath": self.dirpath, 439 | "defaults": self._defaults.copy(), 440 | "clientdefaults": self._clientdefaults.copy(), 441 | "serverdefaults": self._serverdefaults.copy(), 442 | } 443 | for key, value in kwargs.items(): 444 | if key in scenkwargs: 445 | scenkwargs[key].update(value) 446 | 447 | return scenario(**scenkwargs) 448 | 449 | def from_agents(self, agents=None, autolocalsocks=True, **scenkwargs): 450 | """Create a new scenario from prepared agents.""" 451 | return type(self)( 452 | self.prepare(agents), self._defaults, confpy=self.mod 453 | ) 454 | 455 | def __call__( 456 | self, 457 | agents=None, 458 | block=True, 459 | timeout=180, 460 | runner=None, 461 | raise_exc=True, 462 | copy_agents=False, 463 | **kwargs 464 | ): 465 | return plugin.mng.hook.pysipp_run_protocol( 466 | scen=self, 467 | block=block, 468 | timeout=timeout, 469 | runner=runner, 470 | raise_exc=raise_exc, 471 | **kwargs 472 | ) 473 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | 3 | Version 2, June 1991 4 | 5 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 6 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 7 | Everyone is permitted to copy and distribute verbatim copies 8 | of this license document, but changing it is not allowed. 9 | 10 | Preamble 11 | 12 | The licenses for most software are designed to take away your 13 | freedom to share and change it. By contrast, the GNU General Public 14 | License is intended to guarantee your freedom to share and change free 15 | software--to make sure the software is free for all its users. This 16 | General Public License applies to most of the Free Software 17 | Foundation's software and to any other program whose authors commit to 18 | using it. (Some other Free Software Foundation software is covered by 19 | the GNU Lesser General Public License instead.) You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | this service if you wish), that you receive source code or can get it 26 | if you want it, that you can change the software or use pieces of it 27 | in new free programs; and that you know you can do these things. 28 | 29 | To protect your rights, we need to make restrictions that forbid 30 | anyone to deny you these rights or to ask you to surrender the rights. 31 | These restrictions translate to certain responsibilities for you if you 32 | distribute copies of the software, or if you modify it. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must give the recipients all the rights that 36 | you have. You must make sure that they, too, receive or can get the 37 | source code. And you must show them these terms so they know their 38 | rights. 39 | 40 | We protect your rights with two steps: (1) copyright the software, and 41 | (2) offer you this license which gives you legal permission to copy, 42 | distribute and/or modify the software. 43 | 44 | Also, for each author's protection and ours, we want to make certain 45 | that everyone understands that there is no warranty for this free 46 | software. If the software is modified by someone else and passed on, we 47 | want its recipients to know that what they have is not the original, so 48 | that any problems introduced by others will not reflect on the original 49 | authors' reputations. 50 | 51 | Finally, any free program is threatened constantly by software 52 | patents. We wish to avoid the danger that redistributors of a free 53 | program will individually obtain patent licenses, in effect making the 54 | program proprietary. To prevent this, we have made it clear that any 55 | patent must be licensed for everyone's free use or not licensed at all. 56 | 57 | The precise terms and conditions for copying, distribution and 58 | modification follow. 59 | 60 | GNU GENERAL PUBLIC LICENSE 61 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 62 | 63 | 0. This License applies to any program or other work which contains 64 | a notice placed by the copyright holder saying it may be distributed 65 | under the terms of this General Public License. The "Program", below, 66 | refers to any such program or work, and a "work based on the Program" 67 | means either the Program or any derivative work under copyright law: 68 | that is to say, a work containing the Program or a portion of it, 69 | either verbatim or with modifications and/or translated into another 70 | language. (Hereinafter, translation is included without limitation in 71 | the term "modification".) Each licensee is addressed as "you". 72 | 73 | Activities other than copying, distribution and modification are not 74 | covered by this License; they are outside its scope. The act of 75 | running the Program is not restricted, and the output from the Program 76 | is covered only if its contents constitute a work based on the 77 | Program (independent of having been made by running the Program). 78 | Whether that is true depends on what the Program does. 79 | 80 | 1. You may copy and distribute verbatim copies of the Program's 81 | source code as you receive it, in any medium, provided that you 82 | conspicuously and appropriately publish on each copy an appropriate 83 | copyright notice and disclaimer of warranty; keep intact all the 84 | notices that refer to this License and to the absence of any warranty; 85 | and give any other recipients of the Program a copy of this License 86 | along with the Program. 87 | 88 | You may charge a fee for the physical act of transferring a copy, and 89 | you may at your option offer warranty protection in exchange for a fee. 90 | 91 | 2. You may modify your copy or copies of the Program or any portion 92 | of it, thus forming a work based on the Program, and copy and 93 | distribute such modifications or work under the terms of Section 1 94 | above, provided that you also meet all of these conditions: 95 | 96 | a) You must cause the modified files to carry prominent notices 97 | stating that you changed the files and the date of any change. 98 | 99 | b) You must cause any work that you distribute or publish, that in 100 | whole or in part contains or is derived from the Program or any 101 | part thereof, to be licensed as a whole at no charge to all third 102 | parties under the terms of this License. 103 | 104 | c) If the modified program normally reads commands interactively 105 | when run, you must cause it, when started running for such 106 | interactive use in the most ordinary way, to print or display an 107 | announcement including an appropriate copyright notice and a 108 | notice that there is no warranty (or else, saying that you provide 109 | a warranty) and that users may redistribute the program under 110 | these conditions, and telling the user how to view a copy of this 111 | License. (Exception: if the Program itself is interactive but 112 | does not normally print such an announcement, your work based on 113 | the Program is not required to print an announcement.) 114 | 115 | These requirements apply to the modified work as a whole. If 116 | identifiable sections of that work are not derived from the Program, 117 | and can be reasonably considered independent and separate works in 118 | themselves, then this License, and its terms, do not apply to those 119 | sections when you distribute them as separate works. But when you 120 | distribute the same sections as part of a whole which is a work based 121 | on the Program, the distribution of the whole must be on the terms of 122 | this License, whose permissions for other licensees extend to the 123 | entire whole, and thus to each and every part regardless of who wrote it. 124 | 125 | Thus, it is not the intent of this section to claim rights or contest 126 | your rights to work written entirely by you; rather, the intent is to 127 | exercise the right to control the distribution of derivative or 128 | collective works based on the Program. 129 | 130 | In addition, mere aggregation of another work not based on the Program 131 | with the Program (or with a work based on the Program) on a volume of 132 | a storage or distribution medium does not bring the other work under 133 | the scope of this License. 134 | 135 | 3. You may copy and distribute the Program (or a work based on it, 136 | under Section 2) in object code or executable form under the terms of 137 | Sections 1 and 2 above provided that you also do one of the following: 138 | 139 | a) Accompany it with the complete corresponding machine-readable 140 | source code, which must be distributed under the terms of Sections 141 | 1 and 2 above on a medium customarily used for software interchange; or, 142 | 143 | b) Accompany it with a written offer, valid for at least three 144 | years, to give any third party, for a charge no more than your 145 | cost of physically performing source distribution, a complete 146 | machine-readable copy of the corresponding source code, to be 147 | distributed under the terms of Sections 1 and 2 above on a medium 148 | customarily used for software interchange; or, 149 | 150 | c) Accompany it with the information you received as to the offer 151 | to distribute corresponding source code. (This alternative is 152 | allowed only for noncommercial distribution and only if you 153 | received the program in object code or executable form with such 154 | an offer, in accord with Subsection b above.) 155 | 156 | The source code for a work means the preferred form of the work for 157 | making modifications to it. For an executable work, complete source 158 | code means all the source code for all modules it contains, plus any 159 | associated interface definition files, plus the scripts used to 160 | control compilation and installation of the executable. However, as a 161 | special exception, the source code distributed need not include 162 | anything that is normally distributed (in either source or binary 163 | form) with the major components (compiler, kernel, and so on) of the 164 | operating system on which the executable runs, unless that component 165 | itself accompanies the executable. 166 | 167 | If distribution of executable or object code is made by offering 168 | access to copy from a designated place, then offering equivalent 169 | access to copy the source code from the same place counts as 170 | distribution of the source code, even though third parties are not 171 | compelled to copy the source along with the object code. 172 | 173 | 4. You may not copy, modify, sublicense, or distribute the Program 174 | except as expressly provided under this License. Any attempt 175 | otherwise to copy, modify, sublicense or distribute the Program is 176 | void, and will automatically terminate your rights under this License. 177 | However, parties who have received copies, or rights, from you under 178 | this License will not have their licenses terminated so long as such 179 | parties remain in full compliance. 180 | 181 | 5. You are not required to accept this License, since you have not 182 | signed it. However, nothing else grants you permission to modify or 183 | distribute the Program or its derivative works. These actions are 184 | prohibited by law if you do not accept this License. Therefore, by 185 | modifying or distributing the Program (or any work based on the 186 | Program), you indicate your acceptance of this License to do so, and 187 | all its terms and conditions for copying, distributing or modifying 188 | the Program or works based on it. 189 | 190 | 6. Each time you redistribute the Program (or any work based on the 191 | Program), the recipient automatically receives a license from the 192 | original licensor to copy, distribute or modify the Program subject to 193 | these terms and conditions. You may not impose any further 194 | restrictions on the recipients' exercise of the rights granted herein. 195 | You are not responsible for enforcing compliance by third parties to 196 | this License. 197 | 198 | 7. If, as a consequence of a court judgment or allegation of patent 199 | infringement or for any other reason (not limited to patent issues), 200 | conditions are imposed on you (whether by court order, agreement or 201 | otherwise) that contradict the conditions of this License, they do not 202 | excuse you from the conditions of this License. If you cannot 203 | distribute so as to satisfy simultaneously your obligations under this 204 | License and any other pertinent obligations, then as a consequence you 205 | may not distribute the Program at all. For example, if a patent 206 | license would not permit royalty-free redistribution of the Program by 207 | all those who receive copies directly or indirectly through you, then 208 | the only way you could satisfy both it and this License would be to 209 | refrain entirely from distribution of the Program. 210 | 211 | If any portion of this section is held invalid or unenforceable under 212 | any particular circumstance, the balance of the section is intended to 213 | apply and the section as a whole is intended to apply in other 214 | circumstances. 215 | 216 | It is not the purpose of this section to induce you to infringe any 217 | patents or other property right claims or to contest validity of any 218 | such claims; this section has the sole purpose of protecting the 219 | integrity of the free software distribution system, which is 220 | implemented by public license practices. Many people have made 221 | generous contributions to the wide range of software distributed 222 | through that system in reliance on consistent application of that 223 | system; it is up to the author/donor to decide if he or she is willing 224 | to distribute software through any other system and a licensee cannot 225 | impose that choice. 226 | 227 | This section is intended to make thoroughly clear what is believed to 228 | be a consequence of the rest of this License. 229 | 230 | 8. If the distribution and/or use of the Program is restricted in 231 | certain countries either by patents or by copyrighted interfaces, the 232 | original copyright holder who places the Program under this License 233 | may add an explicit geographical distribution limitation excluding 234 | those countries, so that distribution is permitted only in or among 235 | countries not thus excluded. In such case, this License incorporates 236 | the limitation as if written in the body of this License. 237 | 238 | 9. The Free Software Foundation may publish revised and/or new versions 239 | of the General Public License from time to time. Such new versions will 240 | be similar in spirit to the present version, but may differ in detail to 241 | address new problems or concerns. 242 | 243 | Each version is given a distinguishing version number. If the Program 244 | specifies a version number of this License which applies to it and "any 245 | later version", you have the option of following the terms and conditions 246 | either of that version or of any later version published by the Free 247 | Software Foundation. If the Program does not specify a version number of 248 | this License, you may choose any version ever published by the Free Software 249 | Foundation. 250 | 251 | 10. If you wish to incorporate parts of the Program into other free 252 | programs whose distribution conditions are different, write to the author 253 | to ask for permission. For software which is copyrighted by the Free 254 | Software Foundation, write to the Free Software Foundation; we sometimes 255 | make exceptions for this. Our decision will be guided by the two goals 256 | of preserving the free status of all derivatives of our free software and 257 | of promoting the sharing and reuse of software generally. 258 | 259 | NO WARRANTY 260 | 261 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 262 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 263 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 264 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 265 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 266 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 267 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 268 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 269 | REPAIR OR CORRECTION. 270 | 271 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 272 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 273 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 274 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 275 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 276 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 277 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 278 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 279 | POSSIBILITY OF SUCH DAMAGES. 280 | 281 | END OF TERMS AND CONDITIONS 282 | 283 | How to Apply These Terms to Your New Programs 284 | 285 | If you develop a new program, and you want it to be of the greatest 286 | possible use to the public, the best way to achieve this is to make it 287 | free software which everyone can redistribute and change under these terms. 288 | 289 | To do so, attach the following notices to the program. It is safest 290 | to attach them to the start of each source file to most effectively 291 | convey the exclusion of warranty; and each file should have at least 292 | the "copyright" line and a pointer to where the full notice is found. 293 | 294 | {description} 295 | Copyright (C) {year} {fullname} 296 | 297 | This program is free software; you can redistribute it and/or modify 298 | it under the terms of the GNU General Public License as published by 299 | the Free Software Foundation; either version 2 of the License, or 300 | (at your option) any later version. 301 | 302 | This program is distributed in the hope that it will be useful, 303 | but WITHOUT ANY WARRANTY; without even the implied warranty of 304 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 305 | GNU General Public License for more details. 306 | 307 | You should have received a copy of the GNU General Public License along 308 | with this program; if not, write to the Free Software Foundation, Inc., 309 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 310 | 311 | Also add information on how to contact you by electronic and paper mail. 312 | 313 | If the program is interactive, make it output a short notice like this 314 | when it starts in an interactive mode: 315 | 316 | Gnomovision version 69, Copyright (C) year name of author 317 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 318 | This is free software, and you are welcome to redistribute it 319 | under certain conditions; type `show c' for details. 320 | 321 | The hypothetical commands `show w' and `show c' should show the appropriate 322 | parts of the General Public License. Of course, the commands you use may 323 | be called something other than `show w' and `show c'; they could even be 324 | mouse-clicks or menu items--whatever suits your program. 325 | 326 | You should also get your employer (if you work as a programmer) or your 327 | school, if any, to sign a "copyright disclaimer" for the program, if 328 | necessary. Here is a sample; alter the names: 329 | 330 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 331 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 332 | 333 | {signature of Ty Coon}, 1 April 1989 334 | Ty Coon, President of Vice 335 | 336 | This General Public License does not permit incorporating your program into 337 | proprietary programs. If your program is a subroutine library, you may 338 | consider it more useful to permit linking proprietary applications with the 339 | library. If this is what you want to do, use the GNU Lesser General 340 | Public License instead of this License. 341 | --------------------------------------------------------------------------------